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 modules it declares (`mod foo;`), which a bare `use foo::...` in the same
66/// file may legitimately reference.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct FileImports {
69 pub imports: Vec<ImportRef>,
70 pub local_mods: Vec<String>,
71}
72
73impl FileImports {
74 /// Whether this file imports nothing the resolution pass cares about.
75 pub fn is_empty(&self) -> bool {
76 self.imports.is_empty()
77 }
78}
79
80/// Which files a resolution rule may report on, from its `files` / `ignores`
81/// globs. Applied to the importing file.
82pub struct ResolutionFilter {
83 pub files: Option<GlobSet>,
84 pub ignores: Option<GlobSet>,
85}
86
87impl ResolutionFilter {
88 /// Whether a file at `path` is in scope for the rule.
89 pub fn allows(&self, path: &Path) -> bool {
90 if !self.files.as_ref().is_none_or(|g| g.is_match(path)) {
91 return false;
92 }
93 if self.ignores.as_ref().is_some_and(|g| g.is_match(path)) {
94 return false;
95 }
96 true
97 }
98}
99
100/// Per-language extraction and resolution. Each supported language is a
101/// zero-sized type implementing this trait in its own module; the free functions
102/// below select the impl by matching on [`Language`]. The extraction method is
103/// generic over `Doc`, so this is a static-dispatch contract, not a `dyn` one.
104trait LanguageSupport {
105 /// Collect the imports (and, for Rust, local `mod` declarations) of one
106 /// parsed file.
107 fn extract_imports<D: Doc>(&self, root: &AstGrep<D>) -> FileImports;
108
109 /// Whether one import resolves against manifests, the standard library /
110 /// builtins, on-disk paths, or same-file modules. `local_mods` is only
111 /// meaningful for Rust; other languages ignore it.
112 fn resolves(
113 &self,
114 import: &ImportRef,
115 local_mods: &[String],
116 file_dir: &Path,
117 manifests: &mut ManifestResolver,
118 ) -> bool;
119}
120
121/// The first line of an import statement, trimmed, for the finding's
122/// `matched_text`. A multi-line grouped `use` reports only its first line.
123fn first_line(text: &str) -> String {
124 text.lines().next().unwrap_or(text).trim().to_string()
125}
126
127/// Extract a file's imports for the given language.
128pub fn extract_imports<D: Doc>(lang: &Language, root: &AstGrep<D>) -> FileImports {
129 match lang {
130 Language::Rust => Rust.extract_imports(root),
131 Language::TypeScript => TypeScript.extract_imports(root),
132 }
133}
134
135/// Whether a single import resolves, dispatching on the file's language.
136pub fn import_resolves(
137 lang: &Language,
138 import: &ImportRef,
139 local_mods: &[String],
140 file_dir: &Path,
141 manifests: &mut ManifestResolver,
142) -> bool {
143 match lang {
144 Language::Rust => Rust.resolves(import, local_mods, file_dir, manifests),
145 Language::TypeScript => TypeScript.resolves(import, local_mods, file_dir, manifests),
146 }
147}
148
149/// Evaluate one resolution rule against a single file's imports, emitting a
150/// finding per unresolved import. `$import` in the rule message is replaced with
151/// the offending specifier.
152pub fn evaluate_file(
153 rule: &Rule,
154 kind: ResolutionKind,
155 lang: &Language,
156 path: &Path,
157 imports: &FileImports,
158 manifests: &mut ManifestResolver,
159) -> Vec<Finding> {
160 let ResolutionKind::UnresolvedImport = kind;
161 let file_dir = path.parent().unwrap_or_else(|| Path::new("."));
162 imports
163 .imports
164 .iter()
165 .filter(|import| !import_resolves(lang, import, &imports.local_mods, file_dir, manifests))
166 .map(|import| Finding {
167 rule_id: rule.id.clone(),
168 severity: rule.severity.clone(),
169 category: rule.category.clone().unwrap_or_default(),
170 message: rule.message.replace("$import", &import.specifier),
171 note: rule.note.clone(),
172 fix: rule.fix.clone(),
173 file: path.to_path_buf(),
174 line: import.line,
175 column: import.column,
176 end_line: import.end_line,
177 end_column: import.end_column,
178 matched_text: import.matched_text.clone(),
179 confidence: None,
180 escalated: false,
181 })
182 .collect()
183}
184
185#[cfg(test)]
186mod tests;