clang-tools 20.0.0git
run-clang-tidy.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2#
3# ===- run-clang-tidy.py - Parallel clang-tidy runner --------*- python -*--===#
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-exception
8#
9# ===-----------------------------------------------------------------------===#
10# FIXME: Integrate with clang-tidy-diff.py
11
12
13"""
14Parallel clang-tidy runner
15==========================
16
17Runs clang-tidy over all files in a compilation database. Requires clang-tidy
18and clang-apply-replacements in $PATH.
19
20Example invocations.
21- Run clang-tidy on all files in the current working directory with a default
22 set of checks and show warnings in the cpp files and all project headers.
23 run-clang-tidy.py $PWD
24
25- Fix all header guards.
26 run-clang-tidy.py -fix -checks=-*,llvm-header-guard
27
28- Fix all header guards included from clang-tidy and header guards
29 for clang-tidy headers.
30 run-clang-tidy.py -fix -checks=-*,llvm-header-guard extra/clang-tidy \
31 -header-filter=extra/clang-tidy
32
33Compilation database setup:
34http://clang.llvm.org/docs/HowToSetupToolingForLLVM.html
35"""
36
37import argparse
38import asyncio
39from dataclasses import dataclass
40import glob
41import json
42import multiprocessing
43import os
44import re
45import shutil
46import subprocess
47import sys
48import tempfile
49import time
50import traceback
51from types import ModuleType
52from typing import Any, Awaitable, Callable, List, Optional, Tuple, TypeVar
53
54
55yaml: Optional[ModuleType] = None
56try:
57 import yaml
58except ImportError:
59 yaml = None
60
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 True
68 elif val in ["false", "0"]:
69 return False
70
71 # Return ArgumentTypeError so that argparse does not substitute its own error message
72 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 = parent
86 return result
87
88
90 f: 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) -> List[str]:
109 """Gets a command line for clang-tidy."""
110 start = [clang_tidy_binary]
111 if allow_enabling_alpha_checkers:
112 start.append("-allow-enabling-analyzer-alpha-checkers")
113 if exclude_header_filter is not None:
114 start.append(f"--exclude-header-filter={exclude_header_filter}")
115 if header_filter is not None:
116 start.append(f"-header-filter={header_filter}")
117 if line_filter is not None:
118 start.append(f"-line-filter={line_filter}")
119 if use_color is not None:
120 if use_color:
121 start.append("--use-color")
122 else:
123 start.append("--use-color=false")
124 if checks:
125 start.append(f"-checks={checks}")
126 if tmpdir is not None:
127 start.append("-export-fixes")
128 # Get a temporary file. We immediately close the handle so clang-tidy can
129 # overwrite it.
130 (handle, name) = tempfile.mkstemp(suffix=".yaml", dir=tmpdir)
131 os.close(handle)
132 start.append(name)
133 for arg in extra_arg:
134 start.append(f"-extra-arg={arg}")
135 for arg in extra_arg_before:
136 start.append(f"-extra-arg-before={arg}")
137 start.append(f"-p={build_path}")
138 if quiet:
139 start.append("-quiet")
140 if config_file_path:
141 start.append(f"--config-file={config_file_path}")
142 elif config:
143 start.append(f"-config={config}")
144 for plugin in plugins:
145 start.append(f"-load={plugin}")
146 if warnings_as_errors:
147 start.append(f"--warnings-as-errors={warnings_as_errors}")
148 if allow_no_checks:
149 start.append("--allow-no-checks")
150 start.append(f)
151 return start
152
153
154def merge_replacement_files(tmpdir: str, mergefile: str) -> None:
155 """Merge all replacement files in a directory into a single file"""
156 assert yaml
157 # The fixes suggested by clang-tidy >= 4.0.0 are given under
158 # the top level key 'Diagnostics' in the output yaml files
159 mergekey = "Diagnostics"
160 merged = []
161 for replacefile in glob.iglob(os.path.join(tmpdir, "*.yaml")):
162 content = yaml.safe_load(open(replacefile, "r"))
163 if not content:
164 continue # Skip empty files.
165 merged.extend(content.get(mergekey, []))
166
167 if merged:
168 # MainSourceFile: The key is required by the definition inside
169 # include/clang/Tooling/ReplacementsYaml.h, but the value
170 # is actually never used inside clang-apply-replacements,
171 # so we set it to '' here.
172 output = {"MainSourceFile": "", mergekey: merged}
173 with open(mergefile, "w") as out:
174 yaml.safe_dump(output, out)
175 else:
176 # Empty the file:
177 open(mergefile, "w").close()
178
179
180def find_binary(arg: str, name: str, build_path: str) -> str:
181 """Get the path for a binary or exit"""
182 if arg:
183 if shutil.which(arg):
184 return arg
185 else:
186 raise SystemExit(
187 f"error: passed binary '{arg}' was not found or is not executable"
188 )
189
190 built_path = os.path.join(build_path, "bin", name)
191 binary = shutil.which(name) or shutil.which(built_path)
192 if binary:
193 return binary
194 else:
195 raise SystemExit(f"error: failed to find {name} in $PATH or at {built_path}")
196
197
199 args: argparse.Namespace, clang_apply_replacements_binary: str, tmpdir: str
200) -> None:
201 """Calls clang-apply-fixes on a given directory."""
202 invocation = [clang_apply_replacements_binary]
203 invocation.append("-ignore-insert-conflict")
204 if args.format:
205 invocation.append("-format")
206 if args.style:
207 invocation.append(f"-style={args.style}")
208 invocation.append(tmpdir)
209 subprocess.call(invocation)
210
211
212# FIXME Python 3.12: This can be simplified out with run_with_semaphore[T](...).
213T = TypeVar("T")
214
215
217 semaphore: asyncio.Semaphore,
218 f: Callable[..., Awaitable[T]],
219 *args: Any,
220 **kwargs: Any,
221) -> T:
222 async with semaphore:
223 return await f(*args, **kwargs)
224
225
226@dataclass
228 filename: str
229 invocation: List[str]
230 returncode: int
231 stdout: str
232 stderr: str
233 elapsed: float
234
235
236async def run_tidy(
237 args: argparse.Namespace,
238 name: str,
239 clang_tidy_binary: str,
240 tmpdir: str,
241 build_path: str,
242) -> ClangTidyResult:
243 """
244 Runs clang-tidy on a single file and returns the result.
245 """
246 invocation = get_tidy_invocation(
247 name,
248 clang_tidy_binary,
249 args.checks,
250 tmpdir,
251 build_path,
252 args.header_filter,
253 args.allow_enabling_alpha_checkers,
254 args.extra_arg,
255 args.extra_arg_before,
256 args.quiet,
257 args.config_file,
258 args.config,
259 args.line_filter,
260 args.use_color,
261 args.plugins,
262 args.warnings_as_errors,
263 args.exclude_header_filter,
264 args.allow_no_checks,
265 )
266
267 try:
268 process = await asyncio.create_subprocess_exec(
269 *invocation, stdout=subprocess.PIPE, stderr=subprocess.PIPE
270 )
271 start = time.time()
272 stdout, stderr = await process.communicate()
273 end = time.time()
274 except asyncio.CancelledError:
275 process.terminate()
276 await process.wait()
277 raise
278
279 assert process.returncode is not None
280 return ClangTidyResult(
281 name,
282 invocation,
283 process.returncode,
284 stdout.decode("UTF-8"),
285 stderr.decode("UTF-8"),
286 end - start,
287 )
288
289
290async def main() -> None:
291 parser = argparse.ArgumentParser(
292 description="Runs clang-tidy over all files "
293 "in a compilation database. Requires "
294 "clang-tidy and clang-apply-replacements in "
295 "$PATH or in your build directory."
296 )
297 parser.add_argument(
298 "-allow-enabling-alpha-checkers",
299 action="store_true",
300 help="Allow alpha checkers from clang-analyzer.",
301 )
302 parser.add_argument(
303 "-clang-tidy-binary", metavar="PATH", help="Path to clang-tidy binary."
304 )
305 parser.add_argument(
306 "-clang-apply-replacements-binary",
307 metavar="PATH",
308 help="Path to clang-apply-replacements binary.",
309 )
310 parser.add_argument(
311 "-checks",
312 default=None,
313 help="Checks filter, when not specified, use clang-tidy default.",
314 )
315 config_group = parser.add_mutually_exclusive_group()
316 config_group.add_argument(
317 "-config",
318 default=None,
319 help="Specifies a configuration in YAML/JSON format: "
320 " -config=\"{Checks: '*', "
321 ' CheckOptions: {x: y}}" '
322 "When the value is empty, clang-tidy will "
323 "attempt to find a file named .clang-tidy for "
324 "each source file in its parent directories.",
325 )
326 config_group.add_argument(
327 "-config-file",
328 default=None,
329 help="Specify the path of .clang-tidy or custom config "
330 "file: e.g. -config-file=/some/path/myTidyConfigFile. "
331 "This option internally works exactly the same way as "
332 "-config option after reading specified config file. "
333 "Use either -config-file or -config, not both.",
334 )
335 parser.add_argument(
336 "-exclude-header-filter",
337 default=None,
338 help="Regular expression matching the names of the "
339 "headers to exclude diagnostics from. Diagnostics from "
340 "the main file of each translation unit are always "
341 "displayed.",
342 )
343 parser.add_argument(
344 "-header-filter",
345 default=None,
346 help="Regular expression matching the names of the "
347 "headers to output diagnostics from. Diagnostics from "
348 "the main file of each translation unit are always "
349 "displayed.",
350 )
351 parser.add_argument(
352 "-source-filter",
353 default=None,
354 help="Regular expression matching the names of the "
355 "source files from compilation database to output "
356 "diagnostics from.",
357 )
358 parser.add_argument(
359 "-line-filter",
360 default=None,
361 help="List of files with line ranges to filter the warnings.",
362 )
363 if yaml:
364 parser.add_argument(
365 "-export-fixes",
366 metavar="file_or_directory",
367 dest="export_fixes",
368 help="A directory or a yaml file to store suggested fixes in, "
369 "which can be applied with clang-apply-replacements. If the "
370 "parameter is a directory, the fixes of each compilation unit are "
371 "stored in individual yaml files in the directory.",
372 )
373 else:
374 parser.add_argument(
375 "-export-fixes",
376 metavar="directory",
377 dest="export_fixes",
378 help="A directory to store suggested fixes in, which can be applied "
379 "with clang-apply-replacements. The fixes of each compilation unit are "
380 "stored in individual yaml files in the directory.",
381 )
382 parser.add_argument(
383 "-j",
384 type=int,
385 default=0,
386 help="Number of tidy instances to be run in parallel.",
387 )
388 parser.add_argument(
389 "files",
390 nargs="*",
391 default=[".*"],
392 help="Files to be processed (regex on path).",
393 )
394 parser.add_argument("-fix", action="store_true", help="apply fix-its.")
395 parser.add_argument(
396 "-format", action="store_true", help="Reformat code after applying fixes."
397 )
398 parser.add_argument(
399 "-style",
400 default="file",
401 help="The style of reformat code after applying fixes.",
402 )
403 parser.add_argument(
404 "-use-color",
405 type=strtobool,
406 nargs="?",
407 const=True,
408 help="Use colors in diagnostics, overriding clang-tidy's"
409 " default behavior. This option overrides the 'UseColor"
410 "' option in .clang-tidy file, if any.",
411 )
412 parser.add_argument(
413 "-p", dest="build_path", help="Path used to read a compile command database."
414 )
415 parser.add_argument(
416 "-extra-arg",
417 dest="extra_arg",
418 action="append",
419 default=[],
420 help="Additional argument to append to the compiler command line.",
421 )
422 parser.add_argument(
423 "-extra-arg-before",
424 dest="extra_arg_before",
425 action="append",
426 default=[],
427 help="Additional argument to prepend to the compiler command line.",
428 )
429 parser.add_argument(
430 "-quiet", action="store_true", help="Run clang-tidy in quiet mode."
431 )
432 parser.add_argument(
433 "-load",
434 dest="plugins",
435 action="append",
436 default=[],
437 help="Load the specified plugin in clang-tidy.",
438 )
439 parser.add_argument(
440 "-warnings-as-errors",
441 default=None,
442 help="Upgrades warnings to errors. Same format as '-checks'.",
443 )
444 parser.add_argument(
445 "-allow-no-checks",
446 action="store_true",
447 help="Allow empty enabled checks.",
448 )
449 args = parser.parse_args()
450
451 db_path = "compile_commands.json"
452
453 if args.build_path is not None:
454 build_path = args.build_path
455 else:
456 # Find our database
457 build_path = find_compilation_database(db_path)
458
459 clang_tidy_binary = find_binary(args.clang_tidy_binary, "clang-tidy", build_path)
460
461 if args.fix:
462 clang_apply_replacements_binary = find_binary(
463 args.clang_apply_replacements_binary, "clang-apply-replacements", build_path
464 )
465
466 combine_fixes = False
467 export_fixes_dir: Optional[str] = None
468 delete_fixes_dir = False
469 if args.export_fixes is not None:
470 # if a directory is given, create it if it does not exist
471 if args.export_fixes.endswith(os.path.sep) and not os.path.isdir(
472 args.export_fixes
473 ):
474 os.makedirs(args.export_fixes)
475
476 if not os.path.isdir(args.export_fixes):
477 if not yaml:
478 raise RuntimeError(
479 "Cannot combine fixes in one yaml file. Either install PyYAML or specify an output directory."
480 )
481
482 combine_fixes = True
483
484 if os.path.isdir(args.export_fixes):
485 export_fixes_dir = args.export_fixes
486
487 if export_fixes_dir is None and (args.fix or combine_fixes):
488 export_fixes_dir = tempfile.mkdtemp()
489 delete_fixes_dir = True
490
491 try:
492 invocation = get_tidy_invocation(
493 "",
494 clang_tidy_binary,
495 args.checks,
496 None,
497 build_path,
498 args.header_filter,
499 args.allow_enabling_alpha_checkers,
500 args.extra_arg,
501 args.extra_arg_before,
502 args.quiet,
503 args.config_file,
504 args.config,
505 args.line_filter,
506 args.use_color,
507 args.plugins,
508 args.warnings_as_errors,
509 args.exclude_header_filter,
510 args.allow_no_checks,
511 )
512 invocation.append("-list-checks")
513 invocation.append("-")
514 if args.quiet:
515 # Even with -quiet we still want to check if we can call clang-tidy.
516 with open(os.devnull, "w") as dev_null:
517 subprocess.check_call(invocation, stdout=dev_null)
518 else:
519 subprocess.check_call(invocation)
520 except:
521 print("Unable to run clang-tidy.", file=sys.stderr)
522 sys.exit(1)
523
524 # Load the database and extract all files.
525 with open(os.path.join(build_path, db_path)) as f:
526 database = json.load(f)
527 files = {os.path.abspath(os.path.join(e["directory"], e["file"])) for e in database}
528 number_files_in_database = len(files)
529
530 # Filter source files from compilation database.
531 if args.source_filter:
532 try:
533 source_filter_re = re.compile(args.source_filter)
534 except:
535 print(
536 "Error: unable to compile regex from arg -source-filter:",
537 file=sys.stderr,
538 )
539 traceback.print_exc()
540 sys.exit(1)
541 files = {f for f in files if source_filter_re.match(f)}
542
543 max_task = args.j
544 if max_task == 0:
545 max_task = multiprocessing.cpu_count()
546
547 # Build up a big regexy filter from all command line arguments.
548 file_name_re = re.compile("|".join(args.files))
549 files = {f for f in files if file_name_re.search(f)}
550
551 print(
552 "Running clang-tidy for",
553 len(files),
554 "files out of",
555 number_files_in_database,
556 "in compilation database ...",
557 )
558
559 returncode = 0
560 semaphore = asyncio.Semaphore(max_task)
561 tasks = [
562 asyncio.create_task(
564 semaphore,
565 run_tidy,
566 args,
567 f,
568 clang_tidy_binary,
569 export_fixes_dir,
570 build_path,
571 )
572 )
573 for f in files
574 ]
575
576 try:
577 for i, coro in enumerate(asyncio.as_completed(tasks)):
578 result = await coro
579 if result.returncode != 0:
580 returncode = 1
581 if result.returncode < 0:
582 result.stderr += f"{result.filename}: terminated by signal {-result.returncode}\n"
583 progress = f"[{i + 1: >{len(f'{len(files)}')}}/{len(files)}]"
584 runtime = f"[{result.elapsed:.1f}s]"
585 print(f"{progress}{runtime} {' '.join(result.invocation)}")
586 if result.stdout:
587 print(result.stdout, end=("" if result.stderr else "\n"))
588 if result.stderr:
589 print(result.stderr)
590 except asyncio.CancelledError:
591 print("\nCtrl-C detected, goodbye.")
592 for task in tasks:
593 task.cancel()
594 if delete_fixes_dir:
595 assert export_fixes_dir
596 shutil.rmtree(export_fixes_dir)
597 return
598
599 if combine_fixes:
600 print(f"Writing fixes to {args.export_fixes} ...")
601 try:
602 assert export_fixes_dir
603 merge_replacement_files(export_fixes_dir, args.export_fixes)
604 except:
605 print("Error exporting fixes.\n", file=sys.stderr)
606 traceback.print_exc()
607 returncode = 1
608
609 if args.fix:
610 print("Applying fixes ...")
611 try:
612 assert export_fixes_dir
613 apply_fixes(args, clang_apply_replacements_binary, export_fixes_dir)
614 except:
615 print("Error applying fixes.\n", file=sys.stderr)
616 traceback.print_exc()
617 returncode = 1
618
619 if delete_fixes_dir:
620 assert export_fixes_dir
621 shutil.rmtree(export_fixes_dir)
622 sys.exit(returncode)
623
624
625if __name__ == "__main__":
626 asyncio.run(main())
List[str] get_tidy_invocation(str f, str clang_tidy_binary, str checks, Optional[str] tmpdir, str build_path, Optional[str] header_filter, bool allow_enabling_alpha_checkers, List[str] extra_arg, List[str] extra_arg_before, bool quiet, str config_file_path, str config, Optional[str] line_filter, bool use_color, List[str] plugins, Optional[str] warnings_as_errors, Optional[str] exclude_header_filter, bool allow_no_checks)
bool strtobool(str val)
str find_compilation_database(str path)
str find_binary(str arg, str name, str build_path)
None apply_fixes(argparse.Namespace args, str clang_apply_replacements_binary, str tmpdir)
None merge_replacement_files(str tmpdir, str mergefile)
ClangTidyResult run_tidy(argparse.Namespace args, str name, str clang_tidy_binary, str tmpdir, str build_path)
T run_with_semaphore(asyncio.Semaphore semaphore, Callable[..., Awaitable[T]] f, *Any args, **Any kwargs)