blob: cd133ad72b819d792365d279f50ce9b489a57d9f [file] [log] [blame]
# Copyright 2021 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Runs cargo clippy across the given files, dumping diagnostics to a JSON file.
This script is intended specifically for use with Tricium (go/tricium).
"""
import json
import logging
import os
from pathlib import Path
import re
from typing import Any, Dict, Iterable, List, NamedTuple, Text
from chromite.lib import commandline
from chromite.lib import cros_build_lib
class Error(Exception):
"""Base error class for tricium-cargo-clippy."""
class CargoClippyPackagePathError(Error):
"""Raised when no Package Path is provided."""
def __init__(self, source: Text) -> None:
super().__init__(f"{source} does not start with a package path")
self.source = source
class CargoClippyJSONError(Error):
"""Raised when cargo-clippy parsing jobs are not proper JSON."""
def __init__(self, source: Text, line_num: int) -> None:
super().__init__(f"{source}:{line_num}: is not valid JSON")
self.source = source
self.line_num = line_num
class CargoClippyReasonError(Error):
"""Raised when cargo-clippy parsing jobs don't provide a "reason" field."""
def __init__(self, source: Text, line_num: int) -> None:
super().__init__(f"{source}:{line_num}: is missing its reason")
self.source = source
self.line_num = line_num
class CargoClippyFieldError(Error):
"""Raised when cargo-clippy parsing jobs fail to determine a field."""
def __init__(self, source: Text, line_num: int, field: Text) -> None:
super().__init__(
f"{source}:{line_num}: {field} could not be parsed from original"
" json"
)
self.source = source
self.line_num = line_num
self.field = field
def resolve_path(file_path: Text) -> Text:
return str(Path(file_path).resolve())
class CodeLocation(NamedTuple):
"""Holds the location a ClippyDiagnostic Finding."""
file_path: Text
line_start: int
line_end: int
column_start: int
column_end: int
def to_dict(self):
return {**self._asdict(), "file_path": self.file_path}
class ClippyDiagnostic(NamedTuple):
"""Holds information about a compiler message from Clippy."""
locations: Iterable["CodeLocation"]
level: Text
message: Text
def as_json(self):
return json.dumps(
{
**self._asdict(),
"locations": [loc.to_dict() for loc in self.locations],
}
)
def parse_locations(
orig_json: Dict[Text, Any], package_path: Text, git_repo: Text
) -> Iterable["CodeLocation"]:
"""The code locations associated with this diagnostic as an iter.
The relevant code location can appear in either the messages[spans] field,
which will be used if present, or else child messages each have their own
locations specified.
Args:
orig_json: An iterable of clippy entries in original json.
package_path: A resolved path to the rust package.
git_repo: Base directory for git repo to strip out in diagnostics.
Yields:
A CodeLocation object associated with a relevant span.
Raises:
CargoClippyFieldError: Parsing failed to determine any code locations.
"""
spans = orig_json.get("message", {}).get("spans", [])
children = orig_json.get("message", {}).get("children", [])
for child in children:
spans = spans + child.get("spans", [])
locations = set()
for span in spans:
file_path = os.path.join(package_path, span.get("file_name"))
if git_repo and file_path.startswith(f"{git_repo}/"):
file_path = file_path[len(git_repo) + 1 :]
else:
# Remove ebuild work directories from prefix
# Such as: "**/<package>-9999/work/<package>-9999/"
# or: "**/<package>-0.24.52-r9/work/<package>-0.24.52/"
file_path = re.sub(
r"(.*/)?([^/]+)-[^/]+/work/[^/]+/+", "", file_path
)
location = CodeLocation(
file_path=file_path,
line_start=span.get("line_start"),
line_end=span.get("line_end"),
column_start=span.get("column_start"),
column_end=span.get("column_end"),
)
if location not in locations:
locations.add(location)
yield location
def parse_level(src: Text, src_line: int, orig_json: Dict[Text, Any]) -> Text:
"""The level (error or warning) associated with this diagnostic.
Args:
src: Name of the file orig_json was found in.
src_line: Line number where orig_json was found.
orig_json: An iterable of clippy entries in original json.
Returns:
The level of the diagnostic as a string (either error or warning).
Raises:
CargoClippyFieldError: Parsing failed to determine the level.
"""
level = orig_json.get("level")
if not level:
level = orig_json.get("message", {}).get("level")
if not level:
raise CargoClippyFieldError(src, src_line, "level")
return level
def parse_message(src: Text, src_line: int, orig_json: Dict[Text, Any]) -> Text:
"""The formatted linter message for this diagnostic.
Args:
src: Name of the file orig_json was found in.
src_line: Line number where orig_json was found.
orig_json: An iterable of clippy entries in original json.
Returns:
The rendered message of the diagnostic.
Raises:
CargoClippyFieldError: Parsing failed to determine the message.
"""
message = orig_json.get("message", {}).get("rendered")
if message is None:
raise CargoClippyFieldError(src, src_line, "message")
return message
def parse_diagnostics(
src: Text, orig_jsons: Iterable[Text], git_repo: Text
) -> ClippyDiagnostic:
"""Parses original JSON to find the fields of a Clippy Diagnostic.
Args:
src: Name of the file orig_json was found in.
orig_jsons: An iterable of clippy entries in original json.
git_repo: Base directory for git repo to strip out in diagnostics.
Yields:
A ClippyDiagnostic for orig_json.
Raises:
CargoClippyJSONError: if a diagnostic is not valid JSON.
CargoClippyReasonError: if a diagnostic is missing a "reason" field.
CargoClippyFieldError: if a field cannot be determined while parsing.
"""
for src_line, orig_json in enumerate(orig_jsons):
try:
line_json = json.loads(orig_json)
except json.decoder.JSONDecodeError:
json_error = CargoClippyJSONError(src, src_line)
logging.error(json_error)
raise json_error
# We pass the path to the package in a special JSON on the first line
if src_line == 0:
package_path = line_json.get("package_path")
if not package_path:
raise CargoClippyPackagePathError(src)
package_path = resolve_path(package_path)
continue
# Clippy outputs several types of logs, as distinguished by the "reason"
# field, but we only want to process "compiler-message" logs.
reason = line_json.get("reason")
if reason is None:
reason_error = CargoClippyReasonError(src, src_line)
logging.error(reason_error)
raise reason_error
if reason != "compiler-message":
continue
locations = parse_locations(line_json, package_path, git_repo)
level = parse_level(src, src_line, line_json)
message = parse_message(src, src_line, line_json)
# TODO(ryanbeltran): Export suggested replacements
yield ClippyDiagnostic(locations, level, message)
def parse_files(input_dir: Text, git_repo: Text) -> Iterable[ClippyDiagnostic]:
"""Gets all compiler-message lints from all the input files in input_dir.
Args:
input_dir: path to directory to scan for files
git_repo: Base directory for git repo to strip out in diagnostics.
Yields:
Clippy Diagnostics objects found in files in the input directory
"""
for root_path, _, file_names in os.walk(input_dir):
for file_name in file_names:
file_path = os.path.join(root_path, file_name)
with open(file_path, encoding="utf-8") as clippy_file:
yield from parse_diagnostics(file_path, clippy_file, git_repo)
def filter_diagnostics(
diags: Iterable[ClippyDiagnostic],
) -> Iterable[ClippyDiagnostic]:
"""Filters diagnostics and validates schemas."""
for diag in diags:
# ignore redundant messages: "aborting due to previous error..."
if "aborting due to previous error" in diag.message:
continue
# findings with no location are never useful
if not diag.locations:
continue
yield diag
def get_arg_parser() -> commandline.ArgumentParser:
"""Creates an argument parser for this script."""
parser = commandline.ArgumentParser(description=__doc__)
parser.add_argument(
"--output",
required=True,
type="str_path",
help="File to write results to.",
)
parser.add_argument(
"--clippy-json-dir",
type="str_path",
help="Directory where clippy outputs were previously written to.",
)
parser.add_argument(
"--git-repo-path",
type="str_path",
default="",
help="Base directory for git repo to strip out in diagnostics.",
)
return parser
def main(argv: List[str]) -> None:
cros_build_lib.AssertInsideChroot()
logging.basicConfig()
parser = get_arg_parser()
opts = parser.parse_args(argv)
opts.Freeze()
input_dir = resolve_path(opts.clippy_json_dir)
output_path = resolve_path(opts.output)
git_repo = opts.git_repo_path
diagnostics = filter_diagnostics(parse_files(input_dir, git_repo))
with open(output_path, "w", encoding="utf-8") as output_file:
output_file.writelines(f"{diag}\n" for diag in diagnostics)