Skip to content

Instantly share code, notes, and snippets.

@klmr
Last active May 16, 2024 20:30
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')
@ArashPartow
Copy link

A comprehensive and easy to use C++ Makefile example can also be found here:

https://www.partow.net/programming/makefile/index.html

@hilnius
Copy link

hilnius commented May 5, 2022

Hello
I really liked @syffer 's solution (looks awesome !), however I had trouble editing it to make the adjustments I wanted (parameter docs, sections not sorted alphabetically) because it's quite complex for me to understand what these sed commands are doing 😆 so I went for a much simpler bash script which procedurally generates the docs. The script is not super well written I admit (help doc not generated from the actual help target for example, duplicate regexes with escaping, etc.), however it should be fairly simple to edit & extend !
Also, I wanted this 'help' to be available anywhere, from any makefile, so what I did was create two files

my-folder/tools/generate-makefile-help
my-folder/tools/Makefile-help.mk

and added /path/to/my/folder to a $ROOT_PATH environment variable loaded by my bashrc (note there is also a make install command that adds that variable to the bashrc if it's not yet added),
So that anywhere I want to add help target to a makefile, I can just add include $ROOT_PATH/tools/Makefile-helper.mk
here's the code:
Makefile-help.mk:

help:
	@FILE=Makefile ${ROOT_PATH}/tools/generate-makefile-help

generate-makefile-help:

#!/bin/bash

RULE_COLOR="$(tput setaf 6)"
SECTION_COLOR="$(tput setaf 3)"
VARIABLE_COLOR="$(tput setaf 2)"
VALUE_COLOR="$(tput setaf 1)"
CLEAR_STYLE="$(tput sgr0)"
TARGET_STYLED_HELP_NAME="${RULE_COLOR}TARGET${CLEAR_STYLE}"
ARGUMENTS_HELP_NAME="${VARIABLE_COLOR}ARGUMENT${CLEAR_STYLE}=${VALUE_COLOR}VALUE${CLEAR_STYLE}"

echo "Usage: make [$TARGET_STYLED_HELP_NAME [$TARGET_STYLED_HELP_NAME ...]] [$ARGUMENTS_HELP_NAME [$ARGUMENTS_HELP_NAME ...]]"
echo "${SECTION_COLOR}Targets:${CLEAR_STYLE}"
echo "    ${RULE_COLOR}help${CLEAR_STYLE}"
echo "        Get help for commands in this folder"
echo ""

TARGET_REGEX="^[a-zA-Z0-9%_\/%-]+:"
SECTION_REGEX="^##\s*@section\s*(.*)$"
DOCBLOCK_REGEX="^##\s*(.*)$"
PARAM_REGEX="@param\s+([a-zA-Z_]+)(=([^\s]+))?\s*(.*$)?"

COMMENT=""
PARAMS=""
PARAMS_DOC=""
cat $FILE | while read line
do
    # do something with $line here
    if [[ ! -z $line ]]
    then
        if [[ $line =~ $SECTION_REGEX ]]
        then
            SECTION_NAME=$(echo $line | sed -e "s/^##\s*@section\s*\(.*\)$/\1/g")
            echo "$SECTION_COLOR$SECTION_NAME$CLEAR_STYLE:"
        elif [[ $line =~ $TARGET_REGEX ]]
        then
            # if there is no comment for this target, we don't display it in the docs to keep private targets hidden
            if [[ ! -z $COMMENT ]]
            then
                TARGET=$(echo $line | sed -e "s/^\([a-zA-Z0-9%_\/%-]\+\):.*/\1/g")
                echo "    $RULE_COLOR$TARGET$CLEAR_STYLE $PARAMS"
                echo -e "$COMMENT"
                if [[ ! -z $PARAMS_DOC ]]
                then
                    echo "        Params:"
                    echo -e "$PARAMS_DOC"
                fi
            fi
            COMMENT=""
            PARAMS=""
            PARAMS_DOC=""
        elif [[ $line =~ $PARAM_REGEX ]]
        then
            PARAM=$(echo $line | sed -e "s/##\s*@param\s\+\([a-zA-Z_]\+\)\(=\([^[:space:]]\+\)\)\?\s*\(.*\)\?$/${VARIABLE_COLOR}\1${CLEAR_STYLE}=${VALUE_COLOR}\3${CLEAR_STYLE}/g")
            PARAM_DOC=$(echo $line | sed -e "s/##\s*@param\s\+\([a-zA-Z_]\+\)\(=\([^[:space:]]\+\)\)\?\s*\(.*\)\?$/- \1 (ex: \3) \4/g")
            PARAMS="${PARAMS}${PARAM} "
            PARAMS_DOC="${PARAMS_DOC}         ${PARAM_DOC}\n"
        elif [[ $line =~ $DOCBLOCK_REGEX ]]
        then
            # echo "doc : $line"
            # echo $line | sed -e "s/^##\s*\(.*\)$/\1/g"
            LINE_CLEANED=$(echo $line | sed -e "s/^##\s*\(.*\)$/\1/g")
            COMMENT="${COMMENT}        $LINE_CLEANED\n"
        fi
    fi
done

And here's an example Makefile:

# Line to add to any Supermood Makefile - generates the 'help' target from the Makefile comments
include ${SUPERMOOD_ROOT}/tools/Makefile-help.mk

## @section Installation

## Install the Supermood environment (adds $SUPERMOOD_ROOT to your shell)
install:
	@echo Setting SUPERMOOD_ROOT environment
	@./tools/bashrc-setup

## @section Supermood Release log generation

## Generates the production release log from git history.
## Post the output to the #releases slack channel
## @param FROM=1.234.0 Git reference from which we show the log
## @param TO=1.235.0 Git reference to which we show the log
releaselog:
	@./internal/releaselog/display-release-log $$FROM $$TO

## Generates the full release log (including [no-releaselog] commits) from git history.
## Post the output to your squad's channel or #tech-team
## @param FROM=1.234.0 Git reference from which we show the log
## @param TO=1.235.0 Git reference to which we show the log
full-releaselog:
	@./internal/releaselog/display-full-release-log $$FROM $$TO

# This is just hidden, probably a target we don't want people to call
sometarget: any other file
	echo "hello"

.PHONY: install releaselog full-releaselog

Which generates this:
image

@meggiman
Copy link

This has been extremely useful to me. Thanks a lot, everyone! @hilnius I noticed that your solution is currently lacking support for targets defined/documented in included makefiles or if the makefile does not use the default name (i.e. make invoked with -f or with -C). A quick and dirty solution I came up with is to modify your bash script as follows:

#!/bin/bash
RULE_COLOR="$(tput setaf 6)"
SECTION_COLOR="$(tput setaf 3)"
VARIABLE_COLOR="$(tput setaf 2)"
VALUE_COLOR="$(tput setaf 1)"
CLEAR_STYLE="$(tput sgr0)"
TARGET_STYLED_HELP_NAME="${RULE_COLOR}TARGET${CLEAR_STYLE}"
ARGUMENTS_HELP_NAME="${VARIABLE_COLOR}ARGUMENT${CLEAR_STYLE}=${VALUE_COLOR}VALUE${CLEAR_STYLE}"

echo "Usage: make [$TARGET_STYLED_HELP_NAME [$TARGET_STYLED_HELP_NAME ...]] [$ARGUMENTS_HELP_NAME [$ARGUMENTS_HELP_NAME ...]]"
echo "${SECTION_COLOR}Targets:${CLEAR_STYLE}"
echo "    ${RULE_COLOR}help${CLEAR_STYLE}"
echo "        Get help for commands in this folder"
echo ""

TARGET_REGEX="^[a-zA-Z0-9%_\/%-]+:"
SECTION_REGEX="^##\s*@section\s*(.*)$"
DOCBLOCK_REGEX="^##\s*(.*)$"
PARAM_REGEX="@param\s+([a-zA-Z_]+)(=([^\s]+))?\s*(.*$)?"

COMMENT=""
PARAMS=""
PARAMS_DOC=""

for FILE in $MAKEFILES
do
cat $FILE | while read line
  do
    # do something with $line here
    if [[ ! -z $line ]]
    then
        if [[ $line =~ $SECTION_REGEX ]]
        then
            SECTION_NAME=$(echo $line | sed -e "s/^##\s*@section\s*\(.*\)$/\1/g")
            echo "$SECTION_COLOR$SECTION_NAME$CLEAR_STYLE:"
        elif [[ $line =~ $TARGET_REGEX ]]
        then
            # if there is no comment for this target, we don't display it in the docs to keep private targets hidden
            if [[ ! -z $COMMENT ]]
            then
                TARGET=$(echo $line | sed -e "s/^\([a-zA-Z0-9%_\/%-]\+\):.*/\1/g")
                echo "    $RULE_COLOR$TARGET$CLEAR_STYLE $PARAMS"
                echo -e "$COMMENT"
                if [[ ! -z $PARAMS_DOC ]]
                then
                    echo "        Params:"
                    echo -e "$PARAMS_DOC"
                fi
            fi
            COMMENT=""
            PARAMS=""
            PARAMS_DOC=""
        elif [[ $line =~ $PARAM_REGEX ]]
        then
            PARAM=$(echo $line | sed -e "s/##\s*@param\s\+\([a-zA-Z_]\+\)\(=\([^[:space:]]\+\)\)\?\s*\(.*\)\?$/${VARIABLE_COLOR}\1${CLEAR_STYLE}=${VALUE_COLOR}\3${CLEAR_STYLE}/g")
            PARAM_DOC=$(echo $line | sed -e "s/##\s*@param\s\+\([a-zA-Z_]\+\)\(=\([^[:space:]]\+\)\)\?\s*\(.*\)\?$/- \1 (example: \3) \4/g")
            PARAMS="${PARAMS}${PARAM} "
            PARAMS_DOC="${PARAMS_DOC}         ${PARAM_DOC}\n"
        elif [[ $line =~ $DOCBLOCK_REGEX ]]
        then
            # echo "doc : $line"
            # echo $line | sed -e "s/^##\s*\(.*\)$/\1/g"
            LINE_CLEANED=$(echo $line | sed -e "s/^##\s*\(.*\)$/\1/g")
            COMMENT="${COMMENT}        $LINE_CLEANED\n"
        fi
    fi
  done
done

It would then have to be invoked (after all includes) with:

.PHONY: help
help:
	@MAKEFILES="$(MAKEFILE_LIST)" $(mkfile_dir)generate-makefile-help.sh

I.e. if you save this snippet in a Makefile-help.mk as suggested by @hilnius, make sure to include it at the very and of your makefile (or at least after all other makefile includes) in order to catch documentation in all the relevant makefiles. This approach, however, does not work for conditional file inclusion.

@bukowa
Copy link

bukowa commented Feb 15, 2023

@hilnius
To handle export VAR ?= something this works, i guess?

		-e "s|^\(export *\)\($(variable_regex)\)$(variable_assignment_regex)\($(value_regex)\)|$(global_variable_tag_start)\2$(global_variable_tag_end)$(value_tag_start)\3$(value_tag_end)|;" \

i also found improved: https://github.com/cert-manager/cert-manager/blob/7ce1f9cffb70eae4d3dd3572564a90f2553d3b52/make/help.mk

@takuyahara
Copy link

Inspired by deno task:

@echo "$$(tput setaf 2)Available rules:$$(tput sgr0)";sed -ne"/^## /{h;s/.*//;:d" -e"H;n;s/^## /---/;td" -e"s/:.*//;G;s/\\n## /===/;s/\\n//g;p;}" ${MAKEFILE_LIST}|awk -F === -v n=$$(tput cols) -v i=4 -v a="$$(tput setaf 6)" -v z="$$(tput sgr0)" '{printf"- %s%s%s\n",a,$$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"%*s%s\n",-i," ",w[j];}}'

Screenshot 2023-05-09 at 12 59 03

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