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