maprys.net
adventures in make
(last modified 15 Nov 2024)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
Make’s automatic variables are only available within a recipe by default. 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:
.SECONDEXPANSION:
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.
$(PARENT_DIRS):
mkdir -p '$@'
.SECONDEXPANSION:
$(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 wild cards ($(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
clean-gemsets:
rm -rf $(gemsets)
The change from shelling out to find
to using Make’s internal functions improved the run time of this rule by multiple orders of magnitude (minutes to milliseconds). The moral of the story is to learn what functions are available to you from the tools in use.
Conclusion
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.