1# Copyright 2025 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""A CLI utility that checks and fixes formatting for source files.""" 15 16import argparse 17import itertools 18import logging 19import os 20from pathlib import Path 21import sys 22from typing import Collection, Pattern 23 24from pw_cli import log 25from pw_cli.collect_files import ( 26 collect_files_in_current_repo, 27 file_summary, 28) 29from pw_cli.plural import plural 30from pw_cli.tool_runner import BasicSubprocessRunner 31from pw_presubmit.format.core import FileFormatter 32from pw_presubmit.format.private import cli_support 33 34_LOG: logging.Logger = logging.getLogger(__name__) 35 36# Only use for Git, we need to use the system git 100% of the time. 37_git_runner = BasicSubprocessRunner() 38 39 40# Eventually, this should be public. It's currently private to prevent too many 41# external dependencies while it's under development. 42class FormattingSuite: 43 """A suite of code formatters that may be run on a set of files.""" 44 45 def __init__( 46 self, 47 formatters: Collection[FileFormatter], 48 ): 49 self._formatters = formatters 50 51 def main(self) -> int: 52 """Entry point for the formatter CLI.""" 53 # Set up logging. 54 log.install(logging.INFO) 55 56 if 'BUILD_WORKING_DIRECTORY' in os.environ: 57 os.chdir(os.environ['BUILD_WORKING_DIRECTORY']) 58 parser = argparse.ArgumentParser(description=__doc__) 59 cli_support.add_arguments(parser) 60 args = parser.parse_args() 61 return 0 if self.format_files(**vars(args)) else 1 62 63 def format_files( 64 self, 65 paths: Collection[str | Path], 66 base: str | None, 67 exclude: Collection[Pattern] = tuple(), 68 apply_fixes: bool = True, 69 ) -> bool: 70 """Formats files in a repository. 71 72 Args: 73 paths: File paths and pathspects to format. 74 base: Filters paths to only include files modified since this 75 specified Git ref. 76 exclude: Regex patterns to exclude from the set of collected files. 77 apply_fixes: Whether or not to apply formatting fixes to files. 78 79 Returns: 80 True if operation was successful. 81 """ 82 all_files = collect_files_in_current_repo( 83 paths, 84 _git_runner, 85 modified_since_git_ref=base, 86 exclude_patterns=exclude, 87 action_flavor_text='Formatting', 88 ) 89 90 _LOG.info('Checking formatting for %s', plural(all_files, 'file')) 91 92 for line in file_summary( 93 cli_support.relativize_paths(all_files, Path.cwd()) 94 ): 95 print(line, file=sys.stderr) 96 97 all_files = tuple(cli_support.filter_exclusions(all_files)) 98 99 files_by_formatter = cli_support.map_files_to_formatters( 100 all_files, self._formatters 101 ) 102 103 findings = cli_support.check(files_by_formatter) 104 findings_as_list = list( 105 itertools.chain.from_iterable(findings.values()) 106 ) 107 108 cli_support.summarize_findings( 109 findings_as_list, 110 log_fix_command=(not apply_fixes), 111 log_oneliner_summary=True, 112 ) 113 114 if not findings: 115 _LOG.info('Congratulations! No formatting changes needed') 116 return True 117 118 if not apply_fixes: 119 _LOG.error('Formatting errors found') 120 return False 121 122 _LOG.info( 123 'Applying formatting fixes to %s', 124 plural(findings_as_list, 'file'), 125 ) 126 fix_errors = cli_support.fix(findings) 127 if fix_errors: 128 _LOG.error('Failed to apply all formatting fixes') 129 return False 130 131 _LOG.info('Formatting fixes applied successfully') 132 return True 133