Notes for new Make users

Alexander Gromnitsky

First published:
Latest commit:

John Constable would have said that ‘There is nothing ugly in Make; I never saw an ugly build system in my life: for let the number of targets be what it may,–dependencies, recipes, and parallel jobs will always make it manageable.’

It’s funny to see a sudden spike of interest in Make-like tools on HN & reddit, when many start joyfully sharing their favourite tricks. I predict some ppl will get overexcited & start rewriting their existing build infrastructure to be more “Make friendly” only to realize it’s not that straightforward as it sounds.

The discussion about Make ordinaly ends up w/ ideas how Make can be improved. Nevertheless, instead of improving Make, avid & enthusiastic chaps invariably decide to start from ground zero, producing something unequivocally awesome, but incompatible w/ everything that was written before.

If you say Make is suboptimal, no one sane will disagree w/ you. However, it’s a solid, well known tool that (if used correctly, w/o wonky religious zeal) works like the Beverly Clock.

If you really curious about (GNU) Make, read on. I’m not going to evangelize Make, I assume you’ve already decided to try it.

Don’t try to be clever

Regardless of how you like your makefiles now, you’ll cringe at them in 6 months & laugh at them in 1 year. Your style will evolve.

The official docs

When in doubt, don’t google until you read the official manual from cover to cover. It’s a document of an astonishing quality. Go print the pdf.

Adhere to the common terminology

target: prerequisite(s)
	recipe

so in

out/bundle.js: main.js foo.js bar.js
	mkdir $(dir $@)
	browserify main.js -o $@

out/bundle.js is a target that has 3 prerequisites (dependencies): main.js, foo.js, bar.js. The set of lines prefixed w/ the TAB char is a recipe.

Rules may have empty recipes:

deploy: rsync
rsync: compile
	rsync -a out/ user@host:/somewhere/
compile: out/bundle.js

The 1st rule that Make stumbles upon is called a default goal. Here, the default goal is deploy. You can override the default goal by passing the name of a desired target as an argument to the Make cmd:

$ make compile

Macros serve a 2fold purpose: as variables whose values you can override from the command line, & as a mechanism for writing custom functions. I won’t talk about the latter much, but here’s an example of the former, which you may find moderately amusing to play w/:

date = $(shell date -d $(today) +%Y-%m-%d)
# before 2023/03/26 the value of the macro was 'now'
# since then dilbert.com archive is dead
today = 2023-02-28

comic: $(date).gif
	xdg-open $<

%.html:
	wget -q https://web.archive.org/web/20230228210650/https://dilbert.com/strip/$* -O $@

%.gif: %.html
	nokogiri -e 'p $$_.css("img.img-comic").first["src"]' $< | xargs wget -q -O $@

If you save it in the file named Makefile, then type make in the same directory, Make downloads the current Dilbert strip page, parses the html, downloads the .gif image & displays it. If you run make again, it won’t re-download or re-parse anything, but will display the .gif image right away.

If you run

$ make today=2002-04-10
wget -q https://web.archive.org/web/20230228210650/https://dilbert.com/strip/2002-04-10 -O 2002-04-10.html
nokogiri -e 'p $_.css("img.img-comic").first["src"]' 2002-04-10.html | xargs wget -q -O 2002-04-10.gif
xdg-open 2002-04-10.gif

the today macro gets overriden from its default value to the supplied 2002-04-10 string. Notice how date macro recursively expands to get the properly formatted date string. It uses the internal shell() fn to get the output of the external date command.

Most important concept #1: DAG

‘The make utility shall update files that are derived from other files.’
— The Open Group Base Specifications Issue 7

The 1st thing you need to understand about Make is that all you do is contruct DAG. You either do it by hand using simple explicit rules (I’m skipping the recipes for brevity):

foo.js.min: foo.es5
foo.es5: foo.js

which means a straightforward foo.min.jsfoo.es5foo.js graph (where an arrow means depends on) or you use special pattern rules (smtms also called metarules) w/ which Make can generate DAG vertices (nodes) for you automatically:

