Skip to main content

slopguard_core/scanner/
mod.rs

1use std::collections::HashMap;
2use std::fs::read_to_string;
3use std::path::{Path, PathBuf};
4
5use ast_grep_config::{
6    GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_language::SupportLang;
11use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
12use ignore::WalkBuilder;
13use serde::Serialize;
14use serde_yaml::with::singleton_map_recursive;
15use serde_yaml::{to_value, Value};
16use strum::IntoEnumIterator;
17use thiserror::Error;
18
19use crate::cross_file::{self, CrossFileKind, DeclFilter, FileSymbols, SymbolIndex};
20use crate::disable::filter_disabled;
21use crate::finding::Finding;
22use crate::resolution::{self, FileImports, ManifestResolver, ResolutionFilter, ResolutionKind};
23use crate::rule::{Language, Rule, RuleId, Severity};
24use crate::test_filter::{CfgTestRanges, TestPaths};
25
26use engine::{
27    AstEngine, BoxedEngine, CrossFileEngine, CrossFileRule, EngineScope, FileContext, MetricEngine,
28    MetricRule, ProjectContext, RuleContext,
29};
30
31mod engine;
32mod orchestrate;
33
34pub use orchestrate::{count_severities, scan, scan_cached, scan_files, scan_files_cached};
35
36#[derive(Debug, Error)]
37pub enum ScanError {
38    #[error("failed to compile rule '{id}': {source}")]
39    RuleCompile {
40        id: RuleId,
41        #[source]
42        source: RuleConfigError,
43    },
44
45    #[error("invalid glob pattern: {0}")]
46    Glob(#[from] GlobError),
47
48    #[error("rule '{id}': metric rule is missing a threshold")]
49    InvalidMetricRule { id: RuleId },
50}
51
52/// The subset of a slopguard rule that ast-grep understands. slopguard-only
53/// fields (`category`, `fix` as free text, `tests`) are deliberately left out.
54#[derive(Serialize)]
55pub struct AstGrepRule<'a> {
56    pub id: &'a str,
57    pub language: SupportLang,
58    pub severity: &'a Severity,
59    pub message: &'a str,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub note: Option<&'a str>,
62    pub rule: &'a Value,
63    /// ast-grep metavariable constraints (sibling of `rule`). `None` when the
64    /// slopguard rule sets none.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub constraints: Option<&'a Value>,
67    /// ast-grep `fix` template. Only set by the `--fix` pass (from the rule's
68    /// `rewrite`); the detection pass leaves it `None`.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub fix: Option<&'a str>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub files: Option<&'a [String]>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub ignores: Option<&'a [String]>,
75}
76
77/// Rules compiled for scanning, plus a way back to the slopguard rule that
78/// produced each ast-grep config. Holds the raw compiled data; the registered
79/// [`RuleEngine`]s that read it are built on demand.
80struct CompiledRules<'a> {
81    collection: RuleCollection<SupportLang>,
82    by_id: HashMap<(&'a str, Language), &'a Rule>,
83    metrics: Vec<MetricRule<'a>>,
84    cross_file: Vec<CrossFileRule<'a>>,
85    resolution: Vec<ResolutionRule<'a>>,
86    /// Which paths `skip_test_code` rules treat as test code (built-in
87    /// heuristic plus configured `scan.test_paths`).
88    test_paths: TestPaths,
89}
90
91impl CompiledRules<'_> {
92    /// The registered engines, split into the per-file phase and the
93    /// project-level phase by their declared [`EngineScope`]. The AST engine is
94    /// always registered; the metric and cross-file engines only when rules of
95    /// their kind exist.
96    fn engines(&self) -> (Vec<BoxedEngine<'_>>, Vec<BoxedEngine<'_>>) {
97        let mut all: Vec<BoxedEngine<'_>> = vec![Box::new(AstEngine {
98            collection: &self.collection,
99            by_id: &self.by_id,
100        })];
101        if !self.metrics.is_empty() {
102            all.push(Box::new(MetricEngine {
103                rules: &self.metrics,
104            }));
105        }
106        if !self.cross_file.is_empty() {
107            all.push(Box::new(CrossFileEngine {
108                rules: &self.cross_file,
109            }));
110        }
111        all.into_iter()
112            .partition(|e| matches!(e.scope(), EngineScope::PerFile))
113    }
114}
115
116/// A resolution rule prepared for scanning: kind resolved and the file filter
117/// compiled once. Resolution runs outside the [`RuleEngine`] partition: it
118/// resolves each file's imports against on-disk manifests after the per-file
119/// pass, so a manifest edit is reflected even for an unchanged file.
120struct ResolutionRule<'a> {
121    rule: &'a Rule,
122    kind: ResolutionKind,
123    filter: ResolutionFilter,
124}
125
126fn extension_to_lang(path: &Path) -> Option<SupportLang> {
127    SupportLang::from_path(path)
128        .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
129}
130
131pub(crate) fn support_lang_to_language(lang: SupportLang) -> Language {
132    match lang {
133        SupportLang::Rust => Language::Rust,
134        _ => Language::TypeScript,
135    }
136}
137
138pub(crate) fn compile_ast_grep_rule(
139    rule: &AstGrepRule,
140) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
141    let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
142        .and_then(singleton_map_recursive::deserialize)
143        .map_err(RuleConfigError::from)?;
144    RuleConfig::try_from(inner, &GlobalRules::default())
145}
146
147fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
148    let ast_grep_rule = AstGrepRule {
149        id: rule.id.as_str(),
150        language: lang,
151        severity: &rule.severity,
152        message: &rule.message,
153        note: rule.note.as_deref(),
154        rule: &rule.rule,
155        constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
156        fix: None,
157        files: rule.files.as_deref(),
158        ignores: rule.ignores.as_deref(),
159    };
160    compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
161        id: rule.id.clone(),
162        source,
163    })
164}
165
166/// Resolve a metric rule's threshold and compile its path globs.
167fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
168    let Some((metric, threshold)) = rule.metric_spec() else {
169        return Err(ScanError::InvalidMetricRule {
170            id: rule.id.clone(),
171        });
172    };
173    Ok(MetricRule {
174        rule,
175        metric,
176        threshold,
177        min_lines: rule.min_lines,
178        files: rule.files.as_deref().map(build_glob_set).transpose()?,
179        ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
180    })
181}
182
183/// Compile the globs that bound which declarations a cross-file rule may
184/// report. The `kind` is resolved by the caller, which already partitioned the
185/// rules on `is_cross_file()`.
186fn build_cross_file_rule<'a>(
187    rule: &'a Rule,
188    kind: CrossFileKind,
189    test_paths: &TestPaths,
190) -> Result<CrossFileRule<'a>, ScanError> {
191    Ok(CrossFileRule {
192        rule,
193        kind,
194        decl_filter: DeclFilter {
195            files: rule.files.as_deref().map(build_glob_set).transpose()?,
196            ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
197            skip_test_code: rule.skip_test_code,
198            test_paths: test_paths.clone(),
199        },
200    })
201}
202
203/// Compile the globs that bound which files a resolution rule may report on.
204fn build_resolution_rule<'a>(
205    rule: &'a Rule,
206    kind: ResolutionKind,
207) -> Result<ResolutionRule<'a>, ScanError> {
208    Ok(ResolutionRule {
209        rule,
210        kind,
211        filter: ResolutionFilter {
212            files: rule.files.as_deref().map(build_glob_set).transpose()?,
213            ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
214        },
215    })
216}
217
218fn compile_rules<'a>(
219    rules: &'a [Rule],
220    test_paths: TestPaths,
221) -> Result<CompiledRules<'a>, ScanError> {
222    // Cross-file, resolution and metric rules must not reach ast-grep: their
223    // `rule` field is null and would fail to compile.
224    let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
225        rules.iter().partition(|r| r.is_cross_file());
226    let (resolution_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
227        rest.into_iter().partition(|r| r.is_resolution());
228    let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
229        rest.into_iter().partition(|r| r.is_metric());
230
231    let configs = ast_rules
232        .iter()
233        .copied()
234        .flat_map(|rule| {
235            rule.language
236                .ast_grep_langs()
237                .iter()
238                .map(move |lang| compile_rule(rule, *lang))
239        })
240        .collect::<Result<Vec<_>, _>>()?;
241    let cross_file = cross_rules
242        .into_iter()
243        .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
244        .map(|(rule, kind)| build_cross_file_rule(rule, kind, &test_paths))
245        .collect::<Result<Vec<_>, _>>()?;
246    let resolution = resolution_rules
247        .into_iter()
248        .filter_map(|rule| rule.resolution_kind().map(|kind| (rule, kind)))
249        .map(|(rule, kind)| build_resolution_rule(rule, kind))
250        .collect::<Result<Vec<_>, _>>()?;
251    Ok(CompiledRules {
252        collection: RuleCollection::try_new(configs)?,
253        by_id: ast_rules
254            .iter()
255            .copied()
256            .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
257            .collect::<HashMap<_, _>>(),
258        metrics: metric_rules
259            .into_iter()
260            .map(build_metric_rule)
261            .collect::<Result<Vec<_>, _>>()?,
262        cross_file,
263        resolution,
264        test_paths,
265    })
266}
267
268pub(crate) fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
269    let mut builder = GlobSetBuilder::new();
270    for pattern in patterns {
271        builder.add(Glob::new(pattern)?);
272    }
273    builder.build()
274}
275
276/// One file's contribution to a scan: its findings plus, when a cross-file
277/// rule is active, the symbols it adds to the project index. Both come out of
278/// a single parse.
279#[derive(Default)]
280struct FileScan {
281    findings: Vec<Finding>,
282    symbols: FileSymbols,
283    imports: FileImports,
284}
285
286fn scan_file(
287    path: &Path,
288    lang: SupportLang,
289    per_file: &[BoxedEngine],
290    test_paths: &TestPaths,
291    collect_symbols: bool,
292    collect_imports: bool,
293) -> FileScan {
294    let applies = per_file.iter().any(|e| e.applies_to_file(path, lang));
295    // A run scoped to a single cross-file rule has no ast-grep and no metric
296    // rule, yet the file still has to be parsed for its symbols. The same holds
297    // for a resolution-only run and its imports.
298    let want_symbols = collect_symbols && lang == SupportLang::Rust;
299    let want_imports = collect_imports;
300    if !applies && !want_symbols && !want_imports {
301        return FileScan::default();
302    }
303    let Ok(source) = read_to_string(path) else {
304        return FileScan::default();
305    };
306
307    let root = lang.ast_grep(&source);
308    // Built once and shared by every per-file engine and the symbol extractor.
309    let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
310
311    let ctx = RuleContext::File(FileContext {
312        path,
313        lang,
314        rule_lang: support_lang_to_language(lang),
315        source: &source,
316        root: &root,
317        cfg_test: cfg_test.as_ref(),
318        is_test_file: test_paths.is_test(path),
319        test_paths,
320    });
321
322    let mut findings: Vec<Finding> = Vec::new();
323    for engine in per_file {
324        findings.extend(engine.evaluate(&ctx));
325    }
326
327    // Symbols are not run through `filter_disabled`: suppression applies to the
328    // cross-file findings emitted later, against the declaration file's source.
329    let symbols = match (want_symbols, cfg_test.as_ref()) {
330        (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
331        _ => FileSymbols::default(),
332    };
333
334    // Imports are extracted here (cheap, cached with the file) but resolved
335    // later against manifests, so a manifest edit is reflected without a file
336    // change. Not filtered here: suppression applies to the resolution findings.
337    let imports = if want_imports {
338        resolution::extract_imports(&support_lang_to_language(lang), &root)
339    } else {
340        FileImports::default()
341    };
342
343    FileScan {
344        findings: filter_disabled(findings, &source),
345        symbols,
346        imports,
347    }
348}
349
350/// Run the project-level engines and append their findings, if they are due.
351///
352/// A full scan asks for it via `run_cross_file`; a partial scan (`--diff`,
353/// explicit files) collects contributions to warm the cache but leaves the
354/// index incomplete, so it must not evaluate. Centralises the `run_cross_file &&
355/// has project engines` invariant shared by both scan paths.
356fn append_cross_file(
357    findings: &mut Vec<Finding>,
358    project: &[BoxedEngine],
359    run_cross_file: bool,
360    contributions: &[(PathBuf, FileSymbols)],
361) {
362    if !run_cross_file || project.is_empty() {
363        return;
364    }
365    let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
366    let ctx = RuleContext::Project(ProjectContext { index: &index });
367    for engine in project {
368        findings.extend(engine.evaluate(&ctx));
369    }
370}
371
372/// Resolve each file's imports against the manifests on disk and collect the
373/// findings, then drop those a disable comment suppresses in the importing file.
374///
375/// Manifests are read once each through a shared [`ManifestResolver`].
376fn resolution_findings(
377    rules: &[ResolutionRule<'_>],
378    contributions: &[(PathBuf, FileImports)],
379) -> Vec<Finding> {
380    let mut manifests = ManifestResolver::new();
381    let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
382    for (path, imports) in contributions {
383        if imports.is_empty() {
384            continue;
385        }
386        let Some(lang) = extension_to_lang(path).map(support_lang_to_language) else {
387            continue;
388        };
389        for rule in rules {
390            if rule.rule.language != lang || !rule.filter.allows(path) {
391                continue;
392            }
393            let found = resolution::evaluate_file(
394                rule.rule,
395                rule.kind,
396                &lang,
397                path,
398                imports,
399                &mut manifests,
400            );
401            by_file.entry(path.clone()).or_default().extend(found);
402        }
403    }
404    by_file
405        .into_iter()
406        .flat_map(|(path, findings)| match read_to_string(&path) {
407            // A file that can no longer be read keeps its findings unfiltered
408            // rather than losing them.
409            Ok(source) => filter_disabled(findings, &source),
410            Err(_) => findings,
411        })
412        .collect()
413}
414
415/// Run the resolution pass and append its findings, if any resolution rule is
416/// active. Unlike the cross-file pass, resolution runs for both full and partial
417/// (`--diff`, explicit files) scans: an import resolves against manifests on
418/// disk, which are complete regardless of the scanned file set.
419fn append_resolution(
420    findings: &mut Vec<Finding>,
421    compiled: &CompiledRules,
422    contributions: &[(PathBuf, FileImports)],
423) {
424    if !compiled.resolution.is_empty() {
425        findings.extend(resolution_findings(&compiled.resolution, contributions));
426    }
427}
428
429/// Walk `paths` (gitignore-aware) and keep the source files slopguard can parse.
430pub(crate) fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
431    paths
432        .iter()
433        .flat_map(|path| WalkBuilder::new(path).build().flatten())
434        .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
435        .map(|entry| entry.into_path())
436        .filter(|path| !ignores.is_match(path))
437        .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
438        .collect()
439}
440
441/// Keep the given files as-is (no directory walk), dropping the ones that are
442/// ignored by config or written in an unsupported language.
443///
444/// Deliberately not gitignore-aware: a file git reports as changed must be
445/// scanned even when it lives under a hidden or ignored directory.
446pub(crate) fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
447    files
448        .iter()
449        .filter(|path| path.is_file())
450        .filter(|path| !ignores.is_match(path))
451        .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
452        .collect()
453}
454
455#[cfg(test)]
456mod feature_tests;
457#[cfg(test)]
458mod scan_tests;
459#[cfg(test)]
460mod test_support;