#!/usr/bin/env python # Copyright (c) 2020 Egor Tensin # This file is part of the "cmake-common" project. # For details, see https://github.com/egor-tensin/cmake-common # Distributed under the MIT License. """clang-format all C/C++ files in the project This script feeds every C/C++ file in the repository to clang-format, either printing a unified diff between the original and the formatted versions, or formatting the files in-place. """ import argparse from contextlib import contextmanager import difflib import logging import os import subprocess import sys @contextmanager def setup_logging(): level_names = { logging.DEBUG: "DBG", logging.INFO: "INFO", logging.WARNING: "WARN", logging.ERROR: "ERR", logging.CRITICAL: "CRIT", } for lvl, name in level_names.items(): logging.addLevelName(lvl, name) logging.basicConfig( level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S%z", format="%(asctime)s | %(levelname)4s | %(message)s", ) try: yield except Exception as e: logging.exception(e) sys.exit(1) @contextmanager def cd(path): cwd = os.getcwd() os.chdir(path) try: yield finally: os.chdir(cwd) def normalize_path(entry): return os.path.abspath(entry) def run(cmd_line): logging.debug("Running executable: %s", cmd_line) try: return subprocess.run( cmd_line, check=True, universal_newlines=True, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, ) except subprocess.CalledProcessError as e: logging.error("Process finished with exit code %d: %s", e.returncode, cmd_line) logging.error("Its output was:\n%s", e.output) raise def read_file(path): with open(path) as file: return file.read() class ClangFormat: def __init__(self, path, style): self.path = path self.style = style def _get_command_line(self, paths, in_place=False): cmd_line = [self.path, f"-style={self.style}"] if in_place: cmd_line.append("-i") cmd_line.append("--") cmd_line += list(paths) return cmd_line def format_in_place(self, paths): run(self._get_command_line(paths, in_place=True)) @staticmethod def _show_diff(path, formatted): original = read_file(path) if original and not formatted: # Assuming this file is ignored in .clang-format-ignore. return True original = original.split("\n") formatted = formatted.split("\n") original_lbl = f"{path} (original)" formatted_lbl = f"{path} (formatted)" diff = difflib.unified_diff( original, formatted, fromfile=original_lbl, tofile=formatted_lbl, lineterm="", ) clean = True for line in diff: clean = False print(line) return clean def show_diff(self, paths): clean = True for path in paths: formatted = run(self._get_command_line([path])).stdout clean = self._show_diff(path, formatted) and clean return clean def git_root_dir(): cmd_line = ["git", "rev-parse", "--show-toplevel"] root_dir = run(cmd_line).stdout if root_dir[-1] != "\n": raise RuntimeError("git rev-parse --show-toplevel should append a newline?") return root_dir[:-1] def list_git_files(): cmd_line = ["git", "ls-tree", "-r", "-z", "--name-only", "HEAD"] repo_files = run(cmd_line).stdout repo_files = repo_files.split("\0") return repo_files def list_all_files(): return (normalize_path(path) for path in list_git_files()) def excluded(path, exclude): for entry in exclude: if entry == os.path.commonpath((path, entry)): return True return False def filter_files(paths, exclude): if not exclude: return paths return (path for path in paths if not excluded(path, exclude)) CPP_FILE_EXTENSIONS = set( (".c", ".h", ".cc", ".hh", ".cpp", ".hpp", ".cxx", ".hxx", ".cp", ".c++") ) def list_cpp_files(): for path in list_all_files(): ext = os.path.splitext(path)[1].lower() if ext in CPP_FILE_EXTENSIONS: yield path DEFAULT_VERSION = "clang-format" DEFAULT_STYLE = "file" def process_cpp_files( version=DEFAULT_VERSION, style=DEFAULT_STYLE, in_place=False, exclude=None ): clang_format = ClangFormat(version, style) with cd(git_root_dir()): cpp_files = filter_files(list_cpp_files(), exclude) if in_place: clang_format.format_in_place(cpp_files) else: if not clang_format.show_diff(cpp_files): sys.exit(1) def parse_args(argv=None): if argv is None: argv = sys.argv[1:] parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( "-b", "--clang-format", dest="version", default=DEFAULT_VERSION, help="clang-format executable file path", ) parser.add_argument( "-s", "--style", default=DEFAULT_STYLE, help="clang-format -style parameter argument", ) parser.add_argument( "-i", "--in-place", action="store_true", help="edit the files in-place" ) parser.add_argument( "-e", "--exclude", nargs="*", type=normalize_path, help="files or directories to exclude", ) return parser.parse_args(argv) def main(argv=None): args = parse_args(argv) with setup_logging(): process_cpp_files(**vars(args)) if __name__ == "__main__": main()