Kaleidoscope/bin/format-code.py

276 lines
9.7 KiB

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ------------------------------------------------------------------------------
# Copyright (c) 2022 Michael Richters <gedankenexperimenter@gmail.com>
# 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 <http://unlicense.org/>
# ------------------------------------------------------------------------------
"""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="<path>",
help="""
Exclude dir from search (path relative to the pwd)""",
)
parser.add_argument(
'-x',
'--exclude-file',
action='append',
dest='exclude_files',
default=[],
metavar="<file>",
help="""
Exclude <file> (base name only, not a full path) from formatting""",
)
parser.add_argument(
'-r',
'--regex',
dest='regex',
default=r'\.(cpp|h|hpp|inc|ino)$',
metavar="<regex>",
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="<search_dir>",
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 = os.getenv('KALEIDOSCOPE_CODE_FORMATTER')
if clang_format_exe is None:
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)