xo-umbrella2/xo-cmake/bin/xo-clang-format-includes.in

187 lines
7.5 KiB
Bash

#!/usr/bin/env bash
#
# xo-clang-format-includes - enforce the xo include-ordering policy, includes-only
#
# clang-format has no "sort includes but touch nothing else" mode: a style
# governs the WHOLE file, so `clang-format -i` would also reindent/rebrace code.
# This wrapper confines clang-format to each file's leading #include block via
# `--lines`, so only include statements are ever rewritten.
#
# It is opt-in and self-contained: it passes the style explicitly
# (--style=file:...), so it needs NO root .clang-format -- and during the
# interim there should NOT be one, or editors/IDEs would reformat whole files
# against a mostly-default style.
#
# Usage:
# xo-clang-format-includes [--check|--fix] [--build DIR | --style FILE] FILE...
# xo-clang-format-includes [--check|--fix] [--build DIR | --style FILE] --changed [REF]
#
# --check (default) report files whose includes violate policy; exit 1 if any
# --diff show the unified diff --fix would make, WITHOUT applying it; exit 1 if any
# --fix rewrite includes in place
# --build build directory (as passed to cmake -B): use DIR/dot-clang-format,
# the candidate cmake regenerates from the live subsystem graph
# --style explicit dot-clang-format to use (overrides --build)
# --changed operate on C/C++ files changed vs REF (default: HEAD)
# -R DIR recurse DIR, operating on every *.hpp / *.cpp under it
# --normalize-brackets (DEFAULT) rewrite cross-repo "xo/other/..." ->
# <xo/other/...> before ordering (a file in xo-foo/ keeps
# "xo/foo/..." quoted). On by default because WITHOUT it, quoted xo
# includes sort as same-repo (alphabetical) and can REGRESS a
# correctly topo-ordered file; clang-format can't rewrite delimiters.
# --no-normalize-brackets
# disable the above: order includes as-is, leaving delimiters alone.
#
# FILE..., -R DIR, and --changed accumulate; pass any combination.
#
# Style resolution, first that applies: --style, then --build/dot-clang-format,
# then $XO_DOT_CLANG_FORMAT, then the installed snapshot.
set -euo pipefail
STYLE_DEFAULT=@CMAKE_INSTALL_FULL_DATADIR@/etc/xo/dot-clang-format
mode=check
style=""
build=""
changed=0
normalize=1 # cross-repo bracket normalization on by default (opt-out)
ref=HEAD
files=()
while [[ $# -gt 0 ]]; do
case "$1" in
--check) mode=check; shift;;
--diff) mode=diff; shift;;
--fix) mode=fix; shift;;
--normalize-brackets) normalize=1; shift;;
--no-normalize-brackets) normalize=0; shift;;
--build) build="$2"; shift 2;;
--build=*) build="${1#--build=}"; shift;;
--style) style="$2"; shift 2;;
--style=*) style="${1#--style=}"; shift;;
--changed) changed=1; shift
if [[ $# -gt 0 && "$1" != -* ]]; then ref="$1"; shift; fi;;
-R) [[ $# -ge 2 ]] || { echo "$0: -R needs a DIR" >&2; exit 2; }
[[ -d "$2" ]] || { echo "$0: -R: not a directory: $2" >&2; exit 2; }
mapfile -t _rfiles < <(find "$2" -type f \( -name '*.hpp' -o -name '*.cpp' \))
files+=("${_rfiles[@]}")
shift 2;;
-h|--help) sed -n '3,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
--) shift; files+=("$@"); break;;
-*) echo "$0: unknown option: $1" >&2; exit 2;;
*) files+=("$1"); shift;;
esac
done
# style resolution: --style wins, then --build/dot-clang-format (the candidate
# regenerated from the live graph), then $XO_DOT_CLANG_FORMAT, then installed.
if [[ -z "$style" ]]; then
if [[ -n "$build" ]]; then
style="$build/dot-clang-format"
else
style="${XO_DOT_CLANG_FORMAT:-$STYLE_DEFAULT}"
fi
fi
if [[ ! -r "$style" ]]; then
echo "$0: style file not found: $style" >&2
if [[ -n "$build" ]]; then
echo " (from --build $build; has it been configured with cmake?)" >&2
else
echo " pass --build DIR or --style FILE, set \$XO_DOT_CLANG_FORMAT, or install xo-cmake" >&2
fi
exit 2
fi
if [[ $changed -eq 1 ]]; then
mapfile -t _changed < <(git diff --name-only "$ref" -- '*.cpp' '*.hpp' '*.h' '*.cc' '*.hh')
files+=("${_changed[@]}")
fi
if [[ ${#files[@]} -eq 0 ]]; then
echo "$0: no files (pass FILE... or --changed)" >&2
exit 2
fi
# Line range of the leading #include block: first #include through the last
# #include reachable across only blank / comment / preprocessor lines (stop at
# the first line of real code). Empty if the file has no top-level includes.
include_range() {
awk '
/^[[:space:]]*#[[:space:]]*include/ { if (!first) first=NR; last=NR; next }
first && $0 ~ /^[[:space:]]*($|#|\/\/|\/\*|\*)/ { next } # blank/comment/preproc: keep scanning
first { exit } # first real code line: stop
END { if (first) print first ":" last }
' "$1"
}
# Subsystem of a file, from its path: last "xo-<name>/" component, minus "xo-".
# e.g. .../xo-process/src/process/UpxEvent.cpp -> process
subsys_of() {
printf '%s' "$1" | grep -oE 'xo-[^/]+' | tail -1 | sed 's/^xo-//' || true
}
# Rewrite cross-repo include brackets: #include "xo/BAR/..." -> <xo/BAR/...>,
# EXCEPT keep the file's own subsystem quoted (a file in xo-foo/ may write
# "xo/foo/..."). Same-repo bare quotes ("Foo.hpp", "detail/...") are untouched.
AWK_NORMALIZE='
{
line = $0
if (line ~ /^[[:space:]]*#[[:space:]]*include[[:space:]]*"xo\//) {
q1 = index(line, "\""); rest = substr(line, q1 + 1); q2 = index(rest, "\"")
path = substr(rest, 1, q2 - 1); split(path, p, "/"); bar = p[2]
if (bar == FOO)
print line
else
print substr(line, 1, q1 - 1) "<" path ">" substr(rest, q2 + 1)
} else {
print line
}
}'
# Fully processed content of a file (to stdout): optional bracket-normalize,
# then include ordering (clang-format scoped to the include block via --lines).
# When normalizing we feed clang-format on stdin, so --assume-filename keeps its
# main-include (self header) detection working.
formatted_stream() { # $1 = file, $2 = line range
if [[ $normalize -eq 1 ]]; then
awk -v FOO="$(subsys_of "$1")" "$AWK_NORMALIZE" "$1" \
| clang-format --assume-filename="$1" --style="file:$style" --lines="$2"
else
clang-format --style="file:$style" --lines="$2" "$1"
fi
}
rc=0
for f in "${files[@]}"; do
[[ -f "$f" ]] || continue
rng=$(include_range "$f")
[[ -z "$rng" ]] && continue # no includes to order
case "$mode" in
fix)
tmp=$(mktemp)
# Only write when the content actually differs. Writing
# unconditionally would bump every file's mtime on every run, and
# make/ninja key off mtime -- so a no-op --fix over the tree would
# force a full rebuild.
if formatted_stream "$f" "$rng" > "$tmp" && [[ -s "$tmp" ]] \
&& ! cmp -s "$tmp" "$f"; then
cat "$tmp" > "$f" # preserve $f's inode / permissions
fi
rm -f "$tmp"
;;
diff)
diff -u --label "a/$f" --label "b/$f" \
"$f" <(formatted_stream "$f" "$rng") || rc=1
;;
check)
if ! diff -q "$f" <(formatted_stream "$f" "$rng") >/dev/null; then
echo "include-order: $f" >&2
rc=1
fi
;;
esac
done
exit $rc