xref: /aosp_15_r20/external/pigweed/pw_presubmit/py/pw_presubmit/format/bazel.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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"""Code formatter plugin for Bazel build files."""
15
16from pathlib import Path
17from typing import Dict, Final, Iterable, Iterator, List, Sequence, Tuple
18
19from pw_presubmit.format.core import (
20    FileFormatter,
21    FormattedFileContents,
22    FormatFixStatus,
23)
24
25
26class BuildifierFormatter(FileFormatter):
27    """A formatter that runs ``buildifier`` on files."""
28
29    # These warnings are safe to enable because they can always be auto-fixed.
30    DEFAULT_WARNINGS_TO_FIX: Final[Sequence[str]] = (
31        'load',
32        'native-build',
33        'unsorted-dict-items',
34    )
35
36    def __init__(
37        self, warnings_to_fix: Sequence[str] = DEFAULT_WARNINGS_TO_FIX, **kwargs
38    ):
39        super().__init__(**kwargs)
40        self.warnings_to_fix = list(warnings_to_fix)
41
42    @staticmethod
43    def _detect_file_type(file_path: Path) -> str:
44        if file_path.name == 'MODULE.bazel':
45            return 'module'
46        if file_path.name == 'WORKSPACE':
47            return 'workspace'
48        if '.bzl' in file_path.name:
49            return 'bzl'
50        if 'BUILD' in file_path.name or file_path.suffix == '.bazel':
51            return 'build'
52
53        return 'default'
54
55    def _files_by_type(self, paths: Iterable[Path]) -> Dict[str, List[Path]]:
56        all_types = (
57            'module',
58            'workspace',
59            'bzl',
60            'build',
61            'default',
62        )
63        all_files: Dict[str, List[Path]] = {t: [] for t in all_types}
64
65        for file_path in paths:
66            all_files[self._detect_file_type(file_path)].append(file_path)
67
68        return all_files
69
70    def format_file_in_memory(
71        self, file_path: Path, file_contents: bytes
72    ) -> FormattedFileContents:
73        """Uses ``buildifier`` to check the formatting of the requested file.
74
75        The file at ``file_path`` is NOT modified by this check.
76
77        Returns:
78            A populated
79            :py:class:`pw_presubmit.format.core.FormattedFileContents` that
80            contains either the result of formatting the file, or an error
81            message.
82        """
83        proc = self.run_tool(
84            'buildifier',
85            [
86                f'--type={self._detect_file_type(file_path)}',
87                '--lint=fix',
88                '--warnings=' + ','.join(self.warnings_to_fix),
89            ],
90            input=file_contents,
91        )
92        ok = proc.returncode == 0
93        return FormattedFileContents(
94            ok=ok,
95            formatted_file_contents=proc.stdout,
96            error_message=None if ok else proc.stderr.decode(),
97        )
98
99    def format_file(self, file_path: Path) -> FormatFixStatus:
100        """Formats the provided file in-place using ``buildifier``.
101
102        Returns:
103            A FormatFixStatus that contains relevant errors/warnings.
104        """
105        proc = self.run_tool(
106            'buildifier',
107            [
108                f'--type={self._detect_file_type(file_path)}',
109                '--lint=fix',
110                '--warnings=' + ','.join(self.warnings_to_fix),
111                file_path,
112            ],
113        )
114        ok = proc.returncode == 0
115        return FormatFixStatus(
116            ok=ok,
117            error_message=None if ok else proc.stderr.decode(),
118        )
119
120    def format_files(
121        self, paths: Iterable[Path], keep_warnings: bool = True
122    ) -> Iterator[Tuple[Path, FormatFixStatus]]:
123        """Uses ``buildifier`` to format the specified files in-place.
124
125        Returns:
126            An iterator of ``Path`` and
127            :py:class:`pw_presubmit.format.core.FormatFixStatus` pairs for each
128            file that was not successfully formatted. If ``keep_warnings`` is
129            ``True``, any successful format operations with warnings will also
130            be returned.
131        """
132        sorted_files = self._files_by_type(paths)
133        for file_type, type_specific_paths in sorted_files.items():
134            if not type_specific_paths:
135                continue
136
137            proc = self.run_tool(
138                'buildifier',
139                [
140                    f'--type={file_type}',
141                    '--lint=fix',
142                    '--warnings=' + ','.join(self.warnings_to_fix),
143                    *type_specific_paths,
144                ],
145            )
146
147            # If there's an error, fall back to per-file formatting to figure
148            # out which file has problems.
149            if proc.returncode != 0:
150                yield from super().format_files(
151                    type_specific_paths, keep_warnings
152                )
153