Why don't shell builtins have proper man pages?

Because builtins are part of the shell. Any bugs or history they have are bugs and history of the shell itself. They are not independent commands and don't exist outside the shell they are built into.

The equivalent, for bash at least, is the help command. For example:

$ help while
while: while COMMANDS; do COMMANDS; done
    Execute commands as long as a test succeeds.

    Expand and execute COMMANDS as long as the final command in the
    `while' COMMANDS has an exit status of zero.

    Exit Status:
    Returns the status of the last command executed.

All bash builtins have help pages. Even help itself:

$ help help
help: help [-dms] [pattern ...]
    Display information about builtin commands.

    Displays brief summaries of builtin commands.  If PATTERN is
    specified, gives detailed help on all commands matching PATTERN,
    otherwise the list of help topics is printed.

    Options:
      -d    output short description for each topic
      -m    display usage in pseudo-manpage format
      -s    output only a short usage synopsis for each topic matching
        PATTERN

    Arguments:
      PATTERN   Pattern specifiying a help topic

    Exit Status:
    Returns success unless PATTERN is not found or an invalid option is given.

Inspired by @mikeserv's sed script, here's a little function that will print the relevant section of a man page using Perl. Add this line to your shell's initialization file (~/.bashrc for bash):

manperl(){ man "$1" | perl -00ne "print if /^\s*$2\b/"; }

Then, you run it by giving it a man page and the name of a section:

$ manperl bash while
       while list-1; do list-2; done
       until list-1; do list-2; done
              The while command continuously executes the list list-2 as long as the last command in the list list-1 returns an exit
              status of zero.  The until command is identical to the while command, except that the test is negated; list-2 is  exe‐
              cuted  as  long  as the last command in list-1 returns a non-zero exit status.  The exit status of the while and until
              commands is the exit status of the last command executed in list-2, or zero if none was executed.

$ manperl grep SYNOPSIS
SYNOPSIS
       grep [OPTIONS] PATTERN [FILE...]
       grep [OPTIONS] [-e PATTERN | -f FILE] [FILE...]

$ manperl rsync "-r"
       -r, --recursive
              This tells rsync to copy directories recursively.  See also --dirs (-d).

While it's true that some shell builtins may have a scant showing in a complete manual - especially for those bash-specific builtins that you're only likely to use on a GNU system (the GNU folks, as a rule, don't believe in man and prefer their own info pages) - the vast majority of POSIX utilities - shell builtins or otherwise - are very well represented in the POSIX Programmer's Guide.

Here's an excerpt from the bottom of my man sh (which is probably 20 pages long or so...)

enter image description here

All of those are there, and others not mentioned such as set, read, break... well, I don't need to name them all. But note the (1P) at the bottom right - it denotes the POSIX category 1 manual series - those are the man pages I'm talking about.

It may be that you just need to install a package? This looks promising for a Debian system. While help is useful, if you can find it, you should definitely get that POSIX Programmer's Guide series. It can be extremely helpful. And it's constituent pages are very detailed.

That aside, shell builtins are almost always listed in a specific section of the specific shell's manual. zsh, for example, has an entire separate man page for that - (I think it totals at 8 or 9 or so individual zsh pages - including zshall which is huge.)

You can just grep man of course:

man bash 2>/dev/null | 
grep '^[[:blank:]]*read [^`]*[-[]' -A14

   read [-ers] [-a aname] [-d  delim]  [-i  text]  [-n
   nchars]  [-N  nchars]  [-p prompt] [-t timeout] [-u
   fd] [name ...]
          One line is read from the standard input, or
          from  the  file descriptor fd supplied as an
          argument to the -u  option,  and  the  first
          word is assigned to the first name, the sec‐
          ond word to the second name, and so on, with
          leftover words and their intervening separa‐
          tors assigned to the last  name.   If  there
          are  fewer  words read from the input stream
          than names, the remaining names are assigned
          empty  values.   The  characters  in IFS are
          used to split the line into words using  the
          same  rules  the  shell  uses  for expansion

...which is pretty close to what I used to do when searching a shell man page. But help is pretty good in bash in most cases.

I've actually been working on a sed script to handle this kind of stuff recently. It's how I grabbed the section in the picture above. It's still longer than I like, but it's improving - and can be pretty handy. In its current iteration it will pretty reliably extract a context-sensitive section of text as matched to a section or subsection heading based on [a] pattern[s] given it on the command line. It colors its output and prints to stdout.

It works by evaluating indent levels. Non-blank input lines are generally ignored, but when it encounters a blank line it starts to pay attention. It gathers lines from there until it has verified that the current sequence definitely indents further in than did its first line before another blank line occurs or else it drops the thread and waits for the next blank. If the test is successful it attempts to match the lead line against its command-line args.

This means that a match pattern will match:

heading
    match ...
    ...
    ...
        text...

..and..

match
   text

..but not..

heading
    match
    match

    notmatch

..or..

         text

         match
         match
         text

         more text

If a match can be had it starts printing. It will strip the matched line's leading blanks from all lines it prints - so no matter the indent level it found that line on it prints it as if it were at the top. It will continue to print until it encounters another line at an equal or lesser-than indent level than its matched line - so entire sections are grabbed with just a heading match, including any/all subsections, paragraphs they might contain.

So basically if you ask it to match a pattern it will only do so against a subject heading of some kind and will color and print all of the text it finds within the section headed by its match. Nothing is saved as it does this except your first line's indent - and so it can be very fast and handle \newline separated input of virtually any size.

It took me awhile to figure out how to recurse into subheadings like the following:

Section Heading
    Subsection Heading

But I sorted it out eventually.

I did have to rework the whole thing for simplicity's sake, though. While before I had several small loops doing mostly the same things in slightly different ways to fit their context, by varying their means of recursion I managed to de-duplicate the majority of the code. Now there are two loops - one prints and one checks indent. Both depend on the same test - the print loop starts when the test passes and the indent loop takes over when it fails or begins on a blank line.

The whole process is very fast because most of the time it just /./deletes any non-blank line and moves on to the next - even results from zshall populate the screen instantly. This has not changed.

Anyway, it is very useful so far, though. For example, the read thing above can be done like:

mansed bash read

...and it gets the whole block. It can take any patterns or whatever, or multiple arguments, though the first is always the man page in which it should search. Here's a picture of some of its output after I did:

mansed bash read printf

enter image description here

...both blocks are returned whole. I often use it like:

mansed ksh '[Cc]ommand.*'

...for which it is quite useful. Also, getting SYNOPS[ES] makes it really handy:

enter image description here

Here it is if you want to give it a whirl - I won't blame you if you don't though.

mansed() {
MAN_KEEP_FORMATTING=1 man "$1" 2>/dev/null | ( shift
b='[:blank:]' s='[:space:]' bs=$(printf \\b) esc=$(printf '\033\[') n='\
' match=$(printf "\([${b}]*%s[${b}].*\)*" "$@")
sed -n "1p
    /\n/!{  /./{    \$p;d
        };x;    /.*\n/!g;s///;x
    :indent
        /.*\n\n/{s///;x
        };n;\$p;
        /^\([^${s}].*\)*$/{s/./ &/;h;   b indent
        };x;    s/.*\n[^-[]*\n.*//; /./!x;t
        s/[${s}]*$//;   s/\n[${b}]\{2,\}/${n} /;G;h
    };
    #test
    /^\([${b}]*\)\([^${b}].*\n\)\1\([${b}]\)/!b indent
        s//\1\2.\3/
    :print
    /^[${s}]*\n\./{ s///;s/\n\./${n}/
        /${bs}/{s/\n/ & /g;
            s/\(\(.\)${bs}\2\)\{1,\}/${esc}38;5;35m&${esc}0m/g
            s/\(_${bs}[^_]\)\{1,\}/${esc}38;5;75m&${esc}0m/g
            s/.${bs}//g;s/ \n /${n}/g
            s/\(\(${esc}\)0m\2[^m]*m[_ ]\{,2\}\)\{2\}/_/g
        };p;g;N;/\n$/!D
        s//./;  t print
    };
    #match
        s/\n.*/ /;  s/.${bs}//g
        s/^\(${match}\).*/${n}\1/
        /../{   s/^\([${s}]*\)\(.*\)/\1${n}/
        x;  s//${n}\1${n}. \2/; P
    };D
");}

Briefly, the workflow is:

  • any line which is not blank and which does not contain a \newline character is deleted from output.
    • \newline characters never occur in input pattern space. They can only be had as the result of an edit.
  • :print and :indent are both mutually dependent closed loops and are the only way to obtain a \newline.
    • :print's loop cycle begins if the leading characters on a line are a series of blanks followed by a \newline character.
    • :indent's cycle begins on blank lines - or on :print cycle lines which fail #test - but :indent removes all leading blank + \newline sequences from its output.
    • once :print begins it will continue to pull in input lines, strip leading whitespace up to the amount found on the first line in its cycle, translate overstrike and understrike backspace escapes into color terminal escapes, and print the results until #test fails.
    • before :indent begins it first checks hold space for any possible indent continuation (such as a Subsection), and then continues to pull in input as long as #test fails and any line following the first continues to match [-. When a line after the first does not match that pattern it is deleted - and subsequently so are all following lines until the next blank line.
  • #match and #test bridge the two closed loops.
    • #test passes when the leading series of blanks is shorter than the series followed by the last \newline in a line sequence.
    • #match prepends the leading \newlines needed to begin a :print cycle to any of :indent's output sequences which lead with a match to any command-line arg. Those sequences that don't are rendered empty - and the resulting blank line is passed back to :indent.