784 lines · python
1#!/usr/bin/env python32#3# ===-----------------------------------------------------------------------===#4#5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.6# See https://llvm.org/LICENSE.txt for license information.7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception8#9# ===-----------------------------------------------------------------------===#10# FIXME: Integrate with clang-tidy-diff.py11 12 13"""14Parallel clang-tidy runner15==========================16 17Runs clang-tidy over all files in a compilation database. Requires clang-tidy18and clang-apply-replacements in $PATH.19 20Example invocations.21- Run clang-tidy on all files in the current working directory with a default22 set of checks and show warnings in the cpp files and all project headers.23 run-clang-tidy.py $PWD24 25- Fix all header guards.26 run-clang-tidy.py -fix -checks=-*,llvm-header-guard27 28- Fix all header guards included from clang-tidy and header guards29 for clang-tidy headers.30 run-clang-tidy.py -fix -checks=-*,llvm-header-guard extra/clang-tidy \31 -header-filter=extra/clang-tidy32 33Compilation database setup:34https://clang.llvm.org/docs/HowToSetupToolingForLLVM.html35"""36 37import argparse38import asyncio39from dataclasses import dataclass40import glob41import json42import multiprocessing43import os44import re45import shutil46import subprocess47import sys48import tempfile49import time50import traceback51from types import ModuleType52from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, TypeVar53 54 55yaml: Optional[ModuleType] = None56try:57 import yaml58except ImportError:59 yaml = None60 61 62def strtobool(val: str) -> bool:63 """Convert a string representation of truth to a bool following LLVM's CLI argument parsing."""64 65 val = val.lower()66 if val in ["", "true", "1"]:67 return True68 elif val in ["false", "0"]:69 return False70 71 # Return ArgumentTypeError so that argparse does not substitute its own error message72 raise argparse.ArgumentTypeError(73 f"'{val}' is invalid value for boolean argument! Try 0 or 1."74 )75 76 77def find_compilation_database(path: str) -> str:78 """Adjusts the directory until a compilation database is found."""79 result = os.path.realpath("./")80 while not os.path.isfile(os.path.join(result, path)):81 parent = os.path.dirname(result)82 if result == parent:83 print("Error: could not find compilation database.")84 sys.exit(1)85 result = parent86 return result87 88 89def get_tidy_invocation(90 f: Optional[str],91 clang_tidy_binary: str,92 checks: str,93 tmpdir: Optional[str],94 build_path: str,95 header_filter: Optional[str],96 allow_enabling_alpha_checkers: bool,97 extra_arg: List[str],98 extra_arg_before: List[str],99 quiet: bool,100 config_file_path: str,101 config: str,102 line_filter: Optional[str],103 use_color: bool,104 plugins: List[str],105 warnings_as_errors: Optional[str],106 exclude_header_filter: Optional[str],107 allow_no_checks: bool,108 store_check_profile: Optional[str],109) -> List[str]:110 """Gets a command line for clang-tidy."""111 start = [clang_tidy_binary]112 if allow_enabling_alpha_checkers:113 start.append("-allow-enabling-analyzer-alpha-checkers")114 if exclude_header_filter is not None:115 start.append(f"--exclude-header-filter={exclude_header_filter}")116 if header_filter is not None:117 start.append(f"-header-filter={header_filter}")118 if line_filter is not None:119 start.append(f"-line-filter={line_filter}")120 if use_color is not None:121 if use_color:122 start.append("--use-color")123 else:124 start.append("--use-color=false")125 if checks:126 start.append(f"-checks={checks}")127 if tmpdir is not None:128 start.append("-export-fixes")129 # Get a temporary file. We immediately close the handle so clang-tidy can130 # overwrite it.131 (handle, name) = tempfile.mkstemp(suffix=".yaml", dir=tmpdir)132 os.close(handle)133 start.append(name)134 for arg in extra_arg:135 start.append(f"-extra-arg={arg}")136 for arg in extra_arg_before:137 start.append(f"-extra-arg-before={arg}")138 start.append(f"-p={build_path}")139 if quiet:140 start.append("-quiet")141 if config_file_path:142 start.append(f"--config-file={config_file_path}")143 elif config:144 start.append(f"-config={config}")145 for plugin in plugins:146 start.append(f"-load={plugin}")147 if warnings_as_errors:148 start.append(f"--warnings-as-errors={warnings_as_errors}")149 if allow_no_checks:150 start.append("--allow-no-checks")151 if store_check_profile:152 start.append("--enable-check-profile")153 start.append(f"--store-check-profile={store_check_profile}")154 if f:155 start.append(f)156 return start157 158 159def merge_replacement_files(tmpdir: str, mergefile: str) -> None:160 """Merge all replacement files in a directory into a single file"""161 assert yaml162 # The fixes suggested by clang-tidy >= 4.0.0 are given under163 # the top level key 'Diagnostics' in the output yaml files164 mergekey = "Diagnostics"165 merged = []166 for replacefile in glob.iglob(os.path.join(tmpdir, "*.yaml")):167 content = yaml.safe_load(open(replacefile, "r"))168 if not content:169 continue # Skip empty files.170 merged.extend(content.get(mergekey, []))171 172 if merged:173 # MainSourceFile: The key is required by the definition inside174 # include/clang/Tooling/ReplacementsYaml.h, but the value175 # is actually never used inside clang-apply-replacements,176 # so we set it to '' here.177 output = {"MainSourceFile": "", mergekey: merged}178 with open(mergefile, "w") as out:179 yaml.safe_dump(output, out)180 else:181 # Empty the file:182 open(mergefile, "w").close()183 184 185def aggregate_profiles(profile_dir: str) -> Dict[str, float]:186 """Aggregate timing data from multiple profile JSON files"""187 aggregated: Dict[str, float] = {}188 189 for profile_file in glob.iglob(os.path.join(profile_dir, "*.json")):190 try:191 with open(profile_file, "r", encoding="utf-8") as f:192 data = json.load(f)193 profile_data: Dict[str, float] = data.get("profile", {})194 195 for key, value in profile_data.items():196 if key.startswith("time.clang-tidy."):197 if key in aggregated:198 aggregated[key] += value199 else:200 aggregated[key] = value201 except (json.JSONDecodeError, KeyError, IOError) as e:202 print(f"Error: invalid json file {profile_file}: {e}", file=sys.stderr)203 continue204 205 return aggregated206 207 208def print_profile_data(aggregated_data: Dict[str, float]) -> None:209 """Print aggregated checks profile data in the same format as clang-tidy"""210 if not aggregated_data:211 return212 213 # Extract checker names and their timing data214 checkers: Dict[str, Dict[str, float]] = {}215 for key, value in aggregated_data.items():216 parts = key.split(".")217 if len(parts) >= 4 and parts[0] == "time" and parts[1] == "clang-tidy":218 checker_name = ".".join(219 parts[2:-1]220 ) # Everything between "clang-tidy" and the timing type221 timing_type = parts[-1] # wall, user, or sys222 223 if checker_name not in checkers:224 checkers[checker_name] = {"wall": 0.0, "user": 0.0, "sys": 0.0}225 226 checkers[checker_name][timing_type] = value227 228 if not checkers:229 return230 231 total_user = sum(data["user"] for data in checkers.values())232 total_sys = sum(data["sys"] for data in checkers.values())233 total_wall = sum(data["wall"] for data in checkers.values())234 235 sorted_checkers: List[Tuple[str, Dict[str, float]]] = sorted(236 checkers.items(), key=lambda x: x[1]["user"] + x[1]["sys"], reverse=True237 )238 239 def print_stderr(*args: Any, **kwargs: Any) -> None:240 print(*args, file=sys.stderr, **kwargs)241 242 print_stderr(243 "===-------------------------------------------------------------------------==="244 )245 print_stderr(" clang-tidy checks profiling")246 print_stderr(247 "===-------------------------------------------------------------------------==="248 )249 print_stderr(250 f" Total Execution Time: {total_user + total_sys:.4f} seconds ({total_wall:.4f} wall clock)\n"251 )252 253 # Calculate field widths based on the Total line which has the largest values254 total_combined = total_user + total_sys255 user_width = len(f"{total_user:.4f}")256 sys_width = len(f"{total_sys:.4f}")257 combined_width = len(f"{total_combined:.4f}")258 wall_width = len(f"{total_wall:.4f}")259 260 # Header with proper alignment261 additional_width = 9 # for " (100.0%)"262 user_header = "---User Time---".center(user_width + additional_width)263 sys_header = "--System Time--".center(sys_width + additional_width)264 combined_header = "--User+System--".center(combined_width + additional_width)265 wall_header = "---Wall Time---".center(wall_width + additional_width)266 267 print_stderr(268 f" {user_header} {sys_header} {combined_header} {wall_header} --- Name ---"269 )270 271 for checker_name, data in sorted_checkers:272 user_time = data["user"]273 sys_time = data["sys"]274 wall_time = data["wall"]275 combined_time = user_time + sys_time276 277 user_percent = (user_time / total_user * 100) if total_user > 0 else 0278 sys_percent = (sys_time / total_sys * 100) if total_sys > 0 else 0279 combined_percent = (280 (combined_time / total_combined * 100) if total_combined > 0 else 0281 )282 wall_percent = (wall_time / total_wall * 100) if total_wall > 0 else 0283 284 user_str = f"{user_time:{user_width}.4f} ({user_percent:5.1f}%)"285 sys_str = f"{sys_time:{sys_width}.4f} ({sys_percent:5.1f}%)"286 combined_str = f"{combined_time:{combined_width}.4f} ({combined_percent:5.1f}%)"287 wall_str = f"{wall_time:{wall_width}.4f} ({wall_percent:5.1f}%)"288 289 print_stderr(290 f" {user_str} {sys_str} {combined_str} {wall_str} {checker_name}"291 )292 293 user_total_str = f"{total_user:{user_width}.4f} (100.0%)"294 sys_total_str = f"{total_sys:{sys_width}.4f} (100.0%)"295 combined_total_str = f"{total_combined:{combined_width}.4f} (100.0%)"296 wall_total_str = f"{total_wall:{wall_width}.4f} (100.0%)"297 298 print_stderr(299 f" {user_total_str} {sys_total_str} {combined_total_str} {wall_total_str} Total"300 )301 302 303def find_binary(arg: str, name: str, build_path: str) -> str:304 """Get the path for a binary or exit"""305 if arg:306 if shutil.which(arg):307 return arg308 else:309 raise SystemExit(310 f"error: passed binary '{arg}' was not found or is not executable"311 )312 313 built_path = os.path.join(build_path, "bin", name)314 binary = shutil.which(name) or shutil.which(built_path)315 if binary:316 return binary317 else:318 raise SystemExit(f"error: failed to find {name} in $PATH or at {built_path}")319 320 321def apply_fixes(322 args: argparse.Namespace, clang_apply_replacements_binary: str, tmpdir: str323) -> None:324 """Calls clang-apply-fixes on a given directory."""325 invocation = [clang_apply_replacements_binary]326 invocation.append("-ignore-insert-conflict")327 if args.format:328 invocation.append("-format")329 if args.style:330 invocation.append(f"-style={args.style}")331 invocation.append(tmpdir)332 subprocess.call(invocation)333 334 335# FIXME Python 3.12: This can be simplified out with run_with_semaphore[T](...).336T = TypeVar("T")337 338 339async def run_with_semaphore(340 semaphore: asyncio.Semaphore,341 f: Callable[..., Awaitable[T]],342 *args: Any,343 **kwargs: Any,344) -> T:345 async with semaphore:346 return await f(*args, **kwargs)347 348 349@dataclass350class ClangTidyResult:351 filename: str352 invocation: List[str]353 returncode: int354 stdout: str355 stderr: str356 elapsed: float357 358 359async def run_tidy(360 args: argparse.Namespace,361 name: str,362 clang_tidy_binary: str,363 tmpdir: str,364 build_path: str,365 store_check_profile: Optional[str],366) -> ClangTidyResult:367 """368 Runs clang-tidy on a single file and returns the result.369 """370 invocation = get_tidy_invocation(371 name,372 clang_tidy_binary,373 args.checks,374 tmpdir,375 build_path,376 args.header_filter,377 args.allow_enabling_alpha_checkers,378 args.extra_arg,379 args.extra_arg_before,380 args.quiet,381 args.config_file,382 args.config,383 args.line_filter,384 args.use_color,385 args.plugins,386 args.warnings_as_errors,387 args.exclude_header_filter,388 args.allow_no_checks,389 store_check_profile,390 )391 392 try:393 process = await asyncio.create_subprocess_exec(394 *invocation, stdout=subprocess.PIPE, stderr=subprocess.PIPE395 )396 start = time.time()397 stdout, stderr = await process.communicate()398 end = time.time()399 except asyncio.CancelledError:400 process.terminate()401 await process.wait()402 raise403 404 assert process.returncode is not None405 return ClangTidyResult(406 name,407 invocation,408 process.returncode,409 stdout.decode("UTF-8"),410 stderr.decode("UTF-8"),411 end - start,412 )413 414 415async def main() -> None:416 parser = argparse.ArgumentParser(417 description="Runs clang-tidy over all files "418 "in a compilation database. Requires "419 "clang-tidy and clang-apply-replacements in "420 "$PATH or in your build directory."421 )422 parser.add_argument(423 "-allow-enabling-alpha-checkers",424 action="store_true",425 help="Allow alpha checkers from clang-analyzer.",426 )427 parser.add_argument(428 "-clang-tidy-binary", metavar="PATH", help="Path to clang-tidy binary."429 )430 parser.add_argument(431 "-clang-apply-replacements-binary",432 metavar="PATH",433 help="Path to clang-apply-replacements binary.",434 )435 parser.add_argument(436 "-checks",437 default=None,438 help="Checks filter, when not specified, use clang-tidy default.",439 )440 config_group = parser.add_mutually_exclusive_group()441 config_group.add_argument(442 "-config",443 default=None,444 help="Specifies a configuration in YAML/JSON format: "445 " -config=\"{Checks: '*', "446 ' CheckOptions: {x: y}}" '447 "When the value is empty, clang-tidy will "448 "attempt to find a file named .clang-tidy for "449 "each source file in its parent directories.",450 )451 config_group.add_argument(452 "-config-file",453 default=None,454 help="Specify the path of .clang-tidy or custom config "455 "file: e.g. -config-file=/some/path/myTidyConfigFile. "456 "This option internally works exactly the same way as "457 "-config option after reading specified config file. "458 "Use either -config-file or -config, not both.",459 )460 parser.add_argument(461 "-exclude-header-filter",462 default=None,463 help="Regular expression matching the names of the "464 "headers to exclude diagnostics from. Diagnostics from "465 "the main file of each translation unit are always "466 "displayed.",467 )468 parser.add_argument(469 "-header-filter",470 default=None,471 help="Regular expression matching the names of the "472 "headers to output diagnostics from. Diagnostics from "473 "the main file of each translation unit are always "474 "displayed.",475 )476 parser.add_argument(477 "-source-filter",478 default=None,479 help="Regular expression matching the names of the "480 "source files from compilation database to output "481 "diagnostics from.",482 )483 parser.add_argument(484 "-line-filter",485 default=None,486 help="List of files and line ranges to output diagnostics from.",487 )488 if yaml:489 parser.add_argument(490 "-export-fixes",491 metavar="file_or_directory",492 dest="export_fixes",493 help="A directory or a yaml file to store suggested fixes in, "494 "which can be applied with clang-apply-replacements. If the "495 "parameter is a directory, the fixes of each compilation unit are "496 "stored in individual yaml files in the directory.",497 )498 else:499 parser.add_argument(500 "-export-fixes",501 metavar="directory",502 dest="export_fixes",503 help="A directory to store suggested fixes in, which can be applied "504 "with clang-apply-replacements. The fixes of each compilation unit are "505 "stored in individual yaml files in the directory.",506 )507 parser.add_argument(508 "-j",509 type=int,510 default=0,511 help="Number of tidy instances to be run in parallel.",512 )513 parser.add_argument(514 "files",515 nargs="*",516 default=[".*"],517 help="Files to be processed (regex on path).",518 )519 parser.add_argument("-fix", action="store_true", help="apply fix-its.")520 parser.add_argument(521 "-format", action="store_true", help="Reformat code after applying fixes."522 )523 parser.add_argument(524 "-style",525 default="file",526 help="The style of reformat code after applying fixes.",527 )528 parser.add_argument(529 "-use-color",530 type=strtobool,531 nargs="?",532 const=True,533 help="Use colors in diagnostics, overriding clang-tidy's"534 " default behavior. This option overrides the 'UseColor"535 "' option in .clang-tidy file, if any.",536 )537 parser.add_argument(538 "-p", dest="build_path", help="Path used to read a compile command database."539 )540 parser.add_argument(541 "-extra-arg",542 dest="extra_arg",543 action="append",544 default=[],545 help="Additional argument to append to the compiler command line.",546 )547 parser.add_argument(548 "-extra-arg-before",549 dest="extra_arg_before",550 action="append",551 default=[],552 help="Additional argument to prepend to the compiler command line.",553 )554 parser.add_argument(555 "-quiet", action="store_true", help="Run clang-tidy in quiet mode."556 )557 parser.add_argument(558 "-load",559 dest="plugins",560 action="append",561 default=[],562 help="Load the specified plugin in clang-tidy.",563 )564 parser.add_argument(565 "-warnings-as-errors",566 default=None,567 help="Upgrades warnings to errors. Same format as '-checks'.",568 )569 parser.add_argument(570 "-allow-no-checks",571 action="store_true",572 help="Allow empty enabled checks.",573 )574 parser.add_argument(575 "-enable-check-profile",576 action="store_true",577 help="Enable per-check timing profiles, and print a report",578 )579 parser.add_argument(580 "-hide-progress",581 action="store_true",582 help="Hide progress",583 )584 args = parser.parse_args()585 586 db_path = "compile_commands.json"587 588 if args.build_path is not None:589 build_path = args.build_path590 else:591 # Find our database592 build_path = find_compilation_database(db_path)593 594 clang_tidy_binary = find_binary(args.clang_tidy_binary, "clang-tidy", build_path)595 596 if args.fix:597 clang_apply_replacements_binary = find_binary(598 args.clang_apply_replacements_binary, "clang-apply-replacements", build_path599 )600 601 combine_fixes = False602 export_fixes_dir: Optional[str] = None603 delete_fixes_dir = False604 if args.export_fixes is not None:605 # if a directory is given, create it if it does not exist606 if args.export_fixes.endswith(os.path.sep) and not os.path.isdir(607 args.export_fixes608 ):609 os.makedirs(args.export_fixes)610 611 if not os.path.isdir(args.export_fixes):612 if not yaml:613 raise RuntimeError(614 "Cannot combine fixes in one yaml file. Either install PyYAML or specify an output directory."615 )616 617 combine_fixes = True618 619 if os.path.isdir(args.export_fixes):620 export_fixes_dir = args.export_fixes621 622 if export_fixes_dir is None and (args.fix or combine_fixes):623 export_fixes_dir = tempfile.mkdtemp()624 delete_fixes_dir = True625 626 profile_dir: Optional[str] = None627 if args.enable_check_profile:628 profile_dir = tempfile.mkdtemp()629 630 try:631 invocation = get_tidy_invocation(632 None,633 clang_tidy_binary,634 args.checks,635 None,636 build_path,637 args.header_filter,638 args.allow_enabling_alpha_checkers,639 args.extra_arg,640 args.extra_arg_before,641 args.quiet,642 args.config_file,643 args.config,644 args.line_filter,645 args.use_color,646 args.plugins,647 args.warnings_as_errors,648 args.exclude_header_filter,649 args.allow_no_checks,650 None, # No profiling for the list-checks invocation651 )652 invocation.append("-list-checks")653 invocation.append("-")654 # Even with -quiet we still want to check if we can call clang-tidy.655 subprocess.check_call(656 invocation, stdout=subprocess.DEVNULL if args.quiet else None657 )658 except:659 print("Unable to run clang-tidy.", file=sys.stderr)660 sys.exit(1)661 662 # Load the database and extract all files.663 with open(os.path.join(build_path, db_path)) as f:664 database = json.load(f)665 files = {os.path.abspath(os.path.join(e["directory"], e["file"])) for e in database}666 number_files_in_database = len(files)667 668 # Filter source files from compilation database.669 if args.source_filter:670 try:671 source_filter_re = re.compile(args.source_filter)672 except:673 print(674 "Error: unable to compile regex from arg -source-filter:",675 file=sys.stderr,676 )677 traceback.print_exc()678 sys.exit(1)679 files = {f for f in files if source_filter_re.match(f)}680 681 max_task = args.j682 if max_task == 0:683 max_task = multiprocessing.cpu_count()684 685 # Build up a big regexy filter from all command line arguments.686 file_name_re = re.compile("|".join(args.files))687 files = {f for f in files if file_name_re.search(f)}688 689 if not args.hide_progress:690 print(691 f"Running clang-tidy in {max_task} threads for {len(files)} files "692 f"out of {number_files_in_database} in compilation database ..."693 )694 695 returncode = 0696 semaphore = asyncio.Semaphore(max_task)697 tasks = [698 asyncio.create_task(699 run_with_semaphore(700 semaphore,701 run_tidy,702 args,703 f,704 clang_tidy_binary,705 export_fixes_dir,706 build_path,707 profile_dir,708 )709 )710 for f in files711 ]712 713 try:714 for i, coro in enumerate(asyncio.as_completed(tasks)):715 result = await coro716 if result.returncode != 0:717 returncode = 1718 if result.returncode < 0:719 result.stderr += f"{result.filename}: terminated by signal {-result.returncode}\n"720 progress = f"[{i + 1: >{len(f'{len(files)}')}}/{len(files)}]"721 runtime = f"[{result.elapsed:.1f}s]"722 if not args.hide_progress:723 print(f"{progress}{runtime} {' '.join(result.invocation)}")724 if result.stdout:725 print(result.stdout, end=("" if result.stderr else "\n"))726 if result.stderr:727 print(result.stderr)728 except asyncio.CancelledError:729 if not args.hide_progress:730 print("\nCtrl-C detected, goodbye.")731 for task in tasks:732 task.cancel()733 if delete_fixes_dir:734 assert export_fixes_dir735 shutil.rmtree(export_fixes_dir)736 if profile_dir:737 shutil.rmtree(profile_dir)738 return739 740 if args.enable_check_profile and profile_dir:741 # Ensure all clang-tidy stdout is flushed before printing profiling742 sys.stdout.flush()743 aggregated_data = aggregate_profiles(profile_dir)744 if aggregated_data:745 print_profile_data(aggregated_data)746 else:747 print("No profiling data found.")748 749 if combine_fixes:750 if not args.hide_progress:751 print(f"Writing fixes to {args.export_fixes} ...")752 try:753 assert export_fixes_dir754 merge_replacement_files(export_fixes_dir, args.export_fixes)755 except:756 print("Error exporting fixes.\n", file=sys.stderr)757 traceback.print_exc()758 returncode = 1759 760 if args.fix:761 if not args.hide_progress:762 print("Applying fixes ...")763 try:764 assert export_fixes_dir765 apply_fixes(args, clang_apply_replacements_binary, export_fixes_dir)766 except:767 print("Error applying fixes.\n", file=sys.stderr)768 traceback.print_exc()769 returncode = 1770 771 if delete_fixes_dir:772 assert export_fixes_dir773 shutil.rmtree(export_fixes_dir)774 if profile_dir:775 shutil.rmtree(profile_dir)776 sys.exit(returncode)777 778 779if __name__ == "__main__":780 try:781 asyncio.run(main())782 except KeyboardInterrupt:783 pass784