1# Copyright 2024 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"""Supporting helpers for format CLI tooling.""" 15 16import argparse 17import collections 18import json 19import logging 20from pathlib import Path 21import re 22import sys 23import textwrap 24from typing import ( 25 Collection, 26 Dict, 27 Iterable, 28 Iterator, 29 List, 30 Mapping, 31 Sequence, 32 TextIO, 33) 34 35from pw_cli import color, find_config 36from pw_cli.collect_files import add_file_collection_arguments 37from pw_cli.diff import colorize_diff 38from pw_cli.plural import plural 39from pw_presubmit.format.core import ( 40 FileFormatter, 41 FormatFixStatus, 42 FormattedDiff, 43) 44 45_LOG: logging.Logger = logging.getLogger(__name__) 46 47 48def findings_to_formatted_diffs( 49 diffs: dict[Path, str] 50) -> Sequence[FormattedDiff]: 51 """Converts legacy formatter findings to structured findings.""" 52 return [ 53 FormattedDiff( 54 ok=True, 55 diff=finding, 56 error_message=None, 57 file_path=path, 58 ) 59 for path, finding in diffs.items() 60 ] 61 62 63def filter_exclusions(file_paths: Iterable[Path]) -> Iterator[Path]: 64 """Filters paths if they match an exclusion pattern in a pigweed.json.""" 65 # TODO: b/399204950 - Dedupe this with the FormatOptions class. 66 paths_by_config = find_config.paths_by_nearest_config( 67 "pigweed.json", 68 file_paths, 69 ) 70 for config, paths in paths_by_config.items(): 71 if config is None: 72 yield from paths 73 continue 74 config_obj = json.loads(config.read_text()) 75 fmt = config_obj.get('pw', {}).get('pw_presubmit', {}).get('format', {}) 76 exclude = tuple(re.compile(x) for x in fmt.get('exclude', ())) 77 relpaths = [(x.resolve().relative_to(config.parent), x) for x in paths] 78 for relative_path, original_path in relpaths: 79 # Yield the original path if none of the exclusion patterns match. 80 if not [ 81 filt for filt in exclude if filt.search(str(relative_path)) 82 ]: 83 yield original_path 84 85 86def summarize_findings( 87 findings: Sequence[FormattedDiff], 88 log_fix_command: bool, 89 log_oneliner_summary: bool, 90 file: TextIO = sys.stdout, 91) -> None: 92 """Prints a summary of a format check's findings.""" 93 if not findings: 94 return 95 96 if log_oneliner_summary: 97 _LOG.warning( 98 'Found %s with formatting issues:', 99 plural(findings, 'file'), 100 ) 101 102 paths_to_fix = [] 103 for formatting_finding in findings: 104 if not formatting_finding.ok: 105 file.write( 106 f'ERROR: Failed to format {formatting_finding.file_path}:\n' 107 ) 108 if formatting_finding.error_message: 109 file.write( 110 textwrap.indent( 111 formatting_finding.error_message, 112 ' ', 113 ) 114 ) 115 if not formatting_finding.diff: 116 continue 117 118 paths_to_fix.append(formatting_finding.file_path) 119 diff = ( 120 colorize_diff(formatting_finding.diff) 121 if color.is_enabled(file) 122 else formatting_finding.diff 123 ) 124 file.write(diff) 125 126 if log_fix_command: 127 # TODO: https://pwbug.dev/326309165 - Add a Bazel-specific command. 128 format_command = "pw format --fix" 129 130 def path_relative_to_cwd(path: Path): 131 try: 132 return Path(path).resolve().relative_to(Path.cwd().resolve()) 133 except ValueError: 134 return Path(path).resolve() 135 136 paths = " ".join([str(path_relative_to_cwd(p)) for p in paths_to_fix]) 137 message = f' {format_command} {paths}' 138 _LOG.warning('To fix formatting, run:\n\n%s\n', message) 139 140 141def add_arguments(parser: argparse.ArgumentParser) -> None: 142 """Adds formatting CLI arguments to an argument parser.""" 143 add_file_collection_arguments(parser) 144 parser.add_argument( 145 '--check', 146 action='store_false', 147 dest='apply_fixes', 148 help='Only display findings, do not apply formatting fixes.', 149 ) 150 151 152def relativize_paths( 153 paths: Iterable[Path], relative_to: Path 154) -> Iterable[Path]: 155 """Relativizes paths when possible.""" 156 for path in paths: 157 try: 158 yield path.resolve().relative_to(relative_to.resolve()) 159 except ValueError: 160 yield path 161 162 163def check( 164 files_by_formatter: Mapping[FileFormatter, Iterable[Path]] 165) -> Mapping[FileFormatter, Sequence[FormattedDiff]]: 166 """Returns expected diffs for files with incorrect formatting.""" 167 findings_by_formatter = {} 168 for code_formatter, files in files_by_formatter.items(): 169 _LOG.debug('Checking %s', ', '.join(str(f) for f in files)) 170 diffs = list(code_formatter.get_formatting_diffs(files)) 171 if diffs: 172 findings_by_formatter[code_formatter] = diffs 173 return findings_by_formatter 174 175 176def fix( 177 findings_by_formatter: Mapping[FileFormatter, Iterable[FormattedDiff]] 178) -> Mapping[Path, FormatFixStatus]: 179 """Fixes formatting errors in-place.""" 180 errors: Dict[Path, FormatFixStatus] = {} 181 for formatter, diffs in findings_by_formatter.items(): 182 files_to_format = [diff.file_path for diff in diffs] 183 statuses = formatter.format_files(files_to_format) 184 successfully_formatted = set(files_to_format) 185 for path, status in statuses: 186 if not status.ok: 187 successfully_formatted.remove(path) 188 _LOG.error('Failed to format %s', path) 189 errors[path] = status 190 if status.error_message is not None: 191 for line in status.error_message.splitlines(): 192 _LOG.error('%s', line) 193 if successfully_formatted: 194 _LOG.info( 195 'Successfully formatted %s', 196 plural(successfully_formatted, formatter.mnemonic + ' file'), 197 ) 198 199 return errors 200 201 202def map_files_to_formatters( 203 paths: Iterable[Path], 204 formatters: Collection[FileFormatter], 205) -> Mapping[FileFormatter, Iterable[Path]]: 206 """Maps files to formatters.""" 207 formatting_map: Dict[FileFormatter, List[Path]] = collections.defaultdict( 208 list 209 ) 210 for path in paths: 211 formatter_found = False 212 for formatter in formatters: 213 if formatter.file_patterns.matches(path): 214 _LOG.debug('Formatting %s as %s', path, formatter.mnemonic) 215 formatting_map[formatter].append(path) 216 formatter_found = True 217 if not formatter_found: 218 _LOG.debug('No formatter found for %s', path) 219 return formatting_map 220