Skip to main content

slopguard_core/
testing.rs

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
136/// Test all rules' inline test cases. Returns a summary with per-rule results.
137pub 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}