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