use std::collections::HashSet;
use std::path::{Path, PathBuf};
use proc_macro2::Span;
use ra_ap_hir::{AsAssocItem, AssocItemContainer, Semantics};
use ra_ap_ide::RootDatabase;
use ra_ap_syntax::{AstNode, ast};
use serde_json::json;
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use crate::deep::{DeepContext, FileId};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
use crate::functions::type_name;
use crate::ingest::Workspace;
use crate::rules::dead_code::DeadCodeError;
pub const DEAD_TRAIT_IMPL_RULE: &str = "dead-trait-impl";
pub const DEAD_TRAIT_IMPL_RULE_REVISION: u32 = 1;
const DEAD_TRAIT_IMPL_REASON: &str =
"no resolved .method() call site found for this trait impl anywhere in the examined workspace";
#[derive(Debug, Default)]
pub struct DeadTraitImplReport {
pub findings: Vec<Finding>,
pub errors: Vec<DeadCodeError>,
pub checked: usize,
}
struct ImplCandidate {
file_path: PathBuf,
trait_name: String,
type_name: String,
impl_token_span: Span,
line: usize,
}
fn attrs_have_cfg_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
if !attr.path().is_ident("cfg") {
return false;
}
let syn::Meta::List(list) = &attr.meta else {
return false;
};
list.tokens
.to_string()
.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|word| word == "test")
})
}
fn is_blanket_impl(node: &syn::ItemImpl) -> bool {
let generic_idents: HashSet<String> = node
.generics
.type_params()
.map(|param| param.ident.to_string())
.collect();
if generic_idents.is_empty() {
return false;
}
match &*node.self_ty {
syn::Type::Path(type_path)
if type_path.qself.is_none() && type_path.path.segments.len() == 1 =>
{
generic_idents.contains(&type_path.path.segments[0].ident.to_string())
}
_ => false,
}
}
fn candidate_from_impl(file_path: &Path, node: &syn::ItemImpl) -> Option<ImplCandidate> {
let (_, trait_path, _) = node.trait_.as_ref()?;
let method_count = node
.items
.iter()
.filter(|item| matches!(item, syn::ImplItem::Fn(_)))
.count();
if method_count == 0 || is_blanket_impl(node) {
return None;
}
let trait_name = trait_path.segments.last()?.ident.to_string();
Some(ImplCandidate {
file_path: file_path.to_path_buf(),
trait_name,
type_name: type_name(&node.self_ty),
impl_token_span: node.impl_token.span(),
line: node.impl_token.span().start().line,
})
}
struct ImplWalker<'a> {
cfg_test_depth: usize,
file_path: &'a Path,
candidates: Vec<ImplCandidate>,
}
impl<'ast> Visit<'ast> for ImplWalker<'_> {
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
let gated = attrs_have_cfg_test(&node.attrs);
if gated {
self.cfg_test_depth += 1;
}
visit::visit_item_mod(self, node);
if gated {
self.cfg_test_depth -= 1;
}
}
fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
let gated = attrs_have_cfg_test(&node.attrs);
let excluded = self.cfg_test_depth > 0 || gated;
if gated {
self.cfg_test_depth += 1;
}
if !excluded {
self.candidates
.extend(candidate_from_impl(self.file_path, node));
}
visit::visit_item_impl(self, node);
if gated {
self.cfg_test_depth -= 1;
}
}
}
fn collect_candidates(workspace: &Workspace) -> (Vec<ImplCandidate>, Vec<DeadCodeError>) {
let mut candidates = Vec::new();
let mut errors = Vec::new();
crate::rules::dead_code::for_each_parsed_file(workspace, &mut errors, |file, ast| {
let mut walker = ImplWalker {
cfg_test_depth: 0,
file_path: &file.path,
candidates: Vec::new(),
};
walker.visit_file(ast);
candidates.extend(walker.candidates);
});
(candidates, errors)
}
fn resolve_impl_node(
sema: &Semantics<'_, RootDatabase>,
file_id: FileId,
impl_token_span: Span,
) -> Option<ast::Impl> {
crate::rules::api_surface_deep::resolve_span_node(sema, file_id, impl_token_span)
}
fn is_workspace_local_trait(
trait_def: ra_ap_hir::Trait,
db: &RootDatabase,
workspace_crate_names: &HashSet<String>,
) -> bool {
let krate = trait_def.module(db).krate(db);
workspace_crate_names.contains(&crate::rules::api_surface_deep::crate_display_name(
krate, db,
))
}
fn finding_for(candidate: &ImplCandidate) -> Finding {
let evidence = json!({
"tier": "deep",
"file": candidate.file_path,
"trait_name": candidate.trait_name,
"type_name": candidate.type_name,
"reason": DEAD_TRAIT_IMPL_REASON,
});
Finding {
id: format!(
"{DEAD_TRAIT_IMPL_RULE}:{}:{}:{}",
candidate.file_path.display(),
candidate.type_name,
candidate.trait_name,
)
.into(),
rule: DEAD_TRAIT_IMPL_RULE.into(),
severity: Severity::Warn,
location: Location {
file: candidate.file_path.clone(),
line: OneBasedLine::new(candidate.line).expect("proc-macro2 span lines are 1-based"),
item_path: format!("{} as {}", candidate.type_name, candidate.trait_name),
},
evidence_class: EvidenceClass::BoundedSemantic,
origin: Origin::Code,
evidence: Some(evidence),
limitations: None,
caused_by: Vec::new(),
causes: Vec::new(),
}
}
pub fn analyze_workspace(workspace: &Workspace) -> Result<DeadTraitImplReport, DeadCodeError> {
let ctx = DeepContext::load(&workspace.root).map_err(DeadCodeError::Deep)?;
let db = ctx.raw_database();
let sema = Semantics::new(db);
let (syn_candidates, collect_errors) = collect_candidates(workspace);
let workspace_crate_names: HashSet<String> = workspace
.crates
.iter()
.map(|krate| krate.name.replace('-', "_"))
.collect();
ra_ap_hir::attach_db(db, || {
let mut report = DeadTraitImplReport {
errors: collect_errors,
..DeadTraitImplReport::default()
};
let mut resolved: Vec<(ImplCandidate, ra_ap_hir::Impl)> = Vec::new();
for candidate in syn_candidates {
let Some(file_id) = ctx
.file_id(&candidate.file_path)
.map_err(DeadCodeError::Deep)?
else {
continue;
};
let Some(impl_node) = resolve_impl_node(&sema, file_id, candidate.impl_token_span)
else {
continue;
};
let Some(hir_impl) = sema.to_def(&impl_node) else {
continue;
};
let Some(trait_def) = hir_impl.trait_(db) else {
continue;
};
if !is_workspace_local_trait(trait_def, db, &workspace_crate_names) {
continue;
}
resolved.push((candidate, hir_impl));
}
report.checked = resolved.len();
let mut called: HashSet<ra_ap_hir::Impl> = HashSet::new();
for krate in &workspace.crates {
for file in &krate.source_files {
if !file.kind.is_locally_reportable() {
continue;
}
let Some(file_id) = ctx.file_id(&file.path).map_err(DeadCodeError::Deep)? else {
continue;
};
let source_file = sema.parse_guess_edition(file_id);
for method_call in source_file
.syntax()
.descendants()
.filter_map(ast::MethodCallExpr::cast)
{
let Some(function) = sema.resolve_method_call(&method_call) else {
continue;
};
let Some(assoc_item) = function.as_assoc_item(db) else {
continue;
};
if let AssocItemContainer::Impl(impl_) = assoc_item.container(db) {
called.insert(impl_);
}
}
}
}
for (candidate, hir_impl) in &resolved {
if !called.contains(hir_impl) {
report.findings.push(finding_for(candidate));
}
}
Ok(report)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
fn load_single_crate_workspace(dir: &TempDir, lib_source: &str) -> Workspace {
std::fs::write(
dir.join("Cargo.toml"),
r#"
[package]
name = "dead-trait-impl-fixture"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), lib_source).unwrap();
crate::ingest::load(Some(&dir.join("Cargo.toml"))).unwrap()
}
fn dead_trait_impl_findings(report: &DeadTraitImplReport) -> Vec<&Finding> {
report
.findings
.iter()
.filter(|finding| finding.rule == DEAD_TRAIT_IMPL_RULE)
.collect()
}
#[test]
fn workspace_local_trait_impl_never_called_fires() {
let dir = TempDir::new("dead-trait-impl-never-called");
let workspace = load_single_crate_workspace(
&dir,
r#"
pub trait Greeter {
fn greet(&self) -> String;
}
pub struct Robot;
impl Greeter for Robot {
fn greet(&self) -> String {
"beep".to_string()
}
}
"#,
);
let report = analyze_workspace(&workspace).unwrap();
let findings = dead_trait_impl_findings(&report);
assert_eq!(
findings.len(),
1,
"expected exactly one dead-trait-impl finding: {findings:?}"
);
assert!(findings[0].location.item_path.contains("Robot"));
assert!(findings[0].location.item_path.contains("Greeter"));
}
#[test]
fn workspace_local_trait_impl_called_does_not_fire() {
let dir = TempDir::new("dead-trait-impl-called");
let workspace = load_single_crate_workspace(
&dir,
r#"
pub trait Greeter {
fn greet(&self) -> String;
}
pub struct Robot;
impl Greeter for Robot {
fn greet(&self) -> String {
"beep".to_string()
}
}
pub fn run() -> String {
Robot.greet()
}
"#,
);
let report = analyze_workspace(&workspace).unwrap();
assert!(
dead_trait_impl_findings(&report).is_empty(),
"a called trait impl must not fire: {:?}",
report.findings
);
}
#[test]
fn blanket_impl_is_excluded() {
let dir = TempDir::new("dead-trait-impl-blanket");
let workspace = load_single_crate_workspace(
&dir,
r#"
pub trait Loud {
fn shout(&self) -> String;
}
impl<T: std::fmt::Debug> Loud for T {
fn shout(&self) -> String {
format!("{:?}!", self)
}
}
"#,
);
let report = analyze_workspace(&workspace).unwrap();
assert!(
dead_trait_impl_findings(&report).is_empty(),
"a blanket impl must never be a candidate, even though it's never called: {:?}",
report.findings
);
}
#[test]
fn non_workspace_trait_impl_does_not_fire() {
let dir = TempDir::new("dead-trait-impl-std-trait");
let workspace = load_single_crate_workspace(
&dir,
r#"
use std::fmt;
pub struct Widget;
impl fmt::Display for Widget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "widget")
}
}
"#,
);
let report = analyze_workspace(&workspace).unwrap();
assert!(
dead_trait_impl_findings(&report).is_empty(),
"an impl of a non-workspace trait (std::fmt::Display) must never be a candidate: {:?}",
report.findings
);
}
#[test]
fn only_the_unused_impl_of_a_multiply_implemented_trait_fires() {
let dir = TempDir::new("dead-trait-impl-two-impls");
let workspace = load_single_crate_workspace(
&dir,
r#"
pub trait Greeter {
fn greet(&self) -> String;
}
pub struct Robot;
impl Greeter for Robot {
fn greet(&self) -> String {
"beep".to_string()
}
}
pub struct Human;
impl Greeter for Human {
fn greet(&self) -> String {
"hello".to_string()
}
}
pub fn run() -> String {
Robot.greet()
}
"#,
);
let report = analyze_workspace(&workspace).unwrap();
let findings = dead_trait_impl_findings(&report);
assert_eq!(
findings.len(),
1,
"expected exactly one dead-trait-impl finding (Human, not Robot): {findings:?}"
);
assert!(findings[0].location.item_path.contains("Human"));
assert!(!findings[0].location.item_path.contains("Robot"));
}
}