#!/usr/bin/env python import argparse import json import os.path import re import subprocess import sys DEFAULT_FILE_PATTERN = r".*\.[ch](pp)?" # @@ -start,count +start,count @@ CHUNK_PATTERN = r"^@@\s+-\d+,\d+\s+\+(\d+)(?:,(\d+))?\s+@@" def run_shell_command(arguments, process_name=None): """Executes a shell command.""" assert len(arguments) > 0 try: output = subprocess.check_output(arguments, stderr=subprocess.STDOUT) except OSError: _, e, _ = sys.exc_info() process_name = process_name or arguments[0] raise RuntimeError("Error executing {}: {}".format(process_name, e)) else: return output.decode() def transform_globs_into_regexes(globs): """Turns glob patterns into regular expressions.""" return [glob.replace("*", ".*").replace("?", ".") for glob in globs] def get_file_patterns(globs, regexes): """Returns a list of compiled regex objects from globs and regex pattern strings.""" regexes += transform_globs_into_regexes(globs) if not regexes: regexes = [DEFAULT_FILE_PATTERN] return [re.compile(regex + "$") for regex in regexes] def git_diff(args, verbose): """Executes a git diff command in the shell and returns its output.""" # --no-pager gets us the plain output, without pagination. # --no-color removes color codes. command = ["git", "--no-pager", "diff", "--no-color"] + args if verbose: print(" ".join(command)) return run_shell_command(command, process_name="git diff") def filter_files(files, file_patterns): """Returns all files that match any of the patterns.""" filtered = [] for file in files: for pattern in file_patterns: if pattern.match(file): filtered.append(file) return filtered def get_changed_files(revision, paths, verbose): """Runs git diff to get the paths of all changed files.""" # --diff-filter AMU gets us files that are (A)dded, (M)odified or (U)nmerged (in the working copy). # --name-only makes git diff return only the file paths, without any of the source changes. args = ["--diff-filter", "AMU", "--ignore-all-space", "--name-only", revision] output = git_diff(args + paths, verbose) return output.split("\n") def get_all_files(paths): """Yields all files in any of the given paths""" for path in paths: for root, _, files in os.walk(path): for file in files: yield os.path.join(root, file) def get_changed_lines(revision, filename, verbose): """Runs git diff to get the line ranges of all file changes.""" output = git_diff(["--unified=0", revision, filename], verbose) changed_lines = [] for chunk in re.finditer(CHUNK_PATTERN, output, re.MULTILINE): start = int(chunk.group(1)) count = int(chunk.group(2) or 1) changed_lines.append([start, start + count]) return {"name": filename, "lines": changed_lines} def run_clang_tidy(options, line_filters, files): """Executes the actual clang-tidy command in the shell.""" command = [options.clang_tidy_exe, "-p", options.compile_commands_dir] if not options.config_file and os.path.exists(".clang-tidy"): options.config_file = ".clang-tidy" if options.config_file: import yaml with open(options.config_file) as config: # Here we convert the YAML config file to a JSON blob. command += ["-config", json.dumps(yaml.load(config))] if options.checks: command += ["-checks", options.checks] if line_filters: command += ["-line-filter", json.dumps(line_filters)] command += ["-{}".format(arg) for arg in options.extra_args] command += files if options.verbose: print(" ".join(command)) if options.show_command_only: command = [re.sub(r"^([{[].*[]}])$", r"'\1'", arg) for arg in command] return " ".join(command) return run_shell_command(command) def parse_options(): parser = argparse.ArgumentParser(description="Run Clang-Tidy (on your Git changes)") parser.add_argument( "-c", "--clang-tidy-exe", default="clang-tidy", help="Path to clang-tidy executable", ) parser.add_argument( "-e", "--extra-args", nargs="+", default=[], help="Extra arguments to forward to clang-tidy, without the hypen (e.g. -e 'header-filter=\"path\"')", ) parser.add_argument( "-g", "--glob", nargs="+", default=[], help="File patterns as UNIX globs (support * and ?, not recursive **)", ) parser.add_argument( "-x", "--regex", nargs="+", default=[], help="File patterns as regular expressions", ) parser.add_argument( "-d", "--compile-commands-dir", default=".", help="Path to the folder containing compile_commands.json", ) parser.add_argument("-r", "--revision", help="Git revision to get changes from") parser.add_argument( "-p", "--paths", nargs="+", default=["."], help="Lint only the given paths" ) parser.add_argument( "-s", "--show-command-only", action="store_true", help="Only show the command to be executed, without running it", ) parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument( "--config-file", help="Path to a clang-tidy config file. Defaults to '.clang-tidy'.", ) parser.add_argument( "--checks", help="Appends checks to those from the config file (if any)" ) return parser.parse_args() def main(): options = parse_options() if options.revision: files = get_changed_files(options.revision, options.paths, options.verbose) else: files = get_all_files(options.paths) file_patterns = get_file_patterns(options.glob, options.regex) files = filter_files(files, file_patterns) # clang-tidy error's when it does not get input files. if not files: print("No files detected.") sys.exit() line_filters = [] if options.revision: for filename in files: changed_lines = get_changed_lines( options.revision, filename, options.verbose ) line_filters.append(changed_lines) print(run_clang_tidy(options, line_filters, files)) if __name__ == "__main__": main()