1use std::path::Path;
2
3use ast_grep_config::{CombinedScan, RuleCollection};
4use ast_grep_core::tree_sitter::LanguageExt;
5use ast_grep_language::SupportLang;
6use thiserror::Error;
7
8use crate::rule::{Rule, RuleId};
9use crate::scanner::{compile_ast_grep_rule, AstGrepRule};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum RuleTestStatus {
13 Pass,
14 Fail { failures: Vec<TestFailure> },
15 NoTests,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct TestFailure {
20 pub kind: TestFailureKind,
21 pub snippet: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum TestFailureKind {
26 ShouldMatchDidNot,
27 ShouldNotMatchDid,
28}
29
30#[derive(Debug, Clone)]
31pub struct RuleTestResult {
32 pub rule_id: RuleId,
33 pub status: RuleTestStatus,
34}
35
36#[derive(Debug)]
37pub struct TestSummary {
38 pub results: Vec<RuleTestResult>,
39 pub total_tested: usize,
40 pub passed: usize,
41 pub failed: usize,
42 pub no_tests: usize,
43}
44
45#[derive(Debug, Error)]
46pub enum TestError {
47 #[error("failed to compile rule '{id}': {reason}")]
48 CompileError { id: RuleId, reason: String },
49}
50
51fn lang_ext(lang: SupportLang) -> &'static str {
52 match lang {
53 SupportLang::Rust => "test.rs",
54 SupportLang::TypeScript => "test.ts",
55 SupportLang::Tsx => "test.tsx",
56 _ => "test.rs",
57 }
58}
59
60fn count_matches(
61 source: &str,
62 lang: SupportLang,
63 collection: &RuleCollection<SupportLang>,
64) -> usize {
65 let root = lang.ast_grep(source);
66 let applicable = collection.get_rule_from_lang(Path::new(lang_ext(lang)), lang);
67 let combined = CombinedScan::new(applicable);
68 let result = combined.scan(&root, false);
69 result.matches.iter().map(|(_, m)| m.len()).sum()
70}
71
72fn test_one_rule(rule: &Rule) -> Result<RuleTestResult, TestError> {
73 let tests = match &rule.tests {
74 Some(t) => t,
75 None => {
76 return Ok(RuleTestResult {
77 rule_id: rule.id.clone(),
78 status: RuleTestStatus::NoTests,
79 });
80 }
81 };
82
83 let lang = rule.language.ast_grep_langs()[0];
84 let ast_grep_rule = AstGrepRule {
85 id: rule.id.as_str(),
86 language: lang,
87 severity: &rule.severity,
88 message: &rule.message,
89 note: rule.note.as_deref(),
90 rule: &rule.rule,
91 files: None,
92 ignores: None,
93 };
94 let config = compile_ast_grep_rule(&ast_grep_rule).map_err(|e| TestError::CompileError {
95 id: rule.id.clone(),
96 reason: e.to_string(),
97 })?;
98 let collection =
99 RuleCollection::try_new(vec![config]).map_err(|e| TestError::CompileError {
100 id: rule.id.clone(),
101 reason: e.to_string(),
102 })?;
103
104 let mut failures = Vec::new();
105
106 for snippet in &tests.should_match {
107 if count_matches(snippet, lang, &collection) == 0 {
108 failures.push(TestFailure {
109 kind: TestFailureKind::ShouldMatchDidNot,
110 snippet: snippet.clone(),
111 });
112 }
113 }
114
115 for snippet in &tests.should_not_match {
116 if count_matches(snippet, lang, &collection) > 0 {
117 failures.push(TestFailure {
118 kind: TestFailureKind::ShouldNotMatchDid,
119 snippet: snippet.clone(),
120 });
121 }
122 }
123
124 let status = if failures.is_empty() {
125 RuleTestStatus::Pass
126 } else {
127 RuleTestStatus::Fail { failures }
128 };
129
130 Ok(RuleTestResult {
131 rule_id: rule.id.clone(),
132 status,
133 })
134}
135
136pub fn test_rules(rules: &[Rule]) -> Result<TestSummary, TestError> {
138 let mut results = Vec::with_capacity(rules.len());
139 let mut passed = 0;
140 let mut failed = 0;
141 let mut no_tests = 0;
142 let mut total_tested = 0;
143
144 for rule in rules {
145 let result = test_one_rule(rule)?;
146 match &result.status {
147 RuleTestStatus::Pass => {
148 passed += 1;
149 total_tested += 1;
150 }
151 RuleTestStatus::Fail { .. } => {
152 failed += 1;
153 total_tested += 1;
154 }
155 RuleTestStatus::NoTests => {
156 no_tests += 1;
157 }
158 }
159 results.push(result);
160 }
161
162 Ok(TestSummary {
163 results,
164 total_tested,
165 passed,
166 failed,
167 no_tests,
168 })
169}
170
171#[cfg(test)]
172mod tests {
173 use crate::rule::parse_rule;
174
175 use super::*;
176
177 #[test]
178 fn test_rule_pass() {
179 let rule = parse_rule(
180 r#"
181id: test-pass
182language: rust
183severity: error
184message: "No unwrap"
185rule:
186 kind: call_expression
187 regex: '\.unwrap\(\)\s*$'
188tests:
189 should_match:
190 - "fn f() { foo().unwrap(); }"
191 should_not_match:
192 - "fn f() { foo()?; }"
193"#,
194 )
195 .unwrap();
196
197 let result = test_one_rule(&rule).unwrap();
198 assert_eq!(result.rule_id, RuleId::from("test-pass"));
199 assert_eq!(result.status, RuleTestStatus::Pass);
200 }
201
202 #[test]
203 fn test_rule_fail_should_match() {
204 let rule = parse_rule(
205 r#"
206id: test-fail-match
207language: rust
208severity: error
209message: "No unwrap"
210rule:
211 kind: call_expression
212 regex: '\.unwrap\(\)\s*$'
213tests:
214 should_match:
215 - "fn f() { foo()?; }"
216 should_not_match:
217 - "fn f() { bar()?; }"
218"#,
219 )
220 .unwrap();
221
222 let result = test_one_rule(&rule).unwrap();
223 assert!(matches!(result.status, RuleTestStatus::Fail { .. }));
224 if let RuleTestStatus::Fail { failures } = &result.status {
225 assert_eq!(failures.len(), 1);
226 assert_eq!(failures[0].kind, TestFailureKind::ShouldMatchDidNot);
227 }
228 }
229
230 #[test]
231 fn test_rule_fail_should_not_match() {
232 let rule = parse_rule(
233 r#"
234id: test-fail-not-match
235language: rust
236severity: error
237message: "No unwrap"
238rule:
239 kind: call_expression
240 regex: '\.unwrap\(\)\s*$'
241tests:
242 should_match:
243 - "fn f() { foo().unwrap(); }"
244 should_not_match:
245 - "fn f() { bar().unwrap(); }"
246"#,
247 )
248 .unwrap();
249
250 let result = test_one_rule(&rule).unwrap();
251 assert!(matches!(result.status, RuleTestStatus::Fail { .. }));
252 if let RuleTestStatus::Fail { failures } = &result.status {
253 assert_eq!(failures.len(), 1);
254 assert_eq!(failures[0].kind, TestFailureKind::ShouldNotMatchDid);
255 }
256 }
257
258 #[test]
259 fn test_rule_no_tests() {
260 let rule = parse_rule(
261 r#"
262id: no-tests
263language: rust
264severity: error
265message: "No unwrap"
266rule:
267 kind: call_expression
268 regex: '\.unwrap\(\)\s*$'
269"#,
270 )
271 .unwrap();
272
273 let result = test_one_rule(&rule).unwrap();
274 assert_eq!(result.status, RuleTestStatus::NoTests);
275 }
276
277 #[test]
278 fn test_rules_summary() {
279 let pass_rule = parse_rule(
280 r#"
281id: passes
282language: rust
283severity: error
284message: "m"
285rule:
286 kind: call_expression
287 regex: '\.unwrap\(\)\s*$'
288tests:
289 should_match:
290 - "fn f() { foo().unwrap(); }"
291 should_not_match:
292 - "fn f() { foo()?; }"
293"#,
294 )
295 .unwrap();
296
297 let fail_rule = parse_rule(
298 r#"
299id: fails
300language: rust
301severity: error
302message: "m"
303rule:
304 kind: call_expression
305 regex: '\.unwrap\(\)\s*$'
306tests:
307 should_match:
308 - "fn f() { foo()?; }"
309 should_not_match:
310 - "fn f() { bar()?; }"
311"#,
312 )
313 .unwrap();
314
315 let no_test_rule = parse_rule(
316 r#"
317id: notested
318language: rust
319severity: error
320message: "m"
321rule:
322 kind: call_expression
323 regex: '\.unwrap\(\)\s*$'
324"#,
325 )
326 .unwrap();
327
328 let summary = test_rules(&[pass_rule, fail_rule, no_test_rule]).unwrap();
329 assert_eq!(summary.total_tested, 2);
330 assert_eq!(summary.passed, 1);
331 assert_eq!(summary.failed, 1);
332 assert_eq!(summary.no_tests, 1);
333 }
334}