compass

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 · 3172031fb8