|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +import argparse |
| 4 | +import glob |
| 5 | +import multiprocessing |
| 6 | +import os |
| 7 | +import re |
| 8 | +import shutil |
| 9 | +import subprocess |
| 10 | +import sys |
| 11 | +import tempfile |
| 12 | +from functools import partial |
| 13 | +from typing import List |
| 14 | + |
| 15 | +# clang-format, clang-tidy and clang-apply-replacements default version |
| 16 | +# This specific version is used when available, for more consistency between contributors |
| 17 | +CLANG_VER = 14 |
| 18 | + |
| 19 | +# Clang-Format options (see .clang-format for rules applied) |
| 20 | +FORMAT_OPTS = "-i -style=file" |
| 21 | + |
| 22 | +# Clang-Tidy options (see .clang-tidy for checks enabled) |
| 23 | +TIDY_OPTS = "-p ." |
| 24 | +TIDY_FIX_OPTS = "--fix --fix-errors" |
| 25 | + |
| 26 | +# Clang-Apply-Replacements options (used for multiprocessing) |
| 27 | +APPLY_OPTS = "--format --style=file" |
| 28 | + |
| 29 | +# Compiler options used with Clang-Tidy |
| 30 | +# Normal warnings are disabled with -Wno-everything to focus only on tidying |
| 31 | +INCLUDES = "-Iinclude -Isrc -Ibuild -I." |
| 32 | +DEFINES = "-D_LANGUAGE_C -DNON_MATCHING -D_MIPS_SZLONG=32" |
| 33 | +COMPILER_OPTS = f"-fno-builtin -std=gnu90 -m32 -Wno-everything {INCLUDES} {DEFINES}" |
| 34 | + |
| 35 | + |
| 36 | +def get_clang_executable(allowed_executables: List[str]): |
| 37 | + for executable in allowed_executables: |
| 38 | + try: |
| 39 | + subprocess.check_call([executable, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| 40 | + return executable |
| 41 | + except FileNotFoundError or subprocess.CalledProcessError: |
| 42 | + pass |
| 43 | + return None |
| 44 | + |
| 45 | + |
| 46 | +def get_tidy_version(tidy_executable: str): |
| 47 | + tidy_version_run = subprocess.run([tidy_executable, "--version"], stdout=subprocess.PIPE, universal_newlines=True) |
| 48 | + match = re.search(r"LLVM version ([0-9]+)", tidy_version_run.stdout) |
| 49 | + return int(match.group(1)) |
| 50 | + |
| 51 | + |
| 52 | +CLANG_FORMAT = get_clang_executable([f"clang-format-{CLANG_VER}", "clang-format"]) |
| 53 | +if CLANG_FORMAT is None: |
| 54 | + sys.exit(f"Error: neither clang-format nor clang-format-{CLANG_VER} found") |
| 55 | + |
| 56 | +CLANG_TIDY = get_clang_executable([f"clang-tidy-{CLANG_VER}", "clang-tidy"]) |
| 57 | +if CLANG_TIDY is None: |
| 58 | + sys.exit(f"Error: neither clang-tidy nor clang-tidy-{CLANG_VER} found") |
| 59 | + |
| 60 | +CLANG_APPLY_REPLACEMENTS = get_clang_executable([f"clang-apply-replacements-{CLANG_VER}", "clang-apply-replacements"]) |
| 61 | + |
| 62 | +# Try to detect the clang-tidy version and add --fix-notes for version 13+ |
| 63 | +# This is used to ensure all fixes are applied properly in recent versions |
| 64 | +if get_tidy_version(CLANG_TIDY) >= 13: |
| 65 | + TIDY_FIX_OPTS += " --fix-notes" |
| 66 | + |
| 67 | + |
| 68 | +def list_chunks(list: List, chunk_length: int): |
| 69 | + for i in range(0, len(list), chunk_length): |
| 70 | + yield list[i : i + chunk_length] |
| 71 | + |
| 72 | + |
| 73 | +def run_clang_format(files: List[str]): |
| 74 | + exec_str = f"{CLANG_FORMAT} {FORMAT_OPTS} {' '.join(files)}" |
| 75 | + subprocess.run(exec_str, shell=True) |
| 76 | + |
| 77 | + |
| 78 | +def run_clang_tidy(files: List[str]): |
| 79 | + exec_str = f"{CLANG_TIDY} {TIDY_OPTS} {TIDY_FIX_OPTS} {' '.join(files)} -- {COMPILER_OPTS}" |
| 80 | + subprocess.run(exec_str, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| 81 | + |
| 82 | + |
| 83 | +def run_clang_tidy_with_export(tmp_dir: str, files: List[str]): |
| 84 | + (handle, tmp_file) = tempfile.mkstemp(suffix=".yaml", dir=tmp_dir) |
| 85 | + os.close(handle) |
| 86 | + |
| 87 | + exec_str = f"{CLANG_TIDY} {TIDY_OPTS} --export-fixes={tmp_file} {' '.join(files)} -- {COMPILER_OPTS}" |
| 88 | + subprocess.run(exec_str, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| 89 | + |
| 90 | + |
| 91 | +def run_clang_apply_replacements(tmp_dir: str): |
| 92 | + exec_str = f"{CLANG_APPLY_REPLACEMENTS} {APPLY_OPTS} {tmp_dir}" |
| 93 | + subprocess.run(exec_str, shell=True) |
| 94 | + |
| 95 | + |
| 96 | +def add_final_new_line(file: str): |
| 97 | + # https://backreference.org/2010/05/23/sanitizing-files-with-no-trailing-newline/index.html |
| 98 | + # "gets the last character of the file pipes it into read, which will exit with a nonzero exit |
| 99 | + # code if it encounters EOF before newline (so, if the last character of the file isn't a newline). |
| 100 | + # If read exits nonzero, then append a newline onto the file using echo (if read exits 0, |
| 101 | + # that satisfies the ||, so the echo command isn't run)." (https://stackoverflow.com/a/34865616) |
| 102 | + exec_str = f"tail -c1 {file} | read -r _ || echo >> {file}" |
| 103 | + subprocess.run(exec_str, shell=True) |
| 104 | + |
| 105 | + |
| 106 | +def format_files(src_files: List[str], extra_files: List[str], nb_jobs: int): |
| 107 | + if nb_jobs != 1: |
| 108 | + print(f"Formatting files with {nb_jobs} jobs") |
| 109 | + else: |
| 110 | + print(f"Formatting files with a single job (consider using -j to make this faster)") |
| 111 | + |
| 112 | + # Format files in chunks to improve performance while still utilizing jobs |
| 113 | + file_chunks = list(list_chunks(src_files, (len(src_files) // nb_jobs) + 1)) |
| 114 | + |
| 115 | + print("Running clang-format...") |
| 116 | + # clang-format only applies changes in the given files, so it's safe to run in parallel |
| 117 | + with multiprocessing.get_context("fork").Pool(nb_jobs) as pool: |
| 118 | + pool.map(run_clang_format, file_chunks) |
| 119 | + |
| 120 | + print("Running clang-tidy...") |
| 121 | + if nb_jobs > 1: |
| 122 | + # clang-tidy may apply changes in #included files, so when running it in parallel we use --export-fixes |
| 123 | + # then we call clang-apply-replacements to apply all suggested fixes at the end |
| 124 | + tmp_dir = tempfile.mkdtemp() |
| 125 | + |
| 126 | + try: |
| 127 | + with multiprocessing.get_context("fork").Pool(nb_jobs) as pool: |
| 128 | + pool.map(partial(run_clang_tidy_with_export, tmp_dir), file_chunks) |
| 129 | + |
| 130 | + run_clang_apply_replacements(tmp_dir) |
| 131 | + finally: |
| 132 | + shutil.rmtree(tmp_dir) |
| 133 | + else: |
| 134 | + run_clang_tidy(src_files) |
| 135 | + |
| 136 | + print("Adding missing final new lines...") |
| 137 | + # Adding final new lines is safe to do in parallel and can be applied to all types of files |
| 138 | + with multiprocessing.get_context("fork").Pool(nb_jobs) as pool: |
| 139 | + pool.map(add_final_new_line, src_files + extra_files) |
| 140 | + |
| 141 | + print("Done formatting files.") |
| 142 | + |
| 143 | + |
| 144 | +def main(): |
| 145 | + parser = argparse.ArgumentParser(description="Format files in the codebase to enforce most style rules") |
| 146 | + parser.add_argument( |
| 147 | + "--show-paths", |
| 148 | + dest="show_paths", |
| 149 | + action="store_true", |
| 150 | + help="Print the paths to the clang-* binaries used", |
| 151 | + ) |
| 152 | + parser.add_argument("files", metavar="file", nargs="*") |
| 153 | + parser.add_argument( |
| 154 | + "-j", |
| 155 | + dest="jobs", |
| 156 | + type=int, |
| 157 | + nargs="?", |
| 158 | + default=1, |
| 159 | + help="number of jobs to run (default: 1 without -j, number of cpus with -j)", |
| 160 | + ) |
| 161 | + args = parser.parse_args() |
| 162 | + |
| 163 | + if args.show_paths: |
| 164 | + import shutil |
| 165 | + |
| 166 | + print("CLANG_FORMAT ->", shutil.which(CLANG_FORMAT)) |
| 167 | + print("CLANG_TIDY ->", shutil.which(CLANG_TIDY)) |
| 168 | + print("CLANG_APPLY_REPLACEMENTS ->", shutil.which(CLANG_APPLY_REPLACEMENTS)) |
| 169 | + |
| 170 | + nb_jobs = args.jobs or multiprocessing.cpu_count() |
| 171 | + if nb_jobs > 1: |
| 172 | + if CLANG_APPLY_REPLACEMENTS is None: |
| 173 | + sys.exit( |
| 174 | + f"Error: neither clang-apply-replacements nor clang-apply-replacements-{CLANG_VER} found (required to use -j)" |
| 175 | + ) |
| 176 | + |
| 177 | + if args.files: |
| 178 | + files = args.files |
| 179 | + extra_files = [] |
| 180 | + else: |
| 181 | + files = glob.glob("src/**/*.c", recursive=True) |
| 182 | + extra_files = glob.glob("assets/**/*.xml", recursive=True) |
| 183 | + |
| 184 | + format_files(files, extra_files, nb_jobs) |
| 185 | + |
| 186 | + |
| 187 | +if __name__ == "__main__": |
| 188 | + main() |
0 commit comments