blob: e864a619cdc74153471a1c7624962c162a2a9c03 [file] [log] [blame]
Ryan Beltrancfc5c362021-03-02 18:36:18 +00001# Copyright 2021 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Runs cargo clippy across the given files, dumping diagnostics to a JSON file.
6
7This script is intended specifically for use with Tricium (go/tricium).
8"""
9
10import json
11import os
12from pathlib import Path
13import re
Ryan Beltrancfc5c362021-03-02 18:36:18 +000014from typing import List, Dict, Iterable, Any, Text, NamedTuple
15
16from chromite.lib import commandline
17from chromite.lib import cros_build_lib
18from chromite.lib import cros_logging as logging
19
Ryan Beltrancfc5c362021-03-02 18:36:18 +000020
21class Error(Exception):
22 """Base error class for tricium-cargo-clippy."""
23
24
25class CargoClippyJSONError(Error):
26 """Raised when cargo-clippy parsing jobs are not proper JSON."""
27
28 def __init__(self, source: Text, line_num: int):
29 super().__init__(f'{source}:{line_num}: is not valid JSON')
30 self.source = source
31 self.line_num = line_num
32
33
34class CargoClippyReasonError(Error):
35 """Raised when cargo-clippy parsing jobs don't provide a "reason" field."""
36
37 def __init__(self, source: Text, line_num: int):
38 super().__init__(f'{source}:{line_num}: is missing its reason')
39 self.source = source
40 self.line_num = line_num
41
42
43class CargoClippyFieldError(Error):
44 """Raised when cargo-clippy parsing jobs fail to determine a field."""
45
46 def __init__(self, source: Text, line_num: int, field: Text):
47 super().__init__(
48 f'{source}:{line_num}: {field} could not be parsed from original json'
49 )
50 self.source = source
51 self.line_num = line_num
52 self.field = field
53
54
55def resolve_path(file_path: Text) -> Text:
56 return str(Path(file_path).resolve())
57
58
59class CodeLocation(NamedTuple):
60 """Holds the location a ClippyDiagnostic Finding."""
61 file_path: Text
62 file_name: Text
63 line_start: int
64 line_end: int
65 column_start: int
66 column_end: int
67
68 def to_dict(self):
69 return {
70 **self._asdict(),
71 'file_path': resolve_path(self.file_path)
72 }
73
74
75class ClippyDiagnostic(NamedTuple):
76 """Holds information about a compiler message from Clippy."""
77 file_path: Text
78 locations: Iterable['CodeLocation']
79 level: Text
80 message: Text
81
82 def as_json(self):
83 return json.dumps({
84 **self._asdict(),
85 'locations': [loc.to_dict() for loc in self.locations],
86 })
87
88
89def parse_file_path(
90 src: Text, src_line: int, orig_json: Dict[Text, Any]) -> Text:
91 """The path to the file targeted by the lint.
92
93 Args:
94 src: Name of the file orig_json was found in.
95 src_line: Line number where orig_json was found.
96 orig_json: An iterable of clippy entries in original json.
97
98 Returns:
99 A resolved path to the original source location as a string.
100
101 Raises:
102 CargoClippyFieldError: Parsing failed to determine the file path.
103 """
104 target_src_path = orig_json.get('target', {}).get('src_path')
105 if not target_src_path:
106 raise CargoClippyFieldError(src, src_line, 'file_path')
107 return resolve_path(target_src_path)
108
109
110def parse_locations(
111 src: Text,
112 src_line: int,
113 orig_json: Dict[Text, Any],
114 file_path: Text) -> Iterable['CodeLocation']:
115 """The code locations associated with this diagnostic as an iter.
116
117 The relevant code location can appear in either the messages[spans] field,
118 which will be used if present, or else child messages each have their own
119 locations specified.
120
121 Args:
122 src: Name of the file orig_json was found in.
123 src_line: Line number where orig_json was found.
124 orig_json: An iterable of clippy entries in original json.
125 file_path: A resolved path to the original source location.
126
127 Yields:
128 A CodeLocation object associated with a relevant span.
129
130 Raises:
131 CargoClippyFieldError: Parsing failed to determine any code locations.
132 """
133 spans = orig_json.get('message', {}).get('spans', [])
134 children = orig_json.get('message', {}).get('children', [])
135 for child in children:
136 spans = spans + child.get('spans', [])
137 if not spans:
138 raise CargoClippyFieldError(src, src_line, 'locations')
139
140 locations = set()
141 for span in spans:
142 location = CodeLocation(
143 file_path=file_path,
144 file_name=span.get('file_name'),
145 line_start=span.get('line_start'),
146 line_end=span.get('line_end'),
147 column_start=span.get('column_start'),
148 column_end=span.get('column_end'))
149 if location not in locations:
150 locations.add(location)
151 yield location
152
153
154def parse_level(src: Text, src_line: int, orig_json: Dict[Text, Any]) -> Text:
155 """The level (error or warning) associated with this diagnostic.
156
157 Args:
158 src: Name of the file orig_json was found in.
159 src_line: Line number where orig_json was found.
160 orig_json: An iterable of clippy entries in original json.
161
162 Returns:
163 The level of the diagnostic as a string (either error or warning).
164
165 Raises:
166 CargoClippyFieldError: Parsing failed to determine the level.
167 """
168 level = orig_json.get('level')
169 if not level:
170 level = orig_json.get('message', {}).get('level')
171 if not level:
172 raise CargoClippyFieldError(src, src_line, 'level')
173 return level
174
175
176def parse_message(
177 src: Text, src_line: int, orig_json: Dict[Text, Any]) -> Text:
178 """The formatted linter message for this diagnostic.
179
180 Args:
181 src: Name of the file orig_json was found in.
182 src_line: Line number where orig_json was found.
183 orig_json: An iterable of clippy entries in original json.
184
185 Returns:
186 The rendered message of the diagnostic.
187
188 Raises:
189 CargoClippyFieldError: Parsing failed to determine the message.
190 """
191 message = orig_json.get('message', {}).get('rendered')
192 if message is None:
193 raise CargoClippyFieldError(src, src_line, 'message')
194 return message
195
196
197def parse_diagnostics(
198 src: Text, orig_jsons: Iterable[Text]) -> ClippyDiagnostic:
199 """Parses original JSON to find the fields of a Clippy Diagnostic.
200
201 Args:
202 src: Name of the file orig_json was found in.
203 orig_jsons: An iterable of clippy entries in original json.
204
205 Yields:
206 A ClippyDiagnostic for orig_json.
207
208 Raises:
209 CargoClippyJSONError: if a diagnostic is not valid JSON.
210 CargoClippyReasonError: if a diagnostic is missing a "reason" field.
211 CargoClippyFieldError: if a field cannot be determined while parsing.
212 """
213 for src_line, orig_json in enumerate(orig_jsons):
214 try:
215 line_json = json.loads(orig_json)
216 except json.decoder.JSONDecodeError:
217 json_error = CargoClippyJSONError(src, src_line)
218 logging.error(json_error)
219 raise json_error
220 # Clippy outputs several types of logs, as distinguished by the "reason"
221 # field, but we only want to process "compiler-message" logs.
222 reason = line_json.get('reason')
223 if reason is None:
224 reason_error = CargoClippyReasonError(src, src_line)
225 logging.error(reason_error)
226 raise reason_error
227 if reason != 'compiler-message':
228 continue
229
230 file_path = parse_file_path(src, src_line, line_json)
231 locations = parse_locations(src, src_line, line_json, file_path)
232 level = parse_level(src, src_line, line_json)
233 message = parse_message(src, src_line, line_json)
234
235 # TODO(ryanbeltran): Export suggested replacements
236 yield ClippyDiagnostic(file_path, locations, level, message)
237
238
239def parse_files(input_dir: Text) -> Iterable[ClippyDiagnostic]:
240 """Gets all compiler-message lints from all the input files in input_dir.
241
242 Args:
243 input_dir: path to directory to scan for files
244
245 Yields:
246 Clippy Diagnostics objects found in files in the input directory
247 """
248 for root_path, _, file_names in os.walk(input_dir):
249 for file_name in file_names:
250 file_path = os.path.join(root_path, file_name)
251 with open(file_path, encoding='utf-8') as clippy_file:
252 yield from parse_diagnostics(file_path, clippy_file)
253
254
255def filter_diagnostics(
256 diags: Iterable[ClippyDiagnostic],
257 file_filter: Text) -> Iterable[ClippyDiagnostic]:
258 """Filters diagnostics by file_path and message and validates schemas."""
259 # ignore redundant message: "aborting due to previous error..."
260 abort_prev = 'aborting due to previous error'
261 # only include diagnostics if their file path matches the file_filter
262 include_file = include_file_pattern(file_filter).fullmatch
263 yield from (
264 diag for diag in diags
265 if include_file(diag.file_path) and abort_prev not in diag.message)
266
267
268def include_file_pattern(file_filter: Text) -> 're.Pattern':
269 """Constructs a regex pattern matching relevant file paths."""
270 # FIXME(ryanbeltran): currently does not support prefixes for recursive
271 # wildcards such as a**/b.
272 assert not re.search(r'[^/]\*\*', file_filter), (
273 'prefixes for recursive wildcard ** not supported unless ending with /')
274 tmp_char = chr(0)
275 return re.compile(
276 file_filter
277 # Escape any .'s
278 .replace('.', r'\.')
279 # Squash recursive wildcards into a single symbol
280 .replace('**/', tmp_char)
281 .replace('**', tmp_char)
282 # Nonrecursive wildcards match any string of non-"/" symbols
283 .replace('*', r'[^/]*')
284 # Recursive wildcards match any string of symbols
285 .replace(tmp_char, r'(.*/)?')
286 # Some paths may contain "//" which is equivalent to "/"
287 .replace('//', '/')
288 )
289
290
291def get_arg_parser() -> commandline.ArgumentParser:
292 """Creates an argument parser for this script."""
293 parser = commandline.ArgumentParser(description=__doc__)
294 parser.add_argument(
295 '--output', required=True, type='path', help='File to write results to.')
296 parser.add_argument(
297 '--files',
298 required=False,
299 default='/**/*',
300 type='path',
301 help='File(s) to output lints for. If none are specified, this tool '
302 'outputs all lints from clippy after applying filtering '
303 'from |--git-repo-base|, if applicable.')
304 parser.add_argument(
305 '--clippy-json-dir',
306 type='path',
307 help='Directory where clippy outputs were previously written to.')
308 return parser
309
310
311def main(argv: List[str]) -> None:
312 cros_build_lib.AssertInsideChroot()
313
314 logging.basicConfig()
315
316 parser = get_arg_parser()
317 opts = parser.parse_args(argv)
318 opts.Freeze()
319
320 input_dir = resolve_path(opts.clippy_json_dir)
321 output_path = resolve_path(opts.output)
322 file_filter = resolve_path(opts.files)
323
324 diagnostics = filter_diagnostics(parse_files(input_dir), file_filter)
325 with open(output_path, 'w', encoding='utf-8') as output_file:
326 output_file.writelines(f'{diag}\n' for diag in diagnostics)