diff --git a/Makefile b/Makefile index 29a3464f..25246a54 100644 --- a/Makefile +++ b/Makefile @@ -106,10 +106,18 @@ find-filename-conflicts: .PHONY: format check-formatting cpplint cpplint-noisy shellcheck smoke-examples find-filename-conflicts prepare-virtual checkout-platform adjust-git-timestamps docker-bash docker-simulator-tests run-tests simulator-tests setup format: - bin/format-code.sh + bin/format-code.py \ + --exclude-dir 'testing/googletest' \ + --exclude-file 'generated-testcase.cpp' \ + src plugins examples testing check-formatting: - bin/format-code.sh --check + bin/format-code.py \ + --exclude-dir 'testing/googletest' \ + --exclude-file 'generated-testcase.cpp' \ + --check \ + --verbose \ + src plugins examples testing cpplint-noisy: -bin/cpplint.py --filter=-legal/copyright,-build/include,-readability/namespace,-whitespace/line_length,-runtime/references --recursive --extensions=cpp,h,ino src examples diff --git a/bin/format-code.py b/bin/format-code.py new file mode 100755 index 00000000..a71c19ba --- /dev/null +++ b/bin/format-code.py @@ -0,0 +1,271 @@ +#!/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 Kaleidoscope's codebase.""" + +import argparse +import glob +import logging +import os +import re +import subprocess +import sys + + +# ============================================================================== +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( + '-v', + '--verbose', + dest='loglevel', + help="Verbose output", + action='store_const', + const=logging.INFO, + ) + parser.add_argument( + '-q', + '--quiet', + dest='loglevel', + help="Suppress all non-error output", + action='store_const', + const=logging.ERROR, + ) + parser.add_argument( + '-X', + '--exclude-dir', + metavar="", + dest='exclude_dirs', + help="Exclude dir from search (path relative to the pwd)", + action='append', + default=[], + ) + parser.add_argument( + '-x', + '--exclude-file', + metavar="", + dest='exclude_files', + help="Exclude (base name only, not a full path) from formatting", + action='append', + default=[], + ) + parser.add_argument( + '-e', + '--regex', + metavar="", + dest='src_re_str', + help="Regular expression for matching source file names", + default=r'\.(cpp|h|hpp|inc|ino)$', + ) + 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", + ) + 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 setup_logging(loglevel): + """Setup basic logging + + Args: + loglevel (int): minimum loglevel for emitting messages + """ + logformat = "%(message)s" + logging.basicConfig( + level=loglevel, + stream=sys.stdout, + format=logformat, + datefmt="", + ) + return logging.getLogger() + + +# ============================================================================== +def format_code(path, opts, clang_format_cmd): + """Run clang-format on a directory.""" + logging.info("Formatting code in %s...", path) + + src_regex = re.compile(opts.src_re_str) + + src_files = [] + + for root, dirs, files in os.walk(path): + for exclude_path in opts.exclude_dirs: + exclude_path = exclude_path.rstrip(os.path.sep) + if os.path.dirname(exclude_path) == root: + exclude_dir = os.path.basename(exclude_path) + if exclude_dir in dirs: + dirs.remove(exclude_dir) + + for name in files: + if name in opts.exclude_files: + continue + if src_regex.search(name): + src_files.append(os.path.join(root, name)) + + proc = subprocess.run(clang_format_cmd + src_files) + if proc.returncode != 0: + logging.error("Error: clang-format returned non-zero status: %s", proc.returncode) + return + + +# ============================================================================== +def build_file_list(path, src_regex): + """Docstring""" + + # If the specified path is a filename, return it (as a list), regardless of + # whether or not it matches the regex. + if os.path.isfile(path): + return [path] + + # If the specified path is not valid, just return an empty list. + if not os.path.isdir(path): + return [] + + # The specified path is a directory, so we search recursively for files + # contained therein that match the specified regular expression. + source_files = [] + for root, dirs, files in os.walk(path): + # First, ignore all dotfiles (and directories). + dotfiles = set(glob.glob('.*')) + dirs = set(dirs) - dotfiles + files = set(dirs) - dotfiles + + # Check for a list of file glob patterns that should be excluded. + if IWYU_IGNORE_FILE in files: + with open(os.path.join(root, IWYU_IGNORE_FILE)) as f: + for pattern in f.read().splitlines(): + matches = set(glob.glob(os.path.join(root, pattern))) + dirs = set(dirs) - matches + files = set(files) - matches + + # Add all matching files to the list of source files to be formatted. + for f in filter(src_regex.search, files): + source_files.append(os.path.join(root, f)) + + return source_files + + +# ============================================================================== +def warn_if_output(byte_str, msg): + """Convert a string of bytes to a UTF-8 string, break it on newlines. If + there is any output, print a warning message, followed by each line, + indented by four spaces.""" + lines = byte_str.decode('utf-8').splitlines() + if '' in lines: + lines.remove('') + if len(lines) > 0: + logging.warning('%s', msg) + for line in lines: + logging.warning(' %s', line) + return + + +# ============================================================================== +def main(cli_args): + """Parse command-line arguments and format source files.""" + args = parse_args(cli_args) + if args.loglevel is None: + args.loglevel = logging.WARNING + setup_logging(args.loglevel) + + clang_format = os.getenv('CLANG_FORMAT_CMD') + if clang_format is None: + clang_format = 'clang-format' + + clang_format_cmd = [clang_format, '-i'] + + git_diff_cmd = ['git', 'diff', '--exit-code'] + + proc = subprocess.run(git_diff_cmd + ['--name-only'], capture_output=True) + if proc.returncode != 0: + warn_if_output(proc.stdout, 'Warning: you have unstaged changes to these files:') + if not args.force: + logging.warning( + 'Formatting aborted. Stage your changes or use --force to override.') + sys.exit(proc.returncode) + + if args.loglevel >= logging.WARNING: + git_diff_cmd.append('--quiet') + elif args.loglevel <= logging.INFO: + git_diff_cmd.append('--name-only') + + for path in args.targets: + format_code(path, args, clang_format_cmd) + + if args.check: + logging.warning('Checking for changes made by the formatter...') + + proc = subprocess.run(git_diff_cmd + ['--cached'], capture_output=True) + if proc.returncode != 0: + logging.warning( + 'Warning: Your working tree has staged changes. ' + + 'Committed changes might not pass this check.') + warn_if_output(proc.stdout, 'The following files have unstaged changes:') + + proc = subprocess.run(git_diff_cmd, capture_output=True) + if proc.returncode != 0: + warn_if_output(proc.stdout, 'The following files have changes:') + logging.error( + 'Check failed: Please commit formatting changes before submitting.') + sys.exit(proc.returncode) + return + + +# ============================================================================== +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/bin/format-code.sh b/bin/format-code.sh index e81dda2e..db0cc137 100755 --- a/bin/format-code.sh +++ b/bin/format-code.sh @@ -1,25 +1,12 @@ #!/usr/bin/env bash -# Allow the caller to specify a particular version of clang-format to use: -: "${CLANG_FORMAT_CMD:=clang-format}" +: "${KALEIDOSCOPE_DIR:=$(pwd)}" +cd "${KALEIDOSCOPE_DIR}" || exit 1 -# Find all *.cpp and *.h files, except those in `testing/googletest/` and files -# generated by testcase scripts, and run `clang-format` on them: -find ./* -type f \( -name '*.h' -o -name '*.cpp' \) \ - -not \( -path './testing/googletest/*' -o -name 'generated-testcase.cpp' \) \ - -print0 | \ - xargs -0 "${CLANG_FORMAT_CMD}" -i +: "${VERBOSE:=}" -# If we get the `--check` option, return an error if there are any changes to -# the git working tree after running `clang-format`: -if [[ $1 == '--check' ]]; then - - if ! git diff --quiet; then - cat >&2 <