#!/usr/bin/env python3
# Copyright 2021 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
A tool for manage source file formatting.
Assembles a list of files via paths, directories, and/or git diffs and runs
clang-format on them. By default the tool will format the files in place.
-noformat_file will run a check-only mode.
Returns:
0: Either no files were found which need formatting, or if format_file was
enablled and the files were succesfully formatted.
1: Files which need formatting were found but weren't formatted
because -noformat_file was set.
2: The application wasn't configured properly and could not execute.
"""
import difflib
import io
import os
import re
import subprocess
import sys
from absl import app
from absl import flags
# Flag Definitions:
FLAGS = flags.FLAGS
flags.DEFINE_boolean('git_diff', False, 'Use git-diff to assemble a file list')
flags.DEFINE_string('git_range', 'origin/main..', 'the range string when using '
'git-diff.')
flags.DEFINE_multi_string('f', None, 'Append the filename to the list of '
'files to check.')
flags.DEFINE_multi_string('d', None,
'Append the directory to the file list to format.')
flags.DEFINE_boolean('r', False,
"Recurse through the directory's children When formatting a target directory.")
flags.DEFINE_boolean('format_file', True, 'Format files in place.')
flags.DEFINE_boolean("verbose", False, 'Execute in verbose mode.')
flags.DEFINE_boolean("github_log", False, 'Pring special github log format items.')
# Constants:
FILE_TYPE_EXTENSIONS = ('.cpp', '.cc', '.c', '.h', '.m', '.mm', '.java')
"""Tuple: The file types to run clang-format on.
Used to filter out results when searching across directories or git diffs.
"""
FILE_PATHS_TO_IGNORE = re.compile(r'.*ios_pod/swift_headers/.*\.h')
"""Regex pattern for files paths to ignore.
Used to filter out results when searching across directories or git diffs.
"""
# Functions:
def get_formatting_diff_lines(filename):
"""Calculates and returns a printable diff of the formatting changes that
would be applied to the given file by clang-format.
Args:
filename (string): path to the file whose formatting diff to calculate.
Returns:
Iterable[str]: The diff of the formatted file against the original file;
each string in the returned iterable is a "line" of the diff; they could
be printed individually or joined into a single string using something
like os.linesep.join(diff_lines), where `diff_lines` is the return value.
"""
args = ['clang-format', '-style=file', filename]
result = subprocess.run(args, stdout=subprocess.PIPE, check=True)
formatted_lines = [line.rstrip('\r\n')
for line in result.stdout.decode('utf8', errors='replace').splitlines()]
with open(filename, 'rt', encoding='utf8', errors='replace') as f:
original_lines = [line.rstrip('\r\n') for line in f]
return [line.rstrip()
for line in difflib.unified_diff(original_lines, formatted_lines)]
def does_file_need_formatting(filename):
"""Executes clang-format on the file to determine if it includes any
formatting changes the user needs to fix.
Args:
filename (string): path to the file to check.
Returns:
bool: True if the file requires format changes, False if formatting would produce
an identical file.
"""
args = ['clang-format', '-style=file', '-output-replacements-xml', filename]
result = subprocess.run(args, stdout=subprocess.PIPE, check=True)
for line in result.stdout.decode('utf-8').splitlines():
if line.strip().startswith(" Found {0} file(s)'.format(len(filenames)))
print()
return filenames
def list_files_from_directory(path, recurse):
"""Iterates through the the directory and returns a list of files
which match those with extensions defined in FILE_TYPE_EXTENSIONS.
Args:
path (string): the path to a directory to start searching from.
recurse (bool): when True, will also recursively search for files in any
subdirectories found in path.
Returns:
list: the filenames found in the directory which match one of the extensions
in FILE_TYPE_EXTENSIONS.
"""
filenames = []
for root, dirs, files in os.walk(path):
for filename in files:
if(FILE_PATHS_TO_IGNORE.match(filename)):
continue
if filename.endswith(FILE_TYPE_EXTENSIONS):
full_path = os.path.join(root, filename)
if FLAGS.verbose:
print(' - {0}'.format(full_path))
filenames.append(full_path)
if not recurse:
break;
return filenames
def directory_search_list_files():
"""Create a list of files based on the directories defined in the command
line arguments -dr and -d.
Returns:
list: the filenames which match one of the extensions in
FILE_TYPE_EXTENSIONS.
"""
filenames = []
if FLAGS.d:
for directory in FLAGS.d:
print('Searching files in directory: "{0}"'.format(directory))
filenames += list_files_from_directory(directory, FLAGS.r)
if FLAGS.verbose:
print(' > Found {0} file(s)'.format(len(filenames)))
print()
return filenames
def validate_arguments():
"""Ensures that a proper command line configuration exists to create a file
list either via git-diff operations or via manuallyd defined --file lists.
Logs an error if theres an errant configuration.
Returns:
bool: True if the either directories or git diff is defined, or both.
Returns False otherwise signalling that execution should end.
"""
if not FLAGS.git_diff and not FLAGS.f and not FLAGS.d:
print()
print('ERROR: -git_diff not defined, and there are no file or')
print('directory search targets.')
print('Nothing to do. Exiting.')
return False
return True
def main(argv):
if not validate_arguments():
sys.exit(2)
filenames = []
if FLAGS.f:
filenames = FLAGS.f
if FLAGS.d:
filenames += directory_search_list_files()
if FLAGS.git_diff:
filenames += git_diff_list_files()
exit_code = 0
if not filenames:
print('No files to format.')
sys.exit(exit_code)
if FLAGS.verbose:
print('Found {0} file(s). Checking their format.'.format(len(filenames)))
if FLAGS.format_file:
count = 0
for filename in filenames:
if does_file_need_formatting(filename):
if FLAGS.verbose:
print(' - FRMT: "{0}"'.format(filename))
format_file(filename)
count += 1
else:
if FLAGS.verbose:
print(' - OK: "{0}"'.format(filename))
print(' > Formatted {0} file(s).'.format(count))
else:
github_log = ['::error ::FILE FORMATTING ERRORS:','']
count = 0
for filename in filenames:
if does_file_need_formatting(filename):
exit_code = 1
count += 1
github_log.append('- Requires reformatting: "{0}"'.format(filename))
if FLAGS.verbose:
print(' - Requires reformatting: "{0}"'.format(filename))
print('------ BEGIN FORMATTING DIFF OF {0}'.format(filename))
for diff_line in get_formatting_diff_lines(filename):
print(diff_line)
print('------ END FORMATTING DIFF OF {0}'.format(filename))
if FLAGS.verbose:
print(' > Done. {0} file(s) need formatting.'.format(count))
else:
print('{0} file(s) need formatting.'.format(count))
print('run: scripts/format_code.py -git_diff')
if exit_code and FLAGS.github_log:
github_log.append('')
github_log.append('{0} file(s) need formatting.'.format(count))
github_log.append('run: scripts/format_code.py -git_diff')
print('%0A'.join(github_log))
sys.exit(exit_code)
if __name__ == '__main__':
app.run(main)