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    CombinedScan, GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_core::{AstGrep, Doc};
11use ast_grep_language::SupportLang;
12use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
13use ignore::WalkBuilder;
14use serde::Serialize;
15use serde_yaml::with::singleton_map_recursive;
16use serde_yaml::{to_value, Value};
17use strum::IntoEnumIterator;
18use thiserror::Error;
19
20use crate::cross_file::{self, CrossFileKind, DeclFilter, FileSymbols, SymbolIndex};
21use crate::disable::filter_disabled;
22use crate::finding::Finding;
23use crate::metric::{self, Metric};
24use crate::resolution::{self, FileImports, ManifestResolver, ResolutionFilter, ResolutionKind};
25use crate::rule::{Language, Rule, RuleId, Severity};
26use crate::test_filter::{CfgTestRanges, TestPaths};
27
28mod orchestrate;
29
30pub use orchestrate::{count_severities, scan, scan_cached, scan_files, scan_files_cached};
31
32#[derive(Debug, Error)]
33pub enum ScanError {
34    #[error("failed to compile rule '{id}': {source}")]
35    RuleCompile {
36        id: RuleId,
37        #[source]
38        source: RuleConfigError,
39    },
40
41    #[error("invalid glob pattern: {0}")]
42    Glob(#[from] GlobError),
43
44    #[error("rule '{id}': metric rule is missing a threshold")]
45    InvalidMetricRule { id: RuleId },
46}
47
48/// The subset of a slopguard rule that ast-grep understands. slopguard-only
49/// fields (`category`, `fix` as free text, `tests`) are deliberately left out.
50#[derive(Serialize)]
51pub struct AstGrepRule<'a> {
52    pub id: &'a str,
53    pub language: SupportLang,
54    pub severity: &'a Severity,
55    pub message: &'a str,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub note: Option<&'a str>,
58    pub rule: &'a Value,
59    /// ast-grep metavariable constraints (sibling of `rule`). `None` when the
60    /// slopguard rule sets none.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub constraints: Option<&'a Value>,
63    /// ast-grep `fix` template. Only set by the `--fix` pass (from the rule's
64    /// `rewrite`); the detection pass leaves it `None`.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub fix: Option<&'a str>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub files: Option<&'a [String]>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub ignores: Option<&'a [String]>,
71}
72
73/// Rules compiled for scanning, plus a way back to the slopguard rule that
74/// produced each ast-grep config.
75struct CompiledRules<'a> {
76    collection: RuleCollection<SupportLang>,
77    by_id: HashMap<(&'a str, Language), &'a Rule>,
78    metrics: Vec<MetricRule<'a>>,
79    cross_file: Vec<CrossFileRule<'a>>,
80    resolution: Vec<ResolutionRule<'a>>,
81    /// Which paths `skip_test_code` rules treat as test code (built-in
82    /// heuristic plus configured `scan.test_paths`).
83    test_paths: TestPaths,
84}
85
86/// A cross-file rule prepared for scanning: kind resolved and the declaration
87/// filter compiled once.
88struct CrossFileRule<'a> {
89    rule: &'a Rule,
90    kind: CrossFileKind,
91    decl_filter: DeclFilter,
92}
93
94/// A resolution rule prepared for scanning: kind resolved and the file filter
95/// compiled once.
96struct ResolutionRule<'a> {
97    rule: &'a Rule,
98    kind: ResolutionKind,
99    filter: ResolutionFilter,
100}
101
102/// A metric rule prepared for scanning: threshold resolved and `files` /
103/// `ignores` globs compiled once. ast-grep applies those globs for AST rules;
104/// metric rules are evaluated outside ast-grep so they apply them here.
105struct MetricRule<'a> {
106    rule: &'a Rule,
107    metric: Metric,
108    threshold: f64,
109    min_lines: Option<usize>,
110    files: Option<GlobSet>,
111    ignores: Option<GlobSet>,
112}
113
114impl MetricRule<'_> {
115    fn applies_to(&self, path: &Path, lang: &Language) -> bool {
116        self.rule.language == *lang
117            && self.files.as_ref().is_none_or(|g| g.is_match(path))
118            && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
119    }
120}
121
122fn extension_to_lang(path: &Path) -> Option<SupportLang> {
123    SupportLang::from_path(path)
124        .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
125}
126
127pub(crate) fn support_lang_to_language(lang: SupportLang) -> Language {
128    match lang {
129        SupportLang::Rust => Language::Rust,
130        _ => Language::TypeScript,
131    }
132}
133
134pub(crate) fn compile_ast_grep_rule(
135    rule: &AstGrepRule,
136) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
137    let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
138        .and_then(singleton_map_recursive::deserialize)
139        .map_err(RuleConfigError::from)?;
140    RuleConfig::try_from(inner, &GlobalRules::default())
141}
142
143fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
144    let ast_grep_rule = AstGrepRule {
145        id: rule.id.as_str(),
146        language: lang,
147        severity: &rule.severity,
148        message: &rule.message,
149        note: rule.note.as_deref(),
150        rule: &rule.rule,
151        constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
152        fix: None,
153        files: rule.files.as_deref(),
154        ignores: rule.ignores.as_deref(),
155    };
156    compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
157        id: rule.id.clone(),
158        source,
159    })
160}
161
162/// Resolve a metric rule's threshold and compile its path globs.
163fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
164    let Some((metric, threshold)) = rule.metric_spec() else {
165        return Err(ScanError::InvalidMetricRule {
166            id: rule.id.clone(),
167        });
168    };
169    Ok(MetricRule {
170        rule,
171        metric,
172        threshold,
173        min_lines: rule.min_lines,
174        files: rule.files.as_deref().map(build_glob_set).transpose()?,
175        ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
176    })
177}
178
179/// Compile the globs that bound which declarations a cross-file rule may
180/// report. The `kind` is resolved by the caller, which already partitioned the
181/// rules on `is_cross_file()`.
182fn build_cross_file_rule<'a>(
183    rule: &'a Rule,
184    kind: CrossFileKind,
185    test_paths: &TestPaths,
186) -> Result<CrossFileRule<'a>, ScanError> {
187    Ok(CrossFileRule {
188        rule,
189        kind,
190        decl_filter: DeclFilter {
191            files: rule.files.as_deref().map(build_glob_set).transpose()?,
192            ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
193            skip_test_code: rule.skip_test_code,
194            test_paths: test_paths.clone(),
195        },
196    })
197}
198
199/// Compile the globs that bound which files a resolution rule may report on.
200fn build_resolution_rule<'a>(
201    rule: &'a Rule,
202    kind: ResolutionKind,
203) -> Result<ResolutionRule<'a>, ScanError> {
204    Ok(ResolutionRule {
205        rule,
206        kind,
207        filter: ResolutionFilter {
208            files: rule.files.as_deref().map(build_glob_set).transpose()?,
209            ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
210        },
211    })
212}
213
214fn compile_rules<'a>(
215    rules: &'a [Rule],
216    test_paths: TestPaths,
217) -> Result<CompiledRules<'a>, ScanError> {
218    // Cross-file, resolution and metric rules must not reach ast-grep: their
219    // `rule` field is null and would fail to compile.
220    let (cross_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
221        rules.iter().partition(|r| r.is_cross_file());
222    let (resolution_rules, rest): (Vec<&Rule>, Vec<&Rule>) =
223        rest.into_iter().partition(|r| r.is_resolution());
224    let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
225        rest.into_iter().partition(|r| r.is_metric());
226
227    let configs = ast_rules
228        .iter()
229        .copied()
230        .flat_map(|rule| {
231            rule.language
232                .ast_grep_langs()
233                .iter()
234                .map(move |lang| compile_rule(rule, *lang))
235        })
236        .collect::<Result<Vec<_>, _>>()?;
237    let cross_file = cross_rules
238        .into_iter()
239        .filter_map(|rule| rule.cross_file_kind().map(|kind| (rule, kind)))
240        .map(|(rule, kind)| build_cross_file_rule(rule, kind, &test_paths))
241        .collect::<Result<Vec<_>, _>>()?;
242    let resolution = resolution_rules
243        .into_iter()
244        .filter_map(|rule| rule.resolution_kind().map(|kind| (rule, kind)))
245        .map(|(rule, kind)| build_resolution_rule(rule, kind))
246        .collect::<Result<Vec<_>, _>>()?;
247    Ok(CompiledRules {
248        collection: RuleCollection::try_new(configs)?,
249        by_id: ast_rules
250            .iter()
251            .copied()
252            .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
253            .collect::<HashMap<_, _>>(),
254        metrics: metric_rules
255            .into_iter()
256            .map(build_metric_rule)
257            .collect::<Result<Vec<_>, _>>()?,
258        cross_file,
259        resolution,
260        test_paths,
261    })
262}
263
264pub(crate) fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
265    let mut builder = GlobSetBuilder::new();
266    for pattern in patterns {
267        builder.add(Glob::new(pattern)?);
268    }
269    builder.build()
270}
271
272/// One finding per metric rule whose measured value exceeds its threshold.
273///
274/// File-level findings have no source position, so they are anchored at 1:1.
275/// `skip_test_code` drops the whole file: a line-1 finding can never be inside
276/// a `#[cfg(test)]` block.
277fn metric_findings<D: Doc>(
278    metrics: &[&MetricRule],
279    path: &Path,
280    root: &AstGrep<D>,
281    source: &str,
282    lang: &Language,
283    test_paths: &TestPaths,
284    cfg_test: Option<&CfgTestRanges>,
285) -> Vec<Finding> {
286    metrics
287        .iter()
288        .filter(|m| !(m.rule.skip_test_code && test_paths.is_test(path)))
289        .filter(|m| {
290            m.min_lines
291                .is_none_or(|floor| source.lines().count() >= floor)
292        })
293        .filter_map(|m| {
294            // A `skip_test_code` rule also ignores inline `#[cfg(test)]` code, so
295            // a production file's test module does not inflate the metric.
296            let exclude = m.rule.skip_test_code.then_some(cfg_test).flatten();
297            let value = metric::compute(m.metric, lang, root, source, exclude);
298            if !metric::exceeds(value, m.threshold) {
299                return None;
300            }
301            let rendered = m.metric.format_value(value);
302            Some(Finding {
303                rule_id: m.rule.id.clone(),
304                severity: m.rule.severity.clone(),
305                category: m.rule.category.clone().unwrap_or_default(),
306                message: m.rule.message.replace("$value", &rendered),
307                note: m.rule.note.clone(),
308                fix: m.rule.fix.clone(),
309                file: path.to_path_buf(),
310                line: 1,
311                column: 1,
312                end_line: 1,
313                end_column: 1,
314                matched_text: m.metric.describe(value),
315                confidence: None,
316                escalated: false,
317            })
318        })
319        .collect()
320}
321
322/// One file's contribution to a scan: its findings plus, when a cross-file
323/// rule is active, the symbols it adds to the project index. Both come out of
324/// a single parse.
325#[derive(Default)]
326struct FileScan {
327    findings: Vec<Finding>,
328    symbols: FileSymbols,
329    imports: FileImports,
330}
331
332fn scan_file(
333    path: &Path,
334    lang: SupportLang,
335    rules: &CompiledRules,
336    collect_symbols: bool,
337    collect_imports: bool,
338) -> FileScan {
339    let rule_lang = support_lang_to_language(lang);
340    let applicable = rules.collection.get_rule_from_lang(path, lang);
341    let metrics: Vec<&MetricRule> = rules
342        .metrics
343        .iter()
344        .filter(|m| m.applies_to(path, &rule_lang))
345        .collect();
346    // A run scoped to a single cross-file rule has no ast-grep and no metric
347    // rule, yet the file still has to be parsed for its symbols. The same holds
348    // for a resolution-only run and its imports.
349    let want_symbols = collect_symbols && lang == SupportLang::Rust;
350    let want_imports = collect_imports;
351    if applicable.is_empty() && metrics.is_empty() && !want_symbols && !want_imports {
352        return FileScan::default();
353    }
354    let Ok(source) = read_to_string(path) else {
355        return FileScan::default();
356    };
357
358    let root = lang.ast_grep(&source);
359    // Built once and shared by the finding filter and the symbol extractor.
360    let cfg_test = (lang == SupportLang::Rust).then(|| CfgTestRanges::from_root(&root));
361
362    let mut findings: Vec<Finding> = if applicable.is_empty() {
363        Vec::new()
364    } else {
365        let combined = CombinedScan::new(applicable);
366        let result = combined.scan(&root, false);
367
368        let cfg_test = cfg_test.as_ref();
369        let is_test_file = rules.test_paths.is_test(path);
370
371        result
372            .matches
373            .into_iter()
374            .filter_map(|(config, matches)| {
375                let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
376                Some((*rule, matches))
377            })
378            .flat_map(|(rule, matches)| {
379                let skip_test_code = rule.skip_test_code;
380                matches
381                    .into_iter()
382                    .map(move |node_match| {
383                        let start = node_match.start_pos();
384                        let end = node_match.end_pos();
385                        Finding {
386                            rule_id: rule.id.clone(),
387                            severity: rule.severity.clone(),
388                            category: rule.category.clone().unwrap_or_default(),
389                            message: rule.message.clone(),
390                            note: rule.note.clone(),
391                            fix: rule.fix.clone(),
392                            file: path.to_path_buf(),
393                            line: start.line() + 1,
394                            column: start.byte_point().1 + 1,
395                            end_line: end.line() + 1,
396                            end_column: end.byte_point().1 + 1,
397                            matched_text: node_match.text().to_string(),
398                            confidence: None,
399                            escalated: false,
400                        }
401                    })
402                    .filter(move |f| {
403                        if !skip_test_code {
404                            return true;
405                        }
406                        let in_test_code =
407                            is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
408                        !in_test_code
409                    })
410            })
411            .collect()
412    };
413
414    findings.extend(metric_findings(
415        &metrics,
416        path,
417        &root,
418        &source,
419        &rule_lang,
420        &rules.test_paths,
421        cfg_test.as_ref(),
422    ));
423
424    // Symbols are not run through `filter_disabled`: suppression applies to the
425    // cross-file findings emitted later, against the declaration file's source.
426    let symbols = match (want_symbols, cfg_test.as_ref()) {
427        (true, Some(ranges)) => cross_file::extract_rust_symbols(&root, ranges),
428        _ => FileSymbols::default(),
429    };
430
431    // Imports are extracted here (cheap, cached with the file) but resolved
432    // later against manifests, so a manifest edit is reflected without a file
433    // change. Not filtered here: suppression applies to the resolution findings.
434    let imports = if want_imports {
435        resolution::extract_imports(&rule_lang, &root)
436    } else {
437        FileImports::default()
438    };
439
440    FileScan {
441        findings: filter_disabled(findings, &source),
442        symbols,
443        imports,
444    }
445}
446
447/// Assemble the project index from every file's contribution, evaluate each
448/// active cross-file rule, then drop the findings a disable comment suppresses
449/// in the file that declares the symbol.
450fn cross_file_findings(
451    rules: &[CrossFileRule<'_>],
452    contributions: &[(PathBuf, FileSymbols)],
453) -> Vec<Finding> {
454    let index = SymbolIndex::build(contributions.iter().map(|(p, s)| (p.as_path(), s)));
455    let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
456    for rule in rules {
457        let found = cross_file::evaluate(rule.rule, rule.kind, &index, &rule.decl_filter);
458        for finding in found {
459            by_file
460                .entry(finding.file.clone())
461                .or_default()
462                .push(finding);
463        }
464    }
465    by_file
466        .into_iter()
467        .flat_map(|(path, findings)| match read_to_string(&path) {
468            // A file that can no longer be read keeps its findings unfiltered
469            // rather than losing them.
470            Ok(source) => filter_disabled(findings, &source),
471            Err(_) => findings,
472        })
473        .collect()
474}
475
476/// Run the cross-file pass and append its findings, if it is due. A full scan
477/// asks for it via `run_cross_file`; a partial scan (`--diff`, explicit files)
478/// collects contributions to warm the cache but leaves the index incomplete, so
479/// it must not evaluate. Centralises the `run_cross_file && has cross-file
480/// rules` invariant shared by both scan paths.
481fn append_cross_file(
482    findings: &mut Vec<Finding>,
483    compiled: &CompiledRules,
484    run_cross_file: bool,
485    contributions: &[(PathBuf, FileSymbols)],
486) {
487    if run_cross_file && !compiled.cross_file.is_empty() {
488        findings.extend(cross_file_findings(&compiled.cross_file, contributions));
489    }
490}
491
492/// Resolve each file's imports against the manifests on disk and collect the
493/// findings, then drop those a disable comment suppresses in the importing file.
494///
495/// Manifests are read once each through a shared [`ManifestResolver`].
496fn resolution_findings(
497    rules: &[ResolutionRule<'_>],
498    contributions: &[(PathBuf, FileImports)],
499) -> Vec<Finding> {
500    let mut manifests = ManifestResolver::new();
501    let mut by_file: HashMap<PathBuf, Vec<Finding>> = HashMap::new();
502    for (path, imports) in contributions {
503        if imports.is_empty() {
504            continue;
505        }
506        let Some(lang) = extension_to_lang(path).map(support_lang_to_language) else {
507            continue;
508        };
509        for rule in rules {
510            if rule.rule.language != lang || !rule.filter.allows(path) {
511                continue;
512            }
513            let found = resolution::evaluate_file(
514                rule.rule,
515                rule.kind,
516                &lang,
517                path,
518                imports,
519                &mut manifests,
520            );
521            by_file.entry(path.clone()).or_default().extend(found);
522        }
523    }
524    by_file
525        .into_iter()
526        .flat_map(|(path, findings)| match read_to_string(&path) {
527            // A file that can no longer be read keeps its findings unfiltered
528            // rather than losing them.
529            Ok(source) => filter_disabled(findings, &source),
530            Err(_) => findings,
531        })
532        .collect()
533}
534
535/// Run the resolution pass and append its findings, if any resolution rule is
536/// active. Unlike the cross-file pass, resolution runs for both full and partial
537/// (`--diff`, explicit files) scans: an import resolves against manifests on
538/// disk, which are complete regardless of the scanned file set.
539fn append_resolution(
540    findings: &mut Vec<Finding>,
541    compiled: &CompiledRules,
542    contributions: &[(PathBuf, FileImports)],
543) {
544    if !compiled.resolution.is_empty() {
545        findings.extend(resolution_findings(&compiled.resolution, contributions));
546    }
547}
548
549/// Walk `paths` (gitignore-aware) and keep the source files slopguard can parse.
550pub(crate) fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
551    paths
552        .iter()
553        .flat_map(|path| WalkBuilder::new(path).build().flatten())
554        .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
555        .map(|entry| entry.into_path())
556        .filter(|path| !ignores.is_match(path))
557        .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
558        .collect()
559}
560
561/// Keep the given files as-is (no directory walk), dropping the ones that are
562/// ignored by config or written in an unsupported language.
563///
564/// Deliberately not gitignore-aware: a file git reports as changed must be
565/// scanned even when it lives under a hidden or ignored directory.
566pub(crate) fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
567    files
568        .iter()
569        .filter(|path| path.is_file())
570        .filter(|path| !ignores.is_match(path))
571        .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
572        .collect()
573}
574
575#[cfg(test)]
576mod feature_tests;
577#[cfg(test)]
578mod scan_tests;
579#[cfg(test)]
580mod test_support;