Files
eigen/scripts/tidy_compile_db.py
2026-08-19 13:30:23 -07:00

336 lines
14 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: The Eigen Authors
# SPDX-License-Identifier: MPL-2.0
"""Narrow a compilation database to the entries a changed source needs.
``ci/scripts/run-clang-tidy.sh`` checks a changed source through the build
directory's ``compile_commands.json``. clang-tidy parses the file once per
entry naming it, and a split test contributes one entry per ``EIGEN_TEST_PART``
-- 17 for ``test/eigensolver_selfadjoint.cpp``, 41 for ``test/array_cwise.cpp``,
at roughly a minute and 3 GB each. A merge request touching such a file spends
the job's entire timeout on it, and every file after it goes unchecked without
saying so.
Two entries are interchangeable only when they differ solely in which
``-DEIGEN_TEST_PART_<n>`` they define. ``blas/complexdots.cpp`` is compiled
both into the static library and, with ``-DEIGEN_BLAS_BUILD_DLL``, into the
shared one; code that only the second configuration enables is parsed only by
the second entry. Entries are therefore grouped by the arguments that affect
parsing, every group keeps a representative, and only inside a group is a part
chosen.
Within a group the part matters exactly where the source asks for it. A line
inside a ``CALL_SUBTEST_<n>(...)`` invocation or an
``#if defined(EIGEN_TEST_PART_<n>)`` guard exists only when part ``<n>`` is
defined -- the split helper expands the invocation to nothing otherwise. Every
other line is parsed under any part, because the checks ``.clang-tidy`` enables
are syntactic: they fire on declarations rather than on template
instantiations. So each added line is mapped to the parts that can compile it,
and the smallest set of entries covering all of them is retained, up to
``MAX_PARTS``. Whatever that leaves unchecked is named on stdout: reporting a
file clean without having parsed it is the failure this replaces.
Usage: tidy_compile_db.py <database> <source> <outdir> <line-filter>
``<line-filter>`` is the clang-tidy ``--line-filter`` argument that
``scripts/style_common.py`` builds for the same source. Writes the reduced
database to ``<outdir>/compile_commands.json`` and prints a one-line summary
when it dropped anything. Exits 0 when the source is in the database, 1 when
it is not, and 2 when the database cannot be read.
"""
import bisect
import json
import os
import re
import shlex
import sys
# A part costs about a minute and 2 GB, against a 15 minute job budget shared
# with every other changed file, so cover as many parts as a diff needs only up
# to this many. Beyond it the summary line names the parts left unchecked
# rather than letting the job run into its timeout, which is the silent failure
# this whole reduction exists to remove.
MAX_PARTS = 4
# Arguments that name an output rather than describe the parse. They differ per
# split part (`bdcsvd_1.dir` against `bdcsvd_2.dir`) and would defeat grouping.
_VALUE_ARGS = ("-o", "-MT", "-MF", "-MQ", "-MJ")
_OUTPUT_FLAGS = ("-c", "-MD", "-MMD", "-MP")
_PART_DEFINE = re.compile(r"^-DEIGEN_TEST_PART_([0-9]+)(=.*)?$")
_PART_ARG = re.compile(r"-DEIGEN_TEST_PART_([0-9]+)\b")
_DIRECTIVE = re.compile(r"[ \t]*#[ \t]*(if|ifdef|ifndef|elif|else|endif)\b(.*)")
_PART_MENTION = re.compile(r"(!\s*)?EIGEN_TEST_PART_([0-9]+|ALL)\b")
_DEFINED_CALL = re.compile(r"defined\s*\(\s*([A-Za-z_]\w*)\s*\)")
_DEFINED_BARE = re.compile(r"defined\s+([A-Za-z_]\w*)")
_CALL_SUBTEST = re.compile(r"\bCALL_SUBTEST_([0-9]+)\s*\(")
def arguments_of(entry):
"""The compile command of a database entry, as an argument list."""
command = entry.get("command")
if command:
return shlex.split(command)
return list(entry.get("arguments", ()))
def part_of(entry):
"""The ``EIGEN_TEST_PART`` an entry defines, as a string, or None."""
found = _PART_ARG.search(entry.get("command") or " ".join(entry.get("arguments", ())))
return found.group(1) if found else None
def configuration_of(entry):
"""The arguments of an entry that affect how the source is parsed.
Output paths and the part define are excluded, so entries that differ only
in which part they build compare equal and nothing else does.
"""
configuration = []
skip = False
for argument in arguments_of(entry):
if skip:
skip = False
elif argument in _VALUE_ARGS:
skip = True
elif argument in _OUTPUT_FLAGS or (argument.startswith("-o") and len(argument) > 2):
continue
elif not _PART_DEFINE.match(argument):
configuration.append(argument)
return tuple(configuration)
def entries_for(commands, source):
"""The database entries compiling ``source``, in database order."""
source = os.path.realpath(source)
matches = []
for entry in commands:
if not isinstance(entry, dict):
continue
path = entry.get("file")
if not path:
continue
if not os.path.isabs(path):
path = os.path.join(entry.get("directory", ""), path)
if os.path.realpath(path) == source:
matches.append(entry)
return matches
def _part_mentions(condition, negated=False):
"""The parts a preprocessor condition names, split by sense.
``defined`` is elided first so a mention is negated exactly when a ``!``
precedes it. ``EIGEN_TEST_PART_ALL`` is one of the mentions: no entry of a
split test defines it, so a branch that only it can reach is compiled by no
part rather than by every part.
"""
text = _DEFINED_CALL.sub(r"\1", condition)
text = _DEFINED_BARE.sub(r"\1", text)
positive, negative = set(), set()
for mention in _PART_MENTION.finditer(text):
target = negative if bool(mention.group(1)) != negated else positive
target.add(mention.group(2))
return positive, negative
def _branch(positive, negative, universe):
"""The parts under which a branch with these mentions is compiled.
A positive mention is the binding one: ``defined(EIGEN_TEST_PART_3) && X``
still needs part 3, and ``defined(EIGEN_TEST_PART_3) || X`` is at least
compiled under part 3, which is all the caller needs to reach the line.
"""
if positive:
return universe & positive
if negative:
return universe - negative
return universe
def guard_constraints(lines, universe):
"""Map each 1-based line number to the parts its ``#if`` guards allow."""
per_line = [universe] * (len(lines) + 2)
cumulative = [universe]
# Parts already claimed by an earlier branch of each open conditional, so
# that an `#else` can exclude them.
taken = []
index = 0
while index < len(lines):
first = index
directive = _DIRECTIVE.match(lines[index])
condition = ""
if directive:
condition = directive.group(2)
while condition.rstrip().endswith("\\") and index + 1 < len(lines):
index += 1
condition = condition.rstrip()[:-1] + lines[index]
for number in range(first + 1, index + 2):
per_line[number] = cumulative[-1]
if directive:
keyword = directive.group(1)
if keyword in ("if", "ifdef", "ifndef"):
positive, negative = _part_mentions(condition, negated=keyword == "ifndef")
branch = _branch(positive, negative, universe)
taken.append(branch if positive or negative else frozenset())
cumulative.append(cumulative[-1] & branch)
elif keyword in ("elif", "else") and taken:
cumulative.pop()
if keyword == "elif":
positive, negative = _part_mentions(condition)
branch = _branch(positive, negative, universe)
taken[-1] = taken[-1] | (branch if positive or negative else frozenset())
else:
branch = universe - taken[-1]
cumulative.append(cumulative[-1] & branch)
elif keyword == "endif" and taken:
taken.pop()
cumulative.pop()
index += 1
return per_line
def call_constraints(text, universe):
"""Map each 1-based line number to the parts its ``CALL_SUBTEST`` allows.
A line can carry more than one invocation, in which case either part
compiles part of it.
"""
starts = [0] + [newline.end() for newline in re.finditer(r"\n", text)]
per_line = {}
for call in _CALL_SUBTEST.finditer(text):
depth, offset = 1, call.end()
while offset < len(text) and depth:
if text[offset] == "(":
depth += 1
elif text[offset] == ")":
depth -= 1
offset += 1
first = bisect.bisect_right(starts, call.start())
last = bisect.bisect_right(starts, offset - 1)
allowed = universe & {call.group(1)}
for number in range(first, last + 1):
per_line[number] = per_line.get(number, frozenset()) | allowed
return per_line
def required_parts(text, lines, universe):
"""The parts that can compile each of ``lines``, as a list of sets."""
source_lines = text.splitlines()
guards = guard_constraints(source_lines, universe)
calls = call_constraints(text, universe)
return [guards[number] & calls.get(number, universe)
for number in lines if number < len(guards)]
def select_parts(required, order, limit=MAX_PARTS):
"""The smallest set of parts covering ``required``, capped at ``limit``.
Returns the chosen parts and the requirements the cap left uncovered. Ties
go to the part the database lists first, which is what a diff that names no
part at all gets.
"""
chosen = []
uncovered = [need for need in required if need]
while uncovered and len(chosen) < limit:
counts = {}
for need in uncovered:
for part in need:
counts[part] = counts.get(part, 0) + 1
best = min(counts, key=lambda part: (-counts[part], order.index(part)))
chosen.append(best)
uncovered = [need for need in uncovered if best not in need]
return chosen, uncovered
def reduce_entries(matches, text, lines):
"""The entries of ``matches`` needed to parse ``lines`` of ``text``.
Returns the retained entries in database order, the parts the cap left out,
and how many added lines no configured part compiles at all.
"""
groups = {}
for index, entry in enumerate(matches):
groups.setdefault(configuration_of(entry), []).append(index)
keep, skipped, unreachable = set(), set(), 0
for group in groups.values():
parts = [part_of(matches[index]) for index in group]
if len(group) == 1 or None in parts or len(set(parts)) != len(parts):
# Not one configuration split into parts: every entry here parses
# something the others do not.
keep.update(group)
continue
universe = frozenset(parts)
required = required_parts(text, lines, universe)
unreachable = max(unreachable, sum(1 for need in required if not need))
chosen, uncovered = select_parts(required, parts)
skipped |= {part for need in uncovered for part in need}
keep.update(index for index, part in zip(group, parts) if part in chosen)
return [matches[index] for index in sorted(keep)], sorted(skipped, key=int), unreachable
def added_lines(line_filter):
"""The line numbers a clang-tidy ``--line-filter`` argument marks."""
lines = []
for item in json.loads(line_filter or "[]"):
for first, last in item.get("lines", ()):
lines.extend(range(first, last + 1))
return sorted(set(lines))
def summarize(matches, keep, skipped, unreachable):
"""A one-line report of what the reduction dropped, or an empty string.
What the reduction leaves out is stated rather than implied: a job that
prints "all clean" for a file it never parsed is the failure this replaces.
"""
if len(keep) == len(matches) and not skipped and not unreachable:
return ""
parts = sorted((part for part in (part_of(entry) for entry in keep) if part), key=int)
if not parts:
checking = "%d entries" % len(keep)
else:
checking = "part%s %s" % ("" if len(parts) == 1 else "s", ", ".join(parts))
report = "(%d compilation-database entries; checking %s" % (len(matches), checking)
if skipped:
report += "; NOT CHECKED: added lines also need part%s %s" % (
"" if len(skipped) == 1 else "s", ", ".join(skipped))
if unreachable:
report += "; NOT CHECKED: %d added line(s) belong to no configured part" % unreachable
return report + ")"
def main(argv):
database, source, outdir, line_filter = argv[1:5]
try:
with open(database, encoding="utf-8") as handle:
commands = json.load(handle)
except (OSError, ValueError) as error:
print("ERROR: could not read %s: %s" % (database, error), file=sys.stderr)
return 2
if not isinstance(commands, list):
print("ERROR: %s does not contain a JSON array" % database, file=sys.stderr)
return 2
matches = entries_for(commands, source)
if not matches:
return 1
with open(source, encoding="utf-8", errors="replace") as handle:
text = handle.read()
keep, skipped, unreachable = reduce_entries(matches, text, added_lines(line_filter))
os.makedirs(outdir, exist_ok=True)
with open(os.path.join(outdir, "compile_commands.json"), "w", encoding="utf-8") as handle:
json.dump(keep, handle)
report = summarize(matches, keep, skipped, unreachable)
if report:
print(report)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))