brintos

brintos / llvm-project-archived public Read only

0
0
Text · 25.3 KiB · f495f44 Raw
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