#!/usr/bin/env python3 """Strict clang-tidy policy runner for the C++ template project.""" from __future__ import annotations import argparse import json import pathlib import re import subprocess import sys from collections.abc import Sequence from typing import cast from lib import common, path_filter STATUS_PASS = "PASS" STATUS_FAIL = "FAIL" CLANG_TIDY_TOOL = "clang-tidy" MIN_CLANG_TIDY_MAJOR = 17 def detect_system_include_extra_args() -> tuple[str, ...]: """Detect extra -isystem args for static clang-tidy binaries. Statically-linked clang-tidy may lack a resource directory, causing 'stddef.h not found' errors when parsing system headers. Query the local GCC installation for its builtin include path so clang-tidy can find standard C headers regardless of GCC version or distro. """ try: result = subprocess.run( ["gcc", "-print-file-name=include"], capture_output=True, text=True, check=False, ) except FileNotFoundError: return () if result.returncode != 0: return () gcc_include = result.stdout.strip() if gcc_include and pathlib.Path(gcc_include).is_dir(): return (f"--extra-arg=-isystem{gcc_include}",) return () def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) root = common.find_project_root(pathlib.Path(__file__)) try: build_dir_arg = cast(pathlib.Path, args.build_dir) build_dir = build_dir_arg if build_dir_arg.is_absolute() else root / build_dir_arg compile_commands = require_compile_commands(build_dir) files = collect_files(root, cast(Sequence[str], args.files), compile_commands=compile_commands) list_files = cast(bool, args.list_files) dry_run = cast(bool, args.dry_run) if list_files: for path in files: print(display_path(root, path)) return 0 print(f"build_dir: {build_dir}") print(f"candidate_files: {len(files)}") if dry_run: common.print_plan(build_plan(root, build_dir=build_dir, files=files)) print(f"{STATUS_PASS}: dry-run planned clang-tidy strict checks") return 0 tool = require_clang_tidy() common.print_plan(build_plan(root, build_dir=build_dir, files=files, tool=tool)) return run_clang_tidy(root, build_dir=build_dir, files=files, tool=tool) except common.ScriptError as error: print(f"{STATUS_FAIL}: {error}", file=sys.stderr) return 2 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) _ = parser.add_argument( "--build-dir", type=pathlib.Path, default=pathlib.Path("build/debug"), help="Build directory containing compile_commands.json, default: build/debug.", ) _ = parser.add_argument("--dry-run", action="store_true", help="Print planned commands without executing them.") _ = parser.add_argument("--list-files", action="store_true", help="List C/C++ files selected by the path filter.") _ = parser.add_argument( "files", nargs="*", help="Optional C/C++ files to check. Defaults to filtered project C/C++ files.", ) return parser def collect_files( root: pathlib.Path, requested_files: Sequence[str], *, compile_commands: pathlib.Path | None = None, ) -> tuple[pathlib.Path, ...]: if not requested_files: if compile_commands is None: return tuple(path_filter.iter_cpp_files(root)) return compile_command_files(root, compile_commands) files: list[pathlib.Path] = [] for value in requested_files: path = pathlib.Path(value) if not path.is_absolute(): path = root / path path = path.resolve() if not path.is_file(): raise common.ScriptError(f"clang-tidy input is not a file: {value}") if not path_filter.is_cpp_source(path): raise common.ScriptError(f"clang-tidy input is not a C/C++ source/header: {value}") if is_within_root(path, root) and path_filter.is_under_excluded_dir(path, root): raise common.ScriptError(f"clang-tidy input is under an excluded directory: {display_path(root, path)}") files.append(path) return tuple(sorted(dict.fromkeys(files))) def compile_command_files(root: pathlib.Path, compile_commands: pathlib.Path) -> tuple[pathlib.Path, ...]: entries = cast(list[object], json.loads(compile_commands.read_text(encoding="utf-8"))) files: list[pathlib.Path] = [] for entry in entries: if not isinstance(entry, dict): continue entry_map = cast(dict[str, object], entry) file_value = entry_map.get("file") if not isinstance(file_value, str): continue path = pathlib.Path(file_value) if not path.is_absolute(): directory_value = entry_map.get("directory") directory = pathlib.Path(directory_value) if isinstance(directory_value, str) else root path = directory / path path = path.resolve() if not path.is_file(): continue if not is_within_root(path, root): continue if path_filter.is_under_excluded_dir(path, root): continue if not path_filter.is_cpp_source(path): continue files.append(path) return tuple(sorted(dict.fromkeys(files))) _TEST_SUFFIXES = ("_test.", "_benchmark.", "_fuzz.") def _is_test_like(path: pathlib.Path) -> bool: """Return whether a file is a test, benchmark, or fuzz source.""" name = path.name return any(suffix in name for suffix in _TEST_SUFFIXES) def _config_for_file(root: pathlib.Path, path: pathlib.Path) -> pathlib.Path: """Select the clang-tidy config appropriate for the file type. Test, benchmark, and fuzz files use .clang-tidy-test which relaxes readability-magic-numbers. All other files use the strict .clang-tidy. """ if _is_test_like(path): test_config = root / ".clang-tidy-test" if test_config.is_file(): return test_config return root / ".clang-tidy" def build_plan( root: pathlib.Path, *, build_dir: pathlib.Path, files: Sequence[pathlib.Path], tool: str = CLANG_TIDY_TOOL, ) -> tuple[common.CommandPlan, ...]: extra_args = detect_system_include_extra_args() return tuple( common.CommandPlan( label=f"clang-tidy:{display_path(root, path)}", command=( tool, f"-p={build_dir}", f"--config-file={_config_for_file(root, path)}", "--warnings-as-errors=*", "--header-filter=^(src|examples)/.*", *extra_args, str(path), ), cwd=root, required_tool=tool, ) for path in files ) def run_clang_tidy(root: pathlib.Path, *, build_dir: pathlib.Path, files: Sequence[pathlib.Path], tool: str) -> int: if not files: print(f"{STATUS_PASS}: no C/C++ files selected") return 0 failed_files: list[str] = [] for plan in build_plan(root, build_dir=build_dir, files=files, tool=tool): completed = subprocess.run(list(plan.command), cwd=str(root), check=False) if completed.returncode != 0: failed_files.append(plan.label.removeprefix("clang-tidy:")) if not failed_files: print(f"{STATUS_PASS}: clang-tidy verified {len(files)} file(s)") return 0 print(f"{STATUS_FAIL}: clang-tidy failed for {len(failed_files)} file(s)", file=sys.stderr) for failed_file in failed_files: print(f"failed_file: {failed_file}", file=sys.stderr) return 1 def require_compile_commands(build_dir: pathlib.Path) -> pathlib.Path: compile_commands = build_dir / "compile_commands.json" if not compile_commands.is_file(): raise common.ScriptError( "compile_commands.json is required before clang-tidy execution; " + f"expected: {compile_commands}. Configure debug with " + "`cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON`." ) return compile_commands def require_clang_tidy(tool: str = CLANG_TIDY_TOOL) -> str: path = common.require_tool(tool) completed = subprocess.run([path, "--version"], text=True, capture_output=True, check=False) if completed.returncode != 0: detail = completed.stderr.strip() or completed.stdout.strip() or f"exit {completed.returncode}" raise common.ScriptError(f"Unable to run {tool} --version: {detail}") version_text = completed.stdout.strip() major = parse_clang_tidy_major(version_text) if major is None: raise common.ScriptError(f"Unable to parse {tool} version from: {version_text}") if major < MIN_CLANG_TIDY_MAJOR: raise common.ScriptError(f"{tool} {MIN_CLANG_TIDY_MAJOR}+ is required; found major version {major}: {version_text}") print(f"clang-tidy version: {version_text}") return path def parse_clang_tidy_major(version_text: str) -> int | None: match = re.search(r"\bversion\s+(\d+)(?:\.|\b)", version_text) if match is None: return None return int(match.group(1)) def display_path(root: pathlib.Path, path: pathlib.Path) -> str: if is_within_root(path, root): return str(path.relative_to(root)) return str(path) def is_within_root(path: pathlib.Path, root: pathlib.Path) -> bool: try: _ = path.resolve().relative_to(root.resolve()) except ValueError: return False return True if __name__ == "__main__": raise SystemExit(main())