| #!/usr/bin/env python |
| # |
| # Runs clang-tidy on files based on a `compile_commands.json` file |
| # |
| |
| """ |
| Run clang-tidy in parallel on compile databases. |
| |
| Example run: |
| |
| # This prepares the build. NOTE this is `build` not `gen` because the build |
| # step generates required header files (this can be simplified if needed |
| # to invoke ninja to compile only generated files if needed) |
| |
| ./scripts/build/build_examples.py --target linux-x64-chip-tool-clang build |
| |
| # Actually running clang-tidy to check status |
| |
| ./scripts/run-clang-tidy-on-compile-commands.py check |
| |
| # Run and output a fix yaml |
| |
| ./scripts/run-clang-tidy-on-compile-commands.py --export-fixes out/fixes.yaml check |
| |
| # Apply the fixes |
| clang-apply-replacements out/fixes.yaml |
| |
| """ |
| |
| import glob |
| import json |
| import logging |
| import multiprocessing |
| import os |
| import queue |
| import re |
| import shlex |
| import subprocess |
| import sys |
| import tempfile |
| import threading |
| import traceback |
| from pathlib import Path |
| |
| import click |
| import coloredlogs |
| import yaml |
| |
| |
| class TidyResult: |
| def __init__(self, path: str, ok: bool): |
| self.path = path |
| self.ok = ok |
| |
| def __repr__(self): |
| if self.ok: |
| status = "OK" |
| else: |
| status = "FAIL" |
| |
| return "%s(%s)" % (status, self.path) |
| |
| def __str__(self): |
| return self.__repr__() |
| |
| |
| class ClangTidyEntry: |
| """Represents a single entry for running clang-tidy based |
| on a compile_commands.json item. |
| """ |
| |
| def __init__(self, json_entry, gcc_sysroot=None): |
| # Entries in compile_commands: |
| # - "directory": location to run the compile |
| # - "file": a relative path to directory |
| # - "command": full compilation command |
| |
| self.directory = json_entry["directory"] |
| self.file = json_entry["file"] |
| self.valid = False |
| self.clang_arguments = [] |
| self.tidy_arguments = [] |
| |
| command = json_entry["command"] |
| |
| command_items = shlex.split(command) |
| compiler = os.path.basename(command_items[0]) |
| |
| # Clang-tidy complains about "unused argument '-c'" |
| # We could disable that with something like |
| # |
| # self.clang_arguments.append("-Wno-unused-command-line-argument") |
| # |
| # However that seems to potentially disable a lot, so for now just filter out the |
| # offending argument |
| command_items = [arg for arg in command_items if arg not in {"-c", "-S"}] |
| |
| # Allow gcc/g++ invocations to also be tidied - arguments should be |
| # compatible and on darwin gcc/g++ is actually a symlink to clang |
| if compiler in ["clang++", "clang", "gcc", "g++"]: |
| self.valid = True |
| self.clang_arguments = command_items[1:] |
| else: |
| logging.warning("Cannot tidy %s - not a clang compile command", self.file) |
| return |
| |
| if compiler in ["gcc", "g++"] and gcc_sysroot: |
| self.clang_arguments.insert(0, "--sysroot=" + gcc_sysroot) |
| |
| @property |
| def full_path(self): |
| return os.path.abspath(os.path.join(self.directory, self.file)) |
| |
| def ExportFixesTo(self, f: str): |
| self.tidy_arguments.append("--export-fixes") |
| self.tidy_arguments.append(f) |
| |
| def SetChecks(self, checks: str): |
| self.tidy_arguments.append("--checks") |
| self.tidy_arguments.append(checks) |
| |
| def Check(self): |
| logging.debug("Running tidy on %s from %s", self.file, self.directory) |
| try: |
| cmd = ( |
| ["clang-tidy", self.file] |
| + self.tidy_arguments |
| + ["--"] |
| + self.clang_arguments |
| ) |
| logging.debug("Executing: %r" % cmd) |
| |
| proc = subprocess.Popen( |
| cmd, |
| cwd=self.directory, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| ) |
| output, err = proc.communicate() |
| if output: |
| # Output generally contains validation data. Print it out as-is |
| logging.info("TIDY %s: %s", self.file, output.decode("utf-8")) |
| |
| if err: |
| # Most (all?) of our files do contain errors in system-headers so lines like these |
| # are expected: |
| # |
| # ``` |
| # 59 warnings generated. |
| # Suppressed 59 warnings (59 in non-user code). |
| # Use -header-filter=.* to display errors from all non-system headers. |
| # Use -system-headers to display errors from system headers as well. |
| # ``` |
| # |
| # The list below ignores those expected output lines. |
| skip_strings = [ |
| "warnings generated", |
| "in non-user code", |
| "Use -header-filter=.* to display errors from all non-system headers.", |
| "Use -system-headers to display errors from system headers as well.", |
| ] |
| |
| for line in err.decode("utf-8").split("\n"): |
| line = line.strip() |
| |
| if any(map(lambda s: s in line, skip_strings)): |
| continue |
| |
| if not line: |
| continue # no empty lines |
| |
| logging.warning("TIDY %s: %s", self.file, line) |
| |
| if proc.returncode != 0: |
| if proc.returncode < 0: |
| logging.error( |
| "Failed %s with signal %d", self.file, -proc.returncode |
| ) |
| else: |
| logging.warning( |
| "Tidy %s ended with code %d", self.file, proc.returncode |
| ) |
| return TidyResult(self.full_path, False) |
| except Exception: |
| traceback.print_exc() |
| return TidyResult(self.full_path, False) |
| |
| return TidyResult(self.full_path, True) |
| |
| |
| class TidyState: |
| def __init__(self): |
| self.successes = 0 |
| self.failures = 0 |
| self.lock = threading.Lock() |
| self.failed_files = [] |
| |
| def Success(self): |
| with self.lock: |
| self.successes += 1 |
| |
| def Failure(self, path: str): |
| with self.lock: |
| self.failures += 1 |
| self.failed_files.append(path) |
| logging.error("Failed to process %s", path) |
| |
| |
| def find_darwin_gcc_sysroot(): |
| for line in subprocess.check_output( |
| "xcodebuild -sdk -version".split(), text=True |
| ).splitlines(): |
| if not line.startswith("Path: "): |
| continue |
| path = line[line.find(": ") + 2:] |
| if "/MacOSX.platform/" not in path: |
| continue |
| logging.info("Found %s" % path) |
| return path |
| |
| # A hard-coded value that works on default installations |
| logging.warning("Using default platform sdk path. This may be incorrect.") |
| return "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" |
| |
| |
| class ClangTidyRunner: |
| """Handles running clang-tidy""" |
| |
| def __init__(self): |
| self.entries = [] |
| self.state = TidyState() |
| self.fixes_file = None |
| self.fixes_temporary_file_dir = None |
| self.gcc_sysroot = None |
| self.file_names_to_check = set() |
| |
| if sys.platform == "darwin": |
| # Darwin gcc invocation will auto select a system root, however clang requires an explicit path since |
| # we are using the built-in pigweed clang-tidy. |
| logging.info("Searching for a MacOS system root for gcc invocations...") |
| self.gcc_sysroot = find_darwin_gcc_sysroot() |
| logging.info(" Chose: %s" % self.gcc_sysroot) |
| |
| def AddDatabase(self, compile_commands_json): |
| database = json.load(open(compile_commands_json)) |
| |
| for entry in database: |
| item = ClangTidyEntry(entry, self.gcc_sysroot) |
| if not item.valid: |
| continue |
| |
| if item.file in self.file_names_to_check: |
| logging.info("Ignoring additional request for checking %s", item.file) |
| continue |
| |
| self.file_names_to_check.add(item.file) |
| self.entries.append(item) |
| |
| def Cleanup(self): |
| if self.fixes_temporary_file_dir: |
| all_diagnostics = [] |
| |
| # When running over several files, fixes may be applied to the same |
| # file over and over again, like 'append override' can result in the |
| # same override being appended multiple times. |
| already_seen = set() |
| for name in glob.iglob( |
| os.path.join(self.fixes_temporary_file_dir.name, "*.yaml") |
| ): |
| content = yaml.safe_load(open(name, "r")) |
| if not content: |
| continue |
| diagnostics = content.get("Diagnostics", []) |
| |
| # Allow all diagnostics for distinct paths to be applied |
| # at once but never again for future paths |
| for d in diagnostics: |
| if d["DiagnosticMessage"]["FilePath"] not in already_seen: |
| all_diagnostics.append(d) |
| |
| # in the future assume these files were already processed |
| for d in diagnostics: |
| already_seen.add(d["DiagnosticMessage"]["FilePath"]) |
| |
| if all_diagnostics: |
| with open(self.fixes_file, "w") as out: |
| yaml.safe_dump( |
| {"MainSourceFile": "", "Diagnostics": all_diagnostics}, out |
| ) |
| else: |
| open(self.fixes_file, "w").close() |
| |
| logging.info( |
| "Cleaning up directory: %r", self.fixes_temporary_file_dir.name |
| ) |
| self.fixes_temporary_file_dir.cleanup() |
| self.fixes_temporary_file_dir = None |
| |
| def ExportFixesTo(self, f): |
| # use absolute path since running things will change working directories |
| self.fixes_file = os.path.abspath(f) |
| self.fixes_temporary_file_dir = tempfile.TemporaryDirectory( |
| prefix="tidy-", suffix="-fixes" |
| ) |
| |
| logging.info( |
| "Storing temporary fix files into %s", self.fixes_temporary_file_dir.name |
| ) |
| for idx, e in enumerate(self.entries): |
| e.ExportFixesTo( |
| os.path.join( |
| self.fixes_temporary_file_dir.name, "fixes%d.yaml" % (idx + 1,) |
| ) |
| ) |
| |
| def SetChecks(self, checks: str): |
| for e in self.entries: |
| e.SetChecks(checks) |
| |
| def FilterEntries(self, f): |
| for e in self.entries: |
| if not f(e): |
| logging.info("Skipping %s in %s", e.file, e.directory) |
| self.entries = [e for e in self.entries if f(e)] |
| |
| def CheckThread(self, task_queue): |
| while True: |
| entry = task_queue.get() |
| status = entry.Check() |
| |
| if status.ok: |
| self.state.Success() |
| else: |
| self.state.Failure(status.path) |
| |
| task_queue.task_done() |
| |
| def Check(self): |
| count = multiprocessing.cpu_count() |
| task_queue = queue.Queue(count) |
| |
| for _ in range(count): |
| t = threading.Thread(target=self.CheckThread, args=(task_queue,)) |
| t.daemon = True |
| t.start() |
| |
| for e in self.entries: |
| task_queue.put(e) |
| task_queue.join() |
| |
| logging.info("Successfully processed %d path(s)", self.state.successes) |
| if self.state.failures: |
| logging.warning("Failed to process %d path(s)", self.state.failures) |
| logging.warning("The following paths failed clang-tidy checks:") |
| for name in self.state.failed_files: |
| logging.warning(" - %s", name) |
| |
| return self.state.failures == 0 |
| |
| |
| # Supported log levels, mapping string values required for argument |
| # parsing into logging constants |
| __LOG_LEVELS__ = { |
| "debug": logging.DEBUG, |
| "info": logging.INFO, |
| "warn": logging.WARN, |
| "fatal": logging.FATAL, |
| } |
| |
| |
| @click.group(chain=True) |
| @click.option( |
| "--compile-database", |
| default=[], |
| multiple=True, |
| help="Path to `compile_commands.json` to use for executing clang-tidy.", |
| ) |
| @click.option( |
| "--file-include-regex", |
| default="/(src|examples)/", |
| help="regular expression to apply to the file paths for running.", |
| ) |
| @click.option( |
| "--file-exclude-regex", |
| # NOTE: if trying '/third_party/' note that a lot of sources are routed through |
| # paths like `../../examples/chip-tool/third_party/connectedhomeip/src/` |
| default="/(repo|zzz_generated)/", |
| help="Regular expression to apply to the file paths for running. Skip overrides includes.", |
| ) |
| @click.option( |
| "--log-level", |
| default="INFO", |
| type=click.Choice(__LOG_LEVELS__.keys(), case_sensitive=False), |
| help="Determines the verbosity of script output.", |
| ) |
| @click.option( |
| "--no-log-timestamps", |
| default=False, |
| is_flag=True, |
| help="Skip timestaps in log output", |
| ) |
| @click.option( |
| "--export-fixes", |
| default=None, |
| type=click.Path(), |
| help="Where to export fixes to apply.", |
| ) |
| @click.option( |
| "--checks", |
| default=None, |
| type=str, |
| help="Checks to run (passed in to clang-tidy). If not set the .clang-tidy file is used.", |
| ) |
| @click.option( |
| "--file-list-file", |
| default=None, |
| type=click.Path(exists=True), |
| help="When provided, only tidy files that match files mentioned in this file.", |
| ) |
| @click.pass_context |
| def main( |
| context, |
| compile_database, |
| file_include_regex, |
| file_exclude_regex, |
| log_level, |
| no_log_timestamps, |
| export_fixes, |
| checks, |
| file_list_file, |
| ): |
| log_fmt = "%(asctime)s %(levelname)-7s %(message)s" |
| if no_log_timestamps: |
| log_fmt = "%(levelname)-7s %(message)s" |
| coloredlogs.install(level=__LOG_LEVELS__[log_level], fmt=log_fmt) |
| |
| if not compile_database: |
| logging.warning( |
| "Compilation database file not provided. Searching for first item in ./out" |
| ) |
| compile_database = next( |
| glob.iglob("./out/**/compile_commands.json", recursive=True) |
| ) |
| if not compile_database: |
| raise Exception("Could not find `compile_commands.json` in ./out") |
| logging.info("Will use %s for compile", compile_database) |
| compile_database = [compile_database] |
| |
| context.obj = runner = ClangTidyRunner() |
| |
| @context.call_on_close |
| def cleanup(): |
| runner.Cleanup() |
| |
| for name in compile_database: |
| runner.AddDatabase(name) |
| |
| if file_include_regex: |
| r = re.compile(file_include_regex) |
| runner.FilterEntries(lambda e: r.search(e.file)) |
| |
| if file_exclude_regex: |
| r = re.compile(file_exclude_regex) |
| runner.FilterEntries(lambda e: not r.search(e.file)) |
| |
| if file_list_file: |
| acceptable = set() |
| with open(file_list_file, "rt") as f: |
| for file_name in f.readlines(): |
| acceptable.add(Path(file_name.strip()).resolve().as_posix()) |
| |
| runner.FilterEntries(lambda e: e.full_path in acceptable) |
| |
| if export_fixes: |
| runner.ExportFixesTo(export_fixes) |
| |
| if checks: |
| runner.SetChecks(checks) |
| |
| for e in context.obj.entries: |
| logging.info("Will tidy %s", e.full_path) |
| |
| |
| @main.command("check", help="Run clang-tidy check") |
| @click.pass_context |
| def cmd_check(context): |
| if not context.obj.Check(): |
| sys.exit(1) |
| |
| |
| @main.command("fix", help="Run check followd by fix") |
| @click.pass_context |
| def cmd_fix(context): |
| runner = context.obj |
| with tempfile.TemporaryDirectory(prefix="tidy-apply-fixes") as tmpdir: |
| if not runner.fixes_file: |
| runner.ExportFixesTo(os.path.join(tmpdir, "fixes.tmp")) |
| |
| runner.Check() |
| runner.Cleanup() |
| |
| if runner.state.failures: |
| fixes_yaml = os.path.join(tmpdir, "fixes.yaml") |
| with open(fixes_yaml, "w") as out: |
| out.write(open(runner.fixes_file, "r").read()) |
| |
| logging.info("Applying fixes in %s", tmpdir) |
| subprocess.check_call(["clang-apply-replacements", tmpdir]) |
| else: |
| logging.info("No failures detected, no fixes to apply.") |
| |
| |
| if __name__ == "__main__": |
| main(auto_envvar_prefix="CHIP") |