Files
eigen/scripts/style_common.py

224 lines
8.8 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: The Eigen Authors
# SPDX-License-Identifier: MPL-2.0
"""Shared plumbing for the added-line style checks.
Both ``check_style.py`` (textual conventions) and ``clang_tidy_hook.py``
(clang-tidy conventions) need the same two things: the set of line numbers a
change ADDED to each file, and the ability to turn a Claude Code PostToolUse
payload into that set. That logic lives here so the two checkers cannot drift
apart in what they consider an added line.
Run directly to emit a clang-tidy ``--line-filter`` argument for the files a
diff touches, which is how ``ci/scripts/run-clang-tidy.sh`` restricts an MR's
diagnostics to its own lines:
python3 scripts/style_common.py --line-filter BASE [--] [path ...]
"""
import argparse
import json
import os
import re
import subprocess
import sys
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CXX_EXT = {".h", ".hpp", ".hxx", ".cpp", ".cc", ".cxx", ".cu", ".cuh", ".inc"}
EXTENSIONLESS_HEADER_TREES = ("Eigen/", "unsupported/Eigen/")
def is_cxx_path(rel_path):
"""Return whether ``rel_path`` is C++ source, including Eigen's
extensionless public module headers such as ``Eigen/Core``."""
return (os.path.splitext(rel_path)[1].lower() in CXX_EXT
or (not os.path.splitext(os.path.basename(rel_path))[1]
and rel_path.startswith(EXTENSIONLESS_HEADER_TREES)))
def git(root, *args):
return subprocess.run(["git"] + list(args), cwd=root, capture_output=True, text=True)
def read_post_image(root, rel_path):
try:
with open(os.path.join(root, rel_path), encoding="utf-8", errors="replace") as handle:
return handle.read()
except OSError:
return None
def added_lines_from_diff(diff_text):
"""Parse ``git diff -U0`` output into {relative_path: set(added line numbers)}."""
files = {}
path, new_line = None, 0
for raw in diff_text.splitlines():
if raw.startswith("+++ "):
name = raw[4:]
path = None if name == "/dev/null" else name[2:] if name.startswith("b/") else name
elif raw.startswith("@@"):
m = re.search(r"\+(\d+)(?:,(\d+))?", raw)
new_line = int(m.group(1)) if m else 0
elif raw.startswith("+") and not raw.startswith("+++"):
if path is not None:
files.setdefault(path, set()).add(new_line)
new_line += 1
elif not raw.startswith("-") and not raw.startswith("\\"):
# "\ No newline at end of file" is a marker, not a context line.
new_line += 1
return files
def added_from_structured_patch(tool_response):
"""Derive added line numbers from the tool response's structured patch,
which records the exact edited hunks. A valid deletion-only patch returns
an empty set; None means absent or malformed data and enables fallback."""
try:
added = set()
for hunk in tool_response["structuredPatch"]:
line_no = int(hunk["newStart"])
for entry in hunk["lines"]:
if entry.startswith("+"):
added.add(line_no)
line_no += 1
elif not entry.startswith("-") and not entry.startswith("\\"):
# "\ No newline at end of file" is a marker, not a context line.
line_no += 1
return added
except Exception:
return None
def hook_added_line_numbers(content, snippets):
"""Map the strings an edit added onto post-image line numbers.
Each snippet must occur exactly once — a repeated occurrence cannot be
told apart from pre-existing identical text, and would mark unrelated
lines. A snippet's span excludes the line after its trailing newline.
Returns None when any snippet is absent or ambiguous; the caller then
checks the snippet text standalone."""
added = set()
for snippet in snippets:
if not snippet:
continue
start = content.find(snippet)
if start < 0 or content.find(snippet, start + 1) >= 0:
return None
first = content.count("\n", 0, start) + 1
span = snippet.count("\n") + (0 if snippet.endswith("\n") else 1)
added.update(range(first, first + max(span, 1)))
return added
def hook_edit_snippets(payload):
"""Return (rel_path, snippets) for an edit payload, or (None, None) when
the tool call is not an in-repository C++ edit this checker handles."""
tool_input = payload.get("tool_input", {}) or {}
tool_name = payload.get("tool_name", "")
path = tool_input.get("file_path", "") or ""
if not path:
return None, None
rel_path = os.path.relpath(os.path.abspath(path), REPO_ROOT).replace(os.sep, "/")
if rel_path.startswith(".."):
return None, None
if tool_name == "Write":
snippets = [tool_input.get("content", "")]
elif tool_name == "Edit":
snippets = [tool_input.get("new_string", "")]
elif tool_name == "MultiEdit":
snippets = [e.get("new_string", "") for e in tool_input.get("edits", [])]
else:
return None, None
if not any(s.strip() for s in snippets):
return None, None
return rel_path, snippets
def hook_post_image_and_added(payload, rel_path, snippets):
"""Resolve the post-image lines and added line numbers for an edit.
The hook runs after the edit, so the file on disk is the post-image. The
edited lines come from the tool response's structured patch when present,
else from locating a uniquely occurring added string. Returns
(lines, added, exact): ``exact`` is False when the lines could not be
located in the real file and the snippet text is being checked standalone.
"""
text = read_post_image(REPO_ROOT, rel_path)
added = None
lines = None
if text is not None:
lines = text.splitlines()
added = added_from_structured_patch(payload.get("tool_response") or {})
if added is None:
if payload.get("tool_name") == "Write":
added = set(range(1, len(lines) + 1))
else:
added = hook_added_line_numbers(text, snippets)
if added is None:
lines = "\n".join(snippets).splitlines()
return lines, set(range(1, len(lines) + 1)), False
return lines, added, True
def diff_added_lines(base, root=REPO_ROOT, include_untracked=True):
"""Return {rel_path: set(added line numbers)} for everything added since
merge-base(base, HEAD), optionally including whole untracked C++ files."""
merge_base = git(root, "merge-base", base, "HEAD")
resolved = merge_base.stdout.strip() if merge_base.returncode == 0 else base
diff = git(root, "diff", "-U0", "--no-color", resolved)
if diff.returncode not in (0, 1):
sys.stderr.write(diff.stderr)
raise SystemExit(2)
per_file = added_lines_from_diff(diff.stdout)
if include_untracked:
untracked = git(root, "ls-files", "--others", "--exclude-standard")
for rel_path in untracked.stdout.splitlines():
if is_cxx_path(rel_path):
text = read_post_image(root, rel_path)
if text is not None:
per_file.setdefault(rel_path, set()).update(range(1, len(text.splitlines()) + 1))
return per_file
def as_ranges(numbers):
"""Collapse a set of line numbers into sorted inclusive [start, end] pairs."""
ranges = []
for number in sorted(numbers):
if ranges and number == ranges[-1][1] + 1:
ranges[-1][1] = number
else:
ranges.append([number, number])
return ranges
def line_filter_json(per_file):
"""Build clang-tidy's --line-filter argument from {rel_path: line numbers}.
clang-tidy matches ``name`` as a path suffix and, when the filter is
non-empty, reports nothing for files it does not list — so the filter
doubles as the file filter. Repository-relative paths are used rather
than bare basenames, which would collide across modules.
"""
return json.dumps([{"name": rel_path, "lines": as_ranges(lines)}
for rel_path, lines in sorted(per_file.items()) if lines])
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--line-filter", metavar="BASE", required=True,
help="emit a clang-tidy --line-filter for lines added since merge-base(BASE, HEAD)")
parser.add_argument("paths", nargs="*", help="restrict the filter to these repository-relative paths")
args = parser.parse_args()
per_file = diff_added_lines(args.line_filter, include_untracked=False)
if args.paths:
wanted = set(args.paths)
per_file = {p: lines for p, lines in per_file.items() if p in wanted}
print(line_filter_json(per_file))
return 0
if __name__ == "__main__":
sys.exit(main())