1use std::collections::HashMap;
2use std::fs::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::config::Config;
21use crate::disable::filter_disabled;
22use crate::finding::{Finding, ScanResult, ScanStats};
23use crate::rule::{Language, Rule, RuleId, Severity};
24use crate::test_filter::CfgTestRanges;
25
26#[derive(Debug, Error)]
27pub enum ScanError {
28 #[error("failed to compile rule '{id}': {source}")]
29 RuleCompile {
30 id: RuleId,
31 #[source]
32 source: RuleConfigError,
33 },
34
35 #[error("invalid glob pattern: {0}")]
36 Glob(#[from] GlobError),
37}
38
39#[derive(Serialize)]
42pub struct AstGrepRule<'a> {
43 pub id: &'a str,
44 pub language: SupportLang,
45 pub severity: &'a Severity,
46 pub message: &'a str,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub note: Option<&'a str>,
49 pub rule: &'a Value,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub files: Option<&'a [String]>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub ignores: Option<&'a [String]>,
54}
55
56struct CompiledRules<'a> {
59 collection: RuleCollection<SupportLang>,
60 by_id: HashMap<&'a str, &'a Rule>,
61}
62
63fn extension_to_lang(path: &Path) -> Option<SupportLang> {
64 SupportLang::from_path(path)
65 .filter(|lang| Language::iter().any(|l| l.ast_grep_langs().contains(lang)))
66}
67
68pub(crate) fn compile_ast_grep_rule(
69 rule: &AstGrepRule,
70) -> Result<RuleConfig<SupportLang>, RuleConfigError> {
71 let inner: SerializableRuleConfig<SupportLang> = to_value(rule)
72 .and_then(singleton_map_recursive::deserialize)
73 .map_err(RuleConfigError::from)?;
74 RuleConfig::try_from(inner, &GlobalRules::default())
75}
76
77fn compile_rule(rule: &Rule, lang: SupportLang) -> Result<RuleConfig<SupportLang>, ScanError> {
78 let ast_grep_rule = AstGrepRule {
79 id: rule.id.as_str(),
80 language: lang,
81 severity: &rule.severity,
82 message: &rule.message,
83 note: rule.note.as_deref(),
84 rule: &rule.rule,
85 files: rule.files.as_deref(),
86 ignores: rule.ignores.as_deref(),
87 };
88 compile_ast_grep_rule(&ast_grep_rule).map_err(|source| ScanError::RuleCompile {
89 id: rule.id.clone(),
90 source,
91 })
92}
93
94fn compile_rules(rules: &[Rule]) -> Result<CompiledRules<'_>, ScanError> {
95 let configs = rules
96 .iter()
97 .flat_map(|rule| {
98 rule.language
99 .ast_grep_langs()
100 .iter()
101 .map(move |lang| compile_rule(rule, *lang))
102 })
103 .collect::<Result<Vec<_>, _>>()?;
104 Ok(CompiledRules {
105 collection: RuleCollection::try_new(configs)?,
106 by_id: rules
107 .iter()
108 .map(|rule| (rule.id.as_str(), rule))
109 .collect::<HashMap<_, _>>(),
110 })
111}
112
113fn build_glob_set(patterns: &[String]) -> Result<GlobSet, GlobError> {
114 let mut builder = GlobSetBuilder::new();
115 for pattern in patterns {
116 builder.add(Glob::new(pattern)?);
117 }
118 builder.build()
119}
120
121fn scan_file(path: &Path, lang: SupportLang, rules: &CompiledRules) -> Vec<Finding> {
122 let applicable = rules.collection.get_rule_from_lang(path, lang);
123 if applicable.is_empty() {
124 return Vec::new();
125 }
126 let Ok(source) = read_to_string(path) else {
127 return Vec::new();
128 };
129
130 let root = lang.ast_grep(&source);
131 let combined = CombinedScan::new(applicable);
132 let result = combined.scan(&root, false);
133
134 let cfg_test = (lang == SupportLang::Rust && !result.matches.is_empty())
135 .then(|| CfgTestRanges::from_root(&root));
136 let cfg_test = cfg_test.as_ref();
137
138 let findings = result
139 .matches
140 .into_iter()
141 .filter_map(|(config, matches)| {
142 let rule = rules.by_id.get(config.id.as_str())?;
143 Some((*rule, matches))
144 })
145 .flat_map(|(rule, matches)| {
146 let skip_test_code = rule.skip_test_code;
147 matches
148 .into_iter()
149 .map(move |node_match| {
150 let start = node_match.start_pos();
151 let end = node_match.end_pos();
152 Finding {
153 rule_id: rule.id.clone(),
154 severity: rule.severity.clone(),
155 category: rule.category.clone().unwrap_or_default(),
156 message: rule.message.clone(),
157 note: rule.note.clone(),
158 fix: rule.fix.clone(),
159 file: path.to_path_buf(),
160 line: start.line() + 1,
161 column: start.byte_point().1 + 1,
162 end_line: end.line() + 1,
163 end_column: end.byte_point().1 + 1,
164 matched_text: node_match.text().to_string(),
165 }
166 })
167 .filter(move |f| {
168 !(skip_test_code && cfg_test.is_some_and(|r| r.contains_line(f.line)))
169 })
170 })
171 .collect();
172
173 filter_disabled(findings, &source)
174}
175
176pub fn scan(paths: &[PathBuf], rules: &[Rule], config: &Config) -> Result<ScanResult, ScanError> {
178 let compiled = compile_rules(rules)?;
179 let ignores = build_glob_set(&config.scan.ignores)?;
180
181 let files: Vec<(PathBuf, SupportLang)> = paths
182 .iter()
183 .flat_map(|path| WalkBuilder::new(path).build().flatten())
184 .filter(|entry| entry.file_type().is_some_and(|t| t.is_file()))
185 .map(|entry| entry.into_path())
186 .filter(|path| !ignores.is_match(path))
187 .filter_map(|path| extension_to_lang(&path).map(|lang| (path, lang)))
188 .collect();
189
190 let mut findings: Vec<Finding> = files
191 .par_iter()
192 .flat_map_iter(|(path, lang)| scan_file(path, *lang, &compiled))
193 .collect();
194 findings.sort_by(|a, b| (&a.file, a.line, a.column).cmp(&(&b.file, b.line, b.column)));
195 findings.dedup_by(|a, b| {
196 a.rule_id == b.rule_id && a.file == b.file && a.line == b.line && a.column == b.column
197 });
198
199 let (errors, warnings) =
200 findings
201 .iter()
202 .fold((0, 0), |(errors, warnings), f| match f.severity {
203 Severity::Error => (errors + 1, warnings),
204 Severity::Warning => (errors, warnings + 1),
205 });
206
207 Ok(ScanResult {
208 findings,
209 stats: ScanStats {
210 errors,
211 warnings,
212 total: errors + warnings,
213 files_scanned: files.len(),
214 },
215 })
216}
217
218#[cfg(test)]
219mod tests {
220 use std::fs::{create_dir, write};
221
222 use tempfile::tempdir;
223
224 use super::*;
225 use crate::rule::{load_builtin_rules, parse_rule, RuleId};
226
227 fn unwrap_rule() -> Rule {
228 parse_rule(
229 r#"
230id: test-unwrap
231language: rust
232severity: error
233category: correctness
234message: ".unwrap() forbidden"
235rule:
236 kind: call_expression
237 regex: '\.unwrap\(\)\s*$'
238"#,
239 )
240 .unwrap()
241 }
242
243 fn scan_dir(dir: &Path, rules: &[Rule]) -> ScanResult {
244 scan(&[dir.to_path_buf()], rules, &Config::default()).unwrap()
245 }
246
247 #[test]
248 fn scan_finds_unwrap_violation() {
249 let dir = tempdir().unwrap();
250 write(
251 dir.path().join("main.rs"),
252 "fn main() {\n foo().unwrap();\n}\n",
253 )
254 .unwrap();
255 let result = scan_dir(dir.path(), &[unwrap_rule()]);
256 assert_eq!(result.findings.len(), 1);
257 let f = &result.findings[0];
258 assert_eq!(f.rule_id, RuleId::from("test-unwrap"));
259 assert_eq!(f.severity, Severity::Error);
260 assert_eq!(f.line, 2);
261 assert!(f.matched_text.contains("unwrap()"));
262 }
263
264 #[test]
265 fn scan_clean_file_no_findings() {
266 let dir = tempdir().unwrap();
267 write(dir.path().join("main.rs"), "fn main() { let x = foo(); }\n").unwrap();
268 let result = scan_dir(dir.path(), &[unwrap_rule()]);
269 assert_eq!(result.findings.len(), 0);
270 assert_eq!(result.stats.files_scanned, 1);
271 }
272
273 #[test]
274 fn scan_unsupported_language_ignored() {
275 let dir = tempdir().unwrap();
276 write(dir.path().join("script.py"), "x.unwrap()\n").unwrap();
277 write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
278 let result = scan_dir(dir.path(), &[unwrap_rule()]);
279 assert_eq!(result.findings.len(), 0);
280 assert_eq!(result.stats.files_scanned, 1);
281 }
282
283 #[test]
284 fn scan_explicit_file_path() {
285 let dir = tempdir().unwrap();
286 let file = dir.path().join("main.rs");
287 write(&file, "fn main() { foo().unwrap(); }\n").unwrap();
288 let result = scan(&[file], &[unwrap_rule()], &Config::default()).unwrap();
289 assert_eq!(result.findings.len(), 1);
290 assert_eq!(result.stats.files_scanned, 1);
291 }
292
293 #[test]
294 fn rule_ignores_glob_respected() {
295 let dir = tempdir().unwrap();
296 let tests_dir = dir.path().join("tests");
297 create_dir(&tests_dir).unwrap();
298 write(
299 tests_dir.join("test_main.rs"),
300 "fn t() { foo().unwrap(); }\n",
301 )
302 .unwrap();
303 write(
304 dir.path().join("main.rs"),
305 "fn main() { foo().unwrap(); }\n",
306 )
307 .unwrap();
308 let rule = parse_rule(
309 r#"
310id: ign
311language: rust
312severity: error
313category: correctness
314message: x
315rule:
316 kind: call_expression
317 regex: '\.unwrap\(\)\s*$'
318ignores:
319 - "**/tests/**"
320"#,
321 )
322 .unwrap();
323 let result = scan_dir(dir.path(), &[rule]);
324 assert_eq!(result.findings.len(), 1);
325 assert!(result.findings[0]
326 .file
327 .to_string_lossy()
328 .contains("main.rs"));
329 }
330
331 #[test]
332 fn config_ignores_respected() {
333 let dir = tempdir().unwrap();
334 let gen = dir.path().join("generated");
335 create_dir(&gen).unwrap();
336 write(gen.join("out.rs"), "fn g() { foo().unwrap(); }\n").unwrap();
337 write(
338 dir.path().join("main.rs"),
339 "fn main() { foo().unwrap(); }\n",
340 )
341 .unwrap();
342 let mut cfg = Config::default();
343 cfg.scan.ignores = vec!["**/generated/**".to_string()];
344 let result = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap();
345 assert_eq!(result.stats.files_scanned, 1);
346 assert_eq!(result.findings.len(), 1);
347 }
348
349 #[test]
350 fn config_invalid_glob_is_an_error() {
351 let dir = tempdir().unwrap();
352 let mut cfg = Config::default();
353 cfg.scan.ignores = vec!["[".to_string()];
354 let err = scan(&[dir.path().to_path_buf()], &[unwrap_rule()], &cfg).unwrap_err();
355 assert!(matches!(err, ScanError::Glob(_)), "{err:?}");
356 }
357
358 #[test]
359 fn rule_without_kind_fails_to_compile() {
360 let dir = tempdir().unwrap();
361 let rule = parse_rule(
362 r#"
363id: no-kind
364language: rust
365severity: error
366category: correctness
367message: x
368rule:
369 regex: 'unwrap'
370"#,
371 )
372 .unwrap();
373 let err = scan(&[dir.path().to_path_buf()], &[rule], &Config::default()).unwrap_err();
374 assert!(
375 matches!(&err, ScanError::RuleCompile { id, .. } if *id == RuleId::from("no-kind")),
376 "{err:?}"
377 );
378 }
379
380 #[test]
381 fn stats_counting_accurate() {
382 let dir = tempdir().unwrap();
383 write(
384 dir.path().join("a.rs"),
385 "fn f() { foo().unwrap(); bar().unwrap(); }\n",
386 )
387 .unwrap();
388 let result = scan_dir(dir.path(), &[unwrap_rule()]);
389 assert_eq!(result.stats.errors, 2);
390 assert_eq!(result.stats.warnings, 0);
391 assert_eq!(result.stats.total, 2);
392 assert_eq!(result.stats.files_scanned, 1);
393 }
394
395 #[test]
396 fn scan_empty_file() {
397 let dir = tempdir().unwrap();
398 write(dir.path().join("empty.rs"), "").unwrap();
399 let result = scan_dir(dir.path(), &[unwrap_rule()]);
400 assert_eq!(result.findings.len(), 0);
401 assert_eq!(result.stats.files_scanned, 1);
402 }
403
404 #[test]
405 fn scan_complex_rule() {
406 let rule = parse_rule(
407 r#"
408id: dbg
409language: rust
410severity: error
411category: security
412message: debug on secret
413rule:
414 kind: attribute_item
415 all:
416 - regex: 'derive\([^)]*Debug'
417 - precedes:
418 kind: struct_item
419 has:
420 kind: field_identifier
421 regex: '^(api_key|password|secret)$'
422 stopBy: end
423"#,
424 )
425 .unwrap();
426 let dir = tempdir().unwrap();
427 write(
428 dir.path().join("t.rs"),
429 "#[derive(Debug)]\nstruct Config {\n api_key: String,\n}\n",
430 )
431 .unwrap();
432 let result = scan_dir(dir.path(), &[rule]);
433 assert_eq!(result.findings.len(), 1);
434 assert_eq!(result.findings[0].rule_id, RuleId::from("dbg"));
435 }
436
437 #[test]
438 fn typescript_rule_applies_to_tsx_files() {
439 let rule = parse_rule(
440 r#"
441id: ts-any
442language: typescript
443severity: warning
444category: slop
445message: no any
446rule:
447 kind: predefined_type
448 regex: '^any$'
449"#,
450 )
451 .unwrap();
452 let dir = tempdir().unwrap();
453 write(dir.path().join("a.ts"), "let x: any = 1;\n").unwrap();
454 write(dir.path().join("b.tsx"), "let y: any = 2;\n").unwrap();
455 let result = scan_dir(dir.path(), &[rule]);
456 assert_eq!(result.stats.files_scanned, 2);
457 assert_eq!(result.stats.warnings, 2);
458 }
459
460 #[test]
461 fn scan_with_disable_comment_filters_findings() {
462 let dir = tempdir().unwrap();
463 write(
464 dir.path().join("main.rs"),
465 "fn main() {\n // slopguard-disable-next-line\n foo().unwrap();\n bar().unwrap();\n}\n",
466 )
467 .unwrap();
468 let result = scan_dir(dir.path(), &[unwrap_rule()]);
469 assert_eq!(
470 result.findings.len(),
471 1,
472 "only the non-disabled unwrap should remain"
473 );
474 assert_eq!(result.findings[0].line, 4);
475 }
476
477 #[test]
478 fn scan_with_disable_rule_id_filters_only_that_rule() {
479 let dir = tempdir().unwrap();
480 write(
481 dir.path().join("main.rs"),
482 "fn main() {\n // slopguard-disable-next-line test-unwrap\n foo().unwrap();\n}\n",
483 )
484 .unwrap();
485 let result = scan_dir(dir.path(), &[unwrap_rule()]);
486 assert_eq!(result.findings.len(), 0);
487 }
488
489 fn unwrap_rule_skipping_test_code() -> Rule {
490 parse_rule(
491 r#"
492id: test-unwrap
493language: rust
494severity: error
495category: correctness
496message: ".unwrap() forbidden"
497rule:
498 kind: call_expression
499 regex: '\.unwrap\(\)\s*$'
500skip_test_code: true
501"#,
502 )
503 .unwrap()
504 }
505
506 #[test]
507 fn cfg_test_block_excluded_by_post_filter() {
508 let dir = tempdir().unwrap();
509 write(
510 dir.path().join("main.rs"),
511 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
512 )
513 .unwrap();
514 let result = scan_dir(dir.path(), &[unwrap_rule_skipping_test_code()]);
515 assert_eq!(
516 result.findings.len(),
517 1,
518 "should only find the prod unwrap, not the one inside #[cfg(test)]"
519 );
520 assert_eq!(result.findings[0].line, 2);
521 }
522
523 #[test]
524 fn cfg_test_kept_without_skip_test_code() {
525 let dir = tempdir().unwrap();
526 write(
527 dir.path().join("main.rs"),
528 "fn prod() {\n foo().unwrap();\n}\n\n#[cfg(test)]\nmod tests {\n fn test_it() {\n bar().unwrap();\n }\n}\n",
529 )
530 .unwrap();
531 let result = scan_dir(dir.path(), &[unwrap_rule()]);
532 assert_eq!(
533 result.findings.len(),
534 2,
535 "rule without skip_test_code should find both"
536 );
537 }
538
539 #[test]
540 fn all_builtin_rules_compile() {
541 let rules = load_builtin_rules().unwrap();
542 assert!(
543 rules.len() >= 34,
544 "expected at least 34 rules, got {}",
545 rules.len()
546 );
547 let compiled = compile_rules(&rules).expect("all builtin rules should compile");
548 assert_eq!(compiled.by_id.len(), rules.len());
549 }
550}