%.js.min: %.es5
%.es5: %.js

In both cases, when you type make foo.min.js & you indeed have foo.js file–Make creates a proper dependency graph, checks that it doesn’t contain cycles, takes foo.min.js as it’s goal, sees that such a vertex has an out-degree number of 1 (i.e., it has exactly 1 prerequisitefoo.es5), recursively jumps from vertex to vertex until it hits a vertex w/ 0 prereqs (in compsci speak: w/ an out-degree number of 0). Then it creates the lonely leaf & unwinds itself until it reaches the orig goal. If anything goes wrong along the way, Make stops.

In the case w/ the pattern rules, Make cannot create the full DAG until it has all the vertices, hence it automatically searches for a so called implicit rule for file foo.js, finds a perfect match in %.es5: %.js, then looks for a rule for foo.es5 & so on.

This is all you need to know. When Make looks at a single graph endpoint, it runs a recipe for a target (remakes it) iff its prereqs are “newer”. This is how it avoids unnecessary work & as any young lad should know, the only way to improve performance is not to do something faster but not do it at all.

Most important concept #2: 2 phases

The 2nd thing you need to understand is how Make reads makefiles. It does it in 2 phases:

  1. Creates a DAG.
  2. Invokes the recipes during which it resolves the required macros.

E.g., after the phase I, a makefile

opts = -s inline
bundle = foo

$(bundle).es5: $(bundle).js
	babel $(opts) $< -o $@

to Make looks like

opts = ?
bundle = ?

foo.es5: foo.js
	?

Make was forced to expand bundle macro in the rule def line, but it did nothing for opts in the recipe.

If you forget about the 2 phases, it could lead to a misunderstanding when a rule creates some file that you expect Make to pick up later on.

The order of the garter

All macros & rules are hoisted so the order it which they appear in makefiles is irrelevant, but Make has also ordinal variables (called simply expanded variables, SEVs or plainly variables):

src := foo.js bar.js

(note := instead of =) that get expanded exactly 1 time during the phase I only. Hence this will work as expected:

bundle.deps = $(js) vendor/baz.js
js = foo.js bar.js

but this won’t:

bundle.deps := $(js) vendor/baz.js
js := foo.js bar.js

here, bundle.deps will contain only vendor/baz.js string. Remember that if you use SEVs, their order does matter as well as the order of rules/macros that employ variables.

To reiterate again: macros get expanded every time they are accessed. This could be during the phase I if a macro is referenced in a target/prereq portion of a rule, or it can be expanded in the time of the phase II, when the DAG is ready and Make can invoke the recipes.

Variables, on the other hand, get expanded immediately in the course of the phase I.

times_expanded :=
macro = $(shell date; sleep 2; $(eval times_expanded += 1))
var := $(macro)

q:
	@echo var = $(var)
	@echo var = $(var)
	@echo macro = $(macro)
	@echo macro = $(macro)
	@echo times_expanded = $(times_expanded)

The macro that I conveniently named macro should be expanded precisely 3 times:

$ make
var = Tue Mar 6 15:45:57 EET 2018
var = Tue Mar 6 15:45:57 EET 2018
macro = Tue Mar 6 15:45:59 EET 2018
macro = Tue Mar 6 15:46:01 EET 2018
times_expanded = 1 1 1

Notice how the output for the ‘macro =’ lines contains a different time, but for the ‘var’ lines it’s always the same.

$(eval ...) in this example does exactly what you think: it combines a parser and an evaluator of the Make language.

Automatic variables

If you write metarules you can’t do it without the automatic variables, for in a rule

out/.cache/%.js: %.mjs
	babel $< -o $@

it’s impossible to know beforehand the actual names of the files. The popular clamour is that the autovars are too short & confusing. Unless you like to write magic makefiles (e.g., w/ .SECONDEXPANSION nifty tricks), you’ll need to remember only 3 autovar types:

Are they really so hard to grasp?

HN user dahart came up with the following visual mnemonics:

$@ looks like a target, a ring with a bullseye. $< is pointing left, so it’s the first prereq. $^ is like a horizontal bracket that groups all prereqs.”

