Codify the canonical C style and add reindent tooling

AGENTS.md described style in one paragraph that said little more than
"follow the surrounding code", which was not actionable once files had
drifted apart. Replace it with an explicit specification.

The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a
4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is
one tab, depth 3 is a tab plus four spaces. Most of src/ already followed
this; what looked like randomly mixed tabs and spaces was simply cc-mode
output. Document the byte ladder explicitly, since editors that expand
tabs or assume a 4-column tab silently corrupt it.

Rules beyond indentation are derived from counted majorities in the
existing code rather than invented: errctx over e (92 to 45), padded
control parens (140 to 7), pointer binding to the identifier (486 to 5),
and always-brace (only four unbraced bodies exist). Brace and else
placement are called out as house conventions that cc-mode does not
enforce, so "run the indenter" and "follow the guide" cannot conflict.

Also record the naming, error-handling, and API-surface rules that a
consistency sweep showed were being broken silently -- in particular that
a *_RETURN macro inside an ATTEMPT block skips CLEANUP.

Add the tooling to apply it:

  .dir-locals.el          applies the style to every c-mode buffer
  scripts/reindent.el     batch reindent via Emacs
  scripts/reindent.sh     reindent or --check the tree
  scripts/hooks/pre-commit  reindents staged sources

reindent.el deliberately avoids Emacs' tabify: its tabify-regexp is
" [ \t]+", which is not anchored to the start of a line, so it rewrites
runs of spaces anywhere and destroys the hand-aligned value columns in
the bit-flag tables in actor.h and iterator.h. Only leading whitespace is
ever rewritten, and lines starting inside a string literal are skipped.

The hook checks staged content rather than the working tree, so what is
committed is what was verified. It re-stages a fixed file only when the
index and working tree agree, otherwise a partial `git add -p` would
sweep unstaged work into the commit.

Enable with: git config core.hooksPath scripts/hooks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:17:26 -04:00
parent bc782fbffe
commit 8d21d5c7dd
5 changed files with 549 additions and 3 deletions

98
scripts/hooks/pre-commit Executable file
View File

@@ -0,0 +1,98 @@
#!/bin/sh
#
# Reindent staged C sources to the canonical style before the commit is made.
# See AGENTS.md -> "Coding Style".
#
# Enable with:
# git config core.hooksPath scripts/hooks
# Bypass a single commit with `git commit --no-verify`.
#
# The hook checks the *staged* content, not the working tree, so what gets
# committed is what was verified. When a file needs reindenting it is fixed in
# the working tree and re-staged -- but only when the working tree and the index
# agree for that file. If they disagree (a partial `git add -p`), re-staging
# would sweep unstaged work into the commit, so the hook stops and asks you to
# do it yourself.
set -eu
root=$(git rev-parse --show-toplevel)
reindent="$root/scripts/reindent.sh"
# Leave conflict resolution alone.
if [ -e "$root/.git/MERGE_HEAD" ]; then
exit 0
fi
if [ ! -x "$reindent" ]; then
echo "pre-commit: $reindent missing or not executable; skipping style check" >&2
exit 0
fi
# Without Emacs the canonical style cannot be applied or even checked. Warn
# rather than block: a hook that fails closed on a missing optional tool just
# teaches everyone to pass --no-verify.
if ! command -v emacs >/dev/null 2>&1; then
echo "pre-commit: emacs not found; skipping reindent (see AGENTS.md)" >&2
exit 0
fi
staged=$(git diff --cached --name-only --diff-filter=ACMR \
| grep -E '^(src|include|tests|util)/.*\.[ch]$' \
| grep -v -x -F 'include/akgl/SDL_GameControllerDB.h' || true)
if [ -z "$staged" ]; then
exit 0
fi
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT INT TERM
# Reindent a copy of each file's staged content and see whether it moves.
needs=''
for f in $staged; do
copy="$tmp/$(printf '%s' "$f" | tr '/' '_')"
git show ":$f" >"$copy"
cp "$copy" "$copy.orig"
if ! "$reindent" "$copy" >/dev/null 2>&1; then
echo "pre-commit: reindent failed on $f; commit aborted" >&2
exit 1
fi
if ! cmp -s "$copy" "$copy.orig"; then
needs="$needs $f"
fi
done
if [ -z "$needs" ]; then
exit 0
fi
# Refuse to touch anything if even one file is partially staged.
blocked=''
for f in $needs; do
if ! git diff --quiet -- "$f"; then
blocked="$blocked $f"
fi
done
if [ -n "$blocked" ]; then
echo "pre-commit: these files need reindenting but have unstaged changes," >&2
echo "so re-staging them would pull unstaged work into the commit:" >&2
for f in $blocked; do echo " $f" >&2; done
echo >&2
echo "Reindent and stage them yourself, then commit again:" >&2
echo " scripts/reindent.sh$(for f in $blocked; do printf ' %s' "$f"; done)" >&2
echo " git add$(for f in $blocked; do printf ' %s' "$f"; done)" >&2
exit 1
fi
# Safe: for every file needing work, the index and working tree agree.
# shellcheck disable=SC2086
"$reindent" $needs
# shellcheck disable=SC2086
git add $needs
echo "pre-commit: reindented and re-staged:" >&2
for f in $needs; do echo " $f" >&2; done
exit 0

