179 lines
6.0 KiB
Python
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())
|