Skip to main content

slopguard_core/resolution/
mod.rs

1//! Import resolution: verify that every import in a file resolves against the
2//! declared dependencies and on-disk paths, without a full compile.
3//!
4//! Unlike the cross-file pass, resolution is per file: an import references only
5//! the manifests reachable from that file, so it is compatible with `--diff`.
6//! To stay correct under caching, the per-file *extraction* (the list of
7//! imports, [`extract_imports`]) is cached with the file content, while the
8//! *resolution* (matching imports against manifests on disk, [`import_resolves`])
9//! re-runs on every scan. Editing a manifest to add a dependency therefore clears
10//! a finding even when the importing file is unchanged.
11//!
12//! Each language lives in its own module ([`rust`], [`typescript`]) implementing
13//! [`LanguageSupport`]; [`manifest`] holds the shared manifest reading and
14//! caching. This module dispatches on [`Language`] and turns unresolved imports
15//! into findings.
16
17use std::path::Path;
18
19use ast_grep_core::{AstGrep, Doc};
20use globset::GlobSet;
21use serde::{Deserialize, Serialize};
22use strum::Display;
23
24use crate::finding::Finding;
25use crate::rule::{Language, Rule};
26
27mod manifest;
28mod rust;
29mod typescript;
30
31pub(crate) use manifest::strum_derive_crate;
32pub use manifest::ManifestResolver;
33
34use rust::Rust;
35use typescript::TypeScript;
36
37/// Resolution analyses a rule can request. Builtin only: a custom YAML may tune
38/// an existing kind's severity or message, never invent a new one, so an unknown
39/// discriminant is a parse error.
40#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42#[strum(serialize_all = "snake_case")]
43pub enum ResolutionKind {
44    UnresolvedImport,
45}
46
47/// One import reference found in a file, with the location to anchor a finding.
48///
49/// For Rust, `specifier` is the crate-root segment of a `use` (`std::fmt` ->
50/// `std`). For TypeScript, it is the module string (`./foo`, `react`,
51/// `@scope/pkg`). Statement-level `import type` is never recorded: a type-only
52/// import may resolve to ambient declarations that are not a runtime dependency,
53/// so it is out of scope.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ImportRef {
56    pub specifier: String,
57    pub line: usize,
58    pub column: usize,
59    pub end_line: usize,
60    pub end_column: usize,
61    /// First line of the import statement, reported as `matched_text`.
62    pub matched_text: String,
63}
64
65/// What one file contributes to the resolution pass: its imports plus, for Rust,
66/// the names it binds itself (`mod foo;`, `use crate::a::foo;`, `use a::b as
67/// foo;`), which a bare `use foo::...` in the same file may legitimately
68/// reference.
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
70pub struct FileImports {
71    pub imports: Vec<ImportRef>,
72    pub local_names: Vec<String>,
73}
74
75impl FileImports {
76    /// Whether this file imports nothing the resolution pass cares about.
77    pub fn is_empty(&self) -> bool {
78        self.imports.is_empty()
79    }
80}
81
82/// Which files a resolution rule may report on, from its `files` / `ignores`
83/// globs. Applied to the importing file.
84pub struct ResolutionFilter {
85    pub files: Option<GlobSet>,
86    pub ignores: Option<GlobSet>,
87}
88
89impl ResolutionFilter {
90    /// Whether a file at `path` is in scope for the rule.
91    pub fn allows(&self, path: &Path) -> bool {
92        if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
93            return false;
94        }
95        if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
96            return false;
97        }
98        true
99    }
100}
101
102/// Per-language extraction and resolution. Each supported language is a
103/// zero-sized type implementing this trait in its own module; the free functions
104/// below select the impl by matching on [`Language`]. The extraction method is
105/// generic over `Doc`, so this is a static-dispatch contract, not a `dyn` one.
106trait LanguageSupport {
107    /// Collect the imports (and, for Rust, the names bound in the file) of one
108    /// parsed file.
109    fn extract_imports<D: Doc>(&self, root: &AstGrep<D>) -> FileImports;
110
111    /// Whether one import resolves against manifests, the standard library /
112    /// builtins, on-disk paths, or same-file names. `local_names` is only
113    /// meaningful for Rust; other languages ignore it.
114    fn resolves(
115        &self,
116        import: &ImportRef,
117        local_names: &[String],
118        file_dir: &Path,
119        manifests: &mut ManifestResolver,
120    ) -> bool;
121}
122
123/// The first line of an import statement, trimmed, for the finding's
124/// `matched_text`. A multi-line grouped `use` reports only its first line.
125fn first_line(text: &str) -> String {
126    text.lines().next().unwrap_or(text).trim().to_string()
127}
128
129/// Extract a file's imports for the given language.
130pub fn extract_imports<D: Doc>(lang: &Language, root: &AstGrep<D>) -> FileImports {
131    match lang {
132        Language::Rust => Rust.extract_imports(root),
133        Language::TypeScript => TypeScript.extract_imports(root),
134    }
135}
136
137/// Whether a single import resolves, dispatching on the file's language.
138pub fn import_resolves(
139    lang: &Language,
140    import: &ImportRef,
141    local_names: &[String],
142    file_dir: &Path,
143    manifests: &mut ManifestResolver,
144) -> bool {
145    match lang {
146        Language::Rust => Rust.resolves(import, local_names, file_dir, manifests),
147        Language::TypeScript => TypeScript.resolves(import, local_names, file_dir, manifests),
148    }
149}
150
151/// Evaluate one resolution rule against a single file's imports, emitting a
152/// finding per unresolved import. `$import` in the rule message is replaced with
153/// the offending specifier.
154pub fn evaluate_file(
155    rule: &Rule,
156    kind: ResolutionKind,
157    lang: &Language,
158    path: &Path,
159    imports: &FileImports,
160    manifests: &mut ManifestResolver,
161) -> Vec<Finding> {
162    let ResolutionKind::UnresolvedImport = kind;
163    let file_dir = path.parent().unwrap_or_else(|| Path::new("."));
164    imports
165        .imports
166        .iter()
167        .filter(|import| !import_resolves(lang, import, &imports.local_names, file_dir, manifests))
168        .map(|import| Finding {
169            rule_id: rule.id.clone(),
170            severity: rule.severity.clone(),
171            category: rule.category.clone().unwrap_or_default(),
172            message: rule.message.replace("$import", &import.specifier),
173            note: rule.note.clone(),
174            fix: rule.fix.clone(),
175            file: path.to_path_buf(),
176            line: import.line,
177            column: import.column,
178            end_line: import.end_line,
179            end_column: import.end_column,
180            matched_text: import.matched_text.clone(),
181            confidence: None,
182            escalated: false,
183        })
184        .collect()
185}
186
187#[cfg(test)]
188mod tests;