Basti’s Buggy Blog

Poking at software.

How To Write Unmaintainable Makefiles - Part 1

Makefiles can be really useful outside the context of building plain old C/C++ applications. The built-in dependency management makes it possible to avoid a lot of boilerplate bash code when writing some build scripts. They also provide quite sophisticated ways to implement abstractions and logic, which often makes the files hard to read.

Table of Contents

The Task

I had a large amount of screenshot needing to be cropped in the same way, depending on what kind of screenshot it was. Screenshot of type A needs to cropped in a different way than screenshot of type B. To pixel-perfectly crop the images I used imagemagick.

  • Each image file ends in --<type>.png
  • Each type needs to be cropped differently
  • The output files should not have the --<type> suffix

Examples

  • cat--top.pngcut topcat.png
  • dog--bottom.pngcut bottomdog.png

The Boring Solution

A simple task for a plain Makefile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.PHONY: all
all: img-out/calc.jpg img-out/pen.jpg img-out/car.jpg img-out/tea.jpg

img-out/%.jpg: img-in/%--ne.jpg
	convert $< -gravity NorthEast -crop +320+320 +repage $@

img-out/%.jpg: img-in/%--nw.jpg
	convert $< -gravity NorthWest -crop +320+320 +repage $@

img-out/%.jpg: img-in/%--se.jpg
	convert $< -gravity SouthEast -crop +320+320 +repage $@

img-out/%.jpg: img-in/%--sw.jpg
	convert $< -gravity SouthWest -crop +320+320 +repage $@

.PHONY: clean
clean:
	rm -f img-out/*

The .PHONY statement makes sure that make runs the target even if there is a file with the target’s name. This is recommended as good practice and can increase performance (Make Manual).

The pattern target img-out/%.jpg: img-in/%--ne.jpg does multiple things:

  • It defines where to find the source file for the given cropped file
  • It writes the cropped images to the output directory
  • It removes the suffix by using pattern matching

A simple straight forward Makefile, isn’t it? Who would want that? It’s too maintainable!

A Bit of Abstraction Couldn’t Hurt?

What if we want to change the input and output directory down the road? A simple search and replace surely would be too complicated! Let’s define the variables in-dir and out-dir to solve that blocker-issue.

Also we don’t want to modify our Makefile every time we add a new image, so let’s sprinkle a wildcard command in the mix.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
in-dir := img-in
out-dir := img-out

# glob all input images
in-files := $(wildcard $(in-dir)/*)
# img-in/calc--nw.jpg img-in/pen--ne.jpg img-in/car--sw.jpg img-in/tea--se.jpg

# replace input directory with output directory
out-files-suffix := $(patsubst $(in-dir)%,$(out-dir)%,$(in-files))
# img-out/calc--nw.jpg img-out/pen--ne.jpg img-out/car--sw.jpg img-out/tea--se.jpg

# remove the '--*' (urgh!)
# ??? not that easy
# img-out/calc.jpg img-out/pen.jpg img-out/car.jpg img-out/tea.jpg

.PHONY: all
all: $(patsubst $(in-dir)%,$(out-dir)%,)

$(out-dir)/%.jpg: $(in-dir)/%--ne.jpg
	convert $< -gravity NorthEast -crop +320+320 +repage $@

$(out-dir)/%.jpg: $(in-dir)/%--nw.jpg
	convert $< -gravity NorthWest -crop +320+320 +repage $@

$(out-dir)/%.jpg: $(in-dir)/%--se.jpg
	convert $< -gravity SouthEast -crop +320+320 +repage $@

$(out-dir)/%.jpg: $(in-dir)/%--sw.jpg
	convert $< -gravity SouthWest -crop +320+320 +repage $@

.PHONY: clean
clean:
	rm -f $(out-dir)/*

As it turns out, the string transformation from <img>--<type>.png<img>.png is a non-trivial matter in a Makefile. Surely a problem that abstraction can solve 🙃.

Abstraction == Skill

As we want to show off our Makefile knowledge, abstraction is vital. Thankfully the make syntax allows for eval-ing variable contents. Perfect!

  1. Define a “function” that defines a target for each type: <img>--<type>.png<img>.png
  2. Call the “function” to define a type and the instructions to execute

Makefile Functions (Eval + Call)

Make does not provide function definitions, but we can create our own makeshift functions by using eval and call.

Step 1: Define a variable that can be used as the function’s body.
define can be used to define multi-line variables. The thing to keep in mind is that the function’s arguments will be immediately replaced by the provided values.

define myfun
# $(1), $(2), ... are function arguments

# make variables unique by using arguments in their name
# that prevents the variables from being overridden by another function
$(1)_foo := $(2)

# to access a variable's value the unique name has to be used and
# the '$' has to be escaped with another '$'
$(1)_bar := $$($(1)_foo)_suffix

# target definitions are possible too (use unique name)
build-$(1): $(1)-deps
    echo baz
endef

Step 2: Call the define.
Using make’s call function will practically replace all arguments ($(1), $(2), …) with the provided strings ($(call myfun,arg1,arg2)).

Step 3: evil eval.
Using the output of call, which should return a valid Makefile target, we can eval it. Step 2 and 3 combined look like this:

$(eval $(call myfun,arg1,arg2))

Step 4: Debug. Obviously this step is not necessary, as we are all experts. For the rare occasion that someone encounters a problem, simply replace the eval with info. This will print the text to stdout instead of eval-ing it. Our myfun example yields:

arg1_foo := arg2
arg1_bar := $(arg1_foo)_suffix

build-arg1: arg1-deps
    echo baz

Original Problem

Applying our newfound knowledge to the initial problem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
in-dir := img-in
out-dir := img-out

# define variables to set the behavior of each type
# NOTE: the '=' assignment was used in order to prevent
# expansion of the '$<' and '$@' variables
ne = convert $< -gravity NorthEast -crop +320+320 +repage $@
nw = convert $< -gravity NorthWest -crop +320+320 +repage $@
se = convert $< -gravity SouthEast -crop +320+320 +repage $@
sw = convert $< -gravity SouthWest -crop +320+320 +repage $@

transformations := ne nw se sw

# creates a target applying to all files ending in `--$(1).jpg`
# the target will execute the define named `$(1)`
define transformation
# glob all input files
$(1)_in := $$(wildcard $(in-dir)/*--$(1).jpg)
# replace the output directory
$(1)_out := $$(subst $(in-dir)/,$(out-dir)/,$$($(1)_in))
# remove the '--<type>' suffix
$(1)_out := $$(subst --$(1),,$$($(1)_out))
.PHONY: $(1)
$(1): $$($(1)_out)

$(out-dir)/%.jpg: $(in-dir)/%--$(1).jpg
	$$($(1))
endef

# eval all transformations
$(foreach t,$(transformations),$(eval $(call transformation,$(t))))
# $(eval $(call transformation,ne)), $(eval $(call transformation,nw)), ...


.PHONY: all
all: $(transformations)

.PHONY: clean
clean:
	rm -f img-out/*

Results:

  • New image files can be added on the fly, the Makefile does not need to be changed ✔
  • The Makefile now is unmaintainable ✔
  • A new type can be added by defining a new variable with build instructions ✔

TL;DR

Carefully evaluate the benefits you get out of abstractions.