Skip to main content

slopguard_core/cross_file/
mod.rs

1//! Project-wide analyses that no single file can answer.
2//!
3//! The per-file scan contributes a [`FileSymbols`] for every file it parses.
4//! Those contributions are assembled into a [`SymbolIndex`] once the whole walk
5//! is done, and each active cross-file rule is evaluated against that index.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use ast_grep_core::{AstGrep, Doc, Node};
11use globset::GlobSet;
12use serde::{Deserialize, Serialize};
13use strum::Display;
14
15use crate::finding::Finding;
16use crate::rule::Rule;
17use crate::test_filter::{CfgTestRanges, TestPaths};
18
19/// Cross-file analyses a rule can request. Builtin only: a custom YAML may
20/// tune an existing kind's severity or message, never invent a new one, so an
21/// unknown discriminant is a parse error.
22#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24#[strum(serialize_all = "snake_case")]
25pub enum CrossFileKind {
26    SingleImplTrait,
27}
28
29/// A trait declared in a scanned file.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TraitDecl {
32    pub name: String,
33    pub line: usize,
34    pub column: usize,
35    pub end_line: usize,
36    pub end_column: usize,
37    /// The declaration sits inside a `#[cfg(test)]` item.
38    pub in_cfg_test: bool,
39    /// Declaration header reported as `matched_text`, e.g. `pub trait Repository`.
40    pub header: String,
41}
42
43/// An `impl Trait for Type` header found in a scanned file.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct TraitImpl {
46    /// Bare trait name: last path segment, generic arguments stripped.
47    pub trait_name: String,
48    /// `impl<T> Foo for T`: the implementing type is one of the impl's own
49    /// generic parameters, so the trait covers a whole family of types.
50    pub blanket: bool,
51}
52
53/// What one file contributes to the project index.
54///
55/// Deliberately carries no path. The scan cache is keyed on file content, so
56/// two byte-identical files share one entry; pairing the contribution with the
57/// path actually scanned keeps a duplicated file from inheriting the other's
58/// location.
59#[derive(Debug, Clone, Default, Serialize, Deserialize)]
60pub struct FileSymbols {
61    pub traits: Vec<TraitDecl>,
62    pub impls: Vec<TraitImpl>,
63}
64
65impl FileSymbols {
66    /// Whether this file declares or implements nothing the index cares about.
67    pub fn is_empty(&self) -> bool {
68        self.traits.is_empty() && self.impls.is_empty()
69    }
70}
71
72/// The bare name a trait reference resolves to: the last path segment with any
73/// generic arguments stripped. `fmt::Display` -> `Display`, `From<u32>` ->
74/// `From`, `crate::db::Repo` -> `Repo`. Matching is by bare name, not by a
75/// real Rust path resolver.
76fn bare_trait_name(text: &str) -> String {
77    let base = text.split('<').next().unwrap_or(text);
78    base.rsplit("::").next().unwrap_or(base).trim().to_string()
79}
80
81/// The names of an impl's own generic parameters, used to recognise a blanket
82/// impl. Lifetimes and const parameters are skipped: only a plain type
83/// parameter can be the implementing type of a blanket impl.
84fn generic_param_names<D: Doc>(impl_node: &Node<D>) -> Vec<String> {
85    let Some(params) = impl_node.field("type_parameters") else {
86        return Vec::new();
87    };
88    params
89        .children()
90        .filter(|child| child.is_named())
91        .filter_map(|child| {
92            let text = child.text().to_string();
93            // `T: Clone` and `T = u32` keep their head; `'a` and
94            // `const N: usize` do not survive the shape check below.
95            let head = text
96                .split([':', '=', '<'])
97                .next()
98                .unwrap_or(&text)
99                .trim()
100                .to_string();
101            let starts_ok = head.starts_with(|c: char| c.is_alphabetic() || c == '_');
102            let rest_ok = head.chars().all(|c| c.is_alphanumeric() || c == '_');
103            (starts_ok && rest_ok).then_some(head)
104        })
105        .collect()
106}
107
108/// Collect the trait declarations and trait impls of one parsed Rust file.
109///
110/// `#[derive(Trait)]` parses to an `attribute_item`, never an `impl_item`, so
111/// derived implementations are ignored without a special case.
112pub fn extract_rust_symbols<D: Doc>(root: &AstGrep<D>, cfg_test: &CfgTestRanges) -> FileSymbols {
113    let mut symbols = FileSymbols::default();
114    let root_node = root.root();
115    for node in root_node.dfs() {
116        match &*node.kind() {
117            "trait_item" => {
118                let Some(name) = node.field("name") else {
119                    continue;
120                };
121                let start = node.start_pos();
122                let name_end = name.end_pos();
123                let line = start.line() + 1;
124                let text = node.text().to_string();
125                let header = match text.split_once('{') {
126                    Some((head, _)) => head.trim_end().to_string(),
127                    None => text.trim_end().to_string(),
128                };
129                symbols.traits.push(TraitDecl {
130                    name: name.text().to_string(),
131                    line,
132                    column: start.byte_point().1 + 1,
133                    end_line: name_end.line() + 1,
134                    end_column: name_end.byte_point().1 + 1,
135                    in_cfg_test: cfg_test.contains_line(line),
136                    header,
137                });
138            }
139            "impl_item" => {
140                // An inherent `impl Foo { .. }` has no `trait` field and
141                // contributes nothing to the index.
142                let Some(trait_ref) = node.field("trait") else {
143                    continue;
144                };
145                let params = generic_param_names(&node);
146                let impl_type = node
147                    .field("type")
148                    .map(|n| n.text().trim().to_string())
149                    .unwrap_or_default();
150                symbols.impls.push(TraitImpl {
151                    trait_name: bare_trait_name(&trait_ref.text()),
152                    blanket: params.contains(&impl_type),
153                });
154            }
155            _ => {}
156        }
157    }
158    symbols
159}
160
161/// Project-wide symbol index assembled from every scanned file.
162#[derive(Debug, Default)]
163pub struct SymbolIndex {
164    traits: HashMap<String, TraitEntry>,
165}
166
167#[derive(Debug, Default)]
168struct TraitEntry {
169    /// Every declaration seen for this name. More than one makes the name
170    /// ambiguous and the trait is never reported.
171    decls: Vec<(PathBuf, TraitDecl)>,
172    concrete_impls: usize,
173    blanket_impls: usize,
174}
175
176impl SymbolIndex {
177    /// Fold every file's contribution into one index keyed by bare trait name.
178    ///
179    /// Impls are counted for every scanned file, test code included: a trait
180    /// with one production impl plus a test mock is the legitimate "trait
181    /// exists to be mocked" pattern, and the mock counting as a second impl is
182    /// what suppresses the dominant false positive.
183    pub fn build<'a, I>(contributions: I) -> Self
184    where
185        I: IntoIterator<Item = (&'a Path, &'a FileSymbols)>,
186    {
187        let mut traits: HashMap<String, TraitEntry> = HashMap::new();
188        for (path, symbols) in contributions {
189            for decl in &symbols.traits {
190                traits
191                    .entry(decl.name.clone())
192                    .or_default()
193                    .decls
194                    .push((path.to_path_buf(), decl.clone()));
195            }
196            for imp in &symbols.impls {
197                let entry = traits.entry(imp.trait_name.clone()).or_default();
198                if imp.blanket {
199                    entry.blanket_impls += 1;
200                } else {
201                    entry.concrete_impls += 1;
202                }
203            }
204        }
205        Self { traits }
206    }
207}
208
209/// Which trait declarations a cross-file rule may report, derived from the
210/// rule's `files` / `ignores` globs and `skip_test_code`. Applied to the
211/// declaration site only: impls are counted wherever they are found.
212pub struct DeclFilter {
213    pub files: Option<GlobSet>,
214    pub ignores: Option<GlobSet>,
215    pub skip_test_code: bool,
216    pub(crate) test_paths: TestPaths,
217}
218
219impl DeclFilter {
220    /// Whether a declaration found at `path` is in scope for the rule.
221    pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
222        if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
223            return false;
224        }
225        if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
226            return false;
227        }
228        if self.skip_test_code && (self.test_paths.is_test(path) || decl.in_cfg_test) {
229            return false;
230        }
231        true
232    }
233}
234
235/// Evaluate one cross-file rule against the project index.
236///
237/// `single_impl_trait` fires for a trait with exactly one declaration, exactly
238/// one concrete impl and no blanket impl. Zero impls stays silent: the trait is
239/// probably implemented outside this crate. Two or more is a legitimate
240/// abstraction. A blanket impl covers a whole family of types, so it is never
241/// an over-abstraction. A name declared twice is ambiguous and abstains.
242///
243/// The returned order follows `HashMap` iteration and is not stable; the caller
244/// (`normalize_findings`) sorts and dedups before the result is reported.
245pub fn evaluate(
246    rule: &Rule,
247    kind: CrossFileKind,
248    index: &SymbolIndex,
249    filter: &DeclFilter,
250) -> Vec<Finding> {
251    match kind {
252        CrossFileKind::SingleImplTrait => index
253            .traits
254            .values()
255            .filter(|entry| {
256                entry.decls.len() == 1 && entry.concrete_impls == 1 && entry.blanket_impls == 0
257            })
258            .filter_map(|entry| entry.decls.first())
259            .filter(|(path, decl)| filter.allows(path, decl))
260            .map(|(path, decl)| Finding {
261                rule_id: rule.id.clone(),
262                severity: rule.severity.clone(),
263                category: rule.category.clone().unwrap_or_default(),
264                message: rule.message.clone(),
265                note: rule.note.clone(),
266                fix: rule.fix.clone(),
267                file: path.clone(),
268                line: decl.line,
269                column: decl.column,
270                end_line: decl.end_line,
271                end_column: decl.end_column,
272                matched_text: decl.header.clone(),
273                confidence: None,
274                escalated: false,
275            })
276            .collect(),
277    }
278}
279
280#[cfg(test)]
281mod tests;