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    // A cross-file rule needs a whole project to mean anything. A snippet can
155    // never exercise it, and compiling its (null) `rule` to ast-grep would fail,
156    // so `slopguard test` reports it as untested.
157    if rule.is_cross_file() {
158        return Ok(RuleTestResult {
159            rule_id: rule.id.clone(),
160            status: RuleTestStatus::NoTests,
161        });
162    }
163
164    let tests = match &rule.tests {
165        Some(t) => t,
166        None => {
167            return Ok(RuleTestResult {
168                rule_id: rule.id.clone(),
169                status: RuleTestStatus::NoTests,
170            });
171        }
172    };
173
174    if let Some((metric, threshold)) = rule.metric_spec() {
175        return test_metric_rule(rule, metric, threshold, tests);
176    }
177    if rule.is_metric() {
178        return Err(TestError::CompileError {
179            id: rule.id.clone(),
180            reason: "metric rule is missing a threshold".to_string(),
181        });
182    }
183
184    let lang = rule.language.ast_grep_langs()[0];
185    let ast_grep_rule = AstGrepRule {
186        id: rule.id.as_str(),
187        language: lang,
188        severity: &rule.severity,
189        message: &rule.message,
190        note: rule.note.as_deref(),
191        rule: &rule.rule,
192        files: None,
193        ignores: None,
194    };
195    let config = compile_ast_grep_rule(&ast_grep_rule).map_err(|e| TestError::CompileError {
196        id: rule.id.clone(),
197        reason: e.to_string(),
198    })?;
199    let collection =
200        RuleCollection::try_new(vec![config]).map_err(|e| TestError::CompileError {
201            id: rule.id.clone(),
202            reason: e.to_string(),
203        })?;
204
205    let mut failures = Vec::new();
206
207    for snippet in &tests.should_match {
208        if count_matches(snippet, lang, &collection) == 0 {
209            failures.push(TestFailure {
210                kind: TestFailureKind::ShouldMatchDidNot,
211                snippet: snippet.clone(),
212            });
213        }
214    }
215
216    for snippet in &tests.should_not_match {
217        if count_matches(snippet, lang, &collection) > 0 {
218            failures.push(TestFailure {
219                kind: TestFailureKind::ShouldNotMatchDid,
220                snippet: snippet.clone(),
221            });
222        }
223    }
224
225    let status = if failures.is_empty() {
226        RuleTestStatus::Pass
227    } else {
228        RuleTestStatus::Fail { failures }
229    };
230
231    Ok(RuleTestResult {
232        rule_id: rule.id.clone(),
233        status,
234    })
235}
236
237/// Test all rules' inline test cases. Returns a summary with per-rule results.
238pub fn test_rules(rules: &[Rule]) -> Result<TestSummary, TestError> {
239    let mut results = Vec::with_capacity(rules.len());
240    let mut passed = 0;
241    let mut failed = 0;
242    let mut no_tests = 0;
243    let mut total_tested = 0;
244
245    for rule in rules {
246        let result = test_one_rule(rule)?;
247        match &result.status {
248            RuleTestStatus::Pass => {
249                passed += 1;
250                total_tested += 1;
251            }
252            RuleTestStatus::Fail { .. } => {
253                failed += 1;
254                total_tested += 1;
255            }
256            RuleTestStatus::NoTests => {
257                no_tests += 1;
258            }
259        }
260        results.push(result);
261    }
262
263    Ok(TestSummary {
264        results,
265        total_tested,
266        passed,
267        failed,
268        no_tests,
269    })
270}
271
272#[cfg(test)]
273mod tests {
274    use std::fs::{create_dir, write};
275
276    use tempfile::tempdir;
277
278    use crate::rule::{load_builtin_rules, load_custom_rules, parse_rule};
279
280    use super::*;
281
282    #[test]
283    fn cross_file_rule_reports_no_tests() {
284        let rule = parse_rule(
285            r#"
286id: no-single-impl-trait
287language: rust
288severity: warning
289category: slop
290cross_file: single_impl_trait
291message: "Trait with a single implementation in the project."
292"#,
293        )
294        .unwrap();
295
296        let result = test_one_rule(&rule).unwrap();
297        assert_eq!(result.status, RuleTestStatus::NoTests);
298    }
299
300    #[test]
301    fn test_rule_pass() {
302        let rule = parse_rule(
303            r#"
304id: test-pass
305language: rust
306severity: error
307message: "No unwrap"
308rule:
309  kind: call_expression
310  regex: '\.unwrap\(\)\s*$'
311tests:
312  should_match:
313    - "fn f() { foo().unwrap(); }"
314  should_not_match:
315    - "fn f() { foo()?; }"
316"#,
317        )
318        .unwrap();
319
320        let result = test_one_rule(&rule).unwrap();
321        assert_eq!(result.rule_id, RuleId::from("test-pass"));
322        assert_eq!(result.status, RuleTestStatus::Pass);
323    }
324
325    #[test]
326    fn test_rule_fail_should_match() {
327        let rule = parse_rule(
328            r#"
329id: test-fail-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()?; }"
339  should_not_match:
340    - "fn f() { bar()?; }"
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::ShouldMatchDidNot);
350        }
351    }
352
353    #[test]
354    fn test_rule_fail_should_not_match() {
355        let rule = parse_rule(
356            r#"
357id: test-fail-not-match
358language: rust
359severity: error
360message: "No unwrap"
361rule:
362  kind: call_expression
363  regex: '\.unwrap\(\)\s*$'
364tests:
365  should_match:
366    - "fn f() { foo().unwrap(); }"
367  should_not_match:
368    - "fn f() { bar().unwrap(); }"
369"#,
370        )
371        .unwrap();
372
373        let result = test_one_rule(&rule).unwrap();
374        assert!(matches!(result.status, RuleTestStatus::Fail { .. }));
375        if let RuleTestStatus::Fail { failures } = &result.status {
376            assert_eq!(failures.len(), 1);
377            assert_eq!(failures[0].kind, TestFailureKind::ShouldNotMatchDid);
378        }
379    }
380
381    #[test]
382    fn test_rule_no_tests() {
383        let rule = parse_rule(
384            r#"
385id: no-tests
386language: rust
387severity: error
388message: "No unwrap"
389rule:
390  kind: call_expression
391  regex: '\.unwrap\(\)\s*$'
392"#,
393        )
394        .unwrap();
395
396        let result = test_one_rule(&rule).unwrap();
397        assert_eq!(result.status, RuleTestStatus::NoTests);
398    }
399
400    #[test]
401    fn test_rules_summary() {
402        let pass_rule = parse_rule(
403            r#"
404id: passes
405language: rust
406severity: error
407message: "m"
408rule:
409  kind: call_expression
410  regex: '\.unwrap\(\)\s*$'
411tests:
412  should_match:
413    - "fn f() { foo().unwrap(); }"
414  should_not_match:
415    - "fn f() { foo()?; }"
416"#,
417        )
418        .unwrap();
419
420        let fail_rule = parse_rule(
421            r#"
422id: fails
423language: rust
424severity: error
425message: "m"
426rule:
427  kind: call_expression
428  regex: '\.unwrap\(\)\s*$'
429tests:
430  should_match:
431    - "fn f() { foo()?; }"
432  should_not_match:
433    - "fn f() { bar()?; }"
434"#,
435        )
436        .unwrap();
437
438        let no_test_rule = parse_rule(
439            r#"
440id: notested
441language: rust
442severity: error
443message: "m"
444rule:
445  kind: call_expression
446  regex: '\.unwrap\(\)\s*$'
447"#,
448        )
449        .unwrap();
450
451        let summary = test_rules(&[pass_rule, fail_rule, no_test_rule]).unwrap();
452        assert_eq!(summary.total_tested, 2);
453        assert_eq!(summary.passed, 1);
454        assert_eq!(summary.failed, 1);
455        assert_eq!(summary.no_tests, 1);
456    }
457
458    /// `n` lines of trivial Rust, as a whole-file test case.
459    fn rust_lines(n: usize) -> String {
460        (0..n).map(|i| format!("fn f{i}() {{}}\n")).collect()
461    }
462
463    /// A whole-file snippet rendered as a YAML double-quoted scalar, so the
464    /// newlines survive without block-scalar indentation games.
465    fn yaml_scalar(source: &str) -> String {
466        format!("{source:?}")
467    }
468
469    #[test]
470    fn metric_rule_test_pass_inline() {
471        let rule = parse_rule(&format!(
472            r#"
473id: metric-pass
474language: rust
475severity: warning
476category: slop
477metric: file_lines
478threshold: 5
479message: "m"
480tests:
481  should_match:
482    - {}
483  should_not_match:
484    - {}
485"#,
486            yaml_scalar(&rust_lines(8)),
487            yaml_scalar(&rust_lines(3))
488        ))
489        .unwrap();
490
491        let result = test_one_rule(&rule).unwrap();
492        assert_eq!(result.status, RuleTestStatus::Pass);
493    }
494
495    #[test]
496    fn metric_rule_test_fail_should_match() {
497        let rule = parse_rule(&format!(
498            r#"
499id: metric-fail-match
500language: rust
501severity: warning
502category: slop
503metric: file_lines
504threshold: 20
505message: "m"
506tests:
507  should_match:
508    - {}
509"#,
510            yaml_scalar(&rust_lines(3))
511        ))
512        .unwrap();
513
514        let result = test_one_rule(&rule).unwrap();
515        match &result.status {
516            RuleTestStatus::Fail { failures } => {
517                assert_eq!(failures.len(), 1);
518                assert_eq!(failures[0].kind, TestFailureKind::ShouldMatchDidNot);
519            }
520            other => panic!("expected a failure, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn metric_rule_test_fail_should_not_match() {
526        let rule = parse_rule(&format!(
527            r#"
528id: metric-fail-not-match
529language: rust
530severity: warning
531category: slop
532metric: file_lines
533threshold: 2
534message: "m"
535tests:
536  should_not_match:
537    - {}
538"#,
539            yaml_scalar(&rust_lines(6))
540        ))
541        .unwrap();
542
543        let result = test_one_rule(&rule).unwrap();
544        match &result.status {
545            RuleTestStatus::Fail { failures } => {
546                assert_eq!(failures.len(), 1);
547                assert_eq!(failures[0].kind, TestFailureKind::ShouldNotMatchDid);
548            }
549            other => panic!("expected a failure, got {other:?}"),
550        }
551    }
552
553    /// A custom rule directory holding one metric rule plus its fixture.
554    fn fixture_rule_dir(threshold: usize) -> tempfile::TempDir {
555        let dir = tempdir().unwrap();
556        let fixtures = dir.path().join("fixtures");
557        create_dir(&fixtures).unwrap();
558        write(fixtures.join("big.rs"), rust_lines(20)).unwrap();
559        write(fixtures.join("small.rs"), rust_lines(3)).unwrap();
560        write(
561            dir.path().join("metric.yml"),
562            format!(
563                r#"
564id: custom-metric
565language: rust
566severity: warning
567category: slop
568metric: file_lines
569threshold: {threshold}
570message: "m"
571tests:
572  should_match_files:
573    - "fixtures/big.rs"
574  should_not_match_files:
575    - "fixtures/small.rs"
576"#
577            ),
578        )
579        .unwrap();
580        dir
581    }
582
583    #[test]
584    fn metric_rule_test_from_fixture_files() {
585        let dir = fixture_rule_dir(10);
586        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
587        let summary = test_rules(&rules).unwrap();
588        assert_eq!(summary.passed, 1);
589        assert_eq!(summary.failed, 0);
590
591        // A threshold above the large fixture makes should_match fail, and the
592        // reported snippet is the fixture path, not its 20 lines of content.
593        let dir = fixture_rule_dir(100);
594        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
595        let summary = test_rules(&rules).unwrap();
596        assert_eq!(summary.failed, 1);
597        match &summary.results[0].status {
598            RuleTestStatus::Fail { failures } => {
599                assert_eq!(failures.len(), 1);
600                assert_eq!(failures[0].snippet, "fixtures/big.rs");
601            }
602            other => panic!("expected a failure, got {other:?}"),
603        }
604    }
605
606    #[test]
607    fn metric_rule_missing_fixture_errors() {
608        let dir = tempdir().unwrap();
609        write(
610            dir.path().join("metric.yml"),
611            r#"
612id: missing-fixture
613language: rust
614severity: warning
615category: slop
616metric: file_lines
617threshold: 10
618message: "m"
619tests:
620  should_match_files:
621    - "fixtures/gone.rs"
622"#,
623        )
624        .unwrap();
625        let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
626        let err = test_rules(&rules).unwrap_err();
627        assert!(matches!(err, TestError::Fixture { .. }), "{err:?}");
628    }
629
630    #[test]
631    fn all_builtin_metric_rules_pass() {
632        let rules: Vec<Rule> = load_builtin_rules()
633            .unwrap()
634            .into_iter()
635            .filter(|r| r.is_metric())
636            .collect();
637        assert!(!rules.is_empty(), "there should be builtin metric rules");
638        let summary = test_rules(&rules).unwrap();
639        assert_eq!(summary.failed, 0, "{:?}", summary.results);
640        assert_eq!(summary.no_tests, 0, "{:?}", summary.results);
641    }
642}