Skip to main content

slopguard_core/
cross_file.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::{is_test_path, CfgTestRanges};
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}
217
218impl DeclFilter {
219    /// Whether a declaration found at `path` is in scope for the rule.
220    pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
221        if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
222            return false;
223        }
224        if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
225            return false;
226        }
227        if self.skip_test_code && (is_test_path(path) || decl.in_cfg_test) {
228            return false;
229        }
230        true
231    }
232}
233
234/// Evaluate one cross-file rule against the project index.
235///
236/// `single_impl_trait` fires for a trait with exactly one declaration, exactly
237/// one concrete impl and no blanket impl. Zero impls stays silent: the trait is
238/// probably implemented outside this crate. Two or more is a legitimate
239/// abstraction. A blanket impl covers a whole family of types, so it is never
240/// an over-abstraction. A name declared twice is ambiguous and abstains.
241///
242/// The returned order follows `HashMap` iteration and is not stable; the caller
243/// (`normalize_findings`) sorts and dedups before the result is reported.
244pub fn evaluate(
245    rule: &Rule,
246    kind: CrossFileKind,
247    index: &SymbolIndex,
248    filter: &DeclFilter,
249) -> Vec<Finding> {
250    match kind {
251        CrossFileKind::SingleImplTrait => index
252            .traits
253            .values()
254            .filter(|entry| {
255                entry.decls.len() == 1 && entry.concrete_impls == 1 && entry.blanket_impls == 0
256            })
257            .filter_map(|entry| entry.decls.first())
258            .filter(|(path, decl)| filter.allows(path, decl))
259            .map(|(path, decl)| Finding {
260                rule_id: rule.id.clone(),
261                severity: rule.severity.clone(),
262                category: rule.category.clone().unwrap_or_default(),
263                message: rule.message.clone(),
264                note: rule.note.clone(),
265                fix: rule.fix.clone(),
266                file: path.clone(),
267                line: decl.line,
268                column: decl.column,
269                end_line: decl.end_line,
270                end_column: decl.end_column,
271                matched_text: decl.header.clone(),
272                confidence: None,
273                escalated: false,
274            })
275            .collect(),
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use ast_grep_core::tree_sitter::LanguageExt;
282    use ast_grep_language::SupportLang;
283
284    use super::*;
285    use crate::rule::parse_rule;
286
287    fn symbols(source: &str) -> FileSymbols {
288        let root = SupportLang::Rust.ast_grep(source);
289        let cfg_test = CfgTestRanges::from_root(&root);
290        extract_rust_symbols(&root, &cfg_test)
291    }
292
293    #[test]
294    fn extracts_trait_declaration() {
295        let found = symbols("pub trait Repository {\n    fn get(&self);\n}\n");
296        assert_eq!(found.traits.len(), 1);
297        let decl = &found.traits[0];
298        assert_eq!(decl.name, "Repository");
299        assert_eq!(decl.line, 1);
300        assert_eq!(decl.column, 1);
301        assert_eq!(decl.header, "pub trait Repository");
302        assert!(!decl.in_cfg_test);
303    }
304
305    #[test]
306    fn extracts_impl_trait_for_type() {
307        let found = symbols("impl Repository for Postgres {\n    fn get(&self) {}\n}\n");
308        assert_eq!(found.impls.len(), 1);
309        assert_eq!(found.impls[0].trait_name, "Repository");
310        assert!(!found.impls[0].blanket);
311    }
312
313    #[test]
314    fn inherent_impl_contributes_nothing() {
315        let found = symbols("impl Foo {\n    fn a(&self) {}\n}\n");
316        assert!(found.impls.is_empty());
317    }
318
319    #[test]
320    fn scoped_trait_name_resolves_to_last_segment() {
321        let found = symbols("impl fmt::Display for X {}\n");
322        assert_eq!(found.impls[0].trait_name, "Display");
323    }
324
325    #[test]
326    fn generic_trait_name_strips_arguments() {
327        let found = symbols("impl From<u32> for X {}\n");
328        assert_eq!(found.impls[0].trait_name, "From");
329    }
330
331    #[test]
332    fn blanket_impl_detected() {
333        let found = symbols("impl<T> Describe for T {}\n");
334        assert_eq!(found.impls.len(), 1);
335        assert!(found.impls[0].blanket);
336    }
337
338    #[test]
339    fn generic_impl_on_concrete_type_is_not_blanket() {
340        let found = symbols("impl<T: Clone> Describe for Wrapper<T> {}\n");
341        assert_eq!(found.impls.len(), 1);
342        assert!(!found.impls[0].blanket);
343    }
344
345    #[test]
346    fn lifetime_and_const_params_are_not_blanket() {
347        let lifetime = symbols("impl<'a> Foo for &'a Bar {}\n");
348        assert_eq!(lifetime.impls.len(), 1);
349        assert!(!lifetime.impls[0].blanket);
350
351        let konst = symbols("impl<const N: usize> Foo for [u8; N] {}\n");
352        assert_eq!(konst.impls.len(), 1);
353        assert!(!konst.impls[0].blanket);
354    }
355
356    #[test]
357    fn derive_is_not_an_impl() {
358        let found = symbols("#[derive(Clone)]\nstruct S;\n");
359        assert!(found.impls.is_empty());
360        assert!(found.is_empty());
361    }
362
363    #[test]
364    fn trait_in_cfg_test_is_flagged() {
365        let found = symbols("#[cfg(test)]\nmod tests {\n    trait Mockable {}\n}\n");
366        assert_eq!(found.traits.len(), 1);
367        assert!(found.traits[0].in_cfg_test);
368    }
369
370    fn single_impl_rule() -> Rule {
371        parse_rule(
372            r#"
373id: no-single-impl-trait
374language: rust
375severity: warning
376category: slop
377cross_file: single_impl_trait
378message: "Trait with a single implementation in the project."
379"#,
380        )
381        .unwrap()
382    }
383
384    fn decl(name: &str) -> TraitDecl {
385        TraitDecl {
386            name: name.to_string(),
387            line: 3,
388            column: 1,
389            end_line: 3,
390            end_column: 20,
391            in_cfg_test: false,
392            header: format!("pub trait {name}"),
393        }
394    }
395
396    fn concrete(name: &str) -> TraitImpl {
397        TraitImpl {
398            trait_name: name.to_string(),
399            blanket: false,
400        }
401    }
402
403    fn open_filter() -> DeclFilter {
404        DeclFilter {
405            files: None,
406            ignores: None,
407            skip_test_code: false,
408        }
409    }
410
411    fn contribution(
412        path: &str,
413        traits: Vec<TraitDecl>,
414        impls: Vec<TraitImpl>,
415    ) -> (PathBuf, FileSymbols) {
416        (PathBuf::from(path), FileSymbols { traits, impls })
417    }
418
419    /// Evaluate `single_impl_trait` over hand-written per-file contributions.
420    fn findings(contributions: &[(PathBuf, FileSymbols)], filter: &DeclFilter) -> Vec<Finding> {
421        let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
422        evaluate(
423            &single_impl_rule(),
424            CrossFileKind::SingleImplTrait,
425            &index,
426            filter,
427        )
428    }
429
430    #[test]
431    fn single_concrete_impl_fires() {
432        let contributions = [
433            contribution("src/repo.rs", vec![decl("Repository")], vec![]),
434            contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
435        ];
436        assert_eq!(findings(&contributions, &open_filter()).len(), 1);
437    }
438
439    #[test]
440    fn zero_impls_stays_silent() {
441        let contributions = [contribution(
442            "src/repo.rs",
443            vec![decl("Repository")],
444            vec![],
445        )];
446        assert!(findings(&contributions, &open_filter()).is_empty());
447    }
448
449    #[test]
450    fn two_impls_stay_silent() {
451        let contributions = [
452            contribution("src/repo.rs", vec![decl("Repository")], vec![]),
453            contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
454            contribution("src/mem.rs", vec![], vec![concrete("Repository")]),
455        ];
456        assert!(findings(&contributions, &open_filter()).is_empty());
457    }
458
459    #[test]
460    fn blanket_impl_stays_silent() {
461        let blanket = TraitImpl {
462            trait_name: "Describe".to_string(),
463            blanket: true,
464        };
465        let contributions = [
466            contribution("src/describe.rs", vec![decl("Describe")], vec![blanket]),
467            contribution("src/pg.rs", vec![], vec![concrete("Describe")]),
468        ];
469        assert!(findings(&contributions, &open_filter()).is_empty());
470    }
471
472    #[test]
473    fn duplicate_trait_name_stays_silent() {
474        let contributions = [
475            contribution("src/a.rs", vec![decl("Repository")], vec![]),
476            contribution("src/b.rs", vec![decl("Repository")], vec![]),
477            contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
478        ];
479        assert!(findings(&contributions, &open_filter()).is_empty());
480    }
481
482    #[test]
483    fn impl_without_local_declaration_is_ignored() {
484        let contributions = [contribution(
485            "src/pg.rs",
486            vec![],
487            vec![concrete("Serialize")],
488        )];
489        assert!(findings(&contributions, &open_filter()).is_empty());
490    }
491
492    #[test]
493    fn decl_filter_drops_test_path_declaration() {
494        let contributions = [
495            contribution("tests/support.rs", vec![decl("Repository")], vec![]),
496            contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
497        ];
498        assert_eq!(findings(&contributions, &open_filter()).len(), 1);
499
500        let skipping = DeclFilter {
501            files: None,
502            ignores: None,
503            skip_test_code: true,
504        };
505        assert!(findings(&contributions, &skipping).is_empty());
506    }
507
508    #[test]
509    fn finding_points_at_the_declaration() {
510        let contributions = [
511            contribution("src/repo.rs", vec![decl("Repository")], vec![]),
512            contribution("src/pg.rs", vec![], vec![concrete("Repository")]),
513        ];
514        let found = findings(&contributions, &open_filter());
515        assert_eq!(found.len(), 1);
516        assert_eq!(found[0].file, PathBuf::from("src/repo.rs"));
517        assert_eq!(found[0].line, 3);
518        assert_eq!(found[0].matched_text, "pub trait Repository");
519    }
520}