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