Skip to content

Instantly share code, notes, and snippets.

@klmr
Last active July 30, 2025 14:29
Show Gist options
  • Save klmr/575726c7e05d8780505a to your computer and use it in GitHub Desktop.
Save klmr/575726c7e05d8780505a to your computer and use it in GitHub Desktop.
Self-documenting makefiles

What is it?

A “simple” make rule that allows pretty-printing short documentation for the rules inside a Makefile:

screenshot

How does it work?

Easy: simply copy everything starting at .DEFAULT_GOAL := show-help to the end of your own Makefile (or include show-help-minified.make, and copy that file into your project). Then document any rules by adding a single line starting with ## immediately before the rule. E.g.:

## Run unit tests
test:
    ./run-tests

Displaying the documentation is done by simply executing make. This overrides any previously set default command — you may not wish to do so; in that case, simply remove the line that sets the .DEFAULT_GOAL. You can then display the help via make show-help. This makes it less discoverable, of course.

Thanks

Based on an idea by @marmelab.

# Example makefile with some dummy rules
.PHONY: all
## Make ALL the things; this includes: building the target, testing it, and
## deploying to server.
all: test deploy
.PHONY: build
# No documentation; target will be omitted from help display
build:
${MAKE} -C build all
.PHONY: test
## Run unit tests
test: build
./run-tests .
.PHONY: deply
## Deploy to production server
deploy: build
./upload-to-server . $$SERVER_NAME
.PHONY: clean
## Remove temporary build files
clean:
${MAKE} -C build clean
# Plonk the following at the end of your Makefile
.DEFAULT_GOAL := show-help
# Inspired by <http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html>
# sed script explained:
# /^##/:
# * save line in hold space
# * purge line
# * Loop:
# * append newline + line to hold space
# * go to next line
# * if line starts with doc comment, strip comment character off and loop
# * remove target prerequisites
# * append hold space (+ newline) to line
# * replace newline plus comments by `---`
# * print line
# Separate expressions are necessary because labels cannot be delimited by
# semicolon; see <http://stackoverflow.com/a/11799865/1968>
.PHONY: show-help
show-help:
@echo "$$(tput bold)Available rules:$$(tput sgr0)"
@echo
@sed -n -e "/^## / { \
h; \
s/.*//; \
:doc" \
-e "H; \
n; \
s/^## //; \
t doc" \
-e "s/:.*//; \
G; \
s/\\n## /---/; \
s/\\n/ /g; \
p; \
}" ${MAKEFILE_LIST} \
| LC_ALL='C' sort --ignore-case \
| awk -F '---' \
-v ncol=$$(tput cols) \
-v indent=19 \
-v col_on="$$(tput setaf 6)" \
-v col_off="$$(tput sgr0)" \
'{ \
printf "%s%*s%s ", col_on, -indent, $$1, col_off; \
n = split($$2, words, " "); \
line_length = ncol - indent; \
for (i = 1; i <= n; i++) { \
line_length -= length(words[i]) + 1; \
if (line_length <= 0) { \
line_length = ncol - indent - length(words[i]) - 1; \
printf "\n%*s ", -indent, " "; \
} \
printf "%s ", words[i]; \
} \
printf "\n"; \
}' \
| more $(shell test $(shell uname) == Darwin && echo '--no-init --raw-control-chars')
.DEFAULT_GOAL := show-help
# See <https://gist.github.com/klmr/575726c7e05d8780505a> for explanation.
.PHONY: show-help
show-help:
@echo "$$(tput bold)Available rules:$$(tput sgr0)";echo;sed -ne"/^## /{h;s/.*//;:d" -e"H;n;s/^## //;td" -e"s/:.*//;G;s/\\n## /---/;s/\\n/ /g;p;}" ${MAKEFILE_LIST}|LC_ALL='C' sort -f|awk -F --- -v n=$$(tput cols) -v i=19 -v a="$$(tput setaf 6)" -v z="$$(tput sgr0)" '{printf"%s%*s%s ",a,-i,$$1,z;m=split($$2,w," ");l=n-i;for(j=1;j<=m;j++){l-=length(w[j])+1;if(l<= 0){l=n-i-length(w[j])-1;printf"\n%*s ",-i," ";}printf"%s ",w[j];}printf"\n";}'|more $(shell test $(shell uname) == Darwin && echo '-Xr')
@lokshunhung
Copy link

lokshunhung commented Jul 15, 2025

A simplified version of the deno task inspired help script from @takuyahara, using only awk as a single command

