| #!/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)) |