beautiful_makefile

8 September 2022

Developer productivity is a top priority for most engineering organizations. Here at Padok, we apply the DevOps philosophy: developers and SREs alike own all the code. The key to productivity from local development all the way to production is automation. Makefiles are great tools for automation! And isn't it nice when our tools produce beautiful output?

 

No time to waste. This is what a beautiful Makefile looks like:

beautiful_makefiles

Just give me stuff to copy and paste please

Add this to your Makefile and enjoy your beautiful help target:


##@ General

# The help target prints out all targets with their descriptions organized
# beneath their categories. The categories are represented by '##@' and the
# target descriptions by '##'. The awk commands is responsible for reading the
# entire set of makefiles included in this invocation, looking for lines of the
# file as xyz: ## something, and then pretty-format the target and help. Then,
# if there's a line with ##@ something, that gets pretty-printed as a category.
# More info on the usage of ANSI control characters for terminal formatting:
# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
# More info on the awk command:
# http://linuxcommand.org/lc3_adv_awk.php

.PHONY: help
help: ## Display this help.
    @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)

##@ Development

.PHONY: fmt
fmt: ## Format source code.
    go fmt ./...

.PHONY: vet
vet: ## Vet source code.
    go vet ./...

How this beautiful Makefile works

I did not come up with the magic awk command that makes Makefiles beautiful. I got it from the Go operator-sdk for writing Kubernetes operators, which itself got it from kubebuilder, a code generation engine for Kubernetes operators.

However, reverse engineering it enhanced my understanding of awk and terminal formatting. Let’s dive into how the magic one-line works.

This is the command make help runs:

awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf "  \033[36m%-15s\033[0m %s\n", $1, $2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($0, 5) } '  Makefile

Formatted for easier reading:

awk 'BEGIN {
        FS = ":.*##";
        printf "\nUsage:\n  make \033[36m<target>\033[0m\n"
    }

    /^[a-zA-Z_0-9-]+:.*?##/ {
        printf "  \033[36m%-15s\033[0m %s\n", $1, $2
    }

    /^##@/ {
        printf "\n\033[1m%s\033[0m\n", substr($0, 5)
    } '  Makefile

If you don't already know, awk is a general-purpose tool for filtering data streams, like text files. Here, a single awk program takes our Makefile as input and outputs a beautiful help message.

The program is made of three pattern and action pairs that look like this:

pattern { action }

The first pattern is BEGIN, a special pattern that runs its action before awk reads any data. The action has two parts:

FS = ":.*##";
printf "\nUsage:\n  make \033[36m<target>\033[0m\n"

The first part sets the program's field separator (FS = Field Separator), which awk uses to split lines into multiple fields. This separator is used in the later pattern/action pairs.

The second part prints the first lines of the help message. The printf command is like bash's echo, with the added ability to inject values into strings, like C's printf function. Here, we are simply printing this string:


Usage:
  make \033[36m<target>\033[0m

This string includes special sequences called escape codes that your shell interprets to apply formatting to the text that follows. Without those escape codes, the string looks like this:


Usage:
  make <target>

\\033 is an ASCII escape character that marks the beginning of a terminal code. Your terminal interprets the \\033[XXXm sequence to mean "apply color modification XXX" and \\033[0m to mean "end all color modifications".

The 36 in \\033[36m is the color cyan. This webpage has a handy table of available colors.

So now we understand how the first printf command in the awk program prints the top of our Makefile's beautiful help message:

help_message

Now let's look at the second pattern/action pair in the awk program:

/^[a-zA-Z_0-9-]+:.*?##/ {
    printf "  \033[36m%-15s\033[0m %s\n", $1, $2
}

The pattern, /^[a-zA-Z_0-9-]+:.*?##/ is a simple regular expression. It searches for lines that look like target_name: or target_name: ## description. When it finds a line that matches this pattern, it executes the printf command inside the curly brackets.

The $1 and $2 variables are set by awk itself, once it has split the line's contents with the field separator (FS) set at the beginning. The $1 is set to target_name and $2 is either set to description or set to the empty string if the FS pattern did not appear anywhere in the line.

The string given to printf has some escape codes that set text color just like in the earlier printf  command. It also does some aligning by using %-15s instead of a simple %s for the target name. The - left-aligns the target name and the 15 adds whitespace so that the string is 15 characters long. This displays the Makefile's targets in beautiful, perfectly aligned columns:

display_columns

Finally, let's look at the third and last pattern/action pair:

/^##@/ {
    printf "\n\033[1m%s\033[0m\n", substr($0, 5)
}

The pattern matches any line that starts with ##@, like ##@ Section name. The action uses printf again to print the section's name. The 1 in \\033[1m means "write the following text in bold".

The built-in substr(s, p) function returns the substring with s starting at position p. So substr($0, 5) returns the entire line without the first 4 characters. So this action prints section names like this:

section_name

The awk program processes the Makefile's lines one by one, in order. For each line, it applies each pattern/action pair. If the pattern matches the line, it runs the action.

All together, this produces a beautiful self-documenting help message for your Makefile.