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