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