# See https://gist.github.com/klmr/575726c7e05d8780505a?permalink_comment_id=5676387#gistcomment-5676387 for explanation
## Print help text
help:
	@awk -v c0=$$(tput sgr0) -v c1=$$(tput setaf 2) -v c2=$$(tput setaf 6) 'BEGIN{print c1 "Available targets:" c0}/^## /{h=h"\n    "substr($$0,4);next;}/^([^\t].*):/{if(h=="")next;sub(/:.*/,"");print"- "c2 $$0 c0 h}{h=""}' $(MAKEFILE_LIST)
.PHONY: help

Or, the non-minified version in the form of a shell script
help.sh

#!/usr/bin/env bash

awk \
-v c0=$(tput sgr0) \
-v c1=$(tput setaf 2) \
-v c2=$(tput setaf 6) \
'
BEGIN {
    print c1 "Available targets:" c0
}
/^## / {
    h = h "\n    " substr($0 ,4)
    next
}
/^([^\t].*):/ {
    if (h=="") next
    sub(/:.*/, "")
    print "- " c2 $0 c0 h
}
{
    h = ""
}
' \
$1

Makefile

# See https://gist.github.com/klmr/575726c7e05d8780505a?permalink_comment_id=5676387#gistcomment-5676387 for explanation
## Print help text
help:
	@./help.sh "$(MAKEFILE_LIST)"
.PHONY: help

Explanation:

  1. The variables c0, c1, c2 are initialized using -v variable=value, which are later used for colourizing terminal output text
  2. The BEGIN block is executed once, at the start, we use it to print the header Available targets:
  3. awk processes line by line, we try to match a line that looks like rule definition, and prints the accumulated help text;
    the logic for processing each line is as follows:
    a. For a line that begins with ## , we accumulate the help text in variable h, then jumps to process the next line (which skips over step 3c)
    b. For a line that doesn't begin with a tab and has a colon, this line is identified as a target; if there is any accumulated help text in variable h, we print the target name followed by the help text, or do nothing if h is empty
    c. For any line, we clear any text stored in variable h, any stored value is no longer needed because they are either printed in step 3b, or the accumulated help text does not belong to any target

Example output:
image

Generated from this Makefile (click to expand):
# See https://gist.github.com/klmr/575726c7e05d8780505a?permalink_comment_id=5676387#gistcomment-5676387 for explanation
## Print help text for Makefile
help:
	@awk -v c0=$$(tput sgr0) -v c1=$$(tput setaf 2) -v c2=$$(tput setaf 6) 'BEGIN{print c1 "Available targets:" c0}/^## /{h=h"\n    "substr($$0,4);next;}/^([^\t].*):/{if(h=="")next;sub(/:.*/,"");print"- "c2 $$0 c0 h}{h=""}' $(MAKEFILE_LIST)
.PHONY: help

## Make ALL the things; this includes: ## building the target; ## testing it; ## deploying to the server. all: ; @: .PHONY: all
## Run unit tests test: ; @: .PHONY: test
## Deploy to production server deploy: ; @: .PHONY: deploy
## Remove temporary build files clean: ; @: .PHONY: clean

@djerius
Copy link

djerius commented Jul 30, 2025

Here's another over-engineered take, using modules shipped with Perl.

.DEFAULT_GOAL := help

.PHONY: help
help: ## output help
	@perl												\
		-E 'use strict;'									\
		-E 'use Text::Wrap qw(wrap);'								\
		-E 'use List::Util qw(max);'								\
		-E 'use Term::ANSIColor qw(colored);'							\
		-E '$$Term::ANSIColor::EACHLINE = qq{\n};'						\
		-E 'use constant {'									\
		-E '	  BULLET => q{- },'								\
		-E '	  GAP => q{  },'								\
		-E '      C1     => [ qw( BOLD BLUE )],'						\
		-E '      C2     => [ qw( BLUE )],'							\
		-E '      C3     => [ qw( BOLD CYAN )],'						\
		-E '	  WIDTH  => q{$(HELP_WIDTH)} || qx{tput cols},'					\
		-E '    };'										\
		-E 'print colored( C1, qq{$$_\n}) for map { $$_, s/./-/gr } q{Available Targets};'	\
		-E 'my (@C, %H, %ORDER, %IDX);'								\
		-E 'while(<>){'										\
		-E '   /^[#]{3} \s* (.*)/x && do { $$ORDER{$$.} = $$1; next};'				\
		-E '   /^[#]{2} \s* (.*)/x && do { push @C,$$1; next};'					\
		-E '   /^([^\s:]+) \s*:+\s* (?:\#{2}\s*(.*))? /x || do { @C=(); next;};'		\
		-E '   next unless @C || defined $$2;'							\
		-E '   $$ORDER{ $$IDX{$$1} //= $$. } //= $$1;'						\
		-E '   push @{ $$H{$$1} //=[@C]}, $$2 //();'						\
		-E '   @C = ();'									\
		-E '}'											\
		-E 'my $$len = max map { length } keys %H;'						\
		-E 'my $$pad = q{ } x ($$len);'								\
		-E 'my $$indent = q{ } x ($$len + length(GAP) + length(BULLET)) ;'			\
		-E '$$Text::Wrap::columns = WIDTH - $$len - length(BULLET) - length(GAP);'		\
		-E 'for  ( map { $$ORDER{$$_} } sort { $$a <=> $$b } keys %ORDER ) {'			\
		-E ' print ( colored(  C2, wrap(q{},q{},$$_).qq{\n} ) ), next if !exists $$H{$$_};'	\
		-E ' print colored( C2, BULLET), colored(C3, $$_), substr($$pad,0,$$len-length),'	\
		-E '  wrap( GAP, $$indent, join qq{\n}, @{$$H{$$_}} ), qq{\n}'				\
		-E '}'											\
