• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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