Skip to main content

slopguard_core/scanner/
orchestrate.rs

1//! Top-level scan orchestration: collect files, fan out per-file scans (cached
2//! or not), assemble cross-file findings, and shape the [`ScanResult`].
3
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use ast_grep_language::SupportLang;
8use rayon::prelude::*;
9
10use crate::cache::{file_content_hash, rules_hash, CacheEntry, CacheStore};
11use crate::config::Config;
12use crate::cross_file::FileSymbols;
13use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
14use crate::resolution::FileImports;
15use crate::rule::{Rule, Severity};
16use crate::test_filter::TestPaths;
17
18use super::{
19    append_cross_file, append_resolution, build_glob_set, compile_rules, explicit_files, scan_file,
20    walk_files, CompiledRules, FileScan, ScanError,
21};
22
23/// Count errors and warnings in one pass.
24pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
25    let mut errors = 0;
26    let mut warnings = 0;
27    for finding in findings {
28        match finding.severity {
29            Severity::Error => errors += 1,
30            Severity::Warning => warnings += 1,
31        }
32    }
33    (errors, warnings)
34}
35
36/// Sort findings by location and drop duplicates from overlapping rules.
37fn normalize_findings(findings: &mut Vec<Finding>) {
38    findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
39    findings.dedup_by(|a, b| {
40        a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
41    });
42}
43
44/// Scan an already-collected file list without touching the cache.
45///
46/// Symbol collection is driven by the active rules alone, never by
47/// `run_cross_file`: a partial scan must still cache a complete contribution.
48fn scan_collected(
49    files: Vec<(PathBuf, SupportLang)>,
50    compiled: &CompiledRules,
51    run_cross_file: bool,
52) -> ScanResult {
53    let collect_symbols = !compiled.cross_file.is_empty();
54    let collect_imports = !compiled.resolution.is_empty();
55    let scans: Vec<(PathBuf, FileScan)> = files
56        .par_iter()
57        .map(|(path, lang)| {
58            let scan = scan_file(path, *lang, compiled, collect_symbols, collect_imports);
59            (path.clone(), scan)
60        })
61        .collect();
62
63    let mut findings: Vec<Finding> = Vec::new();
64    let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
65    let mut import_contributions: Vec<(PathBuf, FileImports)> = Vec::new();
66    for (path, scan) in scans {
67        findings.extend(scan.findings);
68        if collect_symbols {
69            contributions.push((path.clone(), scan.symbols));
70        }
71        if collect_imports {
72            import_contributions.push((path, scan.imports));
73        }
74    }
75    append_cross_file(&mut findings, compiled, run_cross_file, &contributions);
76    append_resolution(&mut findings, compiled, &import_contributions);
77    normalize_findings(&mut findings);
78
79    let (errors, warnings) = count_severities(&findings);
80
81    ScanResult {
82        findings,
83        stats: ScanStats {
84            errors,
85            warnings,
86            total: errors + warnings,
87            files_scanned: files.len(),
88            baseline_filtered: 0,
89            diff_base: None,
90            files_changed: None,
91        },
92        cache_stats: None,
93    }
94}
95
96/// Scan an already-collected file list, reusing cached findings for files
97/// whose content hash is unchanged.
98///
99/// `prune` drops cache entries that no longer correspond to a scanned file. A
100/// partial scan must pass `false`: it never saw the other files, so their
101/// entries are still valid.
102fn scan_collected_cached(
103    files: Vec<(PathBuf, SupportLang)>,
104    compiled: &CompiledRules,
105    rules: &[Rule],
106    cache_dir: &Path,
107    prune: bool,
108    run_cross_file: bool,
109) -> ScanResult {
110    let collect_symbols = !compiled.cross_file.is_empty();
111    let collect_imports = !compiled.resolution.is_empty();
112    let current_rules_hash = rules_hash(rules);
113    let store = CacheStore::with_dir(cache_dir.to_path_buf()).scoped_to_rules(&current_rules_hash);
114    let rules_changed = store
115        .check_rules_changed(&current_rules_hash)
116        .unwrap_or(true);
117
118    let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
119        .into_iter()
120        .filter_map(|(path, lang)| {
121            let content = fs::read(&path).ok()?;
122            let hash = file_content_hash(&content);
123            Some((path, lang, content, hash))
124        })
125        .collect();
126
127    let mut cached_count = 0usize;
128    let mut changed_count = 0usize;
129    let mut all_findings: Vec<Finding> = Vec::new();
130    let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
131    let mut import_contributions: Vec<(PathBuf, FileImports)> = Vec::new();
132
133    struct FileWork {
134        path: PathBuf,
135        lang: SupportLang,
136        hash: String,
137    }
138
139    let mut to_scan: Vec<FileWork> = Vec::new();
140
141    for (path, lang, _content, hash) in file_contents.iter() {
142        if !rules_changed {
143            if let Some(entry) = store.get(hash) {
144                cached_count += 1;
145                all_findings.extend(entry.findings);
146                if collect_symbols {
147                    contributions.push((path.clone(), entry.symbols));
148                }
149                if collect_imports {
150                    import_contributions.push((path.clone(), entry.imports));
151                }
152                continue;
153            }
154        }
155        changed_count += 1;
156        to_scan.push(FileWork {
157            path: path.clone(),
158            lang: *lang,
159            hash: hash.clone(),
160        });
161    }
162
163    let scanned: Vec<(PathBuf, String, FileScan)> = to_scan
164        .par_iter()
165        .map(|work| {
166            let scan = scan_file(
167                &work.path,
168                work.lang,
169                compiled,
170                collect_symbols,
171                collect_imports,
172            );
173            (work.path.clone(), work.hash.clone(), scan)
174        })
175        .collect();
176
177    for (path, hash, scan) in scanned {
178        let entry = CacheEntry {
179            findings: scan.findings,
180            symbols: scan.symbols,
181            imports: scan.imports,
182        };
183        store.put(&hash, &entry).ok();
184        all_findings.extend(entry.findings);
185        if collect_symbols {
186            contributions.push((path.clone(), entry.symbols));
187        }
188        if collect_imports {
189            import_contributions.push((path, entry.imports));
190        }
191    }
192
193    // Cross-file and resolution evaluation are never cached: they re-run on every
194    // scan (cross-file from the assembled index, resolution from the manifests on
195    // disk, so a manifest edit is reflected even for an unchanged file).
196    append_cross_file(&mut all_findings, compiled, run_cross_file, &contributions);
197    append_resolution(&mut all_findings, compiled, &import_contributions);
198
199    if prune {
200        let current_hashes: Vec<String> =
201            file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
202        store.cleanup(&current_hashes).ok();
203    }
204
205    normalize_findings(&mut all_findings);
206
207    let (errors, warnings) = count_severities(&all_findings);
208
209    let files_scanned = cached_count + changed_count;
210
211    ScanResult {
212        findings: all_findings,
213        stats: ScanStats {
214            errors,
215            warnings,
216            total: errors + warnings,
217            files_scanned,
218            baseline_filtered: 0,
219            diff_base: None,
220            files_changed: None,
221        },
222        cache_stats: Some(CacheStats {
223            cached: cached_count,
224            changed: changed_count,
225        }),
226    }
227}
228
229/// Scan the given paths for rule violations.
230pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
231    let test_paths = TestPaths::new(&config.scan.test_paths)?;
232    let compiled = compile_rules(rules, test_paths)?;
233    let ignores = build_glob_set(&config.scan.ignores)?;
234    Ok(scan_collected(walk_files(paths, &ignores), &compiled, true))
235}
236
237/// Scan with file-level caching. Files whose content hash matches a cached
238/// entry (and whose rules have not changed) return cached findings without
239/// reparsing.
240///
241/// `cache_dir` is the directory where cache files are stored. When a custom
242/// cache directory is specified (via `--cache-dir`, `SLOPGUARD_CACHE_DIR`,
243/// or `scan.cache_dir` in config), pass it directly. Otherwise pass the
244/// project root and use `CacheStore::new` which appends `.slopguard-cache`.
245pub fn scan_cached(
246    paths: &[PathBuf],
247    rules: &[Rule],
248    config: &Config,
249    cache_dir: &Path,
250) -> Result<ScanResult, ScanError> {
251    let test_paths = TestPaths::new(&config.scan.test_paths)?;
252    let compiled = compile_rules(rules, test_paths)?;
253    let ignores = build_glob_set(&config.scan.ignores)?;
254    Ok(scan_collected_cached(
255        walk_files(paths, &ignores),
256        &compiled,
257        rules,
258        cache_dir,
259        true,
260        true,
261    ))
262}
263
264/// Scan an explicit list of files instead of walking directories.
265///
266/// Used by `--diff`, where git already produced the exact file set. Paths that
267/// no longer exist or that slopguard cannot parse are silently skipped.
268///
269/// Cross-file rules are not evaluated here. `--diff` sees only the changed
270/// files, so the project index would be incomplete and its impl counts wrong.
271/// Symbol contributions are still collected and cached, so a later full scan is
272/// not penalised.
273pub fn scan_files(
274    files: &[PathBuf],
275    rules: &[Rule],
276    config: &Config,
277) -> Result<ScanResult, ScanError> {
278    let test_paths = TestPaths::new(&config.scan.test_paths)?;
279    let compiled = compile_rules(rules, test_paths)?;
280    let ignores = build_glob_set(&config.scan.ignores)?;
281    Ok(scan_collected(
282        explicit_files(files, &ignores),
283        &compiled,
284        false,
285    ))
286}
287
288/// Cached variant of [`scan_files`]. Cache pruning is skipped: a partial scan
289/// must not evict the entries of files it did not look at.
290///
291/// Cross-file rules are not evaluated here. `--diff` sees only the changed
292/// files, so the project index would be incomplete and its impl counts wrong.
293/// Symbol contributions are still collected and cached, so a later full scan is
294/// not penalised.
295pub fn scan_files_cached(
296    files: &[PathBuf],
297    rules: &[Rule],
298    config: &Config,
299    cache_dir: &Path,
300) -> Result<ScanResult, ScanError> {
301    let test_paths = TestPaths::new(&config.scan.test_paths)?;
302    let compiled = compile_rules(rules, test_paths)?;
303    let ignores = build_glob_set(&config.scan.ignores)?;
304    Ok(scan_collected_cached(
305        explicit_files(files, &ignores),
306        &compiled,
307        rules,
308        cache_dir,
309        false,
310        false,
311    ))
312}