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::metric::{self, Metric};
9use crate::rule::{self, Rule, RuleId, RuleTests};
10use crate::scanner::{compile_ast_grep_rule, AstGrepRule};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RuleTestStatus {
14    Pass,
15    Fail { failures: Vec<TestFailure> },
16    NoTests,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct TestFailure {
21    pub kind: TestFailureKind,
22    pub snippet: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum TestFailureKind {
27    ShouldMatchDidNot,
28    ShouldNotMatchDid,
29}
30
31#[derive(Debug, Clone)]
32pub struct RuleTestResult {
33    pub rule_id: RuleId,
34    pub status: RuleTestStatus,
35}
36
37#[derive(Debug)]
38pub struct TestSummary {
39    pub results: Vec<RuleTestResult>,
40    pub total_tested: usize,
41    pub passed: usize,
42    pub failed: usize,
43    pub no_tests: usize,
44}
45
46#[derive(Debug, Error)]
47pub enum TestError {
48    #[error("failed to compile rule '{id}': {reason}")]
49    CompileError { id: RuleId, reason: String },
50
51    #[error("rule '{id}': fixture '{path}': {reason}")]
52    Fixture {
53        id: RuleId,
54        path: String,
55        reason: String,
56    },
57}
58
59fn lang_ext(lang: SupportLang) -> &'static str {
60    match lang {
61        SupportLang::Rust => "test.rs",
62        SupportLang::TypeScript => "test.ts",
63        SupportLang::Tsx => "test.tsx",
64        _ => "test.rs",
65    }
66}
67
68fn count_matches(
69    source: &str,
70    lang: SupportLang,
71    collection: &RuleCollection<SupportLang>,
72) -> usize {
73    let root = lang.ast_grep(source);
74    let applicable = collection.get_rule_from_lang(Path::new(lang_ext(lang)), lang);
75    let combined = CombinedScan::new(applicable);
76    let result = combined.scan(&root, false);
77    result.matches.iter().map(|(_, m)| m.len()).sum()
78}
79
80/// The `(label, content)` pairs a metric rule is tested against: inline
81/// whole-file snippets plus the contents of every referenced fixture.
82///
83/// The label is what a failure reports. For a fixture it is the path, which
84/// keeps CLI output short: a 600-line fixture has no useful first line.
85fn metric_cases(
86    rule: &Rule,
87    inline: &[String],
88    fixtures: &[String],
89) -> Result<Vec<(String, String)>, TestError> {
90    let mut cases: Vec<(String, String)> = inline
91        .iter()
92        .map(|snippet| (snippet.clone(), snippet.clone()))
93        .collect();
94    for path in fixtures {
95        let content = rule::read_fixture(rule, path).map_err(|e| TestError::Fixture {
96            id: rule.id.clone(),
97            path: path.clone(),
98            reason: e.to_string(),
99        })?;
100        cases.push((path.clone(), content));
101    }
102    Ok(cases)
103}
104
105/// Run a metric rule's tests: every should-match case must exceed the
106/// threshold, every should-not-match case must stay at or below it.
107fn test_metric_rule(
108    rule: &Rule,
109    metric: Metric,
110    threshold: f64,
111    tests: &RuleTests,
112) -> Result<RuleTestResult, TestError> {
113    let lang = rule.language.ast_grep_langs()[0];
114    let measure = |content: &str| {
115        let root = lang.ast_grep(content);
116        metric::compute(metric, &rule.language, &root, content)
117    };
118
119    let mut failures = Vec::new();
120
121    for (label, content) in metric_cases(rule, &tests.should_match, &tests.should_match_files)? {
122        if !metric::exceeds(measure(&content), threshold) {
123            failures.push(TestFailure {
124                kind: TestFailureKind::ShouldMatchDidNot,
125                snippet: label,
126            });
127        }
128    }
129
130    for (label, content) in
131        metric_cases(rule, &tests.should_not_match, &tests.should_not_match_files)?
132    {
133        if metric::exceeds(measure(&content), threshold) {
134            failures.push(TestFailure {
135                kind: TestFailureKind::ShouldNotMatchDid,
136                snippet: label,
137            });
138        }
139    }
140
141    let status = if failures.is_empty() {
142        RuleTestStatus::Pass
143    } else {
144        RuleTestStatus::Fail { failures }
145    };
146
147    Ok(RuleTestResult {
148        rule_id: rule.id.clone(),
149        status,
150    })
151}
152
153fn test_one_rule(rule: &Rule) -> Result<RuleTestResult, TestError> {
154    let tests = match &rule.tests {
155        Some(t) => t,
156        None => {
157            return Ok(RuleTestResult {
158                rule_id: rule.id.clone(),
159                status: RuleTestStatus::NoTests,
160            });
161        }
162    };
163
164    if let Some((metric, threshold)) = rule.metric_spec() {
165        return test_metric_rule(rule, metric, threshold, tests);
166    }
167    if rule.is_metric() {
168        return Err(TestError::CompileError {
169            id: rule.id.clone(),
170            reason: "metric rule is missing a threshold".to_string(),
171        });
172    }
173
174    let lang = rule.language.ast_grep_langs()[0];
175    let ast_grep_rule = AstGrepRule {
176        id: rule.id.as_str(),
177        language: lang,
178        severity: &rule.severity,
179        message: &rule.message,
180        note: rule.note.as_deref(),
181        rule: &rule.rule,
182        files: None,
183        ignores: None,
184    };
185    let config = compile_ast_grep_rule(&ast_grep_rule).map_err(|e| TestError::CompileError {
186        id: rule.id.clone(),
187        reason: e.to_string(),
188    })?;
189    let collection =
190        RuleCollection::try_new(vec![config]).map_err(|e| TestError::CompileError {
191            id: rule.id.clone(),
192            reason: e.to_string(),
193        })?;
194
195    let mut failures = Vec::new();
196
197    for snippet in &tests.should_match {
198        if count_matches(snippet, lang, &collection) == 0 {
199            failures.push(TestFailure {
200                kind: TestFailureKind::ShouldMatchDidNot,
201                snippet: snippet.clone(),
202            });
203        }
204    }
205
206    for snippet in &tests.should_not_match {
207        if count_matches(snippet, lang, &collection) > 0 {
208            failures.push(TestFailure {
209                kind: TestFailureKind::ShouldNotMatchDid,
210                snippet: snippet.clone(),
211            });
212        }
213    }
214
215    let status = if failures.is_empty() {
216        RuleTestStatus::Pass
217    } else {
218        RuleTestStatus::Fail { failures }
219    };
220
221    Ok(RuleTestResult {
222        rule_id: rule.id.clone(),
223        status,
224    })
225}
226
227/// Test all rules' inline test cases. Returns a summary with per-rule results.
228pub fn test_rules(rules: &[Rule]) -> Result<TestSummary, TestError> {
229    let mut results = Vec::with_capacity(rules.len());
230    let mut passed = 0;
231    let mut failed = 0;
232    let mut no_tests = 0;
233    let mut total_tested = 0;
234
235    for rule in rules {
236        let result = test_one_rule(rule)?;
237        match &result.status {
238            RuleTestStatus::Pass => {
239                passed += 1;
240                total_tested += 1;
241            }
242            RuleTestStatus::Fail { .. } => {
243                failed += 1;
244                total_tested += 1;
245            }
246            RuleTestStatus::NoTests => {
247                no_tests += 1;
248            }
249        }
250        results.push(result);
251    }
252
253    Ok(TestSummary {
254        results,
255        total_tested,
256        passed,
257        failed,
258        no_tests,
259    })
260}
261
262#[cfg(test)]
263mod tests {
264    use std::fs::{create_dir, write};
265
266    use tempfile::tempdir;
267
268    use crate::rule::{load_builtin_rules, load_custom_rules, parse_rule};
269
270    use super::*;
271
272    #[test]
273    fn test_rule_pass() {
274        let rule = parse_rule(
275            r#"
276id: test-pass
277language: rust
278severity: error
279message: "No unwrap"
280rule:
281  kind: call_expression
282  regex: '\.unwrap\(\)\s*$'
283tests:
284  should_match:
285    - "fn f() { foo().unwrap(); }"
286  should_not_match:
287    - "fn f() { foo()?; }"
288"#,
289        )
290        .unwrap();
291
292        let result = test_one_rule(&rule).unwrap();
293        assert_eq!(result.rule_id, RuleId::from("test-pass"));
294        assert_eq!(result.status, RuleTestStatus::Pass);
295    }
296
297    #[test]
298    fn test_rule_fail_should_match() {
299        let rule = parse_rule(
300            r#"
301id: test-fail-match
302language: rust
303severity: error
304message: "No unwrap"
305rule:
306  kind: call_expression
307  regex: '\.unwrap\(\)\s*$'
308tests:
309  should_match:
310    - "fn f() { foo()?; }"
311  should_not_match:
312    - "fn f() { bar()?; }"
313"#,
314        )
315        .unwrap();
316
317        let result = test_one_rule(&rule).unwrap();
318        assert!(matches!(result.status, RuleTestStatus::Fail { .. }));
319        if let RuleTestStatus::Fail { failures } = &result.status {
320            assert_eq!(failures.len(), 1);
321            assert_eq!(failures[0].kind, TestFailureKind::ShouldMatchDidNot);
322        }
323    }
324
325    #[test]
326    fn test_rule_fail_should_not_match() {
327        let rule = parse_rule(
328            r#"
329id: test-fail-not-match
330language: rust
331severity: error
332message: "No unwrap"
333rule:
334  kind: call_expression
335  regex: '\.unwrap\(\)\s*$'
336tests:
337  should_match:
338    - "fn f() { foo().unwrap(); }"
339  should_not_match:
340    - "fn f() { bar().unwrap(); }"
341"#,
342        )
343        .unwrap();
344
345        let result = test_one_rule(&rule).unwrap();
346        assert!(matches!(result.status, RuleTestStatus::Fail { .. }));
347        if let RuleTestStatus::Fail { failures } = &result.status {
348            assert_eq!(failures.len(), 1);
349            assert_eq!(failures[0].kind, TestFailureKind::ShouldNotMatchDid);
350        }
351    }
352
353    #[test]
354    fn test_rule_no_tests() {
355        let rule = parse_rule(
356            r#"
357id: no-tests
358language: rust
359severity: error
360message: "No unwrap"
361rule:
362  kind: call_expression
363  regex: '\.unwrap\(\)\s*$'
364"#,
365        )
366        .unwrap();
367
368        let result = test_one_rule(&rule).unwrap();
369        assert_eq!(result.status, RuleTestStatus::NoTests);
370    }
371
372    #[test]
373    fn test_rules_summary() {
374        let pass_rule = parse_rule(
375            r#"
376id: passes
377language: rust
378severity: error
379message: "m"
380rule:
381  kind: call_expression
382  regex: '\.unwrap\(\)\s*$'
383tests:
384  should_match:
385    - "fn f() { foo().unwrap(); }"
386  should_not_match:
387    - "fn f() { foo()?; }"
388"#,
389        )
390        .unwrap();
391
392        let fail_rule = parse_rule(
393            r#"
394id: fails
395language: rust
396severity: error
397message: "m"
398rule:
399  kind: call_expression
400  regex: '\.unwrap\(\)\s*$'
401tests:
402  should_match:
403    - "fn f() { foo()?; }"
404  should_not_match:
405    - "fn f() { bar()?; }"
406"#,
407        )
408        .unwrap();
409
410        let no_test_rule = parse_rule(
411            r#"
412id: notested
413language: rust
414severity: error
415message: "m"
416rule:
417  kind: call_expression
418  regex: '\.unwrap\(\)\s*$'
419"#,
420        )
421        .unwrap();
422
423        let summary = test_rules(&[pass_rule, fail_rule, no_test_rule]).unwrap();
424        assert_eq!(summary.total_tested, 2);
425        assert_eq!(summary.passed, 1);
426        assert_eq!(summary.failed, 1);
427        assert_eq!(summary.no_tests, 1);
428    }
429
430    /// `n` lines of trivial Rust, as a whole-file test case.
431    fn rust_lines(n: usize) -> String {
432        (0..n).map(|i| format!("fn f{i}() {{}}\n")).collect()
433    }
434
435    /// A whole-file snippet rendered as a YAML double-quoted scalar, so the
436    /// newlines survive without block-scalar indentation games.
437    fn yaml_scalar(source: &str) -> String {
438        format!("{source:?}")
439    }
440
441    #[test]
442    fn metric_rule_test_pass_inline() {
443        let rule = parse_rule(&format!(
444            r#"
445id: metric-pass
446language: rust
447severity: warning
448category: slop
449metric: file_lines
450threshold: 5
451message: "m"
452tests:
453  should_match:
454    - {}
455  should_not_match:
456    - {}
457"#,
458            yaml_scalar(&rust_lines(8)),
459            yaml_scalar(&rust_lines(3))
460        ))
461        .unwrap();
462
463        let result = test_one_rule(&rule).unwrap();
464        assert_eq!(result.status, RuleTestStatus::Pass);
465    }
466
467    #[test]
468    fn metric_rule_test_fail_should_match() {
469        let rule = parse_rule(&format!(
470            r#"
471id: metric-fail-match
472language: rust
473severity: warning
474category: slop
475metric: file_lines
476threshold: 20
477message: "m"
478tests:
479  should_match:
480    - {}
481"#,
482            yaml_scalar(&rust_lines(3))
483        ))
484        .unwrap();
485
486        let result = test_one_rule(&rule).unwrap();
487        match &result.status {
488            RuleTestStatus::Fail { failures } => {
489                assert_eq!(failures.len(), 1);
490                assert_eq!(failures[0].kind, TestFailureKind::ShouldMatchDidNot);
491            }
492            other => panic!("expected a failure, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn metric_rule_test_fail_should_not_match() {
498        let rule = parse_rule(&format!(
499            r#"
500id: metric-fail-not-match
501language: rust
502severity: warning
503category: slop
504metric: file_lines
505threshold: 2
506message: "m"
507tests:
508  should_not_match:
509    - {}
510"#,
511            yaml_scalar(&rust_lines(6))
512        ))
513        .unwrap();
514
515        let result = test_one_rule(&rule).unwrap();
516        match &result.status {
517            RuleTestStatus::Fail { failures } => {
518                assert_eq!(failures.len(), 1);
519                assert_eq!(failures[0].kind, TestFailureKind::ShouldNotMatchDid);
520            }
521            other => panic!("expected a failure, got {other:?}"),
522        }
523    }
524
525    /// A custom rule directory holding one metric rule plus its fixture.
526    fn fixture_rule_dir(threshold: usize) -> tempfile::TempDir {
527        let dir = tempdir().unwrap();
528        let fixtures = dir.path().join("fixtures");
529        create_dir(&fixtures).unwrap();
530        write(fixtures.join("big.rs"), rust_lines(20)).unwrap();
531        write(fixtures.join("small.rs"), rust_lines(3)).unwrap();
532        write(
533            dir.path().join("metric.yml"),
534            format!(
535                r#"
536id: custom-metric
537language: rust
538severity: warning
539category: slop
540metric: file_lines
541threshold: {threshold}
542message: "m"
543tests:
544  should_match_files:
545    - "fixtures/big.rs"
546  should_not_match_files:
547    - "fixtures/small.rs"
548"#
549            ),
550        )
551        .unwrap();
552        dir
553    }
554
555    #[test]
556    fn metric_rule_test_from_fixture_files() {
557        let dir = fixture_rule_dir(10);
558        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
559        let summary = test_rules(&rules).unwrap();
560        assert_eq!(summary.passed, 1);
561        assert_eq!(summary.failed, 0);
562
563        // A threshold above the large fixture makes should_match fail, and the
564        // reported snippet is the fixture path, not its 20 lines of content.
565        let dir = fixture_rule_dir(100);
566        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
567        let summary = test_rules(&rules).unwrap();
568        assert_eq!(summary.failed, 1);
569        match &summary.results[0].status {
570            RuleTestStatus::Fail { failures } => {
571                assert_eq!(failures.len(), 1);
572                assert_eq!(failures[0].snippet, "fixtures/big.rs");
573            }
574            other => panic!("expected a failure, got {other:?}"),
575        }
576    }
577
578    #[test]
579    fn metric_rule_missing_fixture_errors() {
580        let dir = tempdir().unwrap();
581        write(
582            dir.path().join("metric.yml"),
583            r#"
584id: missing-fixture
585language: rust
586severity: warning
587category: slop
588metric: file_lines
589threshold: 10
590message: "m"
591tests:
592  should_match_files:
593    - "fixtures/gone.rs"
594"#,
595        )
596        .unwrap();
597        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
598        let err = test_rules(&rules).unwrap_err();
599        assert!(matches!(err, TestError::Fixture { .. }), "{err:?}");
600    }
601
602    #[test]
603    fn all_builtin_metric_rules_pass() {
604        let rules: Vec<Rule> = load_builtin_rules()
605            .unwrap()
606            .into_iter()
607            .filter(|r| r.is_metric())
608            .collect();
609        assert!(!rules.is_empty(), "there should be builtin metric rules");
610        let summary = test_rules(&rules).unwrap();
611        assert_eq!(summary.failed, 0, "{:?}", summary.results);
612        assert_eq!(summary.no_tests, 0, "{:?}", summary.results);
613    }
614}