run-clang-tidy-fixes.sh 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. #!/usr/bin/env bash
  2. # clang-tidy fixer: git-only by default; --all to scan whole project
  3. # Runs only on files covered by compile_commands.json (safer).
  4. # Parallelized via xargs -P using a temp helper script (bash).
  5. set -euo pipefail
  6. # ---------- Defaults ----------
  7. BUILD_DIR_RELATIVE="build"
  8. DEFAULT_LANG_VALUE="en"
  9. SEARCH_PATHS_DEFAULT="app game render tools ui utils"
  10. GIT_ONLY=1
  11. GIT_BASE="${CLANG_TIDY_GIT_BASE:-origin/main}"
  12. DEFAULT_CHECKS="-*,readability-braces-around-statements"
  13. CHECKS_OVERRIDE="${CLANG_TIDY_AUTO_FIX_CHECKS:-$DEFAULT_CHECKS}"
  14. HEADER_FILTER="${CLANG_TIDY_HEADER_FILTER:-^(?!.*third_party).*$}"
  15. CLANG_TIDY_NICE="${CLANG_TIDY_NICE:-0}"
  16. if command -v nproc >/dev/null 2>&1; then
  17. DEFAULT_JOBS=$(( ( $(nproc) + 1 ) / 2 ))
  18. else
  19. DEFAULT_JOBS=2
  20. fi
  21. JOBS="${CLANG_TIDY_JOBS:-$DEFAULT_JOBS}"
  22. QUIET=1
  23. FIX_ERRORS=0
  24. INCLUDE_HEADERS=0 # <- do NOT feed headers directly
  25. ALLOW_UNCOVERED=0 # <- only run files present in compile DB by default
  26. PASSES=1
  27. RAW_PATHS=""
  28. USER_EXPORT_FIXES_DIR=""
  29. AGGRESSIVE=0
  30. print_help() {
  31. cat <<'EOF'
  32. Usage: scripts/run-clang-tidy-fixes.sh [options]
  33. Options:
  34. --all Run on the whole project (disables git-only; enables verbose)
  35. --base=<ref> Git base to diff against (default: origin/main)
  36. --paths="<p1 p2 ...>" Space- or comma-separated root paths to search
  37. --jobs=<N> Parallel jobs (xargs -P). Default: ~half cores
  38. --checks="<pattern>" Override -checks (e.g., "-*,bugprone-*")
  39. --nice | --no-nice Lower (or not) CPU/IO priority for clang-tidy
  40. --build-dir=<dir> Build dir with compile_commands.json (default: build)
  41. --default-lang=<val> CMake DEFAULT_LANG cache var (default: en)
  42. --verbose | --no-quiet Show clang-tidy output
  43. --quiet Force quiet
  44. --fix-errors Use -fix-errors (stronger auto-fix)
  45. --passes=<N> Run up to N passes (default: 1)
  46. --aggressive Shortcut: --fix-errors + --passes=3
  47. --no-headers (ignored; headers not run directly by default)
  48. --allow-uncovered Attempt files not in compile DB (adds -std=c++20)
  49. --export-fixes=<dir> Save per-file fixes YAMLs into this directory
  50. -h|--help Show this help
  51. Env (also supported):
  52. CLANG_TIDY_GIT_BASE, CLANG_TIDY_JOBS, CLANG_TIDY_AUTO_FIX_CHECKS,
  53. CLANG_TIDY_HEADER_FILTER, CLANG_TIDY_NICE, CLANG_TIDY_FIX_PATHS,
  54. CLANG_TIDY_EXTRA_ARG (single), CLANG_TIDY_EXTRA_ARGS (space-separated)
  55. EOF
  56. }
  57. # ---------- Parse CLI ----------
  58. while [[ $# -gt 0 ]]; do
  59. case "$1" in
  60. --all) GIT_ONLY=0; QUIET=0; shift ;;
  61. --base=*) GIT_BASE="${1#*=}"; shift ;;
  62. --base) GIT_BASE="${2:-$GIT_BASE}"; shift 2 ;;
  63. --paths=*) RAW_PATHS="${1#*=}"; shift ;;
  64. --paths) RAW_PATHS="${2:-}"; shift 2 ;;
  65. --jobs=*) JOBS="${1#*=}"; shift ;;
  66. --jobs) JOBS="${2:-$JOBS}"; shift 2 ;;
  67. --checks=*) CHECKS_OVERRIDE="${1#*=}"; shift ;;
  68. --checks) CHECKS_OVERRIDE="${2:-$CHECKS_OVERRIDE}"; shift 2 ;;
  69. --nice) CLANG_TIDY_NICE=1; shift ;;
  70. --no-nice) CLANG_TIDY_NICE=0; shift ;;
  71. --build-dir=*) BUILD_DIR_RELATIVE="${1#*=}"; shift ;;
  72. --build-dir) BUILD_DIR_RELATIVE="${2:-$BUILD_DIR_RELATIVE}"; shift 2 ;;
  73. --default-lang=*) DEFAULT_LANG_VALUE="${1#*=}"; shift ;;
  74. --default-lang) DEFAULT_LANG_VALUE="${2:-$DEFAULT_LANG_VALUE}"; shift 2 ;;
  75. --verbose|--no-quiet) QUIET=0; VERBOSE_CMD=1; shift ;;
  76. --quiet) QUIET=1; shift ;;
  77. --fix-errors) FIX_ERRORS=1; shift ;;
  78. --passes=*) PASSES="${1#*=}"; shift ;;
  79. --passes) PASSES="${2:-$PASSES}"; shift 2 ;;
  80. --aggressive) AGGRESSIVE=1; FIX_ERRORS=1; PASSES=$(( PASSES < 3 ? 3 : PASSES )); shift ;;
  81. --no-headers) INCLUDE_HEADERS=0; shift ;;
  82. --allow-uncovered) ALLOW_UNCOVERED=1; shift ;;
  83. --export-fixes=*) USER_EXPORT_FIXES_DIR="${1#*=}"; shift ;;
  84. -h|--help) print_help; exit 0 ;;
  85. *) echo "Unknown option: $1"; print_help; exit 2 ;;
  86. esac
  87. done
  88. REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
  89. cd "$REPO_ROOT"
  90. # Paths to search
  91. if [[ -n "${RAW_PATHS:-}" ]]; then
  92. RAW_PATHS="${RAW_PATHS//,/ }"
  93. IFS=' ' read -r -a SEARCH_PATHS <<< "$RAW_PATHS"
  94. else
  95. IFS=' ' read -r -a SEARCH_PATHS <<< "${CLANG_TIDY_FIX_PATHS:-$SEARCH_PATHS_DEFAULT}"
  96. fi
  97. # ---------- Tools / Setup ----------
  98. if ! command -v clang-tidy >/dev/null 2>&1; then
  99. echo "⚠ clang-tidy not found; skipping automatic lint fixes."
  100. exit 0
  101. fi
  102. [[ "$QUIET" -eq 0 ]] && clang-tidy --version || true
  103. BUILD_DIR="${REPO_ROOT}/${BUILD_DIR_RELATIVE}"
  104. mkdir -p "$BUILD_DIR"
  105. if [ ! -f "$BUILD_DIR/compile_commands.json" ]; then
  106. echo "Generating compile_commands.json via CMake (DEFAULT_LANG=${DEFAULT_LANG_VALUE})..."
  107. cmake -S "$REPO_ROOT" -B "$BUILD_DIR" -DDEFAULT_LANG="${DEFAULT_LANG_VALUE}" >/dev/null
  108. fi
  109. if [ ! -f "$BUILD_DIR/compile_commands.json" ]; then
  110. echo "⚠ Unable to locate compile_commands.json in ${BUILD_DIR_RELATIVE}; skipping clang-tidy fixes."
  111. exit 0
  112. fi
  113. # ---------- Collect sources (filesystem) ----------
  114. declare -a FS_SOURCES=()
  115. SRC_GLOBS=( '*.c' '*.cc' '*.cpp' '*.cxx' )
  116. # headers intentionally NOT included as standalone inputs
  117. add_sources_from_find() {
  118. local root="$1"
  119. local -a find_expr=( \( -name "${SRC_GLOBS[0]}" )
  120. for g in "${SRC_GLOBS[@]:1}"; do find_expr+=( -o -name "$g" ); done
  121. find_expr+=( \) )
  122. while IFS= read -r -d '' FILE; do
  123. REL_PATH="${FILE#$REPO_ROOT/}"
  124. FS_SOURCES+=("$REL_PATH")
  125. done < <(find "$root" \
  126. -path "$root/third_party" -prune -o \
  127. -type f "${find_expr[@]}" \
  128. -print0)
  129. }
  130. if [[ "$GIT_ONLY" -eq 1 ]]; then
  131. mapfile -t GIT_FILES < <(git diff --name-only --diff-filter=ACMR "$GIT_BASE"... -- "${SRC_GLOBS[@]}" 2>/dev/null || true)
  132. for f in "${GIT_FILES[@]:-}"; do [[ -f "$f" ]] && FS_SOURCES+=("$f"); done
  133. else
  134. for SEARCH_PATH in "${SEARCH_PATHS[@]}"; do
  135. ABS_PATH="$REPO_ROOT/$SEARCH_PATH"
  136. [[ -d "$ABS_PATH" ]] || continue
  137. add_sources_from_find "$ABS_PATH"
  138. done
  139. fi
  140. if [ ${#FS_SOURCES[@]} -eq 0 ]; then
  141. echo "No C/C++ sources found under: ${SEARCH_PATHS[*]}"
  142. exit 0
  143. fi
  144. # ---------- Build set of files from compile_commands.json ----------
  145. DB_FILES_TXT="$(mktemp)"
  146. if command -v jq >/dev/null 2>&1; then
  147. jq -r '.[].file' "$BUILD_DIR/compile_commands.json" | sed 's#^\./##' > "$DB_FILES_TXT"
  148. else
  149. # best-effort without jq
  150. grep -oE '"file":\s*"[^"]+"' "$BUILD_DIR/compile_commands.json" | sed 's/^"file":\s*"\(.*\)"/\1/' > "$DB_FILES_TXT"
  151. fi
  152. # Normalize DB paths to repo-relative
  153. DB_REL_TXT="$(mktemp)"
  154. awk -v root="$REPO_ROOT/" '{ f=$0; sub("^"root, "", f); print f }' "$DB_FILES_TXT" > "$DB_REL_TXT"
  155. # ---------- Intersect (default) or union (if --allow-uncovered) ----------
  156. declare -a SOURCES=()
  157. if [[ "$ALLOW_UNCOVERED" -eq 1 ]]; then
  158. # union: everything we found on FS; clang-tidy will guess for uncovered (may error)
  159. SOURCES=("${FS_SOURCES[@]}")
  160. else
  161. # intersection: only files present in compile DB
  162. while IFS= read -r f; do
  163. if grep -Fxq "$f" "$DB_REL_TXT"; then
  164. SOURCES+=("$f")
  165. fi
  166. done < <(printf "%s\n" "${FS_SOURCES[@]}" | sort -u)
  167. fi
  168. covered=${#SOURCES[@]}
  169. total=${#FS_SOURCES[@]}
  170. echo "ℹ coverage: ${covered}/${total} source files are in compile_commands.json."
  171. if [ ${#SOURCES[@]} -eq 0 ]; then
  172. echo "Nothing to run: none of the selected sources are in the compilation database."
  173. echo "Tip: build with CMake/Ninja or use 'bear -- cmake --build build' to capture compile commands."
  174. exit 0
  175. fi
  176. # ---------- Run config ----------
  177. NICE_PREFIX=()
  178. if [[ "$CLANG_TIDY_NICE" == "1" ]]; then
  179. command -v nice >/dev/null 2>&1 && NICE_PREFIX+=(nice -n 10)
  180. command -v ionice >/dev/null 2>&1 && NICE_PREFIX+=(ionice -c 2 -n 7)
  181. fi
  182. EXTRA_ARGS=()
  183. [[ -n "${CLANG_TIDY_EXTRA_ARG:-}" ]] && EXTRA_ARGS+=("--extra-arg=${CLANG_TIDY_EXTRA_ARG}")
  184. if [[ -n "${CLANG_TIDY_EXTRA_ARGS:-}" ]]; then
  185. # shellcheck disable=SC2206
  186. EXTRA_ARGS+=(${CLANG_TIDY_EXTRA_ARGS})
  187. fi
  188. # If attempting uncovered files, default to a reasonable standard to help parsing
  189. if [[ "$ALLOW_UNCOVERED" -eq 1 && -z "${CLANG_TIDY_EXTRA_ARG:-}" && -z "${CLANG_TIDY_EXTRA_ARGS:-}" ]]; then
  190. EXTRA_ARGS+=("--extra-arg=-std=c++20")
  191. fi
  192. # Quote each extra arg so the helper can safely eval back to an array
  193. EXTRA_ARGS_STRING=""
  194. for a in "${EXTRA_ARGS[@]:-}"; do
  195. EXTRA_ARGS_STRING+=" $(printf '%q' "$a")"
  196. done
  197. COMMON_ARGS_BASE=( -p "$BUILD_DIR" "-header-filter=$HEADER_FILTER" )
  198. [[ -n "$CHECKS_OVERRIDE" ]] && COMMON_ARGS_BASE+=("-checks=$CHECKS_OVERRIDE")
  199. [[ "$FIX_ERRORS" -eq 1 ]] && COMMON_ARGS_BASE+=(-fix-errors)
  200. # Prepare export dir (per-file) if requested
  201. if [[ -n "$USER_EXPORT_FIXES_DIR" ]]; then
  202. mkdir -p "$USER_EXPORT_FIXES_DIR"
  203. fi
  204. TMP_EXPORT_DIR="$(mktemp -d "${BUILD_DIR}/clang-tidy-fixes-XXXX")"
  205. trap 'rm -rf "$TMP_EXPORT_DIR"' EXIT
  206. echo "Running clang-tidy fixes on ${#SOURCES[@]} file(s) ..."
  207. echo " checks: ${CHECKS_OVERRIDE}"
  208. echo " parallel jobs: ${JOBS}"
  209. [[ "$FIX_ERRORS" -eq 1 ]] && echo " using -fix-errors"
  210. [[ "$PASSES" -gt 1 ]] && echo " multi-pass: ${PASSES} passes (aggressive=${AGGRESSIVE})"
  211. # ---------- Helper script (bash) ----------
  212. TMP_HELPER="$(mktemp "${BUILD_DIR}/clang-tidy-one-XXXX.sh")"
  213. cat > "$TMP_HELPER" <<'HLP'
  214. #!/usr/bin/env bash
  215. set -euo pipefail
  216. f="$1"
  217. export_dir="$2"
  218. yaml="${export_dir}/$(echo "$f" | tr '/ ' '__').yaml"
  219. if [[ "$f" = /* ]]; then
  220. tu="$f"
  221. else
  222. tu="$REPO_ROOT/$f"
  223. fi
  224. cmd=(clang-tidy -fix -p "$BUILD_DIR" "-header-filter=$HEADER_FILTER")
  225. [[ -n "${CHECKS:-}" ]] && cmd+=("-checks=$CHECKS")
  226. [[ "${FIX_ERRORS:-0}" -eq 1 ]] && cmd+=(-fix-errors)
  227. cmd+=("-export-fixes=$yaml" "$tu")
  228. if [[ -n "${EXTRA_ARGS_STRING:-}" ]]; then
  229. eval "extra=( ${EXTRA_ARGS_STRING} )"
  230. filtered_extra=()
  231. for arg in "${extra[@]}"; do
  232. [[ -n "$arg" ]] && filtered_extra+=("$arg")
  233. done
  234. if [[ ${#filtered_extra[@]} -gt 0 ]]; then
  235. cmd+=(-- "${filtered_extra[@]}")
  236. fi
  237. fi
  238. run_cmd() {
  239. if [[ "${NICE:-0}" -eq 1 ]]; then
  240. if command -v ionice >/dev/null 2>&1; then
  241. ionice -c 2 -n 7 nice -n 10 "${cmd[@]}"
  242. else
  243. nice -n 10 "${cmd[@]}"
  244. fi
  245. else
  246. "${cmd[@]}"
  247. fi
  248. }
  249. if [[ "${QUIET:-1}" -eq 1 ]]; then
  250. run_cmd >/dev/null 2>&1 || true
  251. else
  252. echo "[clang-tidy] $f"
  253. if [[ "${VERBOSE_CMD:-0}" -eq 1 ]]; then
  254. printf ' %s\n' "$(printf '%q ' "${cmd[@]}")"
  255. fi
  256. run_cmd || true
  257. fi
  258. HLP
  259. chmod +x "$TMP_HELPER"
  260. # Export env for helper
  261. export BUILD_DIR HEADER_FILTER EXTRA_ARGS_STRING QUIET
  262. export REPO_ROOT VERBOSE_CMD
  263. export CHECKS="${CHECKS_OVERRIDE}"
  264. export FIX_ERRORS
  265. export NICE="${CLANG_TIDY_NICE}"
  266. # ---------- Run (parallel) ----------
  267. pass=1
  268. while [[ $pass -le $PASSES ]]; do
  269. [[ $PASSES -gt 1 ]] && echo "==> Pass ${pass}/${PASSES}"
  270. if command -v xargs >/dev/null 2>&1; then
  271. printf '%s\0' "${SOURCES[@]}" | xargs -0 -P "$JOBS" -I{} bash "$TMP_HELPER" "{}" "$TMP_EXPORT_DIR"
  272. else
  273. for f in "${SOURCES[@]}"; do bash "$TMP_HELPER" "$f" "$TMP_EXPORT_DIR"; done
  274. fi
  275. # Early stop if using multiple passes and nothing else changed
  276. if [[ $PASSES -gt 1 ]] && command -v git >/dev/null 2>&1; then
  277. if git diff --quiet -- "${SOURCES[@]}"; then
  278. echo "No further changes detected; stopping early."
  279. break
  280. fi
  281. fi
  282. pass=$((pass + 1))
  283. done
  284. # ---------- Summaries ----------
  285. repl_total=0
  286. diag_total=0
  287. if ls "$TMP_EXPORT_DIR"/*.yaml >/dev/null 2>&1; then
  288. while IFS= read -r y; do
  289. r=$(grep -c 'ReplacementText:' "$y" || true)
  290. d=$(grep -c 'DiagnosticMessage:' "$y" || true)
  291. repl_total=$((repl_total + r))
  292. diag_total=$((diag_total + d))
  293. done < <(ls "$TMP_EXPORT_DIR"/*.yaml 2>/dev/null || true)
  294. echo "Summary: diagnostics=${diag_total}, replacements=${repl_total} (from per-file YAMLs)."
  295. if [[ -n "$USER_EXPORT_FIXES_DIR" ]]; then
  296. mkdir -p "$USER_EXPORT_FIXES_DIR"
  297. cp -f "$TMP_EXPORT_DIR"/*.yaml "$USER_EXPORT_FIXES_DIR"/ 2>/dev/null || true
  298. echo "Per-file fixes exported to: $USER_EXPORT_FIXES_DIR"
  299. fi
  300. else
  301. echo "Summary: no fixes exported (no applicable fix-its)."
  302. fi
  303. # Changed files summary (git)
  304. if command -v git >/dev/null 2>&1; then
  305. mapfile -t changed < <(git diff --name-only -- "${SOURCES[@]}")
  306. if [[ ${#changed[@]} -gt 0 ]]; then
  307. echo "Changed files (${#changed[@]}):"
  308. printf ' %s\n' "${changed[@]:0:20}"
  309. [[ ${#changed[@]} -gt 20 ]] && echo " ... and $(( ${#changed[@]} - 20 )) more"
  310. else
  311. echo "No tracked files changed (git diff is clean for the selected sources)."
  312. fi
  313. fi
  314. exit 0