brainfuck, 811 775 709 701 bytes
>>-[-[--->]<<-]>--...<++++++++++.>>>>>>>>>>+[->>>>>>+<<,[[-<+<+>>]<----------[[-]->]-[+>-]<<<+[-<[-]>]<[-<+>>+<]++++++[->-------<]>>++++++[-<<++++++>>]>+<<-[<->>]>[<]<-[<------->>]>[<]<-[<-->>]>[<]<-[<-------->>]>[<]<--------------[<--->>]>[<]<--[<---->>]>[<]+++++[-<------>]<+[<----->>]>[<]<--[<------>>]>[<]<[-]<<+>[>>>-]<[>>>]<<<->>++++[->++++++++<]+>>>+>]<]+[<<<<<<]>>>>[.>>>>>>]>>>>-<<<<<<<<<<[<<<<<<]<<<<<.>>>+[->>>>>>>>>>>>>[>>>>>>]<<<<<[<[<<.<<<<]>>>>>>[>>>>>>]<<<<<.>>>>[>+>>>>>[<<<<.>>>>>+>>>>>]<<<<[>>>.>>>]>>>.>>>[.>>>>>>]>>[<<<<<<]<]<<<[>->>>+>[>>>.>>>]>>-<<<<<.<[<<<<<<]<[->>>>>>>[>>>>>>]+>>>>[>>>>>>]>>-<<<<<<[<<<<<<]<]>+[>>>>>>]+>>>>[.>>>>>>]>>[<<<<<<]<<<<]+<<<.>>]>]>[<<<<<<]<<<<<<...
Try it online!
This could probably be golfed more, as this is, like, the second brainfuck program I've written.
Explanation
I should really write a program to do this for me...
Fullscreen Explanation
#####
# Execution will be divided into two phases: input and output.
# The input phase will input all characters and lay them out into memory with metadata.
# The output phase will then loop over the memory and print various segments of it, forming the explanation.
###############
# Input Phase #
###############
#####
# First, let's take a look at the memory layout.
# We'll divide the memory into 6-byte chunks.
# The chunks will be laid out in this format:
# - first header chunk, to denote the start
# - second header chunk, to denote the start
# - first source chunk
# - second source chunk
# - ...
# - last source chunk
# - primary separator chunk (in place of newline)
# - first chunk of first explanation
# - second chunk of first explanation
# - ...
# - last chunk of first explanation
# - separator chunk (in place of newline)
# - first chunk of second explanation
# - ......
# - last chunk of last explanation
# - eof chunk
# The first header chunk will have the following bytes:
# 0 10 96 0 0 0
# (10 is newline, and 96 is backtick)
# The second header chunk will consist entirely of zeroes.
#####
# Initialize the header chunks and output the initial code fence:
>> # Move to cell #2 (0-indexed).
-[-[--->]<<-]>-- # Set cell #2 to backtick (96) (pulled from https://esolangs.org/wiki/Brainfuck_constants).
... # Print it thrice (fine, there is a tiny bit of output in this phase).
<++++++++++. # Set cell #1 to newline (10) and print it.
>>>>>>>>>>+ # Move to cell #5 of the second header chunk and temporarily set it to 1.
#####
# Next, read the input into chunks and initialize them.
# Every non-header non-eof chunk will be initialized to the following format:
# #0: [char] (0 if separator)
# #1: [kind] (which operator the char is)
# #2: [mark] (currently 1; this will occasionally be temporarily set to 0 as a "bookmark")
# #3: 32 (the code for space, used for output)
# #4: [noop] (1 if no-op source chunk or non-primary separator, 0 otherwise)
# #5: [done] (currently 0, undone)
# During the initialization, some cells are used for temporary values.
[ # While the current cell (#5 of the preceding chunk) is non-zero (and thus known to be 1), loop over the input:
- # Subtract 1 from the cell, [done], returning it to 0 (undone)
> # Move to the active chunk.
# Cell #5 will be used for positioning the pointer after a branch ('alignment'):
>>>>>+ # Set cell #5 to 1
#####
# Next, we'll read a character.
<<, # Move to cell #3 and input a character into it (temp).
[ # If this character is non-zero (not eof):
[-<+<+>>] # Duplicate this character into cells #1 and #2 [temp], erasing this cell, #3.
<---------- # Move to cell #2 and subtract 10 (code for newline) from it.
# The chunk is now:
# #0: 0
# #1: char (temp)
# > #2: char - 10 (temp)
# #3: 0
# #4: 0
# #5: 1 (alignment)
#####
# Next, if char is newline (10), we'll set cell #1 to 0.
[[-]->] # If cell #2 (char - 10) is non-zero (the char is not a newline), set this cell to 255 and move to cell #3.
-[+>-] # Move to the first cell to the right set to 1 (cell #5), and set it to 0.
# (The above is done without any net change to other cells.)
<<<+ # Move to cell #2 and add one to it; this cell is now 1 if char is a newline, or 0 otherwise.
[-<[-]>] # If cell #2 is 1, set it back to 0, move to cell #1, clear it, and move back to cell #2.
# From now on, char will be 0 if the character was a newline.
# The chunk is now:
# #0: 0
# #1: char (temp)
# > #2: 0
# #3: 0
# #4: 0
# #5: 0
#####
# Next we'll inialize cell #0 [char] and put a copy of char into cell #2.
< # Move to cell #1.
[-<+>>+<] # Duplicate this value to cells #0 and #2, clearing this cell.
# The chunk is now:
# #0: [char]
# > #1: 0
# #2: char (temp)
# #3: 0
# #4: 0
# #5: 0
#####
# Next, we'll initialize cell #1 [kind] to a number denoting the 'kind' of the char is.
# The kind is an index from 0 to 8 matching the order "N+-<>[],." (where N stands for any no-op).
# The kind could be determined by directly checking if the character matches an operation (char == op).
# In BF, however, it's easier to check if something is not equal to X than if it is equal to X.
# We'll start cell #1 [kind] at the sum of the kind, 36 (1+2+3+4+5+6+7+8).
# For each operation, we'll check if the character does not match that operation, (char != op).
# To do so, we will use cell #2 for checking whether (char - op) is non-zero.
# If the character does not match that operation, we will subtract its kind from the sum.
# After doing so for all of the operations, the result will be the kind of the operation that the character matches.
# We'll do this not in kind order, but in op order: "+" (43), then "," (44), then "-" (45), etc.
#####
>>++++++[-<<++++++>>] # Set cell #1 to 36 and move to cell #3.
>+ # Set cell #4 to 1 to be used for alignment.
# The chunk is now:
# #0: [char]
# #1: 36 (sum of all kinds)
# #2: char (char - 0)
# #3: 0
# > #4: 1 (alignment)
# #5: 0
#####
# The next set of lines are based on using the following pattern for each of the operations:
# Subtract from cell #2 the difference between the char codes for this operation and the previous.
# If cell #2 is not zero (if char != op):
# Remove this kind from the sum by subtracting from cell #1 (kind_sum) the kind of this operation.
# Return to cell #2 (using cell #4 for alignment).
# The resulting chunk will be:
# #0: [char]
# #1: kind_sum
# > #2: char (char - op)
# #3: 0
# #4: 1 (alignment)
# #5: 0
# For '+', check 43:
<++++++[-<------->]<- # Subtract 43 (6 * 7 + 1) from cell #2, the difference between '+' and 0.
# The value in cell #2 is now char - 43.
[ # If the cell #2 is non-zero (char != 43; the character is not '+'):
<->> # Subtract from cell #1 the kind of '+', 1.
] # End if.
>[<]< # Return to cell #2 (using cell #4 for alignment).
# (For the remaining operations, the pattern will be shown in a condensed form.)
# For ',', check 44:
- # Subtract 1 (the difference between ',' and '+').
[<------->>]>[<]< # If not zero, remove 7 from kind_sum. Return to cell #2.
# For '-', check 45:
- # Subtract 1.
[<-->>]>[<]< # If not zero, remove 2 from kind_sum. Return to cell #2.
# For '.', check 46:
- # Subtract 1.
[<-------->>]>[<]< # If not zero, remove 8 from kind_sum. Return to cell #2.
# For '<', check 60:
-------------- # Subtract 14.
[<--->>]>[<]< # If not zero, remove 3 from kind_sum. Return to cell #2.
# For '>', check 62:
-- # Subtract 2.
[<---->>]>[<]< # If not zero, remove 4 from kind_sum. Return to cell #2.
# For '[', check 91
>+++++[-<------>]<+ # Subtract 29 (5 * 6 - 1).
[<----->>]>[<]< # If not zero, remove 5 from kind_sum. Return to cell #2.
# For ']', check 93
-- # Subtract 2.
[<------>>]>[<]< # If not zero, remove 6 from kind_sum. Return to cell #2.
#
[-] # Set cell #2 to zero.
# The chunk is now:
# #0: [char]
# #1: [kind]
# > #2: 0
# #3: 0
# #4: 1 (temp)
# #5: 0
#####
# Next, cell #4 [noop] will be initialized to 0 if char is not a no-op.
<<+ # Move to cell #0, adding 1 (temporarily, for alignment)
> # Move to cell #1.
[>>>-] # If cell #1 [kind] is non-zero (the char is not a no-op),
# move to cell #4 [noop] and subtract 1 to set it to 0.
<[>>>] # Move to cell #3, using cell #0 for realignment.
<<<- # Move to cell #0 and subtract 1 from it, returning it to char.
# The chunk is now:
# > #0: [char]
# #1: [kind]
# #2: 0
# #3: 0
# #4: [noop] (1 if no-op, 0 otherwise)
# #5: 0
#####
# Next, cell #2 [mark] will be initialized to 1 (unmarked) and cell #3 [space] will be initialized to 32 (code for space).
>>++++[->++++++++<] # Move to cell #2 and set cell #3 to 32 (4 * 8)
+ # Set #2 [mark] to 1 (unmarked)
>>>+ # Move to cell #5 and set it to 1 (temporarily, so the input loop continues; this will be unset in the next iteration).
> # Move to cell #0 of the next chunk.
] # End "if non-eof".
< # If at cell #0 of the next chunk (from the preceding branch for processing a source chunk), move to cell #5 of the current chunk;
# otherwise (at cell #3 after the source chunks), move to cell #2.
] # End input loop.
# Every non-header non-eof chunk is now:
# #0: [char] (0 if separator)
# #1: [kind] (which operator the char is)
# #2: [mark] (currently 1; this will occasionally be temporarily set to 0 as a "bookmark")
# #3: 32 (the code for space, used for output)
# #4: [noop] (1 if no-op source chunk or non-primary separator, 0 otherwise)
# #5: [done] (currently 0, undone)
# Since the loop ended, it reached eof, and the head is at cell #2 of the eof chunk.
################
# Output Phase #
################
#####
# First, we will return to the header chunks.
+ # Set cell #2 to 1 (used later to save a byte).
[<<<<<<] # Move left one chunk at a time until cell #2 is zero, reaching the second header chunk.
>>>> # Move to cell #0 of the first source chunk.
[.>>>>>>] # While cell #0 is non-zero, print it and move to the next chunk
# (this writes the first line of the explanation, the code verbatim).
# Now, the head is at cell #0 of the primary separator chunk.
>>>>-<<<< # Set its cell #4 to 0 (used later to break a loop).
<<<<<<[<<<<<<] # Move to the next 0-char chunk to the left (this is the second header chunk).
<<<<<. # Move to cell #1 of the first header chunk (newline) and output it (ending the first line).
#####
# As we go through each line of the explanation, we'll follow this psuedo-code:
# - Output a space for every source character marked as 'done'
# - Output the characters for the active operations (multiple if it's a chain of no-ops, only one otherwise)
# - Mark all active operations 'done'
# - Output a space for every source character after the active ones
# - Output a space followed by the associated explanation
# These steps will be expanded on later.
>>> # Move to cell #4 of the first header chunk.
+ # Set it to one to start the loop.
[ # Begin a loop to print subsequent lines:
#####
# First, we will find the first not done chunk.
- # Set this cell, #4 of the first header chunk, back to 0.
>>>>>>>>>>>>> # Move to cell #5 [done] of the first source chunk.
[>>>>>>] # Go to the first not done cell to the right (it might be the current chunk).
<<<<< # Go to cell #0 [char].
[ # If it's non-zero (if it's a source chunk):
#####
# Next, we will output the leading spaces and the first active character.
<[<<.<<<<] # Go to cell #5 of the first not done chunk to the left
# (this is the second header chunk), outputting spaces along the way.
>>>>>> # Go to cell #5 [done] of the next chunk.
[>>>>>>] # Move to cell #5 of the first non-done cell to the right.
<<<<<. # Move to cell #0 [char] of this chunk (the first active source chunk) and output it.
>>>> # Move to cell #4 [noop] of this chunk.
#####
# Next, the following branch will process a sequence of no-op chunks.
# This is handled differently from non-no-op chunks because sequential no-op characters are printed in one line.
[ # If it's non-zero (if this character is a no-op):
>+ # Set cell #5 [done].
#####
# First, we will output each subsequent active character and mark their chunks as done.
>>>>> # Move to cell #4 [noop] of the next chunk.
[ # While the current chunk is a no-op:
<<<<. # Move to cell #0 [char] and output it.
>>>>>+ # Move to cell #5 [done] and set it to 1.
>>>>> # Move to cell #4 [noop] of the next chunk.
# (Note that the primary separator has cell #4 [noop] set to 0 in order to break this loop)
] # End loop. Now the head is at the cell #4 [noop] of the first non-no-op chunk.
#####
# Next, we will output the trailing spaces and the no-op explanation.
<<<<[>>>.>>>] # Move to the primary separator chunk, outputting a space for each skipped chunk.
>>>.>>> # Move to cell #0 of the first chunk of the first explanation, outputting a space.
[.>>>>>>] # Move to the next non-explanation chunk, outputting each character.
>> # Move to cell #2 [mark].
[<<<<<<] # Move to cell #2 of the second header chunk.
< # Move to cell #1.
] # End if.
<<< # If at cell #1 of the second header chunk (from the preceding branch for processing a no-op), move to cell #4 of the first header chunk;
# otherwise, (still at the cell $4 [noop] of the current source chunk), move to cell #1 [kind].
#####
# Next, the following branch will process a non-no-op chunk.
# This is handled separately from no-op chunks because operators are printed on separate lines.
[ # If cell #1 [kind] is non-zero (if in a non-no-op source chunk):
#####
# First, we will output the trailing spaces and find the primary separator chunk.
>- # Move to cell #2 [mark] and set it to 0 (this marks the cell to return to later).
>>>+ # Move to cell #5 [done] and set it to 1.
>[>>>.>>>] # Move to the primary separator chunk, outputting a space for each skipped chunk.
>>- # Mark this chunk (the primary separator chunk).
<<<<<.< # Move to cell #2 [mark] of the previous chunk, outputting a space.
[<<<<<<] # Move to the first marked (mark == 0) cell to the left (the active source chunk).
< # Move to cell #1 [kind].
#####
# Next, we'll locate the correct explanation.
# We're at cell #2 [kind], which acts as an index to the explanation.
# The primary separator (which precedes the 0th (no-op) explanation) is marked.
[- # Continually decrement the kind:
# Each iteration will unmark the currently marked separator and mark the next separator.
>>>>>>>[>>>>>>] # Move to the first marked cell to the right.
+ # Unmark this chunk.
>>>> # Move to cell #0 of the next chunk.
[>>>>>>] # Move through the explanation chunks to the next separator chunk.
>>- # Mark this separator chunk.
<<<<<<[<<<<<<]< # Return cell #1 [kind] of the active source chunk (as it's marked).
] # End loop.
#####
# Next, we will output the explanation we have located.
>+ # Unmark the current source chunk.
[>>>>>>] # Move to the marked separator chunk.
+ # Unmark this chunk.
>>>> # Move to cell #0 [char] of the next chunk.
[.>>>>>>] # While cell #0 [char] is non-zero, output it and move to the next chunk.
>>[<<<<<<] # Return to the second header chunk.
<<<< # Move to cell #4 of the first header chunk.
] # End if.
# From either branch, we are now at cell #4 of the first header chunk.
+ # Set cell #4 to 1 (to continue the loop).
<<<. # Move to cell #1 (newline) and output it.
>> # Move to cell #3 (which is 0).
] # End if.
> # If at the first header chunk (from the preceding branch for processing a source chunk), move to cell #4 (which is 1, continuing the loop);
# otherwise (still at the primary separator), move to cell #1 (which is 0, ending the loop).
] # End loop.
#####
# Lastly, we will print the final code fence.
>[<<<<<<] # Move to cell #2 of the second header chunk.
<<<<<<... # Move to the first header chunk and output the backtick thrice.
#####
# Finally, we can enjoy the fruits of our efforts: https://pastebin.com/raw/C9yYHx3F.