blob: abbadc70691b46f71f3f6b05cadeab2eccfae3e6 [file] [log] [blame]
#!/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()