#!/usr/bin/env python3 """clang-format policy runner for the C++ template project.""" from __future__ import annotations import argparse 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" MIN_CLANG_FORMAT_MAJOR = 17 CLANG_FORMAT_TOOL = "clang-format" def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) root = common.find_project_root(pathlib.Path(__file__)) try: files = collect_files(root, cast(Sequence[str], args.files)) list_files = cast(bool, args.list_files) fix = cast(bool, args.fix) dry_run = cast(bool, args.dry_run) if list_files: for path in files: print(display_path(root, path)) return 0 mode = "fix" if fix else "check" print(f"format mode: {mode}") print(f"candidate_files: {len(files)}") if dry_run: plan = build_plan(root, mode=mode, files=files) common.print_plan(plan) print(f"{STATUS_PASS}: dry-run planned clang-format {mode}") return 0 tool = require_clang_format() plan = build_plan(root, mode=mode, files=files, tool=tool) common.print_plan(plan) return run_format(root, mode=mode, 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__) group = parser.add_mutually_exclusive_group() _ = group.add_argument("--check", action="store_true", default=True, help="Verify clang-format compliance.") _ = group.add_argument("--fix", action="store_true", help="Rewrite files with clang-format.") _ = 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 format. Defaults to filtered project C/C++ files.", ) return parser def collect_files(root: pathlib.Path, requested_files: Sequence[str]) -> tuple[pathlib.Path, ...]: if not requested_files: return tuple(path_filter.iter_cpp_files(root)) 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"format input is not a file: {value}") if not path_filter.is_cpp_source(path): raise common.ScriptError(f"format 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"format input is under an excluded directory: {display_path(root, path)}") files.append(path) return tuple(sorted(dict.fromkeys(files))) def build_plan( root: pathlib.Path, *, mode: str, files: Sequence[pathlib.Path], tool: str = CLANG_FORMAT_TOOL, style_file: pathlib.Path | None = None, ) -> tuple[common.CommandPlan, ...]: style_path = root / ".clang-format" if style_file is None else style_file command = [tool, f"--style=file:{style_path}"] if mode == "fix": command.append("-i") else: command.extend(("--dry-run", "--Werror")) command.extend(str(path) for path in files) return ( common.CommandPlan( label=f"format-{mode}", command=tuple(command), cwd=root, required_tool=tool, ), ) def run_format(root: pathlib.Path, *, mode: str, files: Sequence[pathlib.Path], tool: str) -> int: if not files: print(f"{STATUS_PASS}: no C/C++ files selected") return 0 command = build_plan(root, mode=mode, files=files, tool=tool)[0].command completed = subprocess.run(list(command), cwd=str(root), check=False) if completed.returncode == 0: action = "formatted" if mode == "fix" else "verified" print(f"{STATUS_PASS}: clang-format {action} {len(files)} file(s)") return 0 print(f"{STATUS_FAIL}: clang-format {mode} failed for {len(files)} file(s)", file=sys.stderr) print("Run `python3 scripts/format.py --fix` to rewrite selected files.", file=sys.stderr) return completed.returncode def require_clang_format(tool: str = CLANG_FORMAT_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_format_major(version_text) if major is None: raise common.ScriptError(f"Unable to parse {tool} version from: {version_text}") if major < MIN_CLANG_FORMAT_MAJOR: raise common.ScriptError( f"{tool} {MIN_CLANG_FORMAT_MAJOR}+ is required; found major version {major}: {version_text}" ) print(f"clang-format version: {version_text}") return path def parse_clang_format_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())