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_core::{AstGrep, Doc};
11use ast_grep_language::SupportLang;
12use globset::{Error as GlobError, Glob, GlobSet, GlobSetBuilder};
13use ignore::WalkBuilder;
14use rayon::prelude::*;
15use serde::Serialize;
16use serde_yaml::with::singleton_map_recursive;
17use serde_yaml::{to_value, Value};
18use strum::IntoEnumIterator;
19use thiserror::Error;
20
21use crate::cache::{file_content_hash, rules_hash, CacheStore};
22use crate::config::Config;
23use crate::disable::filter_disabled;
24use crate::finding::{CacheStats, Finding, ScanResult, ScanStats};
25use crate::metric::{self, Metric};
26use crate::rule::{Language, Rule, RuleId, Severity};
27use crate::test_filter::CfgTestRanges;
28
29#[derive(Debug, Error)]
30pub enum ScanError {
31 #[error("failed to compile rule '{id}': {source}")]
32 RuleCompile {
33 id: RuleId,
34 #[source]
35 source: RuleConfigError,
36 },
37
38 #[error("invalid glob pattern: {0}")]
39 Glob(#[from] GlobError),
40
41 #[error("rule '{id}': metric rule is missing a threshold")]
42 InvalidMetricRule { id: RuleId },
43}
44
45#[derive(Serialize)]
48pub struct AstGrepRule<'a> {
49 pub id: &'a str,
50 pub language: SupportLang,
51 pub severity: &'a Severity,
52 pub message: &'a str,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub note: Option<&'a str>,
55 pub rule: &'a Value,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub files: Option<&'a [String]>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub ignores: Option<&'a [String]>,
60}
61
62struct CompiledRules<'a> {
65 collection: RuleCollection<SupportLang>,
66 by_id: HashMap<(&'a str, Language), &'a Rule>,
67 metrics: Vec<MetricRule<'a>>,
68}
69
70struct MetricRule<'a> {
74 rule: &'a Rule,
75 metric: Metric,
76 threshold: f64,
77 files: Option<GlobSet>,
78 ignores: Option<GlobSet>,
79}
80
81impl MetricRule<'_> {
82 fn applies_to(&self, path: &Path, lang: &Language) -> bool {
83 self.rule.language == *lang
84 && self.files.as_ref().is_none_or(|g| g.is_match(path))
85 && !self.ignores.as_ref().is_some_and(|g| g.is_match(path))
86 }
87}
88
89fn extension_to_lang(path: &Path) -> Option<SupportLang> {
90 SupportLang::from_path(path)
91 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
92}
93
94fn support_lang_to_language(lang: SupportLang) -> Language {
95 match lang {
96 SupportLang::Rust => Language::Rust,
97 _ => Language::TypeScript,
98 }
99}
100
101pub(crate) fn compile_ast_grep_rule(
102 rule: &AstGrepRule,
103) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
104 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
105 .and_then(singleton_map_recursive::deserialize)
106 .map_err(RuleConfigError::from)?;
107 RuleConfig::try_from(inner, &GlobalRules::default())
108}
109
110fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
111 let ast_grep_rule = AstGrepRule {
112 id: rule.id.as_str(),
113 language: lang,
114 severity: &rule.severity,
115 message: &rule.message,
116 note: rule.note.as_deref(),
117 rule: &rule.rule,
118 files: rule.files.as_deref(),
119 ignores: rule.ignores.as_deref(),
120 };
121 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
122 id: rule.id.clone(),
123 source,
124 })
125}
126
127fn build_metric_rule(rule: &Rule) -> Result<MetricRule<'_>, ScanError> {
129 let Some((metric, threshold)) = rule.metric_spec() else {
130 return Err(ScanError::InvalidMetricRule {
131 id: rule.id.clone(),
132 });
133 };
134 Ok(MetricRule {
135 rule,
136 metric,
137 threshold,
138 files: rule.files.as_deref().map(build_glob_set).transpose()?,
139 ignores: rule.ignores.as_deref().map(build_glob_set).transpose()?,
140 })
141}
142
143fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
144 let (metric_rules, ast_rules): (Vec<&Rule>, Vec<&Rule>) =
147 rules.iter().partition(|r| r.is_metric());
148
149 let configs = ast_rules
150 .iter()
151 .copied()
152 .flat_map(|rule| {
153 rule.language
154 .ast_grep_langs()
155 .iter()
156 .map(move |lang| compile_rule(rule, *lang))
157 })
158 .collect::<Result<Vec<_>, _>>()?;
159 Ok(CompiledRules {
160 collection: RuleCollection::try_new(configs)?,
161 by_id: ast_rules
162 .iter()
163 .copied()
164 .map(|rule| ((rule.id.as_str(), rule.language.clone()), rule))
165 .collect::<HashMap<_, _>>(),
166 metrics: metric_rules
167 .into_iter()
168 .map(build_metric_rule)
169 .collect::<Result<Vec<_>, _>>()?,
170 })
171}
172
173fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
174 let mut builder = GlobSetBuilder::new();
175 for pattern in patterns {
176 builder.add(Glob::new(pattern)?);
177 }
178 builder.build()
179}
180
181fn is_test_path(path: &Path) -> bool {
191 let in_test_dir = path.components().any(|c| {
192 let Some(name) = c.as_os_str().to_str() else {
193 return false;
194 };
195 matches!(name, "tests" | "benches" | "examples")
196 || name.ends_with("_tests")
197 || name.ends_with("_test")
198 || name.starts_with("test_")
199 });
200 let test_file_name = path
201 .file_stem()
202 .and_then(|s| s.to_str())
203 .is_some_and(|s| s.ends_with("_test") || s.ends_with("_tests"));
204 in_test_dir || test_file_name
205}
206
207fn metric_findings<D: Doc>(
213 metrics: &[&MetricRule],
214 path: &Path,
215 root: &AstGrep<D>,
216 source: &str,
217 lang: &Language,
218) -> Vec<Finding> {
219 metrics
220 .iter()
221 .filter(|m| !(m.rule.skip_test_code && is_test_path(path)))
222 .filter_map(|m| {
223 let value = metric::compute(m.metric, lang, root, source);
224 if !metric::exceeds(value, m.threshold) {
225 return None;
226 }
227 let rendered = m.metric.format_value(value);
228 Some(Finding {
229 rule_id: m.rule.id.clone(),
230 severity: m.rule.severity.clone(),
231 category: m.rule.category.clone().unwrap_or_default(),
232 message: m.rule.message.replace("$value", &rendered),
233 note: m.rule.note.clone(),
234 fix: m.rule.fix.clone(),
235 file: path.to_path_buf(),
236 line: 1,
237 column: 1,
238 end_line: 1,
239 end_column: 1,
240 matched_text: m.metric.describe(value),
241 confidence: None,
242 escalated: false,
243 })
244 })
245 .collect()
246}
247
248fn scan_file(path: &Path, lang: SupportLang, rules: &CompiledRules) -> Vec<Finding> {
249 let rule_lang = support_lang_to_language(lang);
250 let applicable = rules.collection.get_rule_from_lang(path, lang);
251 let metrics: Vec<&MetricRule> = rules
252 .metrics
253 .iter()
254 .filter(|m| m.applies_to(path, &rule_lang))
255 .collect();
256 if applicable.is_empty() && metrics.is_empty() {
257 return Vec::new();
258 }
259 let Ok(source) = read_to_string(path) else {
260 return Vec::new();
261 };
262
263 let root = lang.ast_grep(&source);
264
265 let mut findings: Vec<Finding> = if applicable.is_empty() {
266 Vec::new()
267 } else {
268 let combined = CombinedScan::new(applicable);
269 let result = combined.scan(&root, false);
270
271 let cfg_test = (lang == SupportLang::Rust && !result.matches.is_empty())
272 .then(|| CfgTestRanges::from_root(&root));
273 let cfg_test = cfg_test.as_ref();
274 let is_test_file = is_test_path(path);
275
276 result
277 .matches
278 .into_iter()
279 .filter_map(|(config, matches)| {
280 let rule = rules.by_id.get(&(config.id.as_str(), rule_lang.clone()))?;
281 Some((*rule, matches))
282 })
283 .flat_map(|(rule, matches)| {
284 let skip_test_code = rule.skip_test_code;
285 matches
286 .into_iter()
287 .map(move |node_match| {
288 let start = node_match.start_pos();
289 let end = node_match.end_pos();
290 Finding {
291 rule_id: rule.id.clone(),
292 severity: rule.severity.clone(),
293 category: rule.category.clone().unwrap_or_default(),
294 message: rule.message.clone(),
295 note: rule.note.clone(),
296 fix: rule.fix.clone(),
297 file: path.to_path_buf(),
298 line: start.line() + 1,
299 column: start.byte_point().1 + 1,
300 end_line: end.line() + 1,
301 end_column: end.byte_point().1 + 1,
302 matched_text: node_match.text().to_string(),
303 confidence: None,
304 escalated: false,
305 }
306 })
307 .filter(move |f| {
308 if !skip_test_code {
309 return true;
310 }
311 let in_test_code =
312 is_test_file || cfg_test.is_some_and(|r| r.contains_line(f.line));
313 !in_test_code
314 })
315 })
316 .collect()
317 };
318
319 findings.extend(metric_findings(&metrics, path, &root, &source, &rule_lang));
320 filter_disabled(findings, &source)
321}
322
323fn walk_files(paths: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
325 paths
326 .iter()
327 .flat_map(|path| WalkBuilder::new(path).build().flatten())
328 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
329 .map(|entry| entry.into_path())
330 .filter(|path| !ignores.is_match(path))
331 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
332 .collect()
333}
334
335fn explicit_files(files: &[PathBuf], ignores: &GlobSet) -> Vec<(PathBuf, SupportLang)> {
341 files
342 .iter()
343 .filter(|path| path.is_file())
344 .filter(|path| !ignores.is_match(path))
345 .filter_map(|path| extension_to_lang(path).map(|lang| (path.clone(), lang)))
346 .collect()
347}
348
349pub fn count_severities(findings: &[Finding]) -> (usize, usize) {
351 let mut errors = 0;
352 let mut warnings = 0;
353 for finding in findings {
354 match finding.severity {
355 Severity::Error => errors += 1,
356 Severity::Warning => warnings += 1,
357 }
358 }
359 (errors, warnings)
360}
361
362fn normalize_findings(findings: &mut Vec<Finding>) {
364 findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
365 findings.dedup_by(|a, b| {
366 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
367 });
368}
369
370fn scan_collected(files: Vec<(PathBuf, SupportLang)>, compiled: &CompiledRules) -> ScanResult {
372 let mut findings: Vec<Finding> = files
373 .par_iter()
374 .flat_map_iter(|(path, lang)| scan_file(path, *lang, compiled))
375 .collect();
376 normalize_findings(&mut findings);
377
378 let (errors, warnings) = count_severities(&findings);
379
380 ScanResult {
381 findings,
382 stats: ScanStats {
383 errors,
384 warnings,
385 total: errors + warnings,
386 files_scanned: files.len(),
387 baseline_filtered: 0,
388 diff_base: None,
389 files_changed: None,
390 },
391 cache_stats: None,
392 }
393}
394
395fn scan_collected_cached(
402 files: Vec<(PathBuf, SupportLang)>,
403 compiled: &CompiledRules,
404 rules: &[Rule],
405 cache_dir: &Path,
406 prune: bool,
407) -> ScanResult {
408 let current_rules_hash = rules_hash(rules);
409 let store = CacheStore::with_dir(cache_dir.to_path_buf()).scoped_to_rules(¤t_rules_hash);
410 let rules_changed = store
411 .check_rules_changed(¤t_rules_hash)
412 .unwrap_or(true);
413
414 let file_contents: Vec<(PathBuf, SupportLang, Vec<u8>, String)> = files
415 .into_iter()
416 .filter_map(|(path, lang)| {
417 let content = fs::read(&path).ok()?;
418 let hash = file_content_hash(&content);
419 Some((path, lang, content, hash))
420 })
421 .collect();
422
423 let mut cached_count = 0usize;
424 let mut changed_count = 0usize;
425 let mut all_findings: Vec<Finding> = Vec::new();
426
427 struct FileWork {
428 path: PathBuf,
429 lang: SupportLang,
430 hash: String,
431 }
432
433 let mut to_scan: Vec<FileWork> = Vec::new();
434
435 for (path, lang, _content, hash) in file_contents.iter() {
436 if !rules_changed {
437 if let Some(cached_findings) = store.get(hash) {
438 cached_count += 1;
439 all_findings.extend(cached_findings);
440 continue;
441 }
442 }
443 changed_count += 1;
444 to_scan.push(FileWork {
445 path: path.clone(),
446 lang: *lang,
447 hash: hash.clone(),
448 });
449 }
450
451 let scanned_findings: Vec<(String, Vec<Finding>)> = to_scan
452 .par_iter()
453 .map(|work| {
454 let findings = scan_file(&work.path, work.lang, compiled);
455 (work.hash.clone(), findings)
456 })
457 .collect();
458
459 for (hash, findings) in scanned_findings {
460 store.put(&hash, &findings).ok();
461 all_findings.extend(findings);
462 }
463
464 if prune {
465 let current_hashes: Vec<String> =
466 file_contents.iter().map(|(_, _, _, h)| h.clone()).collect();
467 store.cleanup(¤t_hashes).ok();
468 }
469
470 normalize_findings(&mut all_findings);
471
472 let (errors, warnings) = count_severities(&all_findings);
473
474 let files_scanned = cached_count + changed_count;
475
476 ScanResult {
477 findings: all_findings,
478 stats: ScanStats {
479 errors,
480 warnings,
481 total: errors + warnings,
482 files_scanned,
483 baseline_filtered: 0,
484 diff_base: None,
485 files_changed: None,
486 },
487 cache_stats: Some(CacheStats {
488 cached: cached_count,
489 changed: changed_count,
490 }),
491 }
492}
493
494pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
496 let compiled = compile_rules(rules)?;
497 let ignores = build_glob_set(&config.scan.ignores)?;
498 Ok(scan_collected(walk_files(paths, &ignores), &compiled))
499}
500
501pub fn scan_cached(
510 paths: &[PathBuf],
511 rules: &[Rule],
512 config: &Config,
513 cache_dir: &Path,
514) -> Result<ScanResult, ScanError> {
515 let compiled = compile_rules(rules)?;
516 let ignores = build_glob_set(&config.scan.ignores)?;
517 Ok(scan_collected_cached(
518 walk_files(paths, &ignores),
519 &compiled,
520 rules,
521 cache_dir,
522 true,
523 ))
524}
525
526pub fn scan_files(
531 files: &[PathBuf],
532 rules: &[Rule],
533 config: &Config,
534) -> Result<ScanResult, ScanError> {
535 let compiled = compile_rules(rules)?;
536 let ignores = build_glob_set(&config.scan.ignores)?;
537 Ok(scan_collected(explicit_files(files, &ignores), &compiled))
538}
539
540pub fn scan_files_cached(
543 files: &[PathBuf],
544 rules: &[Rule],
545 config: &Config,
546 cache_dir: &Path,
547) -> Result<ScanResult, ScanError> {
548 let compiled = compile_rules(rules)?;
549 let ignores = build_glob_set(&config.scan.ignores)?;
550 Ok(scan_collected_cached(
551 explicit_files(files, &ignores),
552 &compiled,
553 rules,
554 cache_dir,
555 false,
556 ))
557}
558
559#[cfg(test)]
560mod tests {
561 use std::fs::{create_dir, write};
562
563 use tempfile::tempdir;
564
565 use super::*;
566 use crate::rule::{load_builtin_rules, parse_rule, RuleId};
567
568 fn unwrap_rule() -> Rule {
569 parse_rule(
570 r#"
571id: test-unwrap
572language: rust
573severity: error
574category: correctness
575message: ".unwrap() forbidden"
576rule:
577 kind: call_expression
578 regex: '\.unwrap\(\)\s*$'
579"#,
580 )
581 .unwrap()
582 }
583
584 fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
585 scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
586 }
587
588 #[test]
589 fn scan_finds_unwrap_violation() {
590 let dir = tempdir().unwrap();
591 write(
592 dir.path().join("main.rs"),
593 "fn main() {\n foo().unwrap();\n}\n",
594 )
595 .unwrap();
596 let result = scan_dir(dir.path(), &[unwrap_rule()]);
597 assert_eq!(result.findings.len(), 1);
598 let f = &result.findings[0];
599 assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
600 assert_eq!(f.severity, Severity::Error);
601 assert_eq!(f.line, 2);
602 assert!(f.matched_text.contains("unwrap()"));
603 }
604
605 #[test]
606 fn scan_clean_file_no_findings() {
607 let dir = tempdir().unwrap();
608 write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
609 let result = scan_dir(dir.path(), &[unwrap_rule()]);
610 assert_eq!(result.findings.len(), 0);
611 assert_eq!(result.stats.files_scanned, 1);
612 }
613
614 #[test]
615 fn scan_unsupported_language_ignored() {
616 let dir = tempdir().unwrap();
617 write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
618 write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
619 let result = scan_dir(dir.path(), &[unwrap_rule()]);
620 assert_eq!(result.findings.len(), 0);
621 assert_eq!(result.stats.files_scanned, 1);
622 }
623
624 #[test]
625 fn scan_explicit_file_path() {
626 let dir = tempdir().unwrap();
627 let file = dir.path().join("main.rs");
628 write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
629 let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
630 assert_eq!(result.findings.len(), 1);
631 assert_eq!(result.stats.files_scanned, 1);
632 }
633
634 #[test]
635 fn rule_ignores_glob_respected() {
636 let dir = tempdir().unwrap();
637 let tests_dir = dir.path().join("tests");
638 create_dir(&tests_dir).unwrap();
639 write(
640 tests_dir.join("test_main.rs"),
641 "fn t() { foo().unwrap(); }\n",
642 )
643 .unwrap();
644 write(
645 dir.path().join("main.rs"),
646 "fn main() { foo().unwrap(); }\n",
647 )
648 .unwrap();
649 let rule = parse_rule(
650 r#"
651id: ign
652language: rust
653severity: error
654category: correctness
655message: x
656rule:
657 kind: call_expression
658 regex: '\.unwrap\(\)\s*$'
659ignores:
660 - "**/tests/**"
661"#,
662 )
663 .unwrap();
664 let result = scan_dir(dir.path(), &[rule]);
665 assert_eq!(result.findings.len(), 1);
666 assert!(result.findings[0]
667 .file
668 .to_string_lossy()
669 .contains("main.rs"));
670 }
671
672 #[test]
673 fn config_ignores_respected() {
674 let dir = tempdir().unwrap();
675 let gen = dir.path().join("generated");
676 create_dir(&gen).unwrap();
677 write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
678 write(
679 dir.path().join("main.rs"),
680 "fn main() { foo().unwrap(); }\n",
681 )
682 .unwrap();
683 let mut cfg = Config::default();
684 cfg.scan.ignores = vec!["**/generated/**".to_string()];
685 let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
686 assert_eq!(result.stats.files_scanned, 1);
687 assert_eq!(result.findings.len(), 1);
688 }
689
690 #[test]
691 fn config_invalid_glob_is_an_error() {
692 let dir = tempdir().unwrap();
693 let mut cfg = Config::default();
694 cfg.scan.ignores = vec!["[".to_string()];
695 let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
696 assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
697 }
698
699 #[test]
700 fn rule_without_kind_fails_to_compile() {
701 let dir = tempdir().unwrap();
702 let rule = parse_rule(
703 r#"
704id: no-kind
705language: rust
706severity: error
707category: correctness
708message: x
709rule:
710 regex: 'unwrap'
711"#,
712 )
713 .unwrap();
714 let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
715 assert!(
716 matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
717 "{err:?}"
718 );
719 }
720
721 #[test]
722 fn stats_counting_accurate() {
723 let dir = tempdir().unwrap();
724 write(
725 dir.path().join("a.rs"),
726 "fn f() { foo().unwrap(); bar().unwrap(); }\n",
727 )
728 .unwrap();
729 let result = scan_dir(dir.path(), &[unwrap_rule()]);
730 assert_eq!(result.stats.errors, 2);
731 assert_eq!(result.stats.warnings, 0);
732 assert_eq!(result.stats.total, 2);
733 assert_eq!(result.stats.files_scanned, 1);
734 }
735
736 #[test]
737 fn scan_empty_file() {
738 let dir = tempdir().unwrap();
739 write(dir.path().join("empty.rs"), "").unwrap();
740 let result = scan_dir(dir.path(), &[unwrap_rule()]);
741 assert_eq!(result.findings.len(), 0);
742 assert_eq!(result.stats.files_scanned, 1);
743 }
744
745 #[test]
746 fn scan_complex_rule() {
747 let rule = parse_rule(
748 r#"
749id: dbg
750language: rust
751severity: error
752category: security
753message: debug on secret
754rule:
755 kind: attribute_item
756 all:
757 - regex: 'derive\([^)]*Debug'
758 - precedes:
759 kind: struct_item
760 has:
761 kind: field_identifier
762 regex: '^(api_key|password|secret)$'
763 stopBy: end
764"#,
765 )
766 .unwrap();
767 let dir = tempdir().unwrap();
768 write(
769 dir.path().join("t.rs"),
770 "#[derive(Debug)]\nstruct Config {\n api_key: String,\n}\n",
771 )
772 .unwrap();
773 let result = scan_dir(dir.path(), &[rule]);
774 assert_eq!(result.findings.len(), 1);
775 assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
776 }
777
778 #[test]
779 fn typescript_rule_applies_to_tsx_files() {
780 let rule = parse_rule(
781 r#"
782id: ts-any
783language: typescript
784severity: warning
785category: slop
786message: no any
787rule:
788 kind: predefined_type
789 regex: '^any$'
790"#,
791 )
792 .unwrap();
793 let dir = tempdir().unwrap();
794 write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
795 write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
796 let result = scan_dir(dir.path(), &[rule]);
797 assert_eq!(result.stats.files_scanned, 2);
798 assert_eq!(result.stats.warnings, 2);
799 }
800
801 #[test]
802 fn scan_with_disable_comment_filters_findings() {
803 let dir = tempdir().unwrap();
804 write(
805 dir.path().join("main.rs"),
806 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n bar().unwrap();\n}\n",
807 )
808 .unwrap();
809 let result = scan_dir(dir.path(), &[unwrap_rule()]);
810 assert_eq!(
811 result.findings.len(),
812 1,
813 "only the non-disabled unwrap should remain"
814 );
815 assert_eq!(result.findings[0].line, 4);
816 }
817
818 #[test]
819 fn scan_with_disable_rule_id_filters_only_that_rule() {
820 let dir = tempdir().unwrap();
821 write(
822 dir.path().join("main.rs"),
823 "fn main() {\n // slopguard-disable-next-line test-unwrap\n foo().unwrap();\n}\n",
824 )
825 .unwrap();
826 let result = scan_dir(dir.path(), &[unwrap_rule()]);
827 assert_eq!(result.findings.len(), 0);
828 }
829
830 fn unwrap_rule_skipping_test_code() -> Rule {
831 parse_rule(
832 r#"
833id: test-unwrap
834language: rust
835severity: error
836category: correctness
837message: ".unwrap() forbidden"
838rule:
839 kind: call_expression
840 regex: '\.unwrap\(\)\s*$'
841skip_test_code: true
842"#,
843 )
844 .unwrap()
845 }
846
847 #[test]
848 fn cfg_test_block_excluded_by_post_filter() {
849 let dir = tempdir().unwrap();
850 write(
851 dir.path().join("main.rs"),
852 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
853 )
854 .unwrap();
855 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
856 assert_eq!(
857 result.findings.len(),
858 1,
859 "should only find the prod unwrap, not the one inside #[cfg(test)]"
860 );
861 assert_eq!(result.findings[0].line, 2);
862 }
863
864 #[test]
865 fn cfg_test_kept_without_skip_test_code() {
866 let dir = tempdir().unwrap();
867 write(
868 dir.path().join("main.rs"),
869 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
870 )
871 .unwrap();
872 let result = scan_dir(dir.path(), &[unwrap_rule()]);
873 assert_eq!(
874 result.findings.len(),
875 2,
876 "rule without skip_test_code should find both"
877 );
878 }
879
880 #[test]
881 fn integration_test_file_excluded_by_skip_test_code() {
882 let dir = tempdir().unwrap();
883 let tests_dir = dir.path().join("tests");
884 std::fs::create_dir(&tests_dir).unwrap();
885 write(
886 tests_dir.join("it.rs"),
887 "fn check() {\n foo().unwrap();\n}\n",
888 )
889 .unwrap();
890 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
891 assert_eq!(
892 result.findings.len(),
893 0,
894 "unwrap inside a tests/ integration file should be skipped"
895 );
896 }
897
898 #[test]
899 fn integration_test_file_kept_without_skip_test_code() {
900 let dir = tempdir().unwrap();
901 let tests_dir = dir.path().join("tests");
902 std::fs::create_dir(&tests_dir).unwrap();
903 write(
904 tests_dir.join("it.rs"),
905 "fn check() {\n foo().unwrap();\n}\n",
906 )
907 .unwrap();
908 let result = scan_dir(dir.path(), &[unwrap_rule()]);
909 assert_eq!(
910 result.findings.len(),
911 1,
912 "rule without skip_test_code should still flag tests/ files"
913 );
914 }
915
916 #[test]
917 fn test_crate_directory_excluded_by_skip_test_code() {
918 let dir = tempdir().unwrap();
919 let test_crate = dir
920 .path()
921 .join("crates")
922 .join("integrations_tests")
923 .join("src");
924 std::fs::create_dir_all(&test_crate).unwrap();
925 write(
926 test_crate.join("api.rs"),
927 "fn check() {\n foo().unwrap();\n}\n",
928 )
929 .unwrap();
930 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
931 assert_eq!(
932 result.findings.len(),
933 0,
934 "unwrap inside a *_tests crate should be skipped"
935 );
936 }
937
938 #[test]
939 fn test_crate_directory_kept_without_skip_test_code() {
940 let dir = tempdir().unwrap();
941 let test_crate = dir
942 .path()
943 .join("crates")
944 .join("integrations_tests")
945 .join("src");
946 std::fs::create_dir_all(&test_crate).unwrap();
947 write(
948 test_crate.join("api.rs"),
949 "fn check() {\n foo().unwrap();\n}\n",
950 )
951 .unwrap();
952 let result = scan_dir(dir.path(), &[unwrap_rule()]);
953 assert_eq!(
954 result.findings.len(),
955 1,
956 "rule without skip_test_code should still flag *_tests crate files"
957 );
958 }
959
960 #[test]
961 fn all_builtin_rules_compile() {
962 let rules = load_builtin_rules().unwrap();
963 assert!(
964 rules.len() >= 55,
965 "expected at least 55 rules, got {}",
966 rules.len()
967 );
968 let compiled = compile_rules(&rules).expect("all builtin rules should compile");
969 let ast_rule_count = rules.iter().filter(|r| !r.is_metric()).count();
970 assert_eq!(compiled.by_id.len(), ast_rule_count);
971 assert!(
972 !compiled.metrics.is_empty(),
973 "builtin rules should include metric rules"
974 );
975 }
976
977 #[test]
978 fn scan_files_only_scans_given_files() {
979 let dir = tempdir().unwrap();
980 let a = dir.path().join("a.rs");
981 let b = dir.path().join("b.rs");
982 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
983 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
984
985 let result = scan_files(&[a], &[unwrap_rule()], &Config::default()).unwrap();
986 assert_eq!(result.findings.len(), 1);
987 assert_eq!(result.stats.files_scanned, 1);
988 assert!(result.findings[0].file.to_string_lossy().contains("a.rs"));
989 }
990
991 #[test]
992 fn scan_files_skips_unsupported_language() {
993 let dir = tempdir().unwrap();
994 let py = dir.path().join("script.py");
995 write(&py, "x.unwrap()\n").unwrap();
996
997 let result = scan_files(&[py], &[unwrap_rule()], &Config::default()).unwrap();
998 assert_eq!(result.findings.len(), 0);
999 assert_eq!(result.stats.files_scanned, 0);
1000 }
1001
1002 #[test]
1003 fn scan_files_skips_missing_file() {
1004 let dir = tempdir().unwrap();
1005 let missing = dir.path().join("gone.rs");
1006
1007 let result = scan_files(&[missing], &[unwrap_rule()], &Config::default()).unwrap();
1008 assert_eq!(result.findings.len(), 0);
1009 assert_eq!(result.stats.files_scanned, 0);
1010 }
1011
1012 #[test]
1013 fn scan_files_respects_config_ignores() {
1014 let dir = tempdir().unwrap();
1015 let gen = dir.path().join("generated");
1016 create_dir(&gen).unwrap();
1017 let out = gen.join("out.rs");
1018 write(&out, "fn g() { foo().unwrap(); }\n").unwrap();
1019
1020 let mut cfg = Config::default();
1021 cfg.scan.ignores = vec!["**/generated/**".to_string()];
1022 let result = scan_files(&[out], &[unwrap_rule()], &cfg).unwrap();
1023 assert_eq!(result.findings.len(), 0);
1024 assert_eq!(result.stats.files_scanned, 0);
1025 }
1026
1027 #[test]
1028 fn scan_files_cached_keeps_other_entries() {
1029 let src_dir = tempdir().unwrap();
1030 let cache_dir = tempdir().unwrap();
1031 let cache_path = cache_dir.path().join("cache");
1032 let a = src_dir.path().join("a.rs");
1033 let b = src_dir.path().join("b.rs");
1034 write(&a, "fn a() {\n foo().unwrap();\n}\n").unwrap();
1035 write(&b, "fn b() {\n bar().unwrap();\n}\n").unwrap();
1036
1037 let rules = [unwrap_rule()];
1038 let config = Config::default();
1039 let paths = [src_dir.path().to_path_buf()];
1040
1041 scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1042
1043 scan_files_cached(&[a], &rules, &config, &cache_path).unwrap();
1045
1046 let result = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1047 let stats = result.cache_stats.as_ref().unwrap();
1048 assert_eq!(stats.cached, 2, "a partial scan must not prune");
1049 assert_eq!(stats.changed, 0);
1050 }
1051
1052 fn metric_rule(body: &str) -> Rule {
1053 parse_rule(body).unwrap()
1054 }
1055
1056 fn file_lines_rule() -> Rule {
1057 metric_rule(
1058 r#"
1059id: test-file-lines
1060language: rust
1061severity: warning
1062category: slop
1063metric: file_lines
1064threshold: 10
1065message: "File is too long"
1066"#,
1067 )
1068 }
1069
1070 fn rust_lines(n: usize) -> String {
1072 (0..n).map(|i| format!("// line {i}\n")).collect()
1073 }
1074
1075 #[test]
1076 fn scan_finds_file_lines_violation() {
1077 let dir = tempdir().unwrap();
1078 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1079 let result = scan_dir(dir.path(), &[file_lines_rule()]);
1080 assert_eq!(result.findings.len(), 1);
1081 let f = &result.findings[0];
1082 assert_eq!(f.rule_id, RuleId::from("test-file-lines"));
1083 assert_eq!(f.line, 1);
1084 assert_eq!(f.column, 1);
1085 assert_eq!(f.end_line, 1);
1086 assert_eq!(f.end_column, 1);
1087 assert_eq!(f.matched_text, "12 lines");
1088 }
1089
1090 #[test]
1091 fn metric_message_interpolates_value() {
1092 let dir = tempdir().unwrap();
1093 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1094 let rule = metric_rule(
1095 r#"
1096id: test-interpolate
1097language: rust
1098severity: warning
1099category: slop
1100metric: file_lines
1101threshold: 10
1102message: "File exceeds 10 lines ($value lines)"
1103"#,
1104 );
1105 let result = scan_dir(dir.path(), &[rule]);
1106 assert_eq!(result.findings.len(), 1);
1107 let message = &result.findings[0].message;
1108 assert!(message.contains("(12 lines)"), "got: {message}");
1109 assert!(!message.contains("$value"), "got: {message}");
1110 }
1111
1112 #[test]
1113 fn metric_rule_below_threshold_no_finding() {
1114 let dir = tempdir().unwrap();
1115 write(dir.path().join("exact.rs"), rust_lines(10)).unwrap();
1116 let result = scan_dir(dir.path(), &[file_lines_rule()]);
1117 assert_eq!(
1118 result.findings.len(),
1119 0,
1120 "a file of exactly the threshold length must not fire"
1121 );
1122 }
1123
1124 #[test]
1125 fn metric_comment_ratio_finding() {
1126 let dir = tempdir().unwrap();
1127 write(
1128 dir.path().join("commented.rs"),
1129 "// a\n// b\n// c\n// d\nfn f() {}\nfn g() {}\nfn h() {}\n",
1130 )
1131 .unwrap();
1132 let rule = metric_rule(
1133 r#"
1134id: test-comment-ratio
1135language: rust
1136severity: warning
1137category: slop
1138metric: comment_ratio
1139threshold: 0.4
1140message: "Too many comments ($value)"
1141"#,
1142 );
1143 let result = scan_dir(dir.path(), &[rule]);
1144 assert_eq!(result.findings.len(), 1);
1145 assert!(
1146 result.findings[0].matched_text.ends_with("comment ratio"),
1147 "got: {}",
1148 result.findings[0].matched_text
1149 );
1150 }
1151
1152 #[test]
1153 fn metric_rule_respects_ignores_glob() {
1154 let dir = tempdir().unwrap();
1155 let gen = dir.path().join("generated");
1156 create_dir(&gen).unwrap();
1157 write(gen.join("out.rs"), rust_lines(12)).unwrap();
1158 write(dir.path().join("main.rs"), rust_lines(12)).unwrap();
1159 let rule = metric_rule(
1160 r#"
1161id: test-metric-ignores
1162language: rust
1163severity: warning
1164category: slop
1165metric: file_lines
1166threshold: 10
1167message: "too long"
1168ignores:
1169 - "**/generated/**"
1170"#,
1171 );
1172 let result = scan_dir(dir.path(), &[rule]);
1173 assert_eq!(result.findings.len(), 1);
1174 assert!(result.findings[0]
1175 .file
1176 .to_string_lossy()
1177 .contains("main.rs"));
1178 }
1179
1180 #[test]
1181 fn metric_rule_respects_files_glob() {
1182 let dir = tempdir().unwrap();
1183 let src = dir.path().join("src");
1184 create_dir(&src).unwrap();
1185 write(src.join("lib.rs"), rust_lines(12)).unwrap();
1186 write(dir.path().join("build.rs"), rust_lines(12)).unwrap();
1187 let rule = metric_rule(
1188 r#"
1189id: test-metric-files
1190language: rust
1191severity: warning
1192category: slop
1193metric: file_lines
1194threshold: 10
1195message: "too long"
1196files:
1197 - "**/src/**/*.rs"
1198"#,
1199 );
1200 let result = scan_dir(dir.path(), &[rule]);
1201 assert_eq!(result.findings.len(), 1);
1202 assert!(result.findings[0].file.to_string_lossy().contains("lib.rs"));
1203 }
1204
1205 #[test]
1206 fn metric_rule_skip_test_code_skips_test_files() {
1207 let dir = tempdir().unwrap();
1208 let tests_dir = dir.path().join("tests");
1209 create_dir(&tests_dir).unwrap();
1210 write(tests_dir.join("it.rs"), rust_lines(12)).unwrap();
1211
1212 let skipping = metric_rule(
1213 r#"
1214id: test-metric-skip
1215language: rust
1216severity: warning
1217category: slop
1218metric: file_lines
1219threshold: 10
1220message: "too long"
1221skip_test_code: true
1222"#,
1223 );
1224 let skipped = scan_dir(dir.path(), &[skipping]);
1225 assert_eq!(skipped.findings.len(), 0);
1226 let reported = scan_dir(dir.path(), &[file_lines_rule()]);
1227 assert_eq!(reported.findings.len(), 1);
1228 }
1229
1230 #[test]
1231 fn metric_only_ruleset_still_scans() {
1232 let dir = tempdir().unwrap();
1233 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1234 let result = scan_dir(dir.path(), &[file_lines_rule()]);
1235 assert_eq!(
1236 result.findings.len(),
1237 1,
1238 "a rule set with only metric rules must still scan files"
1239 );
1240 }
1241
1242 #[test]
1243 fn metric_and_ast_rules_together() {
1244 let dir = tempdir().unwrap();
1245 let mut source = String::from("fn main() {\n foo().unwrap();\n}\n");
1246 source.push_str(&rust_lines(12));
1247 write(dir.path().join("main.rs"), source).unwrap();
1248
1249 let result = scan_dir(dir.path(), &[unwrap_rule(), file_lines_rule()]);
1250 assert_eq!(result.findings.len(), 2);
1251 let first = &result.findings[0];
1252 assert_eq!(first.rule_id, RuleId::from("test-file-lines"));
1253 assert_eq!(first.line, 1);
1254 let second = &result.findings[1];
1255 assert_eq!(second.rule_id, RuleId::from("test-unwrap"));
1256 }
1257
1258 #[test]
1259 fn metric_rule_wrong_language_not_applied() {
1260 let dir = tempdir().unwrap();
1261 write(dir.path().join("big.rs"), rust_lines(12)).unwrap();
1262 let rule = metric_rule(
1263 r#"
1264id: test-metric-ts
1265language: typescript
1266severity: warning
1267category: slop
1268metric: file_lines
1269threshold: 10
1270message: "too long"
1271"#,
1272 );
1273 let result = scan_dir(dir.path(), &[rule]);
1274 assert_eq!(result.findings.len(), 0);
1275 }
1276
1277 #[test]
1278 fn metric_findings_are_cached() {
1279 let src_dir = tempdir().unwrap();
1280 let cache_dir = tempdir().unwrap();
1281 let cache_path = cache_dir.path().join("cache");
1282 write(src_dir.path().join("big.rs"), rust_lines(12)).unwrap();
1283
1284 let rules = [file_lines_rule()];
1285 let config = Config::default();
1286 let paths = [src_dir.path().to_path_buf()];
1287
1288 let first = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1289 assert_eq!(first.findings.len(), 1);
1290 assert_eq!(first.cache_stats.as_ref().unwrap().changed, 1);
1291
1292 let second = scan_cached(&paths, &rules, &config, &cache_path).unwrap();
1293 assert_eq!(second.cache_stats.as_ref().unwrap().cached, 1);
1294 assert_eq!(second.findings.len(), 1);
1295 assert_eq!(second.findings[0].matched_text, "12 lines");
1296 }
1297
1298 #[test]
1299 fn scan_with_cache() {
1300 let src_dir = tempdir().unwrap();
1301 let cache_dir = tempdir().unwrap();
1302 let cache_path = cache_dir.path().join("cache");
1303 write(
1304 src_dir.path().join("main.rs"),
1305 "fn main() {\n foo().unwrap();\n}\n",
1306 )
1307 .unwrap();
1308
1309 let rules = [unwrap_rule()];
1310 let config = Config::default();
1311
1312 let result1 = scan_cached(
1313 &[src_dir.path().to_path_buf()],
1314 &rules,
1315 &config,
1316 &cache_path,
1317 )
1318 .unwrap();
1319
1320 assert_eq!(result1.findings.len(), 1);
1321 let cache_stats1 = result1.cache_stats.as_ref().unwrap();
1322 assert_eq!(cache_stats1.cached, 0, "first scan: nothing cached");
1323 assert_eq!(cache_stats1.changed, 1, "first scan: one file scanned");
1324
1325 let result2 = scan_cached(
1326 &[src_dir.path().to_path_buf()],
1327 &rules,
1328 &config,
1329 &cache_path,
1330 )
1331 .unwrap();
1332
1333 assert_eq!(result2.findings.len(), 1);
1334 let cache_stats2 = result2.cache_stats.as_ref().unwrap();
1335 assert_eq!(cache_stats2.cached, 1, "second scan: one file from cache");
1336 assert_eq!(cache_stats2.changed, 0, "second scan: nothing changed");
1337 }
1338}