87
scripts/reindent.el Normal file
View File

@@ -0,0 +1,87 @@
;;; reindent.el --- batch reindent to cc-mode "stroustrup" -*- lexical-binding: t -*-
;; Reindents each file named on the command line, in place, to the project's
;; canonical style: cc-mode "stroustrup", 4 columns per level, tabs 8 columns
;; wide. See AGENTS.md -> "Coding Style".
;;
;; The style is set explicitly here rather than read from .dir-locals.el so that
;; the result does not depend on where the file lives -- the pre-commit hook
;; runs this over temporary copies outside the project tree.
;;
;; Usage: emacs --batch -Q -l scripts/reindent.el -- FILE...
;;; Code:
(require 'cc-mode)
(setq make-backup-files nil
create-lockfiles nil
auto-save-default nil
vc-handled-backends nil
inhibit-message t)
(defun akgl-retab-leading-whitespace ()
"Rewrite every line's leading whitespace as tabs-then-spaces at `tab-width'.
`indent-region' fixes the column a line starts at, but `indent-line-to' leaves
a line alone when it is already at the right column even if the bytes are
wrong -- eight spaces where the canonical form is one tab. This pass closes
that gap.
Deliberately not `tabify': that function's `tabify-regexp' is \" [ \\t]+\",
which is NOT anchored to the line start, so it rewrites runs of spaces
anywhere on the line and destroys the hand-aligned columns in the bit-flag
tables in actor.h and iterator.h. Only leading whitespace is touched here, and
lines beginning inside a string literal are skipped outright."
(goto-char (point-min))
(while (not (eobp))
(let ((bol (point)))
(unless (nth 3 (syntax-ppss bol))
(skip-chars-forward " \t" (line-end-position))
(let ((col (current-column)))
(unless (or (zerop col) (eolp))
(let ((want (concat (make-string (/ col tab-width) ?\t)
(make-string (% col tab-width) ?\s))))
(unless (string= want (buffer-substring bol (point)))
(delete-region bol (point))
(insert want)))))))
(forward-line 1)))
(defun akgl-reindent-file (path)
"Reindent PATH in place. Returns t if the file changed on disk."
(let ((before (with-temp-buffer
(insert-file-contents path)
(buffer-string))))
(with-current-buffer (find-file-noselect path t)
(c-mode)
(c-set-style "stroustrup")
(setq indent-tabs-mode t
tab-width 8
c-basic-offset 4
require-final-newline t)
;; 1. Put every line at its correct column.
(indent-region (point-min) (point-max))
;; 2. Convert leading whitespace to the canonical tab/space mix.
(akgl-retab-leading-whitespace)
;; 3. Trailing whitespace and a single final newline.
(delete-trailing-whitespace)
(goto-char (point-max))
(unless (bolp) (insert "\n"))
(let ((changed (not (string= before (buffer-string)))))
(when changed (save-buffer))
(kill-buffer)
changed))))
;; Emacs leaves the "--" separator in `command-line-args-left'; drop it, along
;; with any empty argument, so the remainder is exactly the file list.
(dolist (path (seq-remove (lambda (a) (or (string= a "--") (string= a "")))
command-line-args-left))
(when (akgl-reindent-file path)
(princ (format "reindented %s\n" path))))
;; Exit 0 on success whether or not anything changed, so that any non-zero
;; status from this script means a real failure. Callers detect "something
;; changed" from stdout, or by comparing files themselves.
(kill-emacs 0)
;;; reindent.el ends here

92
scripts/reindent.sh Executable file
View File

@@ -0,0 +1,92 @@
#!/bin/sh
#
# Reindent C sources to the project's canonical style (cc-mode "stroustrup",
# 4-column offset, 8-column tabs). See AGENTS.md -> "Coding Style".
#
# scripts/reindent.sh reindent every tracked C source in place
# scripts/reindent.sh FILE... reindent only the named files
# scripts/reindent.sh --check ... report non-conforming files, change nothing
#
# Exit status: 0 if everything already conforms (or was reindented), 1 if
# --check found a file that needs reindenting, 2 on a usage or environment
# error.
set -eu
root=$(git rev-parse --show-toplevel)
elisp="$root/scripts/reindent.el"
# Directories whose C sources are hand-maintained. Anything outside these is
# vendored (deps/) or generated (include/akgl/SDL_GameControllerDB.h) and is
# left alone.
SCOPE='src include tests util'
GENERATED='include/akgl/SDL_GameControllerDB.h'
check=0
if [ "${1:-}" = "--check" ]; then
check=1
shift
fi
if ! command -v emacs >/dev/null 2>&1; then
echo "reindent: emacs not found; cannot verify or apply the canonical style" >&2
exit 2
fi
if [ ! -f "$elisp" ]; then
echo "reindent: missing $elisp" >&2
exit 2
fi
# Build the file list: explicit arguments, or every tracked C source in scope.
if [ "$#" -gt 0 ]; then
files=$(for f in "$@"; do printf '%s\n' "$f"; done)
else
files=$(cd "$root" && git ls-files $SCOPE | grep -E '\.[ch]$' || true)
fi
# Drop generated files and anything that no longer exists on disk.
files=$(printf '%s\n' "$files" | grep -v -x -F "$GENERATED" || true)
files=$(cd "$root" && for f in $files; do [ -f "$f" ] && printf '%s\n' "$f"; done)
if [ -z "$files" ]; then
exit 0
fi
if [ "$check" -eq 0 ]; then
# Reindent in place. A non-zero status here is a real failure -- never
# swallow it, or a broken Emacs would look like "everything already conforms".
# shellcheck disable=SC2086
(cd "$root" && emacs --batch -Q -l "$elisp" -- $files) || {
echo "reindent: emacs failed; no files were reindented" >&2
exit 2
}
exit 0
fi
# --check: reindent throwaway copies and report which originals differ. The
# copies keep their extension so cc-mode still selects the right major mode.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT INT TERM
copies=''
for f in $files; do
dest="$tmp/$(printf '%s' "$f" | tr '/' '_')"
cp "$root/$f" "$dest"
copies="$copies $dest"
done
# shellcheck disable=SC2086
emacs --batch -Q -l "$elisp" -- $copies >/dev/null || {
echo "reindent: emacs failed; cannot determine whether sources conform" >&2
exit 2
}
status=0
for f in $files; do
dest="$tmp/$(printf '%s' "$f" | tr '/' '_')"
if ! cmp -s "$root/$f" "$dest"; then
echo "$f"
status=1
fi
done
exit $status