Functions

Make comes w/ a set of internal functions for text processing. Most of them are pure, idempotent & don’t do any IO. E.g., dir fn

$(dir lib/foo.js vendor/bar.js)

treats its argument as a string, returning lib/ vendor/ regardless of whether lib/foo.js or vendor/bar.js exist.

Some functions (patsubst(), filter(), filter-out()) support a tiny DSL: strings that use % char as a wildcard. If you don’t fully get the purpose of the %, the results are often confusing.

The 2 most important fn that do IO are:

Because of this historical Unix vs. Windows diff, the pattern lang in windcard() & the command syntax in shell() are both inherently non-portable, unfortunately.

Why doesn’t Make support file names w/ spaces?

Initially it was a cunning plan to remove the necessity of having a support for a special list data type. Perhaps Stuart Feldman could have used , instead of a space for a delimiter, but that ship has long sailed. Also recall that many Unix utils that deal w/ file names return the list of file names join()’ed by a space or a newline. Thus the choice of a space char was very natural.

As Make doesn’t have any concept of lists, rules get their prereqs as a string: after expanding all macros, a rule chops the string into pieces that all together look like an array of targets, where each target may or may not be a file name.

In JS it would have looked like:

> src = 'foo.js bar.js'
'foo.js bar.js'
> ` ${src} baz.css`.trim().split(/\s+/)
[ 'foo.js', 'bar.js', 'baz.css' ]

The same goes for function arguments.

Compile everything to 1 directory

You may say it’s a matter of style, but I don’t like seeing the results of compilation scattered along the src files. I consider it a common antipattern. It’s popular, for makefiles that produce such output are the easiest to write, especially for novices. Partially this is also Make’s fault, for its collection of built-in metarules (observable via make -p) has schooled ppl to write similar rules for their tools.

To remove the compilation results, ppl write clean targets, which are usually updated w/ the same consistency as comments in the code.

Don’t write clean or nuke targets. If your output goes under 1 umbrella dir, then all you need to do for starting from the clean slate is to remove 1 directory.

If you’re writing an SPA, copy your static assets & the relevant files from node_modules to the output dir too. A good makefile leaves a ready to deploy directory that doesn’t depend on files left in the src dir.

E.g., say the output directory is named _out. Our src tree:

.
├── src
│   ├── a.js
│   ├── b.js
│   ├── index.html
│   ├── style.css
│   └── main.js
└── Makefile

