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, HashSet};
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    DuplicateErrorMessage,
28}
29
30/// Below this length (message content, quotes excluded) a repeated string is
31/// noise (`"utf-8"`, `"id"`), not a copy-pasted error message.
32const MIN_ERROR_MESSAGE_LEN: usize = 10;
33
34/// Macros whose string arguments are error messages. Matched on the last path
35/// segment, so `anyhow::bail!` and `bail!` both count.
36const ERROR_MACROS: &[&str] = &[
37    "panic",
38    "unreachable",
39    "todo",
40    "unimplemented",
41    "bail",
42    "anyhow",
43];
44
45/// Methods whose string argument is an error message: `.expect("...")`.
46const ERROR_METHODS: &[&str] = &["expect", "expect_err"];
47
48/// A trait declared in a scanned file.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TraitDecl {
51    pub name: String,
52    pub line: usize,
53    pub column: usize,
54    pub end_line: usize,
55    pub end_column: usize,
56    /// The declaration sits inside a `#[cfg(test)]` item.
57    pub in_cfg_test: bool,
58    /// Declaration header reported as `matched_text`, e.g. `pub trait Repository`.
59    pub header: String,
60}
61
62/// An `impl Trait for Type` header found in a scanned file.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct TraitImpl {
65    /// Bare trait name: last path segment, generic arguments stripped.
66    pub trait_name: String,
67    /// `impl<T> Foo for T`: the implementing type is one of the impl's own
68    /// generic parameters, so the trait covers a whole family of types.
69    pub blanket: bool,
70}
71
72/// A string literal used as an error message, kept for the duplicate-message
73/// cross-file analysis.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct ErrorMessageLit {
76    /// Message content, surrounding quotes stripped.
77    pub text: String,
78    pub line: usize,
79    pub column: usize,
80    pub end_line: usize,
81    pub end_column: usize,
82    /// The literal sits inside a `#[cfg(test)]` item.
83    pub in_cfg_test: bool,
84}
85
86/// What one file contributes to the project index.
87///
88/// Deliberately carries no path. The scan cache is keyed on file content, so
89/// two byte-identical files share one entry; pairing the contribution with the
90/// path actually scanned keeps a duplicated file from inheriting the other's
91/// location.
92#[derive(Debug, Clone, Default, Serialize, Deserialize)]
93pub struct FileSymbols {
94    pub traits: Vec<TraitDecl>,
95    pub impls: Vec<TraitImpl>,
96    pub error_messages: Vec<ErrorMessageLit>,
97}
98
99impl FileSymbols {
100    /// Whether this file declares or implements nothing the index cares about.
101    pub fn is_empty(&self) -> bool {
102        self.traits.is_empty() && self.impls.is_empty() && self.error_messages.is_empty()
103    }
104}
105
106/// The bare name a trait reference resolves to: the last path segment with any
107/// generic arguments stripped. `fmt::Display` -> `Display`, `From<u32>` ->
108/// `From`, `crate::db::Repo` -> `Repo`. Matching is by bare name, not by a
109/// real Rust path resolver.
110fn bare_trait_name(text: &str) -> String {
111    let base = text.split('<').next().unwrap_or(text);
112    base.rsplit("::").next().unwrap_or(base).trim().to_string()
113}
114
115/// The names of an impl's own generic parameters, used to recognise a blanket
116/// impl. Lifetimes and const parameters are skipped: only a plain type
117/// parameter can be the implementing type of a blanket impl.
118fn generic_param_names<D: Doc>(impl_node: &Node<D>) -> Vec<String> {
119    let Some(params) = impl_node.field("type_parameters") else {
120        return Vec::new();
121    };
122    params
123        .children()
124        .filter(|child| child.is_named())
125        .filter_map(|child| {
126            let text = child.text().to_string();
127            // `T: Clone` and `T = u32` keep their head; `'a` and
128            // `const N: usize` do not survive the shape check below.
129            let head = text
130                .split([':', '=', '<'])
131                .next()
132                .unwrap_or(&text)
133                .trim()
134                .to_string();
135            let starts_ok = head.starts_with(|c: char| c.is_alphabetic() || c == '_');
136            let rest_ok = head.chars().all(|c| c.is_alphanumeric() || c == '_');
137            (starts_ok && rest_ok).then_some(head)
138        })
139        .collect()
140}
141
142/// The last `::`-separated segment of a macro or path name.
143fn last_segment(text: &str) -> &str {
144    text.rsplit("::").next().unwrap_or(text).trim()
145}
146
147/// Turn a `string_literal` node into an [`ErrorMessageLit`], dropping the
148/// surrounding quotes. Returns `None` for a non-string node, a raw string
149/// (`r"..."`, left out to keep quote-stripping simple), or a message shorter
150/// than [`MIN_ERROR_MESSAGE_LEN`].
151fn string_literal_message<D: Doc>(
152    node: &Node<D>,
153    cfg_test: &CfgTestRanges,
154) -> Option<ErrorMessageLit> {
155    if &*node.kind() != "string_literal" {
156        return None;
157    }
158    let raw = node.text();
159    let raw = raw.trim();
160    let content = raw.strip_prefix('"')?.strip_suffix('"')?;
161    if content.chars().count() < MIN_ERROR_MESSAGE_LEN {
162        return None;
163    }
164    let start = node.start_pos();
165    let end = node.end_pos();
166    let line = start.line() + 1;
167    Some(ErrorMessageLit {
168        text: content.to_string(),
169        line,
170        column: start.byte_point().1 + 1,
171        end_line: end.line() + 1,
172        end_column: end.byte_point().1 + 1,
173        in_cfg_test: cfg_test.contains_line(line),
174    })
175}
176
177/// Collect the error-message string literals of one node, if it is an
178/// error-raising macro (`panic!`, `bail!`, ...) or an `.expect(...)` call.
179fn collect_error_messages<D: Doc>(
180    node: &Node<D>,
181    cfg_test: &CfgTestRanges,
182    out: &mut Vec<ErrorMessageLit>,
183) {
184    match &*node.kind() {
185        "macro_invocation" => {
186            let is_error_macro = node
187                .field("macro")
188                .is_some_and(|m| ERROR_MACROS.contains(&last_segment(&m.text())));
189            if is_error_macro {
190                out.extend(
191                    node.dfs()
192                        .filter_map(|n| string_literal_message(&n, cfg_test)),
193                );
194            }
195        }
196        "call_expression" => {
197            let is_error_method = node.field("function").is_some_and(|f| {
198                &*f.kind() == "field_expression"
199                    && f.field("field")
200                        .is_some_and(|name| ERROR_METHODS.contains(&name.text().trim()))
201            });
202            if is_error_method {
203                if let Some(args) = node.field("arguments") {
204                    out.extend(
205                        args.children()
206                            .filter_map(|n| string_literal_message(&n, cfg_test)),
207                    );
208                }
209            }
210        }
211        _ => {}
212    }
213}
214
215/// Collect the trait declarations and trait impls of one parsed Rust file.
216///
217/// `#[derive(Trait)]` parses to an `attribute_item`, never an `impl_item`, so
218/// derived implementations are ignored without a special case.
219pub fn extract_rust_symbols<D: Doc>(root: &AstGrep<D>, cfg_test: &CfgTestRanges) -> FileSymbols {
220    let mut symbols = FileSymbols::default();
221    let root_node = root.root();
222    for node in root_node.dfs() {
223        match &*node.kind() {
224            "trait_item" => {
225                let Some(name) = node.field("name") else {
226                    continue;
227                };
228                let start = node.start_pos();
229                let name_end = name.end_pos();
230                let line = start.line() + 1;
231                let text = node.text().to_string();
232                let header = match text.split_once('{') {
233                    Some((head, _)) => head.trim_end().to_string(),
234                    None => text.trim_end().to_string(),
235                };
236                symbols.traits.push(TraitDecl {
237                    name: name.text().to_string(),
238                    line,
239                    column: start.byte_point().1 + 1,
240                    end_line: name_end.line() + 1,
241                    end_column: name_end.byte_point().1 + 1,
242                    in_cfg_test: cfg_test.contains_line(line),
243                    header,
244                });
245            }
246            "impl_item" => {
247                // An inherent `impl Foo { .. }` has no `trait` field and
248                // contributes nothing to the index.
249                let Some(trait_ref) = node.field("trait") else {
250                    continue;
251                };
252                let params = generic_param_names(&node);
253                let impl_type = node
254                    .field("type")
255                    .map(|n| n.text().trim().to_string())
256                    .unwrap_or_default();
257                symbols.impls.push(TraitImpl {
258                    trait_name: bare_trait_name(&trait_ref.text()),
259                    blanket: params.contains(&impl_type),
260                });
261            }
262            _ => {}
263        }
264        collect_error_messages(&node, cfg_test, &mut symbols.error_messages);
265    }
266    symbols
267}
268
269/// Project-wide symbol index assembled from every scanned file.
270#[derive(Debug, Default)]
271pub struct SymbolIndex {
272    traits: HashMap<String, TraitEntry>,
273    /// Error-message content -> every occurrence, keyed by exact text so a
274    /// literal repeated across files collides.
275    error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>>,
276}
277
278#[derive(Debug, Default)]
279struct TraitEntry {
280    /// Every declaration seen for this name. More than one makes the name
281    /// ambiguous and the trait is never reported.
282    decls: Vec<(PathBuf, TraitDecl)>,
283    concrete_impls: usize,
284    blanket_impls: usize,
285}
286
287impl SymbolIndex {
288    /// Fold every file's contribution into one index keyed by bare trait name.
289    ///
290    /// Impls are counted for every scanned file, test code included: a trait
291    /// with one production impl plus a test mock is the legitimate "trait
292    /// exists to be mocked" pattern, and the mock counting as a second impl is
293    /// what suppresses the dominant false positive.
294    pub fn build<'a, I>(contributions: I) -> Self
295    where
296        I: IntoIterator<Item = (&'a Path, &'a FileSymbols)>,
297    {
298        let mut traits: HashMap<String, TraitEntry> = HashMap::new();
299        let mut error_messages: HashMap<String, Vec<(PathBuf, ErrorMessageLit)>> = HashMap::new();
300        for (path, symbols) in contributions {
301            for decl in &symbols.traits {
302                traits
303                    .entry(decl.name.clone())
304                    .or_default()
305                    .decls
306                    .push((path.to_path_buf(), decl.clone()));
307            }
308            for imp in &symbols.impls {
309                let entry = traits.entry(imp.trait_name.clone()).or_default();
310                if imp.blanket {
311                    entry.blanket_impls += 1;
312                } else {
313                    entry.concrete_impls += 1;
314                }
315            }
316            for msg in &symbols.error_messages {
317                error_messages
318                    .entry(msg.text.clone())
319                    .or_default()
320                    .push((path.to_path_buf(), msg.clone()));
321            }
322        }
323        Self {
324            traits,
325            error_messages,
326        }
327    }
328}
329
330/// Which trait declarations a cross-file rule may report, derived from the
331/// rule's `files` / `ignores` globs and `skip_test_code`. Applied to the
332/// declaration site only: impls are counted wherever they are found.
333pub struct DeclFilter {
334    pub files: Option<GlobSet>,
335    pub ignores: Option<GlobSet>,
336    pub skip_test_code: bool,
337    pub(crate) test_paths: TestPaths,
338}
339
340impl DeclFilter {
341    /// Whether a declaration found at `path` is in scope for the rule.
342    pub fn allows(&self, path: &Path, decl: &TraitDecl) -> bool {
343        if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
344            return false;
345        }
346        if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
347            return false;
348        }
349        if self.skip_test_code && (self.test_paths.is_test(path) || decl.in_cfg_test) {
350            return false;
351        }
352        true
353    }
354
355    /// Whether an error-message literal found at `path` is in scope for the rule.
356    /// Same globs and `skip_test_code` policy as [`DeclFilter::allows`].
357    pub fn allows_message(&self, path: &Path, msg: &ErrorMessageLit) -> bool {
358        if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
359            return false;
360        }
361        if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
362            return false;
363        }
364        if self.skip_test_code && (self.test_paths.is_test(path) || msg.in_cfg_test) {
365            return false;
366        }
367        true
368    }
369}
370
371/// Evaluate one cross-file rule against the project index.
372///
373/// `single_impl_trait` fires for a trait with exactly one declaration, exactly
374/// one concrete impl and no blanket impl. Zero impls stays silent: the trait is
375/// probably implemented outside this crate. Two or more is a legitimate
376/// abstraction. A blanket impl covers a whole family of types, so it is never
377/// an over-abstraction. A name declared twice is ambiguous and abstains.
378///
379/// The returned order follows `HashMap` iteration and is not stable; the caller
380/// (`normalize_findings`) sorts and dedups before the result is reported.
381pub fn evaluate(
382    rule: &Rule,
383    kind: CrossFileKind,
384    index: &SymbolIndex,
385    filter: &DeclFilter,
386) -> Vec<Finding> {
387    match kind {
388        CrossFileKind::SingleImplTrait => index
389            .traits
390            .values()
391            .filter(|entry| {
392                entry.decls.len() == 1 && entry.concrete_impls == 1 && entry.blanket_impls == 0
393            })
394            .filter_map(|entry| entry.decls.first())
395            .filter(|(path, decl)| filter.allows(path, decl))
396            .map(|(path, decl)| Finding {
397                rule_id: rule.id.clone(),
398                severity: rule.severity.clone(),
399                category: rule.category.clone().unwrap_or_default(),
400                message: rule.message.clone(),
401                note: rule.note.clone(),
402                fix: rule.fix.clone(),
403                file: path.clone(),
404                line: decl.line,
405                column: decl.column,
406                end_line: decl.end_line,
407                end_column: decl.end_column,
408                matched_text: decl.header.clone(),
409                confidence: None,
410                escalated: false,
411            })
412            .collect(),
413        CrossFileKind::DuplicateErrorMessage => index
414            .error_messages
415            .values()
416            .flat_map(|occurrences| {
417                let in_scope: Vec<&(PathBuf, ErrorMessageLit)> = occurrences
418                    .iter()
419                    .filter(|(path, msg)| filter.allows_message(path, msg))
420                    .collect();
421                let distinct_files: HashSet<&Path> =
422                    in_scope.iter().map(|(path, _)| path.as_path()).collect();
423                if distinct_files.len() < 2 {
424                    return Vec::new();
425                }
426                in_scope
427                    .into_iter()
428                    .map(|(path, msg)| Finding {
429                        rule_id: rule.id.clone(),
430                        severity: rule.severity.clone(),
431                        category: rule.category.clone().unwrap_or_default(),
432                        message: rule.message.clone(),
433                        note: rule.note.clone(),
434                        fix: rule.fix.clone(),
435                        file: path.clone(),
436                        line: msg.line,
437                        column: msg.column,
438                        end_line: msg.end_line,
439                        end_column: msg.end_column,
440                        matched_text: msg.text.clone(),
441                        confidence: None,
442                        escalated: false,
443                    })
444                    .collect()
445            })
446            .collect(),
447    }
448}
449
450#[cfg(test)]
451mod tests;