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')
@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