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