Files
2026-05-18 09:41:16 +08:00

179 lines
6.0 KiB
Python

#!/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())