blob: e72d0135762eaf2c24359f93fd6328c84cc635e3 [file] [edit]
#!/usr/bin/env fuchsia-vendored-python
#
# Copyright 2021 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# Runs clippy on a set of gn targets or rust source files
import argparse
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
from rust import (
FUCHSIA_BUILD_DIR,
ROOT_PATH,
GnTarget,
)
def possible_labels(t: dict[str, any], fuchsia_dir: str) -> set[str]:
"""all labels the user could have meant"""
labels = set()
for key in ("clippy_label", "original_label", "actual_label"):
if key in t and t[key]:
gn_t = GnTarget(t[key], fuchsia_dir)
labels.add(str(gn_t))
# For Fuchsia target labels (such as Zircon kernel modules), GN
# attaches variant toolchain qualifiers (e.g.
# `(//zircon/kernel/switch/set/lk_debug_level_2:...)`).
# Adding the toolchain-stripped label allows user input like `fx
# clippy //zircon/kernel/bin:vmzircon.with-tests` or file-based
# target lookups to match without requiring explicit toolchain
# suffixes.
if t.get("target_is_fuchsia"):
labels.add(f"//{gn_t.label_path}:{gn_t.label_name}")
return labels
def main():
args = parse_args()
build_dir = Path(args.out_dir) if args.out_dir else FUCHSIA_BUILD_DIR
rust_target_mapping = Path(
build_dir, "rust_target_mapping.json"
).read_bytes()
rust_target_mapping = json.loads(rust_target_mapping)
rust_target_mapping = [
t for t in rust_target_mapping if not t["disable_clippy"]
]
if not args.all:
# filter rust target mapping
if args.files:
input_files = {os.path.relpath(f, build_dir) for f in args.input}
rust_target_mapping = [
t
for t in rust_target_mapping
for s in t["src"]
if s in input_files
]
else: # they are labels
targets_look_like_sources = [
t for t in args.input if t.endswith(".rs")
]
if targets_look_like_sources:
print(
f"Warning: {targets_look_like_sources} looks like a source file rather than a target, "
"maybe you meant to use --files ?"
)
rust_target_mapping = [
t
for t in rust_target_mapping
for l in args.input
if (
any(
pl.startswith(l[:-2])
for pl in possible_labels(t, args.fuchsia_dir)
)
if l.endswith("**")
else str(GnTarget(l, args.fuchsia_dir))
in possible_labels(t, args.fuchsia_dir)
)
]
clippy_outputs = list(
set(str(Path(t["clippy_output"])) for t in rust_target_mapping)
)
if args.get_outputs:
print(*clippy_outputs, sep="\n")
return 0
if not clippy_outputs:
print("Error: Couldn't find any clippy outputs for those inputs")
return 1
if args.no_build:
run_time = 0
returncode = 0
else:
run_time = time.time()
returncode = build_targets(clippy_outputs, build_dir, args).returncode
lints = {}
for clippy_output in clippy_outputs:
clippy_output = build_dir / clippy_output
# If we failed to build all targets we can keep going and print any
# lints that were collected.
if returncode != 0:
if not clippy_output.exists():
continue
if os.path.getmtime(clippy_output) < run_time:
continue
with open(clippy_output) as f:
error_reported = False
for line in f:
try:
lint = json.loads(line)
except json.decoder.JSONDecodeError:
if not error_reported:
print(f"Malformed output: {clippy_output}")
returncode = 1
error_reported = True
continue
# filter out "n warnings emitted" messages
if not lint["spans"]:
continue
lints[fingerprint_diagnostic(lint)] = lint
if not args.raw:
print("\nClippy Diagnostics\n==================\n")
for lint in lints.values():
print(json.dumps(fix_paths(lint)) if args.raw else lint["rendered"])
if not args.raw:
print(len(lints), "warning(s) emitted\n")
return returncode
# To deduplicate lints, use the message, code, all top level spans, and macro
# expansion spans
def fingerprint_diagnostic(lint):
code = lint.get("code")
def expand_spans(span):
yield span
if expansion := span.get("expansion"):
yield from expand_spans(expansion["span"])
spans = [x for span in lint["spans"] for x in expand_spans(span)]
return (
lint["message"],
code.get("code") if code else None,
frozenset(
(x["file_name"], x["byte_start"], x["byte_end"]) for x in spans
),
)
_RESOLVED_PATH_CACHE = {}
def resolve_generated_kernel_path(file_path):
"""Resolves generated Zircon kernel module paths back to in-tree source files.
Zircon kernel Rust code relies on GN kernel_rust_mod() templates to produce
synthetic wrapper `.rs` files under `gen/`. These generated wrappers
textually pull in real in-tree source files via `include!()`,
`include_str!()`, or `include_bytes!()`.
When Clippy reports diagnostics against these generated `gen/` files, IDE
integrations (like VSCode flycheck) fail to locate the files in the VFS or
report incorrect line spans. This function inspects generated `.rs` wrapper
files for include macro directives to map diagnostic spans back to the
actual repository source files under `zircon/kernel/`.
"""
if file_path in _RESOLVED_PATH_CACHE:
return _RESOLVED_PATH_CACHE[file_path]
abs_path = (
os.path.join(FUCHSIA_BUILD_DIR, file_path)
if not os.path.isabs(file_path)
else file_path
)
norm_path = os.path.normpath(abs_path)
res = norm_path
if "/gen/" in norm_path and norm_path.endswith(".rs"):
dir_path = os.path.dirname(norm_path)
candidates = [norm_path]
if os.path.exists(dir_path):
candidates.extend(
[
os.path.join(dir_path, f)
for f in os.listdir(dir_path)
if f.endswith(".rs")
]
)
for cand in candidates:
if os.path.isfile(cand):
try:
with open(
cand, "r", encoding="utf-8", errors="ignore"
) as f:
content = f.read()
includes = re.findall(
r'include(?:_str|_bytes)?!\("([^"]+)"\);', content
)
for inc in includes:
resolved_inc = os.path.normpath(
os.path.join(os.path.dirname(cand), inc)
)
if "/gen/" not in resolved_inc and os.path.isfile(
resolved_inc
):
res = resolved_inc
break
except Exception:
pass
if res != norm_path:
break
rel_res = os.path.relpath(res)
_RESOLVED_PATH_CACHE[file_path] = rel_res
return rel_res
# Rewrite paths in a diagnostic to be relative to the current directory.
def fix_paths(lint):
for span in lint["spans"]:
span["file_name"] = resolve_generated_kernel_path(span["file_name"])
lint["children"] = [fix_paths(child) for child in lint["children"]]
return lint
def build_targets(clippy_outputs, build_dir, args):
fuchsia_dir = Path(args.fuchsia_dir) if args.fuchsia_dir else ROOT_PATH
if not fuchsia_dir or not fuchsia_dir.is_dir():
fuchsia_dir = Path(__file__).resolve().parents[5]
main_build_script = fuchsia_dir / "build" / "scripts" / "main_build.py"
cmd = [
sys.executable,
str(main_build_script),
"--build-dir",
str(build_dir),
]
if args.verbose:
cmd.append("--verbose")
if args.quiet or args.raw:
cmd.append("--no-status")
cmd.append("ninja")
if args.stop_on_first_error:
cmd += ["-k", "1"]
else:
cmd += ["-k", "0"]
if args.jobs is not None:
cmd += ["-j", str(args.jobs)]
if args.verbose:
cmd += ["--verbose"]
if args.quiet:
cmd += ["--quiet"]
cmd += clippy_outputs
output = sys.stderr if args.raw else None
env = dict(os.environ)
env["FUCHSIA_DIR"] = str(fuchsia_dir)
env.setdefault("NINJA_PERSISTENT_MODE", "1")
if args.raw:
env.pop("FX_BUILD_RBE_STATS", None)
if args.verbose:
print(" ".join([str(arg) for arg in cmd]))
return subprocess.run(cmd, stdout=output, env=env)
def parse_args():
parser = argparse.ArgumentParser(
description="Run cargo clippy on a set of targets or rust files"
)
parser.add_argument(
"--verbose",
"-v",
help="verbose build output",
action="store_true",
default=False,
)
parser.add_argument(
"--quiet",
help="don't show progress status",
action="store_true",
default=os.environ.get("FX_BUILD_QUIET") == "1",
)
parser.add_argument(
"--files",
"-f",
action="store_true",
help="treat the inputs as source files rather than gn targets",
)
inputs = parser.add_mutually_exclusive_group(required=True)
inputs.add_argument(
"input",
nargs="*",
help="GN targets to run. Strings ending in '**' match any targets up to the glob",
default=[],
)
inputs.add_argument(
"--all", action="store_true", help="run on all clippy targets"
)
advanced = parser.add_argument_group("advanced")
advanced.add_argument(
"--out-dir", help="path to the Fuchsia build directory"
)
advanced.add_argument(
"--fuchsia-dir", help="path to the Fuchsia root directory"
)
advanced.add_argument(
"--raw",
action="store_true",
help="emit full json rather than human readable messages",
)
advanced.add_argument(
"--get-outputs",
action="store_true",
help="emit a list of clippy output files rather than lints",
)
advanced.add_argument(
"--no-build",
action="store_true",
help="don't build the clippy output, instead expect that it already exists",
)
advanced.add_argument(
"--stop-on-first-error",
action="store_true",
help="stop on first error",
default=False,
)
advanced.add_argument(
"--jobs",
"-j",
type=int,
help="number of concurrent jobs to run",
default=None,
)
args = parser.parse_args()
if args.verbose:
args.quiet = False
return args
if __name__ == "__main__":
sys.exit(main())