blob: 2ba1358d7e48a2a606fc83ccc3aeb8fc6d6d7e18 [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
Chris McDonald59650c32021-07-20 15:29:28 -060011import logging
Ryan Beltrancfc5c362021-03-02 18:36:18 +000012import os
13from pathlib import Path
Chris McDonald59650c32021-07-20 15:29:28 -060014from typing import Any, Dict, Iterable, List, NamedTuple, Text
Ryan Beltrancfc5c362021-03-02 18:36:18 +000015
16from chromite.lib import commandline
17from chromite.lib import cros_build_lib
Ryan Beltrancfc5c362021-03-02 18:36:18 +000018
Ryan Beltrancfc5c362021-03-02 18:36:18 +000019
20class Error(Exception):
21 """Base error class for tricium-cargo-clippy."""
22
23
24class CargoClippyJSONError(Error):
25 """Raised when cargo-clippy parsing jobs are not proper JSON."""
26
27 def __init__(self, source: Text, line_num: int):
28 super().__init__(f'{source}:{line_num}: is not valid JSON')
29 self.source = source
30 self.line_num = line_num
31
32
33class CargoClippyReasonError(Error):
34 """Raised when cargo-clippy parsing jobs don't provide a "reason" field."""
35
36 def __init__(self, source: Text, line_num: int):
37 super().__init__(f'{source}:{line_num}: is missing its reason')
38 self.source = source
39 self.line_num = line_num
40
41
42class CargoClippyFieldError(Error):
43 """Raised when cargo-clippy parsing jobs fail to determine a field."""
44
45 def __init__(self, source: Text, line_num: int, field: Text):
46 super().__init__(
47 f'{source}:{line_num}: {field} could not be parsed from original json'
48 )
49 self.source = source
50 self.line_num = line_num
51 self.field = field
52
53
54def resolve_path(file_path: Text) -> Text:
55 return str(Path(file_path).resolve())
56
57
58class CodeLocation(NamedTuple):
59 """Holds the location a ClippyDiagnostic Finding."""
60 file_path: Text
61 file_name: Text
62 line_start: int
63 line_end: int
64 column_start: int
65 column_end: int
66
67 def to_dict(self):
68 return {
69 **self._asdict(),
Ryan Beltran923a1312021-07-30 00:28:13 +000070 'file_path': self.file_path
Ryan Beltrancfc5c362021-03-02 18:36:18 +000071 }
72
73
74class ClippyDiagnostic(NamedTuple):
75 """Holds information about a compiler message from Clippy."""
76 file_path: Text
77 locations: Iterable['CodeLocation']
78 level: Text
79 message: Text
80
81 def as_json(self):
82 return json.dumps({
83 **self._asdict(),
84 'locations': [loc.to_dict() for loc in self.locations],
85 })
86
87
88def parse_file_path(
Ryan Beltran923a1312021-07-30 00:28:13 +000089 src: Text, src_line: int, orig_json: Dict[Text, Any], git_repo: Text
90) -> Text:
Ryan Beltrancfc5c362021-03-02 18:36:18 +000091 """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.
Ryan Beltran923a1312021-07-30 00:28:13 +000097 git_repo: Base directory for git repo to strip out in diagnostics.
Ryan Beltrancfc5c362021-03-02 18:36:18 +000098
99 Returns:
100 A resolved path to the original source location as a string.
101
102 Raises:
103 CargoClippyFieldError: Parsing failed to determine the file path.
104 """
105 target_src_path = orig_json.get('target', {}).get('src_path')
106 if not target_src_path:
107 raise CargoClippyFieldError(src, src_line, 'file_path')
Ryan Beltran923a1312021-07-30 00:28:13 +0000108
109 resolved_path = resolve_path(target_src_path)
110 if resolved_path.startswith(f'{git_repo}/'):
111 return resolved_path[len(git_repo)+1:]
112 return resolved_path
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000113
114
115def parse_locations(
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000116 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:
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000125 orig_json: An iterable of clippy entries in original json.
126 file_path: A resolved path to the original source location.
127
128 Yields:
129 A CodeLocation object associated with a relevant span.
130
131 Raises:
132 CargoClippyFieldError: Parsing failed to determine any code locations.
133 """
134 spans = orig_json.get('message', {}).get('spans', [])
135 children = orig_json.get('message', {}).get('children', [])
136 for child in children:
137 spans = spans + child.get('spans', [])
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000138 locations = set()
139 for span in spans:
140 location = CodeLocation(
141 file_path=file_path,
142 file_name=span.get('file_name'),
143 line_start=span.get('line_start'),
144 line_end=span.get('line_end'),
145 column_start=span.get('column_start'),
146 column_end=span.get('column_end'))
147 if location not in locations:
148 locations.add(location)
149 yield location
150
151
152def parse_level(src: Text, src_line: int, orig_json: Dict[Text, Any]) -> Text:
153 """The level (error or warning) associated with this diagnostic.
154
155 Args:
156 src: Name of the file orig_json was found in.
157 src_line: Line number where orig_json was found.
158 orig_json: An iterable of clippy entries in original json.
159
160 Returns:
161 The level of the diagnostic as a string (either error or warning).
162
163 Raises:
164 CargoClippyFieldError: Parsing failed to determine the level.
165 """
166 level = orig_json.get('level')
167 if not level:
168 level = orig_json.get('message', {}).get('level')
169 if not level:
170 raise CargoClippyFieldError(src, src_line, 'level')
171 return level
172
173
174def parse_message(
175 src: Text, src_line: int, orig_json: Dict[Text, Any]) -> Text:
176 """The formatted linter message for this diagnostic.
177
178 Args:
179 src: Name of the file orig_json was found in.
180 src_line: Line number where orig_json was found.
181 orig_json: An iterable of clippy entries in original json.
182
183 Returns:
184 The rendered message of the diagnostic.
185
186 Raises:
187 CargoClippyFieldError: Parsing failed to determine the message.
188 """
189 message = orig_json.get('message', {}).get('rendered')
190 if message is None:
191 raise CargoClippyFieldError(src, src_line, 'message')
192 return message
193
194
195def parse_diagnostics(
Ryan Beltran923a1312021-07-30 00:28:13 +0000196 src: Text, orig_jsons: Iterable[Text], git_repo: Text) -> ClippyDiagnostic:
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000197 """Parses original JSON to find the fields of a Clippy Diagnostic.
198
199 Args:
200 src: Name of the file orig_json was found in.
201 orig_jsons: An iterable of clippy entries in original json.
Ryan Beltran923a1312021-07-30 00:28:13 +0000202 git_repo: Base directory for git repo to strip out in diagnostics.
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000203
204 Yields:
205 A ClippyDiagnostic for orig_json.
206
207 Raises:
208 CargoClippyJSONError: if a diagnostic is not valid JSON.
209 CargoClippyReasonError: if a diagnostic is missing a "reason" field.
210 CargoClippyFieldError: if a field cannot be determined while parsing.
211 """
212 for src_line, orig_json in enumerate(orig_jsons):
213 try:
214 line_json = json.loads(orig_json)
215 except json.decoder.JSONDecodeError:
216 json_error = CargoClippyJSONError(src, src_line)
217 logging.error(json_error)
218 raise json_error
219 # Clippy outputs several types of logs, as distinguished by the "reason"
220 # field, but we only want to process "compiler-message" logs.
221 reason = line_json.get('reason')
222 if reason is None:
223 reason_error = CargoClippyReasonError(src, src_line)
224 logging.error(reason_error)
225 raise reason_error
226 if reason != 'compiler-message':
227 continue
228
Ryan Beltran923a1312021-07-30 00:28:13 +0000229 file_path = parse_file_path(src, src_line, line_json, git_repo)
Ryan Beltran43a00662021-05-17 16:55:24 +0000230 locations = parse_locations(line_json, file_path)
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000231 level = parse_level(src, src_line, line_json)
232 message = parse_message(src, src_line, line_json)
233
234 # TODO(ryanbeltran): Export suggested replacements
235 yield ClippyDiagnostic(file_path, locations, level, message)
236
237
Ryan Beltran923a1312021-07-30 00:28:13 +0000238def parse_files(input_dir: Text, git_repo: Text) -> Iterable[ClippyDiagnostic]:
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000239 """Gets all compiler-message lints from all the input files in input_dir.
240
241 Args:
242 input_dir: path to directory to scan for files
Ryan Beltran923a1312021-07-30 00:28:13 +0000243 git_repo: Base directory for git repo to strip out in diagnostics.
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000244
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:
Ryan Beltran923a1312021-07-30 00:28:13 +0000252 yield from parse_diagnostics(file_path, clippy_file, git_repo)
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000253
254
255def filter_diagnostics(
Ryan Beltran923a1312021-07-30 00:28:13 +0000256 diags: Iterable[ClippyDiagnostic]) -> Iterable[ClippyDiagnostic]:
257 """Filters diagnostics and validates schemas."""
Ryan Beltran43a00662021-05-17 16:55:24 +0000258 for diag in diags:
Ryan Beltran43a00662021-05-17 16:55:24 +0000259 # ignore redundant messages: "aborting due to previous error..."
260 if 'aborting due to previous error' in diag.message:
261 continue
262 # findings with no location are never useful
263 if not diag.locations:
264 continue
265 yield diag
266
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000267
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000268def get_arg_parser() -> commandline.ArgumentParser:
269 """Creates an argument parser for this script."""
270 parser = commandline.ArgumentParser(description=__doc__)
271 parser.add_argument(
272 '--output', required=True, type='path', help='File to write results to.')
273 parser.add_argument(
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000274 '--clippy-json-dir',
275 type='path',
276 help='Directory where clippy outputs were previously written to.')
Ryan Beltran923a1312021-07-30 00:28:13 +0000277 parser.add_argument(
278 '--git-repo-path',
279 type='path',
280 default='',
281 help='Base directory for git repo to strip out in diagnostics.')
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000282 return parser
283
284
285def main(argv: List[str]) -> None:
286 cros_build_lib.AssertInsideChroot()
287
288 logging.basicConfig()
289
290 parser = get_arg_parser()
291 opts = parser.parse_args(argv)
292 opts.Freeze()
293
294 input_dir = resolve_path(opts.clippy_json_dir)
295 output_path = resolve_path(opts.output)
Ryan Beltran923a1312021-07-30 00:28:13 +0000296 git_repo = opts.git_repo_path
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000297
Ryan Beltran923a1312021-07-30 00:28:13 +0000298 diagnostics = filter_diagnostics(parse_files(input_dir, git_repo))
Ryan Beltrancfc5c362021-03-02 18:36:18 +0000299 with open(output_path, 'w', encoding='utf-8') as output_file:
300 output_file.writelines(f'{diag}\n' for diag in diagnostics)