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::collections::{HashMap, HashSet};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use ast_grep_language::SupportLang;
9use rayon::prelude::*;
10
11use crate::cache::{file_content_hash, rules_hash, CacheEntry, CacheStore};
12use crate::config::Config;
13use crate::cross_file::FileSymbols;
14use crate::disable::unused_disable_findings;
15use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
16use crate::resolution::FileImports;
17use crate::rule::{Rule, Severity};
18
19use super::project::{append_cross_file, index_only_files};
20use super::{
21    append_resolution, build_glob_set, compile_rules, explicit_files, scan_file, walk_files,
22    CompiledRules, FileScan, ScanError,
23};
24
25/// Count errors and warnings in one pass.
26pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
27    let mut errors = 0;
28    let mut warnings = 0;
29    for finding in findings {
30        match finding.severity {
31            Severity::Error => errors += 1,
32            Severity::Warning => warnings += 1,
33        }
34    }
35    (errors, warnings)
36}
37
38/// Sort findings by location and drop duplicates from overlapping rules.
39fn normalize_findings(findings: &mut Vec<Finding>) {
40    findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
41    findings.dedup_by(|a, b| {
42        a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
43    });
44}
45
46/// The files a scan reports on, plus the ones it only reads to complete the
47/// cross-file index (see [`index_only_files`]).
48struct ScanSet {
49    files: Vec<(PathBuf, SupportLang)>,
50    index_only: Vec<(PathBuf, SupportLang)>,
51}
52
53impl ScanSet {
54    /// A directory walk of `paths`. File targets among them widen the index to
55    /// their project.
56    fn walk(
57        paths: &[PathBuf],
58        compiled: &CompiledRules,
59        config: &Config,
60    ) -> Result<Self, ScanError> {
61        let ignores = build_glob_set(&config.scan.ignores)?;
62        let files = walk_files(paths, &ignores);
63        let index_only = index_only_files(compiled, paths, &files, &ignores);
64        Ok(Self { files, index_only })
65    }
66
67    /// An explicit file list (`--diff`). Every file widens the index to its
68    /// project.
69    fn explicit(
70        targets: &[PathBuf],
71        compiled: &CompiledRules,
72        config: &Config,
73    ) -> Result<Self, ScanError> {
74        let ignores = build_glob_set(&config.scan.ignores)?;
75        let files = explicit_files(targets, &ignores);
76        let index_only = index_only_files(compiled, targets, &files, &ignores);
77        Ok(Self { files, index_only })
78    }
79
80    /// The paths whose findings are reported.
81    fn reported(&self) -> HashSet<&Path> {
82        self.files.iter().map(|(path, _)| path.as_path()).collect()
83    }
84}
85
86/// Scan a collected file set without touching the cache.
87///
88/// Index-only files are parsed for their symbols alone: nothing of theirs is
89/// reported, and nothing is stored.
90fn scan_collected(set: &ScanSet, compiled: &CompiledRules, apply_disable: bool) -> ScanResult {
91    let (per_file, project) = compiled.engines(apply_disable);
92    let collect_symbols = project.iter().any(|e| e.needs_symbols());
93    let collect_imports = !compiled.resolution.is_empty();
94    let scans: Vec<(PathBuf, FileScan)> = set
95        .files
96        .par_iter()
97        .map(|(path, lang)| {
98            let scan = scan_file(
99                path,
100                *lang,
101                &per_file,
102                &compiled.test_paths,
103                collect_symbols,
104                collect_imports,
105                apply_disable,
106            );
107            (path.clone(), scan)
108        })
109        .collect();
110
111    let mut findings: Vec<Finding> = Vec::new();
112    let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
113    let mut import_contributions: Vec<(PathBuf, FileImports)> = Vec::new();
114    for (path, scan) in scans {
115        findings.extend(scan.findings);
116        if collect_symbols {
117            contributions.push((path.clone(), scan.symbols));
118        }
119        if collect_imports {
120            import_contributions.push((path, scan.imports));
121        }
122    }
123    if collect_symbols {
124        let test_paths = &compiled.test_paths;
125        let extra: Vec<(PathBuf, FileSymbols)> = set
126            .index_only
127            .par_iter()
128            .map(|(path, lang)| {
129                let scan = scan_file(path, *lang, &[], test_paths, true, false, apply_disable);
130                (path.clone(), scan.symbols)
131            })
132            .collect();
133        contributions.extend(extra);
134    }
135    append_cross_file(&mut findings, &project, &contributions, &set.reported());
136    append_resolution(
137        &mut findings,
138        compiled,
139        &import_contributions,
140        apply_disable,
141    );
142    normalize_findings(&mut findings);
143
144    let (errors, warnings) = count_severities(&findings);
145
146    ScanResult {
147        findings,
148        stats: ScanStats {
149            errors,
150            warnings,
151            total: errors + warnings,
152            files_scanned: set.files.len(),
153            baseline_filtered: 0,
154            diff_base: None,
155            files_changed: None,
156        },
157        cache_stats: None,
158    }
159}
160
161/// Scan a collected file set, reusing cached findings for files whose content
162/// hash is unchanged.
163///
164/// Index-only files go through the cache like the others (a miss is scanned in
165/// full and stored, which warms the cache for the next run), but only their
166/// symbols are used and they are left out of the stats.
167///
168/// `prune` drops cache entries that no longer correspond to a scanned file. A
169/// partial scan must pass `false`: it never saw the other files, so their
170/// entries are still valid.
171fn scan_collected_cached(
172    set: &ScanSet,
173    compiled: &CompiledRules,
174    rules: &[Rule],
175    cache_dir: &Path,
176    prune: bool,
177) -> ScanResult {
178    // The cached path always applies disable comments: the raw pass used by
179    // `--report-unused-disable` runs uncached, so the cache only ever stores
180    // suppressed findings.
181    let (per_file, project) = compiled.engines(true);
182    let collect_symbols = project.iter().any(|e| e.needs_symbols());
183    let collect_imports = !compiled.resolution.is_empty();
184    let current_rules_hash = rules_hash(rules);
185    let store = CacheStore::with_dir(cache_dir.to_path_buf()).scoped_to_rules(&current_rules_hash);
186    let rules_changed = store
187        .check_rules_changed(&current_rules_hash)
188        .unwrap_or(true);
189
190    struct FileWork<'a> {
191        path: &'a Path,
192        lang: SupportLang,
193        hash: String,
194        /// False for an index-only file: its findings are not reported.
195        reported: bool,
196    }
197
198    let reported = set.files.iter().map(|file| (file, true));
199    let index_only = set.index_only.iter().map(|file| (file, false));
200    let work: Vec<FileWork> = reported
201        .chain(index_only)
202        .filter_map(|((path, lang), reported)| {
203            let content = fs::read(path).ok()?;
204            Some(FileWork {
205                path,
206                lang: *lang,
207                hash: file_content_hash(&content),
208                reported,
209            })
210        })
211        .collect();
212
213    let mut cached_count = 0usize;
214    let mut changed_count = 0usize;
215    let mut all_findings: Vec<Finding> = Vec::new();
216    let mut contributions: Vec<(PathBuf, FileSymbols)> = Vec::new();
217    let mut import_contributions: Vec<(PathBuf, FileImports)> = Vec::new();
218    let mut take = |file: &FileWork, entry: CacheEntry| {
219        if file.reported {
220            all_findings.extend(entry.findings);
221            if collect_imports {
222                import_contributions.push((file.path.to_path_buf(), entry.imports));
223            }
224        }
225        if collect_symbols {
226            contributions.push((file.path.to_path_buf(), entry.symbols));
227        }
228    };
229
230    let mut to_scan: Vec<&FileWork> = Vec::new();
231    for file in &work {
232        match (!rules_changed).then(|| store.get(&file.hash)).flatten() {
233            Some(entry) => {
234                cached_count += usize::from(file.reported);
235                take(file, entry);
236            }
237            None => {
238                changed_count += usize::from(file.reported);
239                to_scan.push(file);
240            }
241        }
242    }
243
244    let scanned: Vec<(&FileWork, FileScan)> = to_scan
245        .into_par_iter()
246        .map(|file| {
247            let scan = scan_file(
248                file.path,
249                file.lang,
250                &per_file,
251                &compiled.test_paths,
252                collect_symbols,
253                collect_imports,
254                true,
255            );
256            (file, scan)
257        })
258        .collect();
259
260    for (file, scan) in scanned {
261        let entry = CacheEntry {
262            findings: scan.findings,
263            symbols: scan.symbols,
264            imports: scan.imports,
265        };
266        store.put(&file.hash, &entry).ok();
267        take(file, entry);
268    }
269
270    // Cross-file and resolution evaluation are never cached: they re-run on every
271    // scan (cross-file from the assembled index, resolution from the manifests on
272    // disk, so a manifest edit is reflected even for an unchanged file).
273    append_cross_file(&mut all_findings, &project, &contributions, &set.reported());
274    append_resolution(&mut all_findings, compiled, &import_contributions, true);
275
276    if prune {
277        let current_hashes: Vec<String> = work.iter().map(|file| file.hash.clone()).collect();
278        store.cleanup(&current_hashes).ok();
279    }
280
281    normalize_findings(&mut all_findings);
282
283    let (errors, warnings) = count_severities(&all_findings);
284
285    let files_scanned = cached_count + changed_count;
286
287    ScanResult {
288        findings: all_findings,
289        stats: ScanStats {
290            errors,
291            warnings,
292            total: errors + warnings,
293            files_scanned,
294            baseline_filtered: 0,
295            diff_base: None,
296            files_changed: None,
297        },
298        cache_stats: Some(CacheStats {
299            cached: cached_count,
300            changed: changed_count,
301        }),
302    }
303}
304
305/// Scan the given paths for rule violations.
306///
307/// A file among `paths` (as the pre-commit hook passes them) is reported on its
308/// own, but the cross-file index still covers its whole project.
309pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
310    let compiled = compile_rules(rules, config)?;
311    let set = ScanSet::walk(paths, &compiled, config)?;
312    Ok(scan_collected(&set, &compiled, true))
313}
314
315/// Scan with file-level caching. Files whose content hash matches a cached
316/// entry (and whose rules have not changed) return cached findings without
317/// reparsing.
318///
319/// `cache_dir` is the directory where cache files are stored. When a custom
320/// cache directory is specified (via `--cache-dir`, `SLOPGUARD_CACHE_DIR`,
321/// or `scan.cache_dir` in config), pass it directly. Otherwise pass the
322/// project root and use `CacheStore::new` which appends `.slopguard-cache`.
323///
324/// Stale entries are pruned only when every path is a directory: a file target
325/// (the pre-commit hook) is a partial scan and must keep the other entries.
326pub fn scan_cached(
327    paths: &[PathBuf],
328    rules: &[Rule],
329    config: &Config,
330    cache_dir: &Path,
331) -> Result<ScanResult, ScanError> {
332    let compiled = compile_rules(rules, config)?;
333    let set = ScanSet::walk(paths, &compiled, config)?;
334    let prune = paths.iter().all(|path| path.is_dir());
335    Ok(scan_collected_cached(
336        &set, &compiled, rules, cache_dir, prune,
337    ))
338}
339
340/// Scan an explicit list of files instead of walking directories.
341///
342/// Used by `--diff`, where git already produced the exact file set. Paths that
343/// no longer exist or that slopguard cannot parse are silently skipped.
344///
345/// Cross-file rules still see the whole project: the index is completed from
346/// the project each file belongs to, and only the findings located in the
347/// given files are reported.
348pub fn scan_files(
349    files: &[PathBuf],
350    rules: &[Rule],
351    config: &Config,
352) -> Result<ScanResult, ScanError> {
353    let compiled = compile_rules(rules, config)?;
354    let set = ScanSet::explicit(files, &compiled, config)?;
355    Ok(scan_collected(&set, &compiled, true))
356}
357
358/// Compute `unused-disable` findings for the given walked paths: a full raw scan
359/// (disable comments not applied) whose findings reveal which directives would
360/// have suppressed something. Cross-file rules run, as in [`scan`].
361///
362/// This is the walk (non-diff) variant used by `--report-unused-disable`. It is
363/// uncached on purpose: the cache stores suppressed findings, which cannot tell
364/// which directives were consumed.
365pub fn scan_unused_disables(
366    paths: &[PathBuf],
367    rules: &[Rule],
368    config: &Config,
369) -> Result<Vec<Finding>, ScanError> {
370    let compiled = compile_rules(rules, config)?;
371    let set = ScanSet::walk(paths, &compiled, config)?;
372    let raw = scan_collected(&set, &compiled, false);
373    Ok(unused_from_raw(&set.files, &raw.findings))
374}
375
376/// Explicit-file (diff) variant of [`scan_unused_disables`]. Cross-file rules
377/// run over the whole project, as in [`scan_files`], and only the changed files
378/// are checked for unused directives.
379pub fn scan_files_unused_disables(
380    files: &[PathBuf],
381    rules: &[Rule],
382    config: &Config,
383) -> Result<Vec<Finding>, ScanError> {
384    let compiled = compile_rules(rules, config)?;
385    let set = ScanSet::explicit(files, &compiled, config)?;
386    let raw = scan_collected(&set, &compiled, false);
387    Ok(unused_from_raw(&set.files, &raw.findings))
388}
389
390/// Group raw findings by file and, for each collected file, report the disable
391/// directives that suppressed nothing. Files that cannot be read are skipped.
392fn unused_from_raw(files: &[(PathBuf, SupportLang)], raw: &[Finding]) -> Vec<Finding> {
393    let mut by_file: HashMap<&Path, Vec<Finding>> = HashMap::new();
394    for finding in raw {
395        by_file
396            .entry(finding.file.as_path())
397            .or_default()
398            .push(finding.clone());
399    }
400    let empty: Vec<Finding> = Vec::new();
401    let mut out = Vec::new();
402    for (path, _lang) in files {
403        let Ok(source) = fs::read_to_string(path) else {
404            continue;
405        };
406        let file_raw = by_file.get(path.as_path()).unwrap_or(&empty);
407        out.extend(unused_disable_findings(path, &source, file_raw));
408    }
409    out
410}
411
412/// Cached variant of [`scan_files`]. Cache pruning is skipped: a partial scan
413/// must not evict the entries of files it did not look at.
414pub fn scan_files_cached(
415    files: &[PathBuf],
416    rules: &[Rule],
417    config: &Config,
418    cache_dir: &Path,
419) -> Result<ScanResult, ScanError> {
420    let compiled = compile_rules(rules, config)?;
421    let set = ScanSet::explicit(files, &compiled, config)?;
422    Ok(scan_collected_cached(
423        &set, &compiled, rules, cache_dir, false,
424    ))
425}