blob: 970f27e7889cc7dbe6b88c34a4c30e2c2b3b4ddc [file] [edit]
#!/usr/bin/env python3
# SPDX-FileCopyrightText: The Eigen Authors
# SPDX-License-Identifier: MPL-2.0
"""Run clang-tidy on the lines an edit added.
``.clang-tidy`` is the authority on the conventions clang-tidy can express, so
rather than restate them this hook runs the real thing on the edited file and
reports only diagnostics on added lines (``--line-filter``). Without that
restriction the checks would be unusable interactively: ``Eigen/src`` holds
~4100 ``typedef``s, and whole-file output buries the two lines just written.
Editing a header under ``Eigen/src`` cannot be compiled directly — the module's
``InternalHeaderCheck.h`` ``#error``s out — so a driver includes the module
umbrella first and then the edited header. The umbrella name is read from the
``#error "Please include <X>"`` directive. This mirrors what
``ci/scripts/run-clang-tidy.sh`` does for merge requests. Including the edited
header explicitly also covers a new header that the umbrella does not export
yet.
LLVM's ``clang-tidy-diff.py`` cannot replace this routing: it invokes changed
headers directly, which trips Eigen's internal-header guard, and a PostToolUse
payload is not a unified diff.
No compilation database is required: ``-std=c++14 -I<repo>`` is enough to parse
any file in the C++14 trees, which keeps the hook usable in a fresh checkout
with no build directory.
The hook fails open — a missing clang-tidy, an unparsable file, a translation
unit that does not compile, or any unexpected error exits 0 rather than blocking
the harness. Only real findings on added lines block.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from style_common import (REPO_ROOT, diff_added_lines, hook_edit_snippets, # noqa: E402
hook_post_image_and_added, is_cxx_path, line_filter_json)
# Parsing an umbrella costs about a second, so keep the hook off trees whose
# conventions these checks do not describe (bench/, demos/, doc/ examples).
CHECKED_TREES = ("Eigen/", "unsupported/Eigen/", "test/", "unsupported/test/", "failtest/", "blas/", "lapack/")
SRC_TREES = ("Eigen/src/", "unsupported/Eigen/src/")
# Modules whose umbrella needs a third-party header we cannot assume is present.
EXTERNAL_DEP_MODULES = ("AccelerateSupport", "CholmodSupport", "KLUSupport", "MetisSupport",
"PaStiXSupport", "PardisoSupport", "SPQRSupport", "SuperLUSupport",
"UmfPackSupport")
PLEASE_INCLUDE = re.compile(r'"Please include ([^ "]+)')
TIMEOUT_SECONDS = 30
def module_of(rel_path):
"""Return the ``Eigen/src/<Module>`` name owning ``rel_path``, else None."""
m = re.match(r"^(?:unsupported/)?Eigen/src/([^/]+)/", rel_path)
return m.group(1) if m else None
def umbrella_for(rel_path, root=REPO_ROOT):
"""Return the module umbrella to include when linting a src-tree header.
The source of truth is the ``#error "Please include <X>"`` directive on the
header itself or on its sibling ``InternalHeaderCheck.h``; the
``<root>/<Module>`` heuristic covers arch-specific backends nested deeper
than the module root, which carry no directive of their own.
"""
module = module_of(rel_path)
if module is None or module in EXTERNAL_DEP_MODULES:
return None
for candidate in (rel_path, os.path.join(os.path.dirname(rel_path), "InternalHeaderCheck.h")):
try:
with open(os.path.join(root, candidate), encoding="utf-8", errors="replace") as handle:
hit = PLEASE_INCLUDE.search(handle.read())
except OSError:
continue
if hit and os.path.isfile(os.path.join(root, hit.group(1))):
return hit.group(1)
fallback = ("unsupported/Eigen/" if rel_path.startswith("unsupported/") else "Eigen/") + module
return fallback if os.path.isfile(os.path.join(root, fallback)) else None
def cuda_include_dir(default_root="/usr/local/cuda"):
"""Include directory of an installed CUDA toolkit, or None if there is none.
Every header in the GPU module that includes ``GpuSupport.h`` reaches
``<cuda_runtime.h>``. Without it clang drops the include and every
declaration behind it, leaving an AST too truncated to check the module
against; with it the header is checked like any other.
``default_root`` is where the toolkit is looked for once the environment
names none; a test passes a directory that holds no toolkit so the
negative case does not depend on the host.
"""
roots = [os.environ.get(name) for name in ("CUDAToolkit_ROOT", "CUDA_HOME", "CUDA_PATH")]
roots.append(default_root)
for root in roots:
if root and os.path.isfile(os.path.join(root, "include", "cuda_runtime.h")):
return os.path.join(root, "include")
return None
def compile_args(rel_path, root=REPO_ROOT):
"""Include paths sufficient to parse ``rel_path`` without a compile database."""
args = ["-std=c++14", "-I" + root]
if rel_path.startswith(("test/", "unsupported/test/")):
args += ["-I" + os.path.join(root, "test")]
cuda = cuda_include_dir()
if cuda:
args += ["-isystem", cuda]
return args
def tidy_target(rel_path, tmpdir, root=REPO_ROOT):
"""Return the path clang-tidy should be pointed at, or None to skip.
Source files are linted directly; src-tree headers go through a generated
driver that includes their module umbrella followed by the header itself.
"""
if rel_path.startswith(SRC_TREES) or not os.path.splitext(rel_path)[1]:
include = umbrella_for(rel_path, root) if rel_path.startswith(SRC_TREES) else rel_path
if include is None:
return None
driver = os.path.join(tmpdir, "tidy_driver_" + rel_path.replace("/", "_") + ".cpp")
with open(driver, "w", encoding="utf-8") as handle:
handle.write("#include <%s>\n" % include)
if rel_path.startswith(SRC_TREES):
handle.write("#include <%s>\n" % rel_path)
return driver
if os.path.splitext(rel_path)[1].lower() in (".cpp", ".cc", ".cxx"):
return os.path.join(root, rel_path)
return None # a .h outside the src trees has no reliable standalone TU
def run_clang_tidy(per_file, root=REPO_ROOT, tidy=None):
"""Lint the added lines of each file in ``per_file``.
Returns (diagnostics, skipped): ``diagnostics`` are clang-tidy's warning
lines, ``skipped`` a list of (path, reason) for files that could not be
checked. A file whose translation unit does not compile is one of those:
clang-tidy emits no useful check output for it, and reporting it as clean
would claim a check that never ran.
"""
tidy = shutil.which(tidy or "clang-tidy")
if tidy is None:
return None, []
diagnostics, skipped = [], []
with tempfile.TemporaryDirectory() as tmpdir:
for rel_path, added in sorted(per_file.items()):
if not added or not is_cxx_path(rel_path) or not rel_path.startswith(CHECKED_TREES):
continue
target = tidy_target(rel_path, tmpdir, root)
if target is None:
skipped.append((rel_path, "no standalone translation unit"))
continue
cmd = [tidy, "--quiet",
"--config-file=" + os.path.join(root, ".clang-tidy"),
"--header-filter=" + re.escape(rel_path),
"--line-filter=" + line_filter_json({rel_path: added}),
target, "--"] + compile_args(rel_path, root)
try:
done = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_SECONDS)
except OSError:
skipped.append((rel_path, "clang-tidy could not be run"))
continue
except subprocess.TimeoutExpired:
skipped.append((rel_path, "clang-tidy timed out after %ds" % TIMEOUT_SECONDS))
continue
broke = False
output = "\n".join(part for part in (done.stdout, done.stderr) if part)
for line in output.splitlines():
if not re.search(r": (warning|error): ", line):
continue
if "clang-diagnostic-error" in line or ": error: " in line:
broke = True
continue
diagnostics.append(re.sub(r"^" + re.escape(root) + "/", "", line))
if broke:
skipped.append((rel_path, "translation unit did not compile"))
elif done.returncode != 0:
skipped.append((rel_path, "clang-tidy failed"))
return diagnostics, skipped
def run_hook_mode(tidy=None):
try:
payload = json.load(sys.stdin)
except Exception:
return 0
rel_path, snippets = hook_edit_snippets(payload)
if rel_path is None or not is_cxx_path(rel_path) or not rel_path.startswith(CHECKED_TREES):
return 0
_, added, exact = hook_post_image_and_added(payload, rel_path, snippets)
if not exact or not added:
# Without real line numbers a line filter would point at the wrong
# lines; silence beats a misplaced diagnostic.
return 0
diagnostics, skipped = run_clang_tidy({rel_path: added}, tidy=tidy)
if not diagnostics:
broken = [reason for path, reason in (skipped or []) if reason == "translation unit did not compile"]
if broken:
# A header edited mid-refactor routinely fails to parse on its own,
# so show the user a non-blocking notice rather than feeding Claude
# an error with no finding to act on.
message = "clang-tidy (%s): not checked — the translation unit did not compile." % rel_path
sys.stdout.write(json.dumps({"systemMessage": message}) + "\n")
return 0
return 0
sys.stderr.write("clang-tidy (%s) — added lines only:\n" % rel_path)
for line in diagnostics[:8]:
sys.stderr.write(" %s\n" % line)
if len(diagnostics) > 8:
sys.stderr.write(" ... and %d more\n" % (len(diagnostics) - 8))
sys.stderr.write("Advisory: these are Eigen conventions from .clang-tidy; keep a flagged construct "
"only if it is justified, and say why at the construct.\n")
return 2
def run_diff_mode(base, tidy=None):
per_file = diff_added_lines(base)
diagnostics, skipped = run_clang_tidy(per_file, tidy=tidy)
if diagnostics is None:
sys.stderr.write("%s not found; skipping.\n" % (tidy or "clang-tidy"))
return 0
for line in diagnostics:
print(line)
for rel_path, reason in skipped:
print("%s: not checked (%s)" % (rel_path, reason))
if diagnostics:
print("\n%d clang-tidy finding(s) on added lines." % len(diagnostics))
return 1 if diagnostics else 0
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--diff", metavar="BASE", help="lint lines added since merge-base(BASE, HEAD)")
mode.add_argument("--claude-hook", action="store_true", help="run as a Claude Code PostToolUse hook")
parser.add_argument("-b", "--binary", metavar="CLANG_TIDY",
help="clang-tidy executable to use, as a PATH name or a full path "
"(e.g. clang-tidy-19 from LLVM's version-suffixed packages); "
"default: clang-tidy")
args = parser.parse_args()
if args.claude_hook:
try:
return run_hook_mode(tidy=args.binary)
except Exception:
return 0 # a broken hook must not block the harness
if args.binary and shutil.which(args.binary) is None:
# In diff mode an explicitly named binary that is absent is a usage
# error; silently skipping would claim a check that never ran. Hook
# mode above keeps the fail-open contract instead.
parser.error("--binary %s not found" % args.binary)
return run_diff_mode(args.diff, tidy=args.binary)
if __name__ == "__main__":
sys.exit(main())