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