Don't 'Make' me repeat myself

Posted on January 13, 2020 by Niels

tldr: click here for the Rust Makefile

When working in a terminal youā€™re often typing the same commands, over and over. This is fine if theyā€™re short, but when passing arguments things can get clunky fast. At best you hit Ctrl-r (reverse-i-search), attempting to remember part of the command and crossing your fingers itā€™s still in your history. At worst you have to search the internet each time your shellā€™s history expired.

To combat this I used to write separate shell scripts for each command I didnā€™t want to lose. Then a colleague introduced me to (GNU) Make and now I often think: ā€œHow did I get anything done without a Makefile?!ā€ Mind you I was very late to this party because according to Wikipedia, Make first appeared inā€¦ April 1976!

What is Make?

In short: Make is used to run shell commands which you define by name, in a Makefile.

What does a Makefile look like?

Below is a Makefile. Thereā€™s a little syntax going on but Iā€™ll explain that shortly. :

greet:
	@echo My name is $(shell id -un) and it is currently $(shell date)

Letā€™s run it:

$ make greet
My name is Spartacus and it is currently Mon Jan 13 02:29:38 JST 2020

Now let me explain the Make specific syntax thatā€™s present.

  1. The @ suppresses the command itself in the output. If we left it out, the output would be:

    $ make greet
    echo My name is Spartacus and it is currently Mon Jan 13 02:29:38 JST 2020
    My name is Spartacus and it is currently Mon Jan 13 02:29:38 JST 2020
  2. When you want to use the $(...) form for command substitution, you instead write $(shell ...) (for more info: the shell function)

Wrapping up

Abstracting away commands is handy, especially if you switch between programming languages or build-tools a lot. When these repetitive tasks arenā€™t broken, you usually donā€™t care which tools are being used in the background. So, to make my life easier I use the same names for my Make targets regardless of which programming language the project is written in. This way I can just run make build in any language, because I set it up once.

Thereā€™s a lot of nice features in Make I havenā€™t discussed because I rarely use them. The one I do want to mention now is ā€œchaining targetsā€. With very little syntax you can tell Make that your target depends on another targetā€™s success. Imagine something like: first build, then if that succeeds test, then if that succeeds make a release etc. Hereā€™s an arbitrary example:

build:
	@cargo build

log-build: build
	@echo Build success with commit hash \
	$(shell git log -1 --format="%H") \
	on $(shell date). >> ./build.log

The only line of interest here is log-build: build. The syntax is short and clear. We define that when we run make log-build we first run the ā€˜buildā€™ target, and only if that succeeds do we run ā€˜log-buildā€™.

Rust Makefile

Without further ado hereā€™s the Makefile I start with when creating a new Rust project. Itā€™s little more than a thin wrapper around cargo, but the ā€˜formatā€™ and ā€˜docsā€™ commands contain just enough syntax to make me forget or mistype them.

ROOT = $(dir $(realpath $(firstword $(MAKEFILE_LIST))))

run:
	@cargo run

build:
	@cargo build

test:
	@cargo test

format:
	@cd $(ROOT) && \
	find src -name '*.rs' -exec rustfmt {} \;

lint:
	@cd $(ROOT) && \
	cargo clippy

docs:
	@cargo doc --open