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