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;