1use 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
26pub 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
39fn 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
47struct ScanSet {
50 files: Vec<(PathBuf, SupportLang)>,
51 index_only: Vec<(PathBuf, SupportLang)>,
52}
53
54impl ScanSet {
55 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 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 fn reported(&self) -> HashSet<&Path> {
83 self.files.iter().map(|(path, _)| path.as_path()).collect()
84 }
85}
86
87fn 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
162fn 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 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(¤t_rules_hash);
189 let rules_changed = store
190 .check_rules_changed(¤t_rules_hash)
191 .unwrap_or(true);
192
193 struct FileWork<'a> {
194 path: &'a Path,
195 lang: SupportLang,
196 hash: String,
197 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 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(¤t_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
308pub 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
318pub 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
345pub 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
361pub 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
379pub 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
397pub 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
411fn 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
433pub 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
452pub 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}