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