#!/usr/bin/env python3 # -*- coding: utf-8 -*- # ------------------------------------------------------------------------------ # Copyright (c) 2022 Michael Richters # This is free and unencumbered software released into the public domain. # Anyone is free to copy, modify, publish, use, compile, sell, or # distribute this software, either in source code form or as a compiled # binary, for any purpose, commercial or non-commercial, and by any # means. # In jurisdictions that recognize copyright laws, the author or authors # of this software dedicate any and all copyright interest in the # software to the public domain. We make this dedication for the benefit # of the public at large and to the detriment of our heirs and # successors. We intend this dedication to be an overt act of # relinquishment in perpetuity of all present and future rights to this # software under copyright law. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. # For more information, please refer to # ------------------------------------------------------------------------------ """This script runs clang-format on a Kaleidoscope repository.""" import argparse import logging import os import re import shutil import subprocess import sys sys.dont_write_bytecode = True from common import check_git_diff, setup_logging, split_on_newlines, split_on_nulls # ============================================================================== def parse_args(args): """Parse command line parameters Args: args (list[str]): command line parameters as list of strings (for example ``["--help"]``). Returns: :obj:`argparse.Namespace`: command line parameters namespace """ parser = argparse.ArgumentParser( description=""" Recursively search specified directories and format source files with clang-format. By default, it operates on Arduino C++ source files with extensions: *.{cpp,h,hpp,inc,ino}.""") parser.add_argument( '-q', '--quiet', dest='loglevel', action='store_const', const=logging.ERROR, default=logging.WARNING, help=""" Suppress output except warnings and errors.""", ) parser.add_argument( '-v', '--verbose', action='store_const', dest='loglevel', const=logging.INFO, help=""" Output verbose debugging information.""", ) parser.add_argument( '-d', '--debug', action='store_const', dest='loglevel', const=logging.DEBUG, help=""" Save output from `include-what-you-use` for processed files beside the originals, with a '.iwyu' suffix, for debugging purposes.""", ) parser.add_argument( '-X', '--exclude-dir', action='append', dest='exclude_dirs', default=[], metavar="", help=""" Exclude dir from search (path relative to the pwd)""", ) parser.add_argument( '-x', '--exclude-file', action='append', dest='exclude_files', default=[], metavar="", help=""" Exclude (base name only, not a full path) from formatting""", ) parser.add_argument( '-r', '--regex', dest='regex', default=r'\.(cpp|h|hpp|inc|ino)$', metavar="", help=""" Regular expression for matching source file names""", ) parser.add_argument( '-z', '-0', action='store_const', dest='input_splitter', const=split_on_nulls, default=split_on_newlines, help=""" When reading target filenames from standard input, break on NULL characters instead of newlines.""", ) parser.add_argument( '-f', '--force', action='store_true', help=""" Format code even if there are unstaged changes""", ) parser.add_argument( '--check', action='store_true', help=""" Check for changes after formatting by running `git diff --exit-code`. If there are any changes after formatting, a non-zero exit code is returned.""", ) parser.add_argument( 'targets', metavar="", nargs='*', help=""" A list of files and/or directories to search for source files to format.""", ) return parser.parse_args(args) # ============================================================================== def main(): """Parse command-line arguments and format source files. """ # Parse command-line argumets: opts = parse_args(sys.argv[1:]) # Set up logging system: setup_logging(opts.loglevel) # ---------------------------------------------------------------------- # Unless we've been given the `--force` option, check for unstaged changes to avoid # clobbering any work in progress: exit_code = 0 if not opts.force: changed_files = check_git_diff() if len(changed_files) > 0: logging.error("Working tree has unstaged changes; aborting") return 1 # Locate `clang-format` executable: clang_format_exe = shutil.which('clang-format') logging.debug("Found `clang-format` executable: %s", clang_format_exe) clang_format_cmd = [clang_format_exe, '-i'] if opts.loglevel <= logging.INFO: clang_format_cmd.append('--verbose') # ---------------------------------------------------------------------- # Read targets from command line: targets = opts.targets logging.debug("CLI target parameters: %s", targets) # If stdin is a pipe, read target filenames from it: if not sys.stdin.isatty(): targets += opts.input_splitter(sys.stdin.read()) logging.debug("All targets: %s", targets) # Prepare exclusion lists. The file excludes are basenames only, and the dirs get # converted to absolute path names. exclude_files = set(opts.exclude_files) exclude_dirs = set(os.path.abspath(_) for _ in opts.exclude_dirs) # Convert target paths to absolute, and remove any that are excluded: target_paths = set(os.path.abspath(_) for _ in targets if _ not in exclude_dirs) logging.debug("Target paths: %s", target_paths) # Build separate sets of target files and dirs. Later, we'll search target dirs and add # matching target files to the files set. target_files = set() target_dirs = set() for t in target_paths: if os.path.isfile(t): target_files.add(os.path.abspath(t)) elif os.path.isdir(t): target_dirs.add(os.path.abspath(t)) logging.debug("Target files after separating: %s", target_files) logging.debug("Target dirs after separating: %s", target_dirs) # Remove excluded filenames: target_files -= set(_ for _ in target_files if os.path.basename(_) in exclude_files) # Remove files and dirs in excluded dirs: target_files -= set(_ for _ in target_files for x in exclude_dirs if _.startswith(x)) target_dirs -= set(_ for _ in target_dirs for x in exclude_dirs if _.startswith(x)) # Compile regex for matching files to be formatted: target_matcher = re.compile(opts.regex) # Remove target files that don't match the regex: logging.debug("Target files before matching regex: %s", target_files) target_files = set(_ for _ in target_files if target_matcher.search(_)) logging.debug("Target files after matching regex: %s", target_files) # Search target dirs for non-excluded files, and add them to `target_files`: logging.debug("Searching target dirs: %s", target_dirs) for path in target_dirs: for root, dirs, files in os.walk(path): # Prune excluded dirs for x in exclude_dirs: if x in (os.path.join(root, _) for _ in dirs): dirs.remove(os.path.basename(x)) # Add non-excluded files for f in files: if target_matcher.search(f) and f not in exclude_files: target_files.add(os.path.join(root, f)) if len(target_files) == 0: logging.error("No target files found; exiting.") return 1 # Run clang-format on target files: proc = subprocess.run(clang_format_cmd + sorted(target_files)) if proc.returncode != 0: logging.error("Error: clang-format returned non-zero status: %s", proc.returncode) return proc.returncode else: logging.info("Finished formatting target files.") # If we've been asked to check for changes made by the formatter: if opts.check: logging.warning('Checking for changes made by the formatter...') changed_files = check_git_diff() if len(changed_files) == 0: logging.warning("No files changed. Congratulations!") else: logging.warning("Found files with changes after formatting:") exit_code = 1 for f in changed_files: logging.warning(" %s", f) return exit_code # ============================================================================== if __name__ == "__main__": try: sys.exit(main()) except KeyboardInterrupt: logging.info("Aborting") sys.exit(1)