skim-makefile
#!/bin/bash
# Summarizes a Makefile's targets as a markdown bullet list.
#
# Usage: skim-makefile PATH
#
# Emits a markdown fragment (a bullet list, possibly preceded by a short
# prose line). No headings: the caller frames the section.
#
# Hard rule: parse only, never invoke.
# Repos can be untrusted content; the existence of a Makefile must not
# translate into code execution. We never run `make`, never expand
# variables, never follow includes. A Makefile that defines targets via
# include or eval will look empty here, and that's the correct safe behavior.
#
# Two-pass parse, in order of preference:
#
# 1. Annotated targets: `name: [deps] ## description`.
# A widely cargo-culted convention (often paired with a `help` target
# that greps for the same pattern). When present, treated as the
# author's curated entrypoint surface -- unannotated targets in such a
# Makefile are typically private helpers and are dropped.
#
# 2. Bare targets: `name:` or `name: deps`.
# Fallback when no annotations are found. Filters pattern rules (`%`),
# variable assignments (regex requires `:` followed by space or EOL,
# which naturally excludes `:=`), and dotted internals (`.PHONY`, ...).
set -euo pipefail
if [ $# -lt 1 ]; then
echo "usage: skim-makefile PATH" >&2
exit 2
fi
makefile="$1"
if [ ! -f "$makefile" ]; then
echo "skim-makefile: not a file: $makefile" >&2
exit 2
fi
# Cap on listed targets; large recipe lists self-truncate with a counter.
MAX_TARGETS=50
annotated=$(grep -E '^[a-zA-Z0-9_][a-zA-Z0-9_.-]*[[:space:]]*:[^=]*##' "$makefile" || true)
if [ -n "$annotated" ]; then
echo "Annotated targets (\`name: ## description\` convention):"
echo
printf '%s\n' "$annotated" \
| awk -F'##' '{
split($1, a, ":");
name = a[1];
gsub(/^[ \t]+|[ \t]+$/, "", name);
desc = $2;
gsub(/^[ \t]+|[ \t]+$/, "", desc);
printf "- `%s` -- %s\n", name, desc;
}' \
| head -n "$MAX_TARGETS"
total=$(printf '%s\n' "$annotated" | wc -l)
if [ "$total" -gt "$MAX_TARGETS" ]; then
echo "- ... ($((total - MAX_TARGETS)) more not shown)"
fi
exit 0
fi
# Bare-target parse.
mapfile -t targets < <(
grep -E '^[a-zA-Z0-9_][a-zA-Z0-9_.-]*[[:space:]]*::?([[:space:]]|$)' "$makefile" \
| grep -v '%' \
| sed -E 's/^([a-zA-Z0-9_][a-zA-Z0-9_.-]*).*/\1/' \
| awk '!seen[$0]++' \
|| true
)
if [ ${#targets[@]} -gt 0 ]; then
echo "Targets (parsed; no \`## description\` annotations found):"
echo
n=0
for t in "${targets[@]}"; do
if [ "$n" -ge "$MAX_TARGETS" ]; then
echo "- ... ($((${#targets[@]} - MAX_TARGETS)) more not shown)"
break
fi
echo "- \`$t\`"
n=$((n + 1))
done
exit 0
fi
# File present but yielded no targets we recognize.
# Useful signal in itself: tells the caller "go read it".
echo "(No targets parsed; the Makefile may define targets via include or eval.)"
skim-makefile · 2.8 KiB · d1183e1e6e