#!/usr/bin/env python3 # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. """Run clang-tidy on NSS source files using compile_commands.json.""" import argparse import json import os import re import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed EXTENSIONS = {".c", ".cc", ".cpp", ".cxx"} DEFAULT_EXCLUDES = { "gtests/google_test", "lib/sqlite", "lib/zlib", "lib/freebl/verified", } def find_compile_commands(repo_root): """Locate compile_commands.json in common build output locations.""" candidates = [ os.path.join(repo_root, "out", "Debug", "compile_commands.json"), os.path.join(repo_root, "out", "Release", "compile_commands.json"), ] for path in candidates: if os.path.isfile(path): return path return None def ensure_compile_commands(repo_root): """Build NSS and generate compile_commands.json if missing. Returns the path to compile_commands.json, or None on failure. """ cc_path = find_compile_commands(repo_root) if cc_path: return cc_path target_dir = os.path.join(repo_root, "out", "Debug") cc_path = os.path.join(target_dir, "compile_commands.json") print("compile_commands.json not found — building NSS...", file=sys.stderr) result = subprocess.run([os.path.join(repo_root, "build.sh")], cwd=repo_root) if result.returncode != 0: print("error: build.sh failed", file=sys.stderr) return None print("Generating compile_commands.json...", file=sys.stderr) with open(cc_path, "w") as f: result = subprocess.run( ["ninja", "-C", target_dir, "-t", "compdb"], stdout=f, cwd=repo_root ) if result.returncode != 0: print("error: ninja compdb failed", file=sys.stderr) return None return cc_path def load_translation_units( compile_commands_path, repo_root, include_paths=None, exclude_paths=None, files=None ): """Load and filter translation units from compile_commands.json. Args: compile_commands_path: Path to compile_commands.json. repo_root: Repository root for computing relative paths. include_paths: If set, only include files under these prefixes. exclude_paths: Exclude files matching these prefixes. files: If set, only include these specific files. Returns: List of (file, directory, command) tuples. """ with open(compile_commands_path) as f: entries = json.load(f) excludes = DEFAULT_EXCLUDES | set(exclude_paths or []) units = [] seen = set() for entry in entries: src = entry.get("file", "") directory = entry.get("directory", "") # Resolve to repo-relative path for filtering. if os.path.isabs(src): abs_src = src else: abs_src = os.path.normpath(os.path.join(directory, src)) rel = os.path.relpath(abs_src, repo_root) ext = os.path.splitext(rel)[1] if ext not in EXTENSIONS: continue if any(rel.startswith(ex) for ex in excludes): continue if include_paths and not any(rel.startswith(p) for p in include_paths): continue if files is not None: # Match against both absolute and relative paths. if rel not in files and abs_src not in files and src not in files: continue # Deduplicate: a file may appear multiple times in # compile_commands.json (e.g. compiled for different targets). # Only analyse each source file once. if rel in seen: continue seen.add(rel) units.append((src, directory, entry)) return units def parse_diff(diff_text): """Parse unified diff output into a dict of {file: [[start, end], ...]}. Only tracks added/modified line ranges (the ``+`` side of the diff) so that we can tell clang-tidy which lines are "interesting". """ file_lines = {} current_file = None hunk_re = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") for line in diff_text.splitlines(): # Detect the destination filename. if line.startswith("+++ b/"): current_file = line[6:] continue if line.startswith("+++ "): # hg-style: +++ b/path or +++ path current_file = line[4:].lstrip("b/") continue m = hunk_re.match(line) if m and current_file is not None: start = int(m.group(1)) count = int(m.group(2)) if m.group(2) is not None else 1 if count == 0: # Pure deletion hunk — no new lines. continue end = start + count - 1 file_lines.setdefault(current_file, []).append([start, end]) return file_lines def build_line_filter(changed_lines, repo_root, compile_commands_path): """Build a clang-tidy --line-filter JSON string from *changed_lines*. *changed_lines* is the dict returned by :func:`parse_diff`. *compile_commands_path* is used to determine the build directory so that paths in the filter match the ``file`` entries that clang-tidy sees from compile_commands.json. Returns a JSON string suitable for ``--line-filter=...``, or *None* if there are no entries. """ if not changed_lines: return None # clang-tidy matches --line-filter names against the "file" field in # compile_commands.json. For NSS those are typically relative to the # build directory (e.g. "../../lib/foo/bar.c"). Construct the same # relative paths so the filter actually matches. build_dir = os.path.dirname(os.path.abspath(compile_commands_path)) entries = [] for path, ranges in changed_lines.items(): abs_path = os.path.normpath(os.path.join(repo_root, path)) rel_path = os.path.relpath(abs_path, build_dir) entries.append({"name": rel_path, "lines": ranges}) return json.dumps(entries, separators=(",", ":")) def get_diff(repo_root, base_rev=None): """Obtain a unified diff from the VCS in *repo_root*. If *base_rev* is given it is used as the base revision; otherwise the working-directory changes are diffed. """ if os.path.isdir(os.path.join(repo_root, ".hg")): cmd = ["hg", "diff", "-U0"] if base_rev: cmd += ["--rev", base_rev] elif os.path.isdir(os.path.join(repo_root, ".git")): if base_rev: cmd = ["git", "diff", "-U0", base_rev] else: cmd = ["git", "diff", "-U0", "HEAD"] else: return None result = subprocess.run(cmd, capture_output=True, text=True, cwd=repo_root) if result.returncode != 0: return None return result.stdout def run_one( clang_tidy, src, directory, entry, checks, fix, fail_on_warnings, line_filter=None ): """Run clang-tidy on a single translation unit.""" cmd = [clang_tidy, "-p", directory] if checks: cmd += ["--checks=" + checks] if fix: cmd += ["--fix"] if fail_on_warnings: cmd += ["--warnings-as-errors=*"] if line_filter: cmd += ["--line-filter=" + line_filter] cmd.append(src) result = subprocess.run(cmd, capture_output=True, text=True, cwd=directory) return src, result def main(argv=None): parser = argparse.ArgumentParser(description="Run clang-tidy on NSS source files") parser.add_argument( "-p", "--compile-commands", help="Path to compile_commands.json (auto-detected if omitted)", ) parser.add_argument( "-j", "--jobs", type=int, default=os.cpu_count(), help="Number of parallel jobs (default: CPU count)", ) parser.add_argument( "--clang-tidy", default="clang-tidy", help="Path to clang-tidy binary" ) parser.add_argument( "--checks", help="Override checks (passed to clang-tidy --checks)" ) parser.add_argument( "--include", action="append", default=[], help="Only analyse files under these path prefixes", ) parser.add_argument( "--exclude", action="append", default=[], help="Exclude files under these path prefixes", ) parser.add_argument( "--fail-on-warnings", action="store_true", help="Treat all warnings as errors" ) parser.add_argument( "--fix", action="store_true", help="Apply suggested fixes in-place" ) parser.add_argument( "--build", action="store_true", help="Build NSS and generate compile_commands.json if missing", ) parser.add_argument( "--diff", action="store_true", help="Only report diagnostics on changed lines (uses VCS diff)", ) parser.add_argument( "--diff-base", help="Base revision for --diff (default: working-directory changes)", ) parser.add_argument( "files", nargs="*", help="Specific files to check (default: all matching files)" ) args = parser.parse_args(argv) # Locate repo root (parent of automation/). repo_root = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) # Find compile_commands.json. if args.compile_commands: cc_path = args.compile_commands elif args.build: cc_path = ensure_compile_commands(repo_root) else: cc_path = find_compile_commands(repo_root) if not cc_path or not os.path.isfile(cc_path): print( "error: compile_commands.json not found. " "Build first with: ./build.sh (or pass --build)", file=sys.stderr, ) return 1 # Build a line filter when --diff is requested, and restrict the # file set to only files that appear in the diff. line_filter = None diff_files = None if args.diff or args.diff_base: diff_text = get_diff(repo_root, args.diff_base) if diff_text is None: print("error: could not obtain diff from VCS", file=sys.stderr) return 1 changed_lines = parse_diff(diff_text) line_filter = build_line_filter(changed_lines, repo_root, cc_path) diff_files = set(os.path.normpath(f) for f in changed_lines) include_paths = args.include or None file_set = set(os.path.normpath(f) for f in args.files) if args.files else None # When diffing, restrict to changed files (intersect with any # explicit file list if one was provided). if diff_files is not None: if file_set is not None: file_set = file_set & diff_files else: file_set = diff_files units = load_translation_units( cc_path, repo_root, include_paths=include_paths, exclude_paths=args.exclude, files=file_set, ) if not units: if file_set: print("No matching translation units for the given files.", file=sys.stderr) return 0 print("No translation units found.", file=sys.stderr) return 1 print("Running clang-tidy on {} file(s)...".format(len(units)), file=sys.stderr) jobs = args.jobs failures = [] with ThreadPoolExecutor(max_workers=jobs) as pool: futures = { pool.submit( run_one, args.clang_tidy, src, directory, entry, args.checks, args.fix, args.fail_on_warnings, line_filter, ): src for src, directory, entry in units } for future in as_completed(futures): src, result = future.result() # Print any output (diagnostics) from clang-tidy. if result.stdout: print(result.stdout, end="") if result.stderr: print(result.stderr, end="", file=sys.stderr) if result.returncode != 0: failures.append(src) if failures: print( "\nclang-tidy found issues in {} file(s):".format(len(failures)), file=sys.stderr, ) for f in sorted(failures): print(" {}".format(f), file=sys.stderr) return 1 print("clang-tidy: no issues found.", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())