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