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;