
bash-doxygen, Doxygen documentation for Bash
6 min read
Last week, I wrote about mktext, a small library that came out of a larger Bash project and became useful enough to deserve its own boundary. bash-doxygen is another tool in that family, although it solves a very different problem: turning carefully documented Bash source into something Doxygen can understand.
I have used Doxygen for years because I like keeping implementation-level contracts close to the code they describe. Bash is awkward territory for that approach. Doxygen understands several programming languages directly, but Bash is not one of the languages for which it can reliably infer functions, parameters, variables, and declarations on its own.
One answer would be to build a Bash parser. That would be a much larger project than the problem justified.
Instead, bash-doxygen takes a narrower approach. It looks for Doxygen-style comment blocks immediately followed by recognizable Bash declarations, then emits a small pseudo-C++ representation that Doxygen can index. Undocumented helpers remain undocumented. Source that does not participate in the documentation contract is left alone.
That distinction matters. bash-doxygen is not trying to prove that it fully understands a Bash program. It is a documentation compiler for a deliberately small subset of Bash declarations.
What it looks like
The preferred documentation style uses contiguous ## comments immediately before the function or variable being documented:
## @brief Read a file from disk.
## @details
## The caller is responsible for validating the path before calling this
## function.
## @param path File path to read.
## @returns 0 on success; non-zero otherwise.
read_file() {
cat -- "$1"
}Run the filter directly with awk:
awk -f ./doxygen-bash.awk ./script.bash > ./script.dox.cppThe generated .cpp file is an intermediate representation for Doxygen. It is not intended to compile, and the original Bash source remains the code being documented.
The filter also integrates with a normal Doxyfile:
FILTER_PATTERNS = *.sh=./doxygen-bash.awk \
*.bash=./doxygen-bash.awk
EXTENSION_MAPPING = sh=C++ bash=C++
EXTRACT_ALL = NOFrom Doxygen’s perspective, the filter gives it declarations in a language it already knows how to index. From the Bash project’s perspective, the maintained source remains Bash with ordinary Doxygen commands in comments.
Documentation is opt-in
One of the decisions I like most about bash-doxygen is what it refuses to infer. A function or variable is emitted only when it is preceded by a Doxygen comment block.
That means a private helper such as this:
__normalize_path() {
# implementation detail
:
}is not automatically promoted into the generated API documentation merely because the filter happened to notice a function-shaped declaration.
This follows the same general rule I use elsewhere: the tool should not make a policy decision merely because it has enough information to make a guess. The developer decides what is part of the documented surface.
Familiar Doxygen commands
bash-doxygen preserves normal Doxygen commands such as @brief, @details, @param, @returns, @retval, @note, @warning, @see, and custom aliases. It only needs to interpret the structural pieces necessary to connect a comment block to the Bash declaration that follows it.
For variables, an explicit @var can make that relationship verifiable:
## @var CACHE_DIR
## @brief Directory used for cached data.
readonly CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/example"The filter recognizes common Bash declaration forms, including readonly, export, local, and declare variants for indexed arrays, associative arrays, integers, namerefs, and case-transforming variables. The generated representation can retain useful characteristics without pretending those Bash constructs literally are C++ types.
Documentation drift can become a build failure
Documentation is most useful when it remains connected to the code it describes. bash-doxygen therefore diagnoses mismatches such as an @fn naming one function while the following declaration names another, an @var attached to a function, or a documentation block that is not followed by a recognized declaration.
By default, those diagnostics are warnings. With --strict, a diagnostic also causes a non-zero exit status:
awk -f ./doxygen-bash.awk -- --strict ./script.bash > ./script.dox.cppThat makes strict mode useful in CI. A refactor that changes a documented function name without updating the documentation does not have to become a quiet piece of documentation debt.
There is also a --compact mode that suppresses placeholder blank lines in the intermediate output. The default keeps those blank lines so diagnostics from Doxygen remain closer to the corresponding locations in the original Bash source.
A small parser on purpose
The temptation with a tool like this is scope creep. Once a filter recognizes a few Bash constructs, it is easy to imagine teaching it progressively more syntax until it becomes a partial shell parser with all of the complexity and edge cases that implies.
That is not the goal.
bash-doxygen recognizes the declaration shapes necessary to connect explicit Doxygen documentation to functions and variables. It is permissive about common whitespace and declaration styles, while remaining conservative about the claims it makes about the source.
The project backs that boundary with small behavior-focused fixtures. Successful translations are compared with golden pseudo-C++ output, diagnostic fixtures test warning and strict-mode behavior, and the suite runs against both the maintained source and the generated distribution artifact.
That combination is important to me. Documentation tooling should be boring in the best sense of the word: predictable, inspectable, and unwilling to invent knowledge about source code that it does not actually possess.
bash-doxygen is available on GitHub, including the filter, tests, Architecture Decision Records, and release tooling.
Next, I’ll look at another piece of the toolchain: bashdeps, the small dependency manager I use to pin and verify exact external artifacts before a build consumes them.