!matthew

my self-documenting makefile template

2023.05.29 :: 6 min.

It was not long into my development career before I turned to GNU Make to help manage repetitive development tasks across various projects I contributed to or, to simply manage my dotfiles. Adding a goal and having a simple tab-to-complete made it a compelling choice over the numerous {build,test,deploy}.sh scripts polluting my project's root.

After coming across this post on a self-documenting Makefiles I was compelled to consider an iteration on my own approach. Unfortunately, François' implementation did not fully satisfy some of the conventions found in my projects such as parameterized goal names (example below).

# ..etc...

${APP_NAME}-db:
	echo "does something."

# ...etc...

considered alternatives.

Before sharpening my pencil I sought out other solutions in the build/command-runner problem-space but did not convince myself that adopting one of these new tools would provide some 10x benefit over make.

  • ninja-build, is an assembler better suited for C++ projects and not for running commands
  • just, is best alternative I came across but is not as universally available or familiar as make
  • other Makefile recipes, were either too complex or focused on C/C++ project builds

If just were ever to become a more widely used default within the Linux ecosystem, for now, I think this is the tool I will most likely jump to. As of this writing (Spring 2023) though it is not so I will stick to what I am already productive with.

Now onto how I iterated from artisanally crafted make help messages to something more automatic.

objectives.

This tool is extremely high-touch across many of my code bases so I tried to stick to a few guidelines

  • Be simple, keep magic and chrome to a minimum
  • Avoid 3rd-party, use default tools available on UNIX-like operating systems
  • Easily adoptable, an update cannot break any existing conventions or require project-specific logic

approach.

With the above in mind lets run through an simple example Makefile below.

.DEFAULT_GOAL := help

# variables assigned with the `?=` operator can
# be overriden with an env var of the same name
APP_NAME ?= great-project
PORT ?= 4000
COMPOSE := "docker compose run --rm"  # TODO: some todo comment


build:  ## build the app service image
        @echo "I generated a build"
.PHONY: build

test:  ## run full suite of tests
        @echo "I ran the test"
.PHONY: test

up:  ## start the dev stack
        @echo "Dev stack has been started, maybe with the '$(COMPOSE)' command"
.PHONY: up

${APP_NAME}-db:  ## Launch a bash shell in the db service
        @echo "Launched DB client"
.PHONY: ${APP_NAME}-db

help:  ## Show this help
        @echo "\nSpecify a $(APP_NAME) command from the list below:\n"
        @grep -E "^[[:graph:]]*[:]{1}[[:space:]]{1,}[#]{2}[[:space:]]{1,}[[:alnum:][:blank:]]{1,}$$" ${MAKEFILE_LIST} \
                | sed 's/\$${APP_NAME}/${APP_NAME}/g' \
                | awk 'BEGIN {FS = "[:]{1}[[:space:]]{1,}[#]{2}[[:space:]]{1,}"}; {printf "  %-25s %s\n", $$1, $$2}'
        @echo ""

For projects with any command-line interface I include a "prelude" that gives a brief description and maybe some hints on how to use the tool. In our example I have a simple echo that prompts the user specify a command along with the $APP_NAME to avoid confusion when context switching.

@echo "\nSpecify a $(APP_NAME) command from the list below:\n"

Nearly all the meat of this update is in a series of three commands, one piped into the next. I will step through each one in each section below.

grep -E "^[[:graph:]]*[:]{1}[[:space:]]{1,}[#]{2}[[:space:]]{1,}[[:alnum:][:blank:]]{1,}$$" ${MAKEFILE_LIST} \
    | sed 's/\$${APP_NAME}/${APP_NAME}/g' \
    | awk 'BEGIN {FS = "[:]{1}[[:space:]]{1,}[#]{2}[[:space:]]{1,}"}; {printf "  %-25s %s\n", $$1, $$2}'

Note: you will see $$ where you may expect a singular $. This is required due to the commands being executed within a Makefile. I will exclude the extra $ in the summaries below to make it easier to copy-pasta into your terminal.

grep.

The grep command will return every line that matches a goal name (e.g., clean:) followed by two octothorpes (##) which will then be piped to sed. If a goal name omits a comment starting with ## then it will not be included in the help message. If you are unfamiliar with Regular Expressions (RegExs), lets break it down bit-by-bit.

grep -E "^[[:graph:]]*[:]{1}[[:space:]]{1,}[#]{2}[[:space:]]{1,}[[:alnum:][:blank:]]{1,}$"

This RegEx will return lines that:

  • Start with a goal name: ^[[:graph:]]*[:]{1}
  • Followed by 1 or more spaces preceding two octothorpes: [[:space:]]{1,}[#]{2}
  • Followed by 1 or more spaces: [[:space:]]{1,}
  • And ending with 1 or more alphanumeric characters or spaces: [[:alnum:][:blank:]]{1,}$$

Which will ultimately send the following lines to our next command in the pipeline: sed.

build:  ## build the app service image
test:  ## run full suite of tests
up:  ## start the dev stack
${APP_NAME}-db:  ## Launch a bash shell in the db service
help:  ## Show this help

sed.

The sed command will take grep's output and replace literal instances of ${APP_NAME} for the value it is defined to. In our example, $APP_NAME is set to great-project but can be over ridden by your shell's env if an environment variable with the same name has been defined which is why APP_NAME is defined with the =? assignment operator.

sed 's/\$${APP_NAME}/${APP_NAME}/g'

The sed command will send output very similar to what it received from grep and to awk.

build:  ## build the app service image
test:  ## run full suite of tests
up:  ## start the dev stack
great-project-db:  ## Launch a bash shell in the db service
help:  ## Show this help

awk.

We now have all the pieces needed to finally assemble our make help output. The awk command will separate the output of sed into two fields and then pass those fields as arguments to printf to print our final help message content. awk gets a wrap of being complicated but fear not, this program is pretty basic and I have broken out each line for greater legibility 👀 and removed the escape codes responsible for coloring the output.

awk 'BEGIN {
	FS = "[:]{1}[[:space:]]{1,}[#]{2}[[:space:]]{1,}"
};
{
		printf "  %-25s %s\n", $1, $2
}'
  • The entire awk program is between single quotes to indicate to the shell to not interpret any special characters.
  • The BEGIN block defines the special Field Seperator value using a subset of the regular expression provided to the previous grep command. The pattern separates each line by the varying amount of spaces that exist after a single colon (:) and dual octothorpes (##) into two fields:
    • $1 our goal name and,
    • $2 our goal description
  • The following block passes those two fields as arguments to the printf format string which finally prints our help message.
    • %-25s is the first string argument where - means left-justified and 25 means that the goal name field's less than 25 characters should be padded with spaces.
    • %s is the second string argument that gets printed out as-is

Finally, I emit a simple newline via echo "" to give some buffer between what can be a long help message and the current line of a terminal.

wrapping up.

I treat the above as a baseline and overall provides consistency when jumping from one project to the next regardless of the host system, language, framework or deployment method. The above solution does not aim to be a tautology for All Your Problems but it serves my needs and I offer it up as inspiration for your own approach.