Jiby's toolbox

Jb Doyon’s personal website

Documenting Makefiles via a help message

Posted on — Jul 21, 2024

I’ve written in the past about using Makefiles for build automation and documentation. But sometimes I wish to provide help to users, that doesn’t require being a Makefiles expert to read!

Let’s explore how we can teach users our build system by turning the project’s Makefile (and awk) into a cheap documentation-as-code tool.

What it looks like

make help
Code Snippet 1: How to ask for help
Figure 1: Result of asking for help

Figure 1: Result of asking for help

As expected, when asking to disable colors:

make help NO_COLOR=1
Code Snippet 2: How to ask for help, without color
Figure 2: Help, without colors

Figure 2: Help, without colors

The Makefile

This all works with a Makefile target named “help”, running awk over the Makefile itself to filter/transform it into what we want to see.

# Run 'make help' to see guidance on usage of this Makefile
# Note that comments with single # aren't rendered, requires ##

ANSI_COLOR_RED=\033[31;1;4m
ANSI_COLOR_RESET=\033[0m
ifneq (${NO_COLOR}, )
ANSI_COLOR_RED=
ANSI_COLOR_RESET=
endif

## Generate the help message by reading the Makefile
.PHONY: help
help:
	@echo "This makefile contains the following targets," \
          "from most commonly used to least:" \
          "(docs first, then target name)"
	@echo "Set NO_COLOR=1 to disable ANSI color coding\n"
	@awk \
		'/^##/ {sub(/## */,""); print} \
		/^[a-z0-9-]+:/ && !/\.PHONY:/ \
			{sub(/:.*/, ""); \
				print "⮡   ${ANSI_COLOR_RED}" \
					$$0 \
					"${ANSI_COLOR_RESET}\n" \
			}' \
		Makefile

# Sample Makefile command being documented:

## Set up the virtualenv and install the package + dependencies
.PHONY: install
install:
	poetry install
Code Snippet 3: The full Makefile used above

I’m happy to say that, while I’ve seen this idea a few times out there, this specific implementation has a few things not seen elsewehere, and I’m pretty happy about it. Let’s dig into it.

Breaking it down

Looking at the file’s content, top to bottom.

First, a simple message for curious people peeking at the file, to tell them that help is on the way:

# Run 'make help' to see guidance on usage of this Makefile

This is to give hope to people who may have opened the Makefile out of desperation, not interested in details, just wanting to know how to operate the repo. The message for them is “we’ve got docs, no need to read the whole file!”

# Note that comments with single # aren't rendered, requires ##

We follow with a quick explanation about how we determine the comments that get rendered (useful info for users), from the ones that aren’t (implementation details). Of course, this specific line being only one # means it will not show in the rendered version: an implementation detail indeed.

ANSI_COLOR_RED=\033[31;1;4m
ANSI_COLOR_RESET=\033[0m
ifneq (${NO_COLOR}, )
ANSI_COLOR_RED=
ANSI_COLOR_RESET=
endif

We want to print help in color for convenience, but we’re very aware that ANSI color codes aren’t for everyone, all the time, because we’ve read and abide by https://no-color.org/, which states:

Command-line software which adds ANSI color to its output by default should check for a NO_COLOR environment variable that, when present and not an empty string (regardless of its value), prevents the addition of ANSI color.

So we set internal variables to the values of ANSI color codes for red + color-reset, and if the NO_COLOR variable is set (not equal to empty string, per the quote “regardless of its value”), override those values to be the empty string, nullifying them, printing nothing instead, no color markers.

## Generate the help message by reading the Makefile
.PHONY: help
help:

Finally we get to a target and its documentation!

This is a simple Makefile target, set as phony (this is NOT about creating a file named “help”, but a command to run always when running make help, see Phony Targets documentation) with some user-facing documentation above it.

As for the content of this target, what to run when asked for help…

	@echo "This makefile contains the following targets," \
          "from most commonly used to least:" \
          "(docs first, then target name)\n"

First, we runs the command echo (the shell script built-in), printing a top-level general help message. Note the @ prefix, which is a Makefile thing, used to disable printing the command being run before its output: We don’t need to double-print the message (command to run as well as the resulting message from running it), so we put an @ in front.

	@echo "Set NO_COLOR=1 to disable ANSI color coding\n"

Similarly, we notify the users about the NO_COLOR flag.

And finally the real meat of this trick:

	@awk \
		'/^##/ {sub(/## */,""); print} \
		/^[a-z0-9-]+:/ && !/\.PHONY:/ \
			{sub(/:.*/, ""); \
				print "⮡   ${ANSI_COLOR_RED}" \
					$$0 \
					"${ANSI_COLOR_RESET}\n" \
			}' \
		Makefile

This awk command processes the Makefile itself for printing docs. In general, awk is called like awk 'a whole awk program in here' filename-to-process, with in our case the filename being Makefile.

As for the awk program being run over the file: awk’s language works by defining patterns then actions to do on each pattern. Patterns usually are regular expressions delimited by slashes like /pattern/, filtering the line being processed, and actions are delimited by {do_stuff}. Specifically, we run two patterns-and-actions.

# Filter to only match lines starting with ##
/^##/
# ... and, for these matching lines...
{
    sub(/## */,"");  # remove the ## prefix in line + whitespace
    print  # and print the modified line
}
Code Snippet 4: First pattern + action, a doc-extractor command, annotated

The result of running this single pattern + action on our previous file is:

Figure 3: Result of just the first pattern-action combo over the Makefile. We see just the docs!

Figure 3: Result of just the first pattern-action combo over the Makefile. We see just the docs!

And the second command, broken down first between pattern then action:

# Filter to makefile targets only.
# done via filtering lines that have a : character
/^[a-z0-9-]+:/
&&
# but Phony targets would be captured here, so exclude them
# using the ! in front of pattern
# Escaping . which has a meaning in regexes
!/\.PHONY:/
Code Snippet 5: Makefile target name detection pattern

That pattern ensures we have the Makefile target selected, but we want to make it printed with color etc. We do this via the action run over the pattern, below.

{
    # Erase anything after the target name, like dependencies
    sub(/:.*/, "");
    # Print the modified line, with some text before and after
    print \
        "⮡   ${ANSI_COLOR_RED}"  # pretty arrow + 4 spaces + red color
        $$0  # The original line = $0. Makefiles escape $ as $$
        "${ANSI_COLOR_RESET}\n"  # Print the ANSI color reset
}
Code Snippet 6: Print the name of the makefile target, as action over previous pattern

Why do it like this

I was trying to tailor this script to be convenient for prospective users, while remaining fairly simple to implement, and easy to explain to curious minds. Let’s review a few of the technical decisions that went into it.

Why documentation then command?

The first choice was having the docs show up first in the result, then the command being documented: Usually, documentation systems print the command first (as a kind of section header), and then describe it with documentation.

But in the Makefile itself it would make no sense to have the command first, then have the documentation below it. Usually the docs are like javadocs, written just before the code, and the docs renderer swaps the order.

To find a way to do command then docs would have meant the script would need to store commands and docs, then swap their order in the output, which increases complexity of our script.

Much easier to highlight that the command being printed is after the docs by taking advantage of indentation, with fancy characters like that right-angled arrow, and of course we color the output.

So we just “filter and print” the whole file, top to bottom, in the order the file is written, taking advantage of awk doing all the heavy lifting for us.

Of course, when we use ANSI colors, it’s pretty important that we support the NO_COLOR directive, it’s just being a good command-line citizen.

Why use Makefile’s echo?

I could have avoided the echo command, and instead have all the text inside the Makefile, with two ## for the “these are the commands” and “Set NO_COLOR=1” messages. The choice to use an “echo” instead is minor, but it highlights an important point around documentation:

First, this would mean we’re mixing up the two kinds of users of the Makefile: the people needing help about the build system, are likely not the people who will open up the makefile and read a block of text from it.

The other issue is that after the overall help message, I wanted a couple of empty lines of separation, which would require me to write into the Makefile a couple of empty lines with just ##, defacing the Makefile for the sake of the aesthetics of the documentation message?

Nah, much nicer to have the Makefile have an extra echo command to prefix the actual documentation. This way we’ve split messages aimed at basic help vs Makefile-editing wizards.

Why two hashes (##) to trigger the documentation?

As described, we have two kinds of users to write docs for: devs who just want simple help (using make help), who likely will be overwhelmed by the details of the Makefile (for now), and devs who will read the whole Makefile and likely edit it. Because of the different targets, we need to make some docs messages (comments) visible to one group but not the other.

I thus steal the trick from javadocs, that simple comments are technical details, but comments with two characters are aimed at documentation, generated by extracting from the code.

In the purest sense, this is documentation-as-code: Documentation living inside the repository next to the code it described, the docs is managed as code, versioned, reviewed etc. I do seem to gravitate around documentation as code in multiple posts, so I may try to collect my thoughts around it in a future article.

Conclusion

I’ve presented before that I really like Makefiles, and I acknowledge that for Makefile non-experts, it can be difficult to understand what’s going on.

My interest is making sure that entry-level users can find guidance on running project-related commands without having to become experts themselves. Usually, the discovery process around Makefiles means opening the Makefile and getting overwhelmed by the complexity. This system of make help addresses that, by providing some kind of documentation-as-code, summarizing what a user may want to do, easing them in.

On the way I got to talk about a few things I like, from awk, to docs as code, and justify the multiple users of documentation.

So I hope this Makefile documentation-as-code trick is useful to pass on.