Docstrings for Bash

Bash doesn't have docstrings — nothing that belongs to a function as just documentation and accessible as such.

This bothers me. Can't we fix that in some simple way?

I think we can.

Formats

I came up with two formats:

#1

foo()
{ <<_
(arg1 arg2 arg3)
Summary ARG1, ARG2, ARG3.
More info.
_
  expressions ;}

#2

foo()
{ : arg1 arg2 arg3 <<_
Summary ARG1, ARG2, ARG3.
More info.
_
  expressions ;}

The second takes one line less and looks good. It's the one I'll use.

Proof of concept

As a proof of concept, and without further ado, here's a minimum viable script filenamed frob:

#!/usr/bin/env bash

# Frob -- Frobnicate things

# SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful>
# SPDX-License-Identifier: GPL-3.0-or-later

#---------------------#
# Author:  flandrew   #
# Created: 2025-10-30 #
# Updated: 2025-11-06 #
#---------------------#
# Version: 0.1.0      #
#---------------------#

### Commentary
#
# Proof of concept for simple Bash docstrings.
#
#############################################################################

### Code

doc()
{ : fun <<_
Output the docstring of FUN.
_
  : "${1:-doc}"; type "$_" | sed -n "/<<_$/,/^_$/p" |
      sed "1 {s/ <<_.*/)/ ; s/[^:]*: */$_ (/}; $ d" ;}

frobnicate()
{ : thing <<_
Whatever THING might be, output 42.
Error if no THING is passed.
_
  if (("$#">0)); then echo 42
  else echo "We need a thing" >&2; exit 1
  fi ;}

flange()
{ : flange size <<_
If SIZE is not 42, frobnicate FLANGE.
Note that frobnicated flanges will not be refrobnicated.
_
  local flange="$1" size="$2"
  ((size == 42)) || frobnicate "${flange:?}" ;}

widget()
{ : widget weight <<_
If WIDGET has WEIGHT, frobnicate it.
_
  local widget="$1" weight="$2"
  ((weight > 0)) && frobnicate "${widget:?}" ;}

main()
{ : arg _rest <<_
Frobnicate things, including flanges and widgets.

ARG is one of:
  doc | frobnicate | flange | widget

The docstring of each ARG is accessible with:
  doc ARG
_
  if (("$#" > 0)); then "$@"; else doc main; fi ;}

main "$@"
exit 0

Ok, let's make it executable:

PATH+=":$PWD"
chmod +x frob

and test it.

Testing it

What happens with no arguments?

frob
main (arg _rest)
Frobnicate things, including flanges and widgets.

ARG is one of:
  doc | frobnicate | flange | widget

The docstring of each ARG is accessible with:
  doc ARG

And with doc:

frob doc
doc (fun)
Output the docstring of FUN.

Let's see the documentation for this support function:

frob doc frobnicate
frobnicate (thing)
Whatever THING might be, output 42.
Error if no THING is passed.

Ok, let's run it:

frob frobnicate

returns error (1), and this message:

We need a thing

Ok, then:

frob frobnicate this
42

Ok, but how do I frobnicate flanges, specifically?

frob doc flange
flange (flange size)
If SIZE is not 42, frobnicate FLANGE.
Note that frobnicated flanges will not be refrobnicated.

Let's try this, then:

frob flange this-flange-here 20
42

Let's frobnicate a widget, for a change.
But what's the syntax for that, again?

frob doc widget
widget (widget weight)
If WIDGET has WEIGHT, frobnicate it.

Ah, ok.

frob widget this-widget-here 10
42

Comments

Some comments about the format and the code.

The format

Quick comparison:

Python

def quux(arg1, arg2, arg3=None)
    """Quuxify arg1, arg2, and, optionally, arg3.
    For more info, read quux-specs."""

Emacs Lisp

(defun quux (arg1 arg2 &optional arg3)
  "Quuxify ARG1, ARG2, and, optionally, ARG3.
For more info, read ‘quux-specs’.")

Bash

With braces:

quux()
{ : arg1 arg2 _arg3 <<_
Quuxify ARG1, ARG2, and, optionally, ARG3.
For more info, read quux-specs.
_
  … ;}

With parentheses:

quux()
(: arg1 arg2 _arg3 <<_
Quuxify ARG1, ARG2, and, optionally, ARG3.
For more info, read quux-specs.
_)

Good enough?

The format itself is as simple as it gets given Bash constraints:

  • We can't put anything inside quux's ().
  • Bringing the args to the first line would mean indenting to the colon
    (well, at least with Emacs):

    quux() { : arg1 arg2 _arg3 <<_
    Quuxify ARG1, ARG2, and, optionally, ARG3.
    For more info, read quux-specs.
    _
             … ;}
    #        ^
    
    large-named-quux() { : arg1 arg2 _arg3 <<_
    Quuxify ARG1, ARG2, and, optionally, ARG3.
    For more info, read quux-specs.
    _
                         … ;}
    #                    ^
    

    You can, and doc() will work just the same — but doesn't seem to help much.

  • Removing the lone _ would mean ditching Here Docs, which would mean adding quotes.
    We don't want that, do we? Quotes used above: Python = 6 · Elisp = 4 · Bash = 0.

The code

To work properly, doc() has to take into account some corner cases, such as functions that are defined with parentheses instead of braces (doc works the same), and functions with no docstrings (doc has no output). And doc itself has a docstring, which it can read if you doc doc or simply doc.

It'd be great if Bash developers turned this into actual syntax, with some internal doc function. Until then, you'll have to include a doc yourself for it to work.

Two lines of code was the best I could do, for a total of six lines:

doc()
{ : fun <<_
Output the docstring of FUN.
_
  : "${1:-doc}"; type "$_" | sed -n "/<<_$/,/^_$/p" |
      sed "1 {s/ <<_.*/)/ ; s/[^:]*: */$_ (/}; $ d" ;}

Too many?

Well, if you don't mind dispensing with its self-referential elegance, you can cut it down to three lines:

doc() { : "${1?doc fun  # Output the docstring of FUN}"
        type "$_" | sed -n "/<<_$/,/^_$/p" |
            sed "1 {s/ <<_.*/)/ ; s/[^:]*: */$_ (/}; $ d" ;}

And then we can have Bash docstrings.

📆 2025-11-08