1use std::collections::HashMap;
2use std::fs::{self, read_to_string};
3use std::path::{Path, PathBuf};
4
5use ast_grep_config::{
6 CombinedScan, GlobalRules, RuleCollection, RuleConfig, RuleConfigError, SerializableRuleConfig,
7};
8use ast_grep_core::tree_sitter::LanguageExt;
9use ast_grep_core::Language as AstLanguage;
10use ast_grep_language::SupportLang;
11use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
12use ignore::WalkBuilder;
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_yaml::with::singleton_map_recursive;
16use serde_yaml::{to_value, Value};
17use strum::IntoEnumIterator;
18use thiserror::Error;
19
20use crate::cache::{file_content_hash, rules_hash, CacheStore};
21use crate::config::Config;
22use crate::disable::filter_disabled;
23use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
24use crate::rule::{Language, Rule, RuleId, Severity};
25use crate::test_filter::CfgTestRanges;
26
27#[derive(Debug, Error)]
28pub enum ScanError {
29 #[error("failed to compile rule '{id}': {source}")]
30 RuleCompile {
31 id: RuleId,
32 #[source]
33 source: RuleConfigError,
34 },
35
36 #[error("invalid glob pattern: {0}")]
37 Glob(#[from] GlobError),
38}
39
40#[derive(Serialize)]
43pub struct AstGrepRule<'a> {
44 pub id: &'a str,
45 pub language: SupportLang,
46 pub severity: &'a Severity,
47 pub message: &'a str,
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub note: Option<&'a str>,
50 pub rule: &'a Value,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub files: Option<&'a [String]>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub ignores: Option<&'a [String]>,
55}
56
57struct CompiledRules<'a> {
60 collection: RuleCollection<SupportLang>,
61 by_id: HashMap<(&'a str, Language), &'a Rule>,
62}
63
64fn extension_to_lang(path: &Path) -> Option<SupportLang> {
65 SupportLang::from_path(path)
66 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
67}
68
69fn support_lang_to_language(lang: SupportLang) -> Language {
70 match lang {
71 SupportLang::Rust => Language::Rust,
72 _ => Language::TypeScript,
73 }
74}
75
76pub(crate) fn compile_ast_grep_rule(
77 rule: &AstGrepRule,
78) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
79 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
80 .and_then(singleton_map_recursive::deserialize)
81 .map_err(RuleConfigError::from)?;
82 RuleConfig::try_from(inner, &GlobalRules::default())
83}
84
85fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
86 let ast_grep_rule = AstGrepRule {
87 id: rule.id.as_str(),
88 language: lang,
89 severity: &rule.severity,
90 message: &rule.message,
91 note: rule.note.as_deref(),
92 rule: &rule.rule,
93 files: rule.files.as_deref(),
94 ignores: rule.ignores.as_deref(),
95 };
96 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
97 id: rule.id.clone(),
98 source,
99 })
100}
101
102fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
103 let configs = rules
104 .iter()
105 .flat_map(|rule| {
106 rule.language
107 .ast_grep_langs()
108 .iter()
109 .map(move |lang| compile_rule(rule, *lang))
110 })
111 .collect::<Result<Vec<_>, _>>()?;
112 Ok(CompiledRules {
113 collection: RuleCollection::try_new(configs)?,
114 by_id: rules
115 .iter()
116 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
117 .collect::<HashMap<_, _>>(),
118 })
119}
120
121fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
122 let mut builder = GlobSetBuilder::new();
123 for pattern in patterns {
124 builder.add(Glob::new(pattern)?);
125 }
126 builder.build()
127}
128
129fn scan_file(path: &Path, lang: SupportLang, rules: &CompiledRules) -> Vec<Finding> {
130 let applicable = rules.collection.get_rule_from_lang(path, lang);
131 if applicable.is_empty() {
132 return Vec::new();
133 }
134 let Ok(source) = read_to_string(path) else {
135 return Vec::new();
136 };
137
138 let root = lang.ast_grep(&source);
139 let combined = CombinedScan::new(applicable);
140 let result = combined.scan(&root, false);
141
142 let cfg_test = (lang == SupportLang::Rust && !result.matches.is_empty())
143 .then(|| CfgTestRanges::from_root(&root));
144 let cfg_test = cfg_test.as_ref();
145
146 let findings = result
147 .matches
148 .into_iter()
149 .filter_map(|(config, matches)| {
150 let rule_lang = support_lang_to_language(lang);
151 let rule = rules.by_id.get(&(config.id.as_str(), rule_lang))?;
152 Some((*rule, matches))
153 })
154 .flat_map(|(rule, matches)| {
155 let skip_test_code = rule.skip_test_code;
156 matches
157 .into_iter()
158 .map(move |node_match| {
159 let start = node_match.start_pos();
160 let end = node_match.end_pos();
161 Finding {
162 rule_id: rule.id.clone(),
163 severity: rule.severity.clone(),
164 category: rule.category.clone().unwrap_or_default(),
165 message: rule.message.clone(),
166 note: rule.note.clone(),
167 fix: rule.fix.clone(),
168 file: path.to_path_buf(),
169 line: start.line() + 1,
170 column: start.byte_point().1 + 1,
171 end_line: end.line() + 1,
172 end_column: end.byte_point().1 + 1,
173 matched_text: node_match.text().to_string(),
174 confidence: None,
175 }
176 })
177 .filter(move |f| {
178 !(skip_test_code && cfg_test.is_some_and(|r| r.contains_line(f.line)))
179 })
180 })
181 .collect();
182
183 filter_disabled(findings, &source)
184}
185
186pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
188 let compiled = compile_rules(rules)?;
189 let ignores = build_glob_set(&config.scan.ignores)?;
190
191 let files: Vec<(PathBuf, SupportLang)> = paths
192 .iter()
193 .flat_map(|path| WalkBuilder::new(path).build().flatten())
194 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
195 .map(|entry| entry.into_path())
196 .filter(|path| !ignores.is_match(path))
197 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
198 .collect();
199
200 let mut findings: Vec<Finding> = files
201 .par_iter()
202 .flat_map_iter(|(path, lang)| scan_file(path, *lang, &compiled))
203 .collect();
204 findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
205 findings.dedup_by(|a, b| {
206 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
207 });
208
209 let (errors, warnings) =
210 findings
211 .iter()
212 .fold((0, 0), |(errors, warnings), f| match f.severity {
213 Severity::Error => (errors + 1, warnings),
214 Severity::Warning => (errors, warnings + 1),
215 });
216
217 Ok(ScanResult {
218 findings,
219 stats: ScanStats {
220 errors,
221 warnings,
222 total: errors + warnings,
223 files_scanned: files.len(),
224 },
225 cache_stats: None,
226 })
227}
228
229pub fn scan_cached(
238 paths: &[PathBuf],
239 rules: &[Rule],
240 config: &Config,
241 cache_dir: &Path,
242) -> Result<ScanResult, ScanError> {
243 let compiled = compile_rules(rules)?;
244 let ignores = build_glob_set(&config.scan.ignores)?;
245
246 let files: Vec<(PathBuf, SupportLang)> = paths
247 .iter()
248 .flat_map(|path| WalkBuilder::new(path).build().flatten())
249 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
250 .map(|entry| entry.into_path())
251 .filter(|path| !ignores.is_match(path))
252 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
253 .collect();
254
255 let store = CacheStore::with_dir(cache_dir.to_path_buf());
256 let current_rules_hash = rules_hash(rules);
257 let rules_changed = store
258 .check_rules_changed(¤t_rules_hash)
259 .unwrap_or(true);
260
261 let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
262 .into_iter()
263 .filter_map(|(path, lang)| {
264 let content = fs::read(&path).ok()?;
265 let hash = file_content_hash(&content);
266 Some((path, lang, content, hash))
267 })
268 .collect();
269
270 let mut cached_count = 0usize;
271 let mut changed_count = 0usize;
272 let mut all_findings: Vec<Finding> = Vec::new();
273
274 struct FileWork {
275 path: PathBuf,
276 lang: SupportLang,
277 hash: String,
278 }
279
280 let mut to_scan: Vec<FileWork> = Vec::new();
281
282 for (path, lang, _content, hash) in file_contents.iter() {
283 if !rules_changed {
284 if let Some(cached_findings) = store.get(hash) {
285 cached_count += 1;
286 all_findings.extend(cached_findings);
287 continue;
288 }
289 }
290 changed_count += 1;
291 to_scan.push(FileWork {
292 path: path.clone(),
293 lang: *lang,
294 hash: hash.clone(),
295 });
296 }
297
298 let scanned_findings: Vec<(String, Vec<Finding>)> = to_scan
299 .par_iter()
300 .map(|work| {
301 let findings = scan_file(&work.path, work.lang, &compiled);
302 (work.hash.clone(), findings)
303 })
304 .collect();
305
306 for (hash, findings) in scanned_findings {
307 store.put(&hash, &findings).ok();
308 all_findings.extend(findings);
309 }
310
311 let current_hashes: Vec<String> = file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
312 store.cleanup(¤t_hashes).ok();
313
314 all_findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
315 all_findings.dedup_by(|a, b| {
316 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
317 });
318
319 let (errors, warnings) =
320 all_findings
321 .iter()
322 .fold((0, 0), |(errors, warnings), f| match f.severity {
323 Severity::Error => (errors + 1, warnings),
324 Severity::Warning => (errors, warnings + 1),
325 });
326
327 let files_scanned = cached_count + changed_count;
328
329 Ok(ScanResult {
330 findings: all_findings,
331 stats: ScanStats {
332 errors,
333 warnings,
334 total: errors + warnings,
335 files_scanned,
336 },
337 cache_stats: Some(CacheStats {
338 cached: cached_count,
339 changed: changed_count,
340 }),
341 })
342}
343
344#[cfg(test)]
345mod tests {
346 use std::fs::{create_dir, write};
347
348 use tempfile::tempdir;
349
350 use super::*;
351 use crate::rule::{load_builtin_rules, parse_rule, RuleId};
352
353 fn unwrap_rule() -> Rule {
354 parse_rule(
355 r#"
356id: test-unwrap
357language: rust
358severity: error
359category: correctness
360message: ".unwrap() forbidden"
361rule:
362 kind: call_expression
363 regex: '\.unwrap\(\)\s*$'
364"#,
365 )
366 .unwrap()
367 }
368
369 fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
370 scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
371 }
372
373 #[test]
374 fn scan_finds_unwrap_violation() {
375 let dir = tempdir().unwrap();
376 write(
377 dir.path().join("main.rs"),
378 "fn main() {\n foo().unwrap();\n}\n",
379 )
380 .unwrap();
381 let result = scan_dir(dir.path(), &[unwrap_rule()]);
382 assert_eq!(result.findings.len(), 1);
383 let f = &result.findings[0];
384 assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
385 assert_eq!(f.severity, Severity::Error);
386 assert_eq!(f.line, 2);
387 assert!(f.matched_text.contains("unwrap()"));
388 }
389
390 #[test]
391 fn scan_clean_file_no_findings() {
392 let dir = tempdir().unwrap();
393 write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
394 let result = scan_dir(dir.path(), &[unwrap_rule()]);
395 assert_eq!(result.findings.len(), 0);
396 assert_eq!(result.stats.files_scanned, 1);
397 }
398
399 #[test]
400 fn scan_unsupported_language_ignored() {
401 let dir = tempdir().unwrap();
402 write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
403 write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
404 let result = scan_dir(dir.path(), &[unwrap_rule()]);
405 assert_eq!(result.findings.len(), 0);
406 assert_eq!(result.stats.files_scanned, 1);
407 }
408
409 #[test]
410 fn scan_explicit_file_path() {
411 let dir = tempdir().unwrap();
412 let file = dir.path().join("main.rs");
413 write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
414 let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
415 assert_eq!(result.findings.len(), 1);
416 assert_eq!(result.stats.files_scanned, 1);
417 }
418
419 #[test]
420 fn rule_ignores_glob_respected() {
421 let dir = tempdir().unwrap();
422 let tests_dir = dir.path().join("tests");
423 create_dir(&tests_dir).unwrap();
424 write(
425 tests_dir.join("test_main.rs"),
426 "fn t() { foo().unwrap(); }\n",
427 )
428 .unwrap();
429 write(
430 dir.path().join("main.rs"),
431 "fn main() { foo().unwrap(); }\n",
432 )
433 .unwrap();
434 let rule = parse_rule(
435 r#"
436id: ign
437language: rust
438severity: error
439category: correctness
440message: x
441rule:
442 kind: call_expression
443 regex: '\.unwrap\(\)\s*$'
444ignores:
445 - "**/tests/**"
446"#,
447 )
448 .unwrap();
449 let result = scan_dir(dir.path(), &[rule]);
450 assert_eq!(result.findings.len(), 1);
451 assert!(result.findings[0]
452 .file
453 .to_string_lossy()
454 .contains("main.rs"));
455 }
456
457 #[test]
458 fn config_ignores_respected() {
459 let dir = tempdir().unwrap();
460 let gen = dir.path().join("generated");
461 create_dir(&gen).unwrap();
462 write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
463 write(
464 dir.path().join("main.rs"),
465 "fn main() { foo().unwrap(); }\n",
466 )
467 .unwrap();
468 let mut cfg = Config::default();
469 cfg.scan.ignores = vec!["**/generated/**".to_string()];
470 let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
471 assert_eq!(result.stats.files_scanned, 1);
472 assert_eq!(result.findings.len(), 1);
473 }
474
475 #[test]
476 fn config_invalid_glob_is_an_error() {
477 let dir = tempdir().unwrap();
478 let mut cfg = Config::default();
479 cfg.scan.ignores = vec!["[".to_string()];
480 let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
481 assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
482 }
483
484 #[test]
485 fn rule_without_kind_fails_to_compile() {
486 let dir = tempdir().unwrap();
487 let rule = parse_rule(
488 r#"
489id: no-kind
490language: rust
491severity: error
492category: correctness
493message: x
494rule:
495 regex: 'unwrap'
496"#,
497 )
498 .unwrap();
499 let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
500 assert!(
501 matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
502 "{err:?}"
503 );
504 }
505
506 #[test]
507 fn stats_counting_accurate() {
508 let dir = tempdir().unwrap();
509 write(
510 dir.path().join("a.rs"),
511 "fn f() { foo().unwrap(); bar().unwrap(); }\n",
512 )
513 .unwrap();
514 let result = scan_dir(dir.path(), &[unwrap_rule()]);
515 assert_eq!(result.stats.errors, 2);
516 assert_eq!(result.stats.warnings, 0);
517 assert_eq!(result.stats.total, 2);
518 assert_eq!(result.stats.files_scanned, 1);
519 }
520
521 #[test]
522 fn scan_empty_file() {
523 let dir = tempdir().unwrap();
524 write(dir.path().join("empty.rs"), "").unwrap();
525 let result = scan_dir(dir.path(), &[unwrap_rule()]);
526 assert_eq!(result.findings.len(), 0);
527 assert_eq!(result.stats.files_scanned, 1);
528 }
529
530 #[test]
531 fn scan_complex_rule() {
532 let rule = parse_rule(
533 r#"
534id: dbg
535language: rust
536severity: error
537category: security
538message: debug on secret
539rule:
540 kind: attribute_item
541 all:
542 - regex: 'derive\([^)]*Debug'
543 - precedes:
544 kind: struct_item
545 has:
546 kind: field_identifier
547 regex: '^(api_key|password|secret)$'
548 stopBy: end
549"#,
550 )
551 .unwrap();
552 let dir = tempdir().unwrap();
553 write(
554 dir.path().join("t.rs"),
555 "#[derive(Debug)]\nstruct Config {\n api_key: String,\n}\n",
556 )
557 .unwrap();
558 let result = scan_dir(dir.path(), &[rule]);
559 assert_eq!(result.findings.len(), 1);
560 assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
561 }
562
563 #[test]
564 fn typescript_rule_applies_to_tsx_files() {
565 let rule = parse_rule(
566 r#"
567id: ts-any
568language: typescript
569severity: warning
570category: slop
571message: no any
572rule:
573 kind: predefined_type
574 regex: '^any$'
575"#,
576 )
577 .unwrap();
578 let dir = tempdir().unwrap();
579 write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
580 write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
581 let result = scan_dir(dir.path(), &[rule]);
582 assert_eq!(result.stats.files_scanned, 2);
583 assert_eq!(result.stats.warnings, 2);
584 }
585
586 #[test]
587 fn scan_with_disable_comment_filters_findings() {
588 let dir = tempdir().unwrap();
589 write(
590 dir.path().join("main.rs"),
591 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n bar().unwrap();\n}\n",
592 )
593 .unwrap();
594 let result = scan_dir(dir.path(), &[unwrap_rule()]);
595 assert_eq!(
596 result.findings.len(),
597 1,
598 "only the non-disabled unwrap should remain"
599 );
600 assert_eq!(result.findings[0].line, 4);
601 }
602
603 #[test]
604 fn scan_with_disable_rule_id_filters_only_that_rule() {
605 let dir = tempdir().unwrap();
606 write(
607 dir.path().join("main.rs"),
608 "fn main() {\n // slopguard-disable-next-line test-unwrap\n foo().unwrap();\n}\n",
609 )
610 .unwrap();
611 let result = scan_dir(dir.path(), &[unwrap_rule()]);
612 assert_eq!(result.findings.len(), 0);
613 }
614
615 fn unwrap_rule_skipping_test_code() -> Rule {
616 parse_rule(
617 r#"
618id: test-unwrap
619language: rust
620severity: error
621category: correctness
622message: ".unwrap() forbidden"
623rule:
624 kind: call_expression
625 regex: '\.unwrap\(\)\s*$'
626skip_test_code: true
627"#,
628 )
629 .unwrap()
630 }
631
632 #[test]
633 fn cfg_test_block_excluded_by_post_filter() {
634 let dir = tempdir().unwrap();
635 write(
636 dir.path().join("main.rs"),
637 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
638 )
639 .unwrap();
640 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
641 assert_eq!(
642 result.findings.len(),
643 1,
644 "should only find the prod unwrap, not the one inside #[cfg(test)]"
645 );
646 assert_eq!(result.findings[0].line, 2);
647 }
648
649 #[test]
650 fn cfg_test_kept_without_skip_test_code() {
651 let dir = tempdir().unwrap();
652 write(
653 dir.path().join("main.rs"),
654 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
655 )
656 .unwrap();
657 let result = scan_dir(dir.path(), &[unwrap_rule()]);
658 assert_eq!(
659 result.findings.len(),
660 2,
661 "rule without skip_test_code should find both"
662 );
663 }
664
665 #[test]
666 fn all_builtin_rules_compile() {
667 let rules = load_builtin_rules().unwrap();
668 assert!(
669 rules.len() >= 55,
670 "expected at least 55 rules, got {}",
671 rules.len()
672 );
673 let compiled = compile_rules(&rules).expect("all builtin rules should compile");
674 assert_eq!(compiled.by_id.len(), rules.len());
675 }
676
677 #[test]
678 fn scan_with_cache() {
679 let src_dir = tempdir().unwrap();
680 let cache_dir = tempdir().unwrap();
681 let cache_path = cache_dir.path().join("cache");
682 write(
683 src_dir.path().join("main.rs"),
684 "fn main() {\n foo().unwrap();\n}\n",
685 )
686 .unwrap();
687
688 let rules = [unwrap_rule()];
689 let config = Config::default();
690
691 let result1 = scan_cached(
692 &[src_dir.path().to_path_buf()],
693 &rules,
694 &config,
695 &cache_path,
696 )
697 .unwrap();
698
699 assert_eq!(result1.findings.len(), 1);
700 let cache_stats1 = result1.cache_stats.as_ref().unwrap();
701 assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
702 assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
703
704 let result2 = scan_cached(
705 &[src_dir.path().to_path_buf()],
706 &rules,
707 &config,
708 &cache_path,
709 )
710 .unwrap();
711
712 assert_eq!(result2.findings.len(), 1);
713 let cache_stats2 = result2.cache_stats.as_ref().unwrap();
714 assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
715 assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
716 }
717}