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