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