adventures in make |

adventures in make

(last modified 27 Jun 2023)

In the last couple of years I’ve taken a slowly increasing interest in lower level programming and compiled software tools. When one thinks of these arenas, C, Rust, Golang, and Zig usually come to mind – among others. I’ve had a genuinely great time diving into this different realm compared to my comfy world of Ruby and Python. I’m not going to talk about those programming languages today, but rather the quintessential build tool that has stood the test of time: Make. I want to share some of the cool things I’ve learned in my journey with Make.

I’m not going to bore you with the basics of GNU Make. If you’re unfamiliar, the docs are a great place to start.

Secondary Expansion

By default, Make’s automatic variables are only available within a recipe. Most of the time that’s perfectly fine because a target and its prerequisites are usually static. For my specific case, I needed to define a prerequisite that was dynamic based on the target string. $@ normally evaluates to an empty string in the prerequisites list because it has no value yet.

This is where secondary expansion comes in. When an optional directive is placed above a rule, Make is instructed to carry out a second expansion phase for the prerequisites list after its usual expansion phase.

Consider this makefile:

ONEVAR := onefile
TWOVAR := twofile
myfile: $(ONEVAR) $$(TWOVAR)

After the first expansion phase, the prerequisites list becomes onefile and $(TWOVAR). The secondary phase expands the second prerequisite to its final value of twofile.

This made it considerably easier for me to set up installing dotfiles while ensuring parent directories exist.

    mkdir -p '$@'

$(FILES_WITH_PARENT_DIRS): $$(dir $$@)
    ln -s $(GIT_ROOT)/$@ '$@'

There is some other weirdness that can go on with secondary expansion, but the documentation calls out those situations so I will direct you there if you have questions.

Help Output

Unfortunately, Make is lacking in its “help the user” department. The most help output a user will usually get is by typing make into their terminal, then pressing tab and seeing if any tab-completion pops up. Not the greatest user experience when your only real option is to decipher the source.

A common pattern I’ve started coming across is a distinct rule that spits out nicely formatted information describing other rules in the Make environment.

.PHONY: help
help: ### this help output
    @printf "available targets:\n   ---\n"
    @egrep -h "\s###\s" $(MAKEFILE_LIST) \
        | sort \
        | awk 'BEGIN {FS = ":.*?### "} {printf "%-20s %s\n", $$1, $$2}'

I started with fancier rules from other projects, but what you see above is the rule I’ve settled on after refactoring. Now, any target line that’s followed by a triple pound (###) comment, will have that comment displayed as help output for the rule. At the end you get some nice output like what I’ve shown below.

$ make help
available targets:
goenv/install        install goenv
goenv/rm             uninstall goenv
goenv/update         update goenv
help                 this help output
install              install dotfiles
nvm/rm               uninstall nvm
nvm/update           update (or install) nvm
pyenv/install        install pyenv and plugins
pyenv/rm             uninstall pyenv and plugins
pyenv/update         update pyenv and plugins
rbenv/install        install rbenv and plugins
rbenv/rm             uninstall rbenv and plugins
rbenv/update         update rbenv and plugins
rustup/install       install rust tools
rustup/rm            uninstall rust tools
vimplugins/install   install vim plugins
vimplugins/rm        remove all vim plugins
vimplugins/update    update vim plugins
xmodmap              install ~/.Xmodmap
xprofile             install ~/.xprofile

Built-in Functions

Use Make’s built-in functions and capabilities as much as possible. If you have a hunch, “I feel like Make should really be able to do this,” then it probably can!

I had the same thought – but ignored it – once when I needed to find a bunch of files across a number of subdirectories. Other developers on the team had simply shelled out to find in the past, so I assumed that was the way to do it. In a later project I did the same, but it worked against enough subdirectories that it was considerably slow. I revisited my, “Make should be able to do this,” thought and started reading the docs.

Make indeed has functions for iterating over lists ($(foreach)), and generating file lists based on wildcards ($(wildcard)). With a little bit of trial and error along with the help of official documentation, I was able to come up with the following bits in my makefile.

gemset_paths := $(wildcard tools/rbenv/versions/*/gemsets)
gemsets := $(foreach path,$(gemset_paths),$(wildcard $(path)/*))

.PHONY: clean-gemsets
    rm -rf $(gemsets)

The change from shelling out find to using Make’s internal functions improved the runtime of this rule from literal minutes to milliseconds. Moral of the story is to learn what functions are available to from the tools you are using.


These are the neat little things I’ve learned about Make recently. A tool that was once basically magic with far too many symbols has now become a tool I reach for often when I need some quick, repeatable tasks or cleanup work that needs doing. I made copious use of Make for my dotfiles installer which was previously written in Ruby. It is now faster, less error prone, and broader in scope.

It may be old, but that in no way precludes it from being a tool worth learning. Make is worth your time and effort.