image
Generated from this Makefile (click to expand):
#---------------------------------------------------------------------
### Help

.DEFAULT_GOAL := help

.PHONY: help
help: ## output help
	@perl												\
		-E 'use strict;'									\
		-E 'use Text::Wrap qw(wrap);'								\
		-E 'use List::Util qw(max);'								\
		-E 'use Term::ANSIColor qw(colored);'							\
		-E '$$Term::ANSIColor::EACHLINE = qq{\n};'						\
		-E 'use constant {'									\
		-E '	  BULLET => q{- },'								\
		-E '	  GAP => q{  },'								\
		-E '      C1     => [ qw( BOLD BLUE )],'						\
		-E '      C2     => [ qw( BLUE )],'							\
		-E '      C3     => [ qw( BOLD CYAN )],'						\
		-E '	  WIDTH  => q{$(HELP_WIDTH)} || qx{tput cols},'					\
		-E '    };'										\
		-E 'print colored( C1, qq{$$_\n}) for map { $$_, s/./-/gr } q{Available Targets};'	\
		-E 'my (@C, %H, %ORDER, %IDX);'								\
		-E 'while(<>){'										\
		-E '   /^[#]{3} \s* (.*)/x && do { $$ORDER{$$.} = $$1; next};'				\
		-E '   /^[#]{2} \s* (.*)/x && do { push @C,$$1; next};'					\
		-E '   /^([^\s:]+) \s*:+\s* (?:\#{2}\s*(.*))? /x || do { @C=(); next;};'		\
		-E '   next unless @C || defined $$2;'							\
		-E '   $$ORDER{ $$IDX{$$1} //= $$. } //= $$1;'						\
		-E '   push @{ $$H{$$1} //=[@C]}, $$2 //();'						\
		-E '   @C = ();'									\
		-E '}'											\
		-E 'my $$len = max map { length } keys %H;'						\
		-E 'my $$pad = q{ } x ($$len);'								\
		-E 'my $$indent = q{ } x ($$len + length(GAP) + length(BULLET)) ;'			\
		-E '$$Text::Wrap::columns = WIDTH - $$len - length(BULLET) - length(GAP);'		\
		-E 'for  ( map { $$ORDER{$$_} } sort { $$a <=> $$b } keys %ORDER ) {'			\
		-E ' print ( colored(  C2, wrap(q{},q{},$$_).qq{\n} ) ), next if !exists $$H{$$_};'	\
		-E ' print colored( C2, BULLET), colored(C3, $$_), substr($$pad,0,$$len-length),'	\
		-E '  wrap( GAP, $$indent, join qq{\n}, @{$$H{$$_}} ), qq{\n}'				\
		-E '}'											\
		$(MAKEFILE_LIST)


###
### target names are aligned.
###
### target descriptions are wrapped to the terminal width, which can be overridden with the HELP_WIDTH macro
###


###
### Lines which start with '###' are independent of the targets, and are rendered in order
### An empty ### comment leaves an empty line
###


## this is a target description
## multiple lines are supported; each will be wrapped separately
target1 :
	echo target1


target2 : ## documentation can be the same line as the target
	echo target2


target3 :: ## you can abuse the double colon rule if you like
target3 :: ## here's another line for target3
target3 ::
	echo target3

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment