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