mirror of
https://github.com/XRPLF/rippled.git
synced 2026-04-29 15:37:57 +00:00
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ayaz Salikhov <mathbunnyru@users.noreply.github.com> Co-authored-by: Bart <bthomee@users.noreply.github.com>
207 lines
5.9 KiB
Python
Executable File
207 lines
5.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Pre-commit hook that runs clang-tidy on changed files using run-clang-tidy."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
HEADER_EXTENSIONS = {".h", ".hpp", ".ipp"}
|
|
SOURCE_EXTENSIONS = {".cpp"}
|
|
INCLUDE_RE = re.compile(r"^\s*#\s*include\s*[<\"]([^>\"]+)[>\"]")
|
|
|
|
|
|
def find_run_clang_tidy() -> str | None:
|
|
for candidate in ("run-clang-tidy-21", "run-clang-tidy"):
|
|
if path := shutil.which(candidate):
|
|
return path
|
|
return None
|
|
|
|
|
|
def find_build_dir(repo_root: Path) -> Path | None:
|
|
for name in (".build", "build"):
|
|
candidate = repo_root / name
|
|
if (candidate / "compile_commands.json").exists():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def build_include_graph(build_dir: Path, repo_root: Path) -> tuple[dict, set]:
|
|
"""
|
|
Scan all files reachable from compile_commands.json and build an inverted include graph.
|
|
|
|
Returns:
|
|
inverted: header_path -> set of files that include it
|
|
source_files: set of all TU paths from compile_commands.json
|
|
"""
|
|
with open(build_dir / "compile_commands.json") as f:
|
|
db = json.load(f)
|
|
|
|
source_files = {Path(e["file"]).resolve() for e in db}
|
|
include_roots = [repo_root / "include", repo_root / "src"]
|
|
inverted: dict[Path, set[Path]] = defaultdict(set)
|
|
|
|
to_scan: set[Path] = set(source_files)
|
|
scanned: set[Path] = set()
|
|
|
|
while to_scan:
|
|
file = to_scan.pop()
|
|
if file in scanned or not file.exists():
|
|
continue
|
|
scanned.add(file)
|
|
|
|
content = file.read_text()
|
|
|
|
for line in content.splitlines():
|
|
m = INCLUDE_RE.match(line)
|
|
if not m:
|
|
continue
|
|
for root in include_roots:
|
|
candidate = (root / m.group(1)).resolve()
|
|
if candidate.exists():
|
|
inverted[candidate].add(file)
|
|
if candidate not in scanned:
|
|
to_scan.add(candidate)
|
|
break
|
|
|
|
return inverted, source_files
|
|
|
|
|
|
def find_tus_for_headers(
|
|
headers: list[Path],
|
|
inverted: dict[Path, set[Path]],
|
|
source_files: set[Path],
|
|
) -> set[Path]:
|
|
"""
|
|
For each header, pick one TU that transitively includes it.
|
|
Prefers a TU whose stem matches the header's stem, otherwise picks the first found.
|
|
"""
|
|
result: set[Path] = set()
|
|
|
|
for header in headers:
|
|
preferred: Path | None = None
|
|
visited: set[Path] = {header}
|
|
stack: list[Path] = [header]
|
|
|
|
while stack:
|
|
h = stack.pop()
|
|
for inc in inverted.get(h, ()):
|
|
if inc in source_files:
|
|
if inc.stem == header.stem:
|
|
preferred = inc
|
|
break
|
|
if preferred is None:
|
|
preferred = inc
|
|
if inc not in visited:
|
|
visited.add(inc)
|
|
stack.append(inc)
|
|
if preferred is not None and preferred.stem == header.stem:
|
|
break
|
|
|
|
if preferred is not None:
|
|
result.add(preferred)
|
|
|
|
return result
|
|
|
|
|
|
def resolve_files(
|
|
input_files: list[str], build_dir: Path, repo_root: Path
|
|
) -> list[str]:
|
|
"""
|
|
Split input into source files and headers. Source files are passed through;
|
|
headers are resolved to the TUs that transitively include them.
|
|
"""
|
|
sources: list[Path] = []
|
|
headers: list[Path] = []
|
|
|
|
for f in input_files:
|
|
p = Path(f).resolve()
|
|
if p.suffix in SOURCE_EXTENSIONS:
|
|
sources.append(p)
|
|
elif p.suffix in HEADER_EXTENSIONS:
|
|
headers.append(p)
|
|
|
|
if not headers:
|
|
return [str(p) for p in sources]
|
|
|
|
print(
|
|
f"Resolving {len(headers)} header(s) to compilation units...", file=sys.stderr
|
|
)
|
|
inverted, source_files = build_include_graph(build_dir, repo_root)
|
|
tus = find_tus_for_headers(headers, inverted, source_files)
|
|
|
|
if not tus:
|
|
print(
|
|
"Warning: no compilation units found that include the modified headers; "
|
|
"skipping clang-tidy for headers.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
return sorted({str(p) for p in (*sources, *tus)})
|
|
|
|
|
|
def staged_files(repo_root: Path) -> list[str]:
|
|
result = subprocess.run(
|
|
["git", "diff", "--staged", "--name-only", "--diff-filter=d"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=repo_root,
|
|
)
|
|
if result.returncode != 0:
|
|
print(
|
|
"clang-tidy check failed: 'git diff --staged' command failed.",
|
|
file=sys.stderr,
|
|
)
|
|
if result.stderr:
|
|
print(result.stderr, file=sys.stderr)
|
|
sys.exit(result.returncode or 1)
|
|
return [str(repo_root / p) for p in result.stdout.splitlines() if p]
|
|
|
|
|
|
def main():
|
|
if not os.environ.get("TIDY"):
|
|
return 0
|
|
|
|
repo_root = Path(__file__).parent.parent
|
|
files = staged_files(repo_root)
|
|
if not files:
|
|
return 0
|
|
|
|
run_clang_tidy = find_run_clang_tidy()
|
|
if not run_clang_tidy:
|
|
print(
|
|
"clang-tidy check failed: TIDY is enabled but neither "
|
|
"'run-clang-tidy-21' nor 'run-clang-tidy' was found in PATH.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
build_dir = find_build_dir(repo_root)
|
|
if not build_dir:
|
|
print(
|
|
"clang-tidy check failed: no build directory with compile_commands.json found "
|
|
"(looked for .build/ and build/)",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
tidy_files = resolve_files(files, build_dir, repo_root)
|
|
if not tidy_files:
|
|
return 0
|
|
|
|
result = subprocess.run(
|
|
[run_clang_tidy, "-quiet", "-p", str(build_dir), "-fix", "-allow-no-checks"]
|
|
+ tidy_files
|
|
)
|
|
return result.returncode
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|