We need to transpile multiple src/*.js files before combining them to a bundle.

$ cat Makefile
out := _out
cache := $(out)/.cache
build := $(out)/development
mkdir = @mkdir -p $(dir $@)

all: $(build)/main.js

js.src := $(wildcard src/*.js)
js.dest := $(addprefix $(cache)/, $(js.src))

$(build)/main.js: $(js.dest)
	$(mkdir)
	browserify $(cache)/src/main.js -o $@

$(cache)/%.js: %.js
	$(mkdir)
	babel $< -o $@

Run Make, & it creates the umbrella dir & populates it w/ the compilation results:

$ make
babel src/a.js -o _out/.cache/src/a.js
babel src/b.js -o _out/.cache/src/b.js
babel src/main.js -o _out/.cache/src/main.js
browserify _out/.cache/src/main.js -o _out/development/main.js

$ tree --noreport -a _out
_out/
├── .cache
│   └── src
│       ├── a.js
│       ├── b.js
│       └── main.js
└── development
    └── main.js

Of course to be able to test _out/development in the browser we need to copy the static assets to the umbrella dir too. Adding this to the makefile

$(build)/%: src/%
	$(mkdir)
	cp $< $@

static.src := $(wildcard src/*.css src/*.html)
static.dest := $(patsubst src/%, $(build)/%, $(static.src))

all: $(static.dest)

… accomplishes our goal:

$ make
cp src/style.css _out/development/style.css
cp src/index.html _out/development/index.html

Multiple builds

It’s easy to add support for several builds from a single src directory. Make supports conditional directives, w/ which you may alter parameters to transpilers, change the output dir, etc. E.g., the enhanced version of the makefile from the prev section:

NODE_ENV ?= development
out := _out
cache := $(out)/.cache.$(NODE_ENV)
build := $(out)/$(NODE_ENV)
babel.opts := -s inline
browserify.opts := -d

ifeq ($(NODE_ENV), production)
babel.opts := --minified
browserify.opts :=
endif

mkdir = @mkdir -p $(dir $@)

all: $(build)/main.js

js.src := $(wildcard src/*.js)
js.dest := $(addprefix $(cache)/, $(js.src))

$(build)/main.js: $(js.dest)
	$(mkdir)
	browserify $(browserify.opts) $(cache)/src/main.js -o $@

$(cache)/%.js: %.js
	$(mkdir)
	babel $(babel.opts) $< -o $@

Conditional directives are evaluated during the phase I. There’s also if() fn that can be used in macros, but there’s no eq() fn (it exists in the Make’s src code, but under ‘experimental’ flag), that limits the applicability of if(). You can play w/ filter() inside if() (for if() treats a 0-length string or anything that expands to such a string as false) but it quickly gets unreadable.

Chain of rules

There are 2 categories of ppl:

  1. those who write multiple rules, that work in a chain: e.g., first we transpile js, second we minify the transpiled output;

    %.js.min: %.es5
    %.es5: %.js
  2. those who write a single rule .min.js → js, doing all steps in 1 recipe; there are 2 subcategories of such ppl as well:

    1. those who create tmp files in the recipe;

    2. those who use pipes, avoiding tmp files whatsoever; unfortunately I cannot recoment this method, for /bin/sh doesn’t signal an error if any of the cmds in a pipeline fail, except for the last one; with this approach you may (& will) easily end up w/ garbage in output or 0-length files;

      if you are perfectly sure that a system your makefile is going to run on has bash, you can add

      SHELL := bash -o pipefail
      .DELETE_ON_ERROR:

      at the beginning of the makefile. .DELETE_ON_ERROR has nothing to do with bash but with it Make automatically deletes the target when the recipe line fails.

When deciding what target to build next, Make is capable of recognising which targets are temporary.

foo.js.min: foo.es5
foo.es5: foo.js

Here, foo.es5 is an intermediary, but as it’s explicitly mentioned in makefile, it won’t be recognized as such.

The makefile below (taken from a simple shopping-hours program) uses the sequence of implicit metarules to create 2 UMD bundles: a minimized es5 & a usual js parcel:

out := dist

mkdir = @mkdir -p $(dir $@)
bundle.name := $(out)/shopping_hours

compile: $(bundle.name).min.js

$(out)/%.min.js: $(out)/%.es5.js
	uglifyjs $< -o $@ -mc

$(out)/%.es5.js: $(out)/%.js
	babel --presets `npm -g root`/babel-preset-es2015 $< -o $@

$(bundle.name).js: index.js
	$(mkdir)
	browserify -s $(basename $(notdir $@)) $< -o $@
$ make
browserify -s shopping_hours index.js -o dist/shopping_hours.js
babel --presets `npm -g root`/babel-preset-es2015 dist/shopping_hours.js -o dist/shopping_hours.es5.js
uglifyjs dist/shopping_hours.es5.js -o dist/shopping_hours.min.js -mc
rm dist/shopping_hours.es5.js

Notice the last line (the rm command). There’s nowhere such a line could be found in the makefile! Make has automatically deduced that dist/shopping_hours.es5.js vertex is temporal & auto removed it before exiting.

Write makefiles in shell scripts stead

If you already have a bunch of small .sh files–move them to 1 makefile in the form of 1 .sh file == 1 target + recipe. By doint this you’ll get for free:

  1. the dependency management (what target to run first);
  2. command line args processing (foo=bar args).

Make can run several recipes at once. Recall the Dilbert makefile. To download all the comics (starting from April 16, 1989) just generate the corresponding dates & pass them as targets. There is no need to modify the makefile itself.

Generate the target names:

$ seq `date -d 1989-04-16 +%s` $((60*60*24)) `date +%s` | xargs -Isec date -d @sec +%Y-%m-%d.gif > targets.txt
$ head -3 !$
1989-04-16.gif
1989-04-17.gif
1989-04-18.gif

Run Make w/ 50 parallel jobs (on a 2nd thought, don’t type that):

$ make -j50 `cat targets.txt`

(I’ve got banned pretty quickly.)

The beauty of the approach is that you may press Ctrl-C any time & when you run Make again it won’t re-download already processed pages.

You can even mask the makefile for a standalone script: add a proper shebang line to the aforementioned Dilbert makefile:

$ printf '%s\n\n' '#!/usr/bin/make -f' | cat - Makefile > dilbert
$ chmod +x !$

Now users can run dilbert today=1999-02-21 w/o ever suspecting they are using Make.

Tabs & shell

‘Why the tab in column 1? Yacc was new, Lex was brand new. I hadn’t tried either, so I figured this would be a good excuse to learn. After getting myself snarled up with my first stab at Lex, I just did something simple with the pattern newline-tab. It worked, it stayed. And then a few weeks later I had a user population of about a dozen, most of them friends, and I didn’t want to screw up my embedded base. The rest, sadly, is history.’
— Stuart Feldman

First, fix your editor, any decent one can highlight tabs. Second, if you’re still against tabs, redefine .RECIPEPREFIX variable:

blank :=
space := $(blank) $(blank)
.RECIPEPREFIX := $(space)

w/ which you can use spaces before any recipe line.

If you’re feeling mischievous, Make has divers ‘secret’ vars & targets for you. For instance, you don’t have to ‘struggle’ w/ the ancient bash, for it’s possible to program recipes in any language that supports evaluations straight from the command line. This’ll help your prank get started:

$ cat Makefile
SHELL := node
.SHELLFLAGS := -e

date != console.log(new Date())
q:
	@console.log('today is $(date)')
$ make
today is 2018-03-03T10:02:05.789Z

!= is a syntactic sugar for the shell() fn.

Think that running every recipe line in a sub-shell is ‘expensive’? Make’s got you covered! Mentioning .ONESHELL target in a makefile sends all lines in a recipe to the sub-shell in bulk. If you resolve to profit by this circumstance, don’t complain about the consequences of inability to auto-detect errors in the middle of recipes.

Canned recipes, custom functions

You can exploit macros as user-defined functions. Because a macro gets expanded every time it’s being accesed, it’s save to include autovars in it or refs to other macros. Perhaps, the 2 most common examples are:

copy = cp $< $@
mkdir = @mkdir -p $(dir $@)

as in

_build/%.html: src/%.html
	$(mkdir)
	$(copy)

Such macros are called canned recipes.

If you need to pass a parameter to a macro, use a special call() fn to invoke it:

find = $(shell find . -name \*.$1 -type f)
src := $(call find,js)

Because find contains refs to params ($1), it’s called a parametrised function.

Deps

Writing makefiles for small programs often means a manual dependency management. If some out/bundle.js depends on several .js modules, it’s not hard to specify its prereqs manualy, but for large programs it becomes unmanageable.

While there are several ways to tackle the dependency problem, the most popular one is colloquially called Tromey’s Way.

I’ve noticed that various (programming) books authors love to give examples that have little or no bearing w/ the subject, via explaining how they’ve solved some minuscule problem in the course of preparing their manuscript. This is just a ~30KB markdown file, but I feel that such a great tradition of ‘here’s an example from my book toolchain!’ should be continued.

Before handing down the .md file to pandoc, I preprocess it w/ erb. This allows me to write

<%= File.read 'dilbert/Makefile' %>

instead of a copy-pasting. But when I edit such a referenced makefile I want the .md file to be auto-recompiled. Manually specifying all included files as prereqs is lame, so I have a lilliputian script that reads the .md file & prints:

_out/web/index.html: dilbert/Makefile
_out/web/index.html: umbrella/ver1.mk
...

I inject those lines into a makefile (via Make’s include directive). A simplified version of the whole scheme looks like:

define make-depend
@mkdir -p $(cache)
./deps $@ < $< > $(cache)/$*.d
endef

$(out)/%.html: %.md
	$(mkdir)
	erb $< | pandoc -o $@
	$(make-depend)

-include $(cache)/index.d

defineendef is a multiline macro. ‘deps’ is the script in question. The main trick here is to invoke the macro after the successful .md to .html transformation. If I edit the .md–it doesn’t matter if there are new File.read commands in it, for Make rebuilds the .md file regardless, but if I change any of File.read’ed files & then run Make, it picks up the proper prereq list & sees that it ought to recompile the .md.

Watchers

To auto-recompile on changes w/o manualy invoking Make means using an external file “watcher”. I have my own little watcher that plays different sounds depending on the return value of Make.

# npm i -g watchthis

Then in the project dir:

$ watchthis -e _out make test

See, how easy the life is, when you compile everything to 1 directory (_out, in this example).

If you feel it’s time to be frugal with inotify watchers, try

$ while true; do make -q || make; sleep 2; done

The reason for make -q || make, instead of a simple make, is to prevent a terminal from filling up with annoying ‘make: Nothing to be done for target’ messages. The downside is that you compute the DAG twice for each ‘succesful’ run, not to mention a cpu waste from constant polling.

Auto-restarting

Make can auto-restart itself during a DAG construction if

  1. it reads another makefile (via include directive), &
  2. it has found a rule where the target == included makefile name.
include _out/.npm
_out/.npm: package.json
	npm i
	touch $@

How is this useful? If you have targets w/ prereqs from node_modules dir (e.g., a css library or already minified bundles) those files must exist before Make runs, otherwise Make throws an error. The auto-restarting facility allows Make to set forth with a clean slate and read the makefile anew, this time having all vertices in the DAG you’ve specified.

Files similar to _out/.npm are called empty targets.

Microscope

By default, all rules, macros & plain variables are placed in the global scope. Autovars are the obvious exception, for they are local to a particular recipe.

You can provide target-specific macros/vars for a rule. Say we want to compile .js files with different options and to a different directory, depending on the value of NODE_ENV environment variable:

NODE_ENV ?= development
cache := _out/.cache/$(NODE_ENV)

$(cache)/%.js: %.js
	@mkdir -p $(dir $@)
	babel $(babel.opts) $< -o $@

$(NODE_ENV): $(addprefix $(cache)/, $(wildcard *.js))

babel.opts := -s inline
production: babel.opts := --minified

The name of the default rule in this example is equal to the value of the NODE_ENV var. If there’s no NODE_ENV in the environment, we presume it’s equal to ‘development’:

$ make
babel -s inline a.js -o _out/.cache/development/a.js
babel -s inline b.js -o _out/.cache/development/b.js

Nothing interesting is going on, until we override the said NODE_ENV var:

$ make NODE_ENV=production
babel --minified a.js -o _out/.cache/production/a.js
babel --minified b.js -o _out/.cache/production/b.js

We have 0 conditional directives in the makefile, but the value of babel.opts has somehow changed nevertheless. The following 2 lines:

$(NODE_ENV): $(addprefix $(cache)/, $(wildcard *.js))
production: babel.opts := --minified

after the phase I become:

production: _out/.cache/production/a.js _out/.cache/production/b.js
production: babel.opts := --minified

The last line overrides the value of the global babel.opts variable, but only for the target named production. That target, in its turn, has 2 prereqs, each of which inherits the value of babel.opts from their incoming vertex (production). I.e., Make internally generates a rule for _out/.cache/production/a.js that looks quite similar to:

_out/.cache/production/a.js: a.js  babel.opts := --minified
	@mkdir -p $(dir $@)
	babel $(babel.opts) $< -o $@

Debugging

Many saints have suffered martyrdom. Many users of Make have learned to tolerate the absence of debugging facilities. There’s --trace CLO but reading its output is tedious.

You’d expect Make to have an option ‘print how you see this makefile after the phase I’ (or in another words, ‘show me how you have constructed the DAG’), but no such feature exists in a polished state. You resort to -pk & grep:

$ make -rR -pk -q | grep -v ^#

(-rR disables a load of Make’s built-in implicit metarules/vars for C/C++). This also prints every environment variable (for Make converts every env var into a macro (yes!), which is an interesting choice security-wise). With a little sh wrapper this could be slightly less irritating:

$ cat make-phase1
#!/bin/sh

# unset `BASH_FUNC_which%%` and friends
for fn in `env | awk -F= '/^BASH_FUNC_/ {print $1}' | tr -d 'A-Z%_'`; do
    unset "$fn"
done

annoying='TERMCAP|LS_COLORS'
make -rR -pk -q "$@" | grep -Ev "^(#|$annoying)" | cat -s | less

Then type ‘make-phase1’ & pass any params to it as you would’ve done w/ ‘make’.

Even if you’re not a puts debugger, Make forces you to become one.

Everybody knows the trick how to print the value of the macro/variable, right? Create debug.mk file somewhere not far away, e.g., in ~/lib/:

p-%:
	@echo "$(strip $($*))" | tr ' ' \\n

Then type:

$ make -f ~/lib/debug.mk p-SHELL
/bin/sh

Adding -f makefile however many times you want & passing p-macroName, as a target, prints the expanded value of the macro.

There’s also a build-in warning() fn that expands its param & prints it to the stderr. It could be inserted anywhere, for its actual expanded value is an empty string.

Metarules could be frustrating if you’re unsure how the matching is done against provided targets. In the absence of a REPL of any kind, fall back to a helper .mk again:

$ cat ~/lib/metarule.mk
match: $(T)

$(P):
	@echo '$$@ == $@'
	@echo '$$* == $*'

To work properly it shoult be run in some tmp dir:

$ (cd /tmp; make -f ~/lib/metarule.mk P=out/%.js T=out/foo/bar.js)
$@ == out/foo/bar.js
$* == foo/bar

$* is a stem autovar that matches the % portion of the metarule. P is for ‘pattern’, T is for ‘target’. You know that the target doesn’t match when Make fails:

$ (cd /tmp; make -f ~/lib/metarule.mk P=out/%.js T=bar.js)
make: *** No rule to make target 'bar.js', needed by 'match'.  Stop.

Make has a ‘dry run’ CLO (-n) that just prints recipes instead of executing them. Combined w/ -t option, you get a terse view of what targets Make is going to rebuild. E.g., dry run the final makefile from Compile everything to 1 directory section:

$ make -tn -f ver2.mk
touch _out/.cache/src/a.js
touch _out/.cache/src/b.js
touch _out/.cache/src/main.js
touch _out/development/main.js
touch _out/development/style.css
touch _out/development/index.html

Beware that even w/ -n, Make expands all vars & required macros, so if any of them contain the shell() fn, Make executes it irrespective of the -n presence.

Additional reading

Apart from the official manual, a series of essays from the current GNU Make maintainer (Paul Smith) are super informative. His profile at stackoverflow, strangely enough, is mostly about Make.

Eric Melski’s blog posts:

John Graham-Cumming’s The GNU Make Book.

The original Stuart Feldman’s paper (pdf, Bell Labs, 1978): Make–A Program for Maintaining Computer Programs

The AWK programming language book has a Make-like program that fits on one page! (Ch.7, p.178.) Whilst it supports only static rules (no metarules, macros or functions), it’s easy to imagine how one could combine it w/ a decent preprocessor.

What’s Wrong With GNU make? “paper” lists divers valid points, although in a somewhat disgruntled style.

Make It Simple – An Empirical Analysis of GNU Make Feature Use in Open Source Projects (2015) – a study that looks at the diff between handwritten vs. generated makefiles.

If you have a compsci degree, you are accustomed to graphs, their terminology et al., thus when writing makefiles you should feel at home. My degree was in materials science/nanomaterials, therefore I’ve found the Ch 14 Graph Algorithms of Data Structures and Algorithms in Java very helpful.