Skip to main content

slopguard_core/testing/
mod.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::fix::fix_snippet;
9use crate::metric::{self, Metric};
10use crate::rule::{self, Rule, RuleId, RuleTests};
11use crate::scanner::{compile_ast_grep_rule, AstGrepRule};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum RuleTestStatus {
15    Pass,
16    Fail { failures: Vec<TestFailure> },
17    NoTests,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TestFailure {
22    pub kind: TestFailureKind,
23    pub snippet: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum TestFailureKind {
28    ShouldMatchDidNot,
29    ShouldNotMatchDid,
30    /// A `should_fix` case's rewrite did not produce the expected `after`.
31    FixMismatch {
32        actual: String,
33    },
34}
35
36#[derive(Debug, Clone)]
37pub struct RuleTestResult {
38    pub rule_id: RuleId,
39    pub status: RuleTestStatus,
40}
41
42#[derive(Debug)]
43pub struct TestSummary {
44    pub results: Vec<RuleTestResult>,
45    pub total_tested: usize,
46    pub passed: usize,
47    pub failed: usize,
48    pub no_tests: usize,
49}
50
51#[derive(Debug, Error)]
52pub enum TestError {
53    #[error("failed to compile rule '{id}': {reason}")]
54    CompileError { id: RuleId, reason: String },
55
56    #[error("rule '{id}': fixture '{path}': {reason}")]
57    Fixture {
58        id: RuleId,
59        path: String,
60        reason: String,
61    },
62}
63
64fn lang_ext(lang: SupportLang) -> &'static str {
65    match lang {
66        SupportLang::Rust => "test.rs",
67        SupportLang::TypeScript => "test.ts",
68        SupportLang::Tsx => "test.tsx",
69        _ => "test.rs",
70    }
71}
72
73fn count_matches(
74    source: &str,
75    lang: SupportLang,
76    collection: &RuleCollection<SupportLang>,
77) -> usize {
78    let root = lang.ast_grep(source);
79    let applicable = collection.get_rule_from_lang(Path::new(lang_ext(lang)), lang);
80    let combined = CombinedScan::new(applicable);
81    let result = combined.scan(&root, false);
82    result.matches.iter().map(|(_, m)| m.len()).sum()
83}
84
85/// The `(label, content)` pairs a metric rule is tested against: inline
86/// whole-file snippets plus the contents of every referenced fixture.
87///
88/// The label is what a failure reports. For a fixture it is the path, which
89/// keeps CLI output short: a 600-line fixture has no useful first line.
90fn metric_cases(
91    rule: &Rule,
92    inline: &[String],
93    fixtures: &[String],
94) -> Result<Vec<(String, String)>, TestError> {
95    let mut cases: Vec<(String, String)> = inline
96        .iter()
97        .map(|snippet| (snippet.clone(), snippet.clone()))
98        .collect();
99    for path in fixtures {
100        let content = rule::read_fixture(rule, path).map_err(|e| TestError::Fixture {
101            id: rule.id.clone(),
102            path: path.clone(),
103            reason: e.to_string(),
104        })?;
105        cases.push((path.clone(), content));
106    }
107    Ok(cases)
108}
109
110/// Run a metric rule's tests: every should-match case must fire, every
111/// should-not-match case must not. A metric fires only when the file reaches
112/// `min_lines` (if set) and its value exceeds the threshold, matching the
113/// scanner so a below-floor should-match case is a genuine failure.
114fn test_metric_rule(
115    rule: &Rule,
116    metric: Metric,
117    threshold: f64,
118    tests: &RuleTests,
119) -> Result<RuleTestResult, TestError> {
120    let lang = rule.language.ast_grep_langs()[0];
121    let fires = |content: &str| {
122        if let Some(floor) = rule.min_lines {
123            if content.lines().count() < floor {
124                return false;
125            }
126        }
127        let root = lang.ast_grep(content);
128        // Fixtures are self-contained metric cases: count everything, so a
129        // fixture that exercises the metric is not silently emptied by
130        // `#[cfg(test)]` exclusion.
131        metric::exceeds(
132            metric::compute(metric, &rule.language, &root, content, None),
133            threshold,
134        )
135    };
136
137    let mut failures = Vec::new();
138
139    for (label, content) in metric_cases(rule, &tests.should_match, &tests.should_match_files)? {
140        if !fires(&content) {
141            failures.push(TestFailure {
142                kind: TestFailureKind::ShouldMatchDidNot,
143                snippet: label,
144            });
145        }
146    }
147
148    for (label, content) in
149        metric_cases(rule, &tests.should_not_match, &tests.should_not_match_files)?
150    {
151        if fires(&content) {
152            failures.push(TestFailure {
153                kind: TestFailureKind::ShouldNotMatchDid,
154                snippet: label,
155            });
156        }
157    }
158
159    let status = if failures.is_empty() {
160        RuleTestStatus::Pass
161    } else {
162        RuleTestStatus::Fail { failures }
163    };
164
165    Ok(RuleTestResult {
166        rule_id: rule.id.clone(),
167        status,
168    })
169}
170
171fn test_one_rule(rule: &Rule) -> Result<RuleTestResult, TestError> {
172    // A cross-file or resolution rule needs a whole project (and its manifests)
173    // to mean anything. A snippet can never exercise it, and compiling its
174    // (null) `rule` to ast-grep would fail, so `slopguard test` reports it as
175    // untested; its logic is covered by unit and integration tests instead.
176    if rule.is_cross_file() || rule.is_resolution() {
177        return Ok(RuleTestResult {
178            rule_id: rule.id.clone(),
179            status: RuleTestStatus::NoTests,
180        });
181    }
182
183    let tests = match &rule.tests {
184        Some(t) => t,
185        None => {
186            return Ok(RuleTestResult {
187                rule_id: rule.id.clone(),
188                status: RuleTestStatus::NoTests,
189            });
190        }
191    };
192
193    if let Some((metric, threshold)) = rule.metric_spec() {
194        return test_metric_rule(rule, metric, threshold, tests);
195    }
196    if rule.is_metric() {
197        return Err(TestError::CompileError {
198            id: rule.id.clone(),
199            reason: "metric rule is missing a threshold".to_string(),
200        });
201    }
202
203    let lang = rule.language.ast_grep_langs()[0];
204    let ast_grep_rule = AstGrepRule {
205        id: rule.id.as_str(),
206        language: lang,
207        severity: &rule.severity,
208        message: &rule.message,
209        note: rule.note.as_deref(),
210        rule: &rule.rule,
211        constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
212        fix: None,
213        files: None,
214        ignores: None,
215    };
216    let config = compile_ast_grep_rule(&ast_grep_rule).map_err(|e| TestError::CompileError {
217        id: rule.id.clone(),
218        reason: e.to_string(),
219    })?;
220    let collection =
221        RuleCollection::try_new(vec![config]).map_err(|e| TestError::CompileError {
222            id: rule.id.clone(),
223            reason: e.to_string(),
224        })?;
225
226    let mut failures = Vec::new();
227
228    for snippet in &tests.should_match {
229        if count_matches(snippet, lang, &collection) == 0 {
230            failures.push(TestFailure {
231                kind: TestFailureKind::ShouldMatchDidNot,
232                snippet: snippet.clone(),
233            });
234        }
235    }
236
237    for snippet in &tests.should_not_match {
238        if count_matches(snippet, lang, &collection) > 0 {
239            failures.push(TestFailure {
240                kind: TestFailureKind::ShouldNotMatchDid,
241                snippet: snippet.clone(),
242            });
243        }
244    }
245
246    for case in &tests.should_fix {
247        let actual = fix_snippet(rule, &case.before).map_err(|e| TestError::CompileError {
248            id: rule.id.clone(),
249            reason: e.to_string(),
250        })?;
251        if actual != case.after {
252            failures.push(TestFailure {
253                kind: TestFailureKind::FixMismatch { actual },
254                snippet: case.before.clone(),
255            });
256        }
257    }
258
259    let status = if failures.is_empty() {
260        RuleTestStatus::Pass
261    } else {
262        RuleTestStatus::Fail { failures }
263    };
264
265    Ok(RuleTestResult {
266        rule_id: rule.id.clone(),
267        status,
268    })
269}
270
271/// Test all rules' inline test cases. Returns a summary with per-rule results.
272pub fn test_rules(rules: &[Rule]) -> Result<TestSummary, TestError> {
273    let mut results = Vec::with_capacity(rules.len());
274    let mut passed = 0;
275    let mut failed = 0;
276    let mut no_tests = 0;
277    let mut total_tested = 0;
278
279    for rule in rules {
280        let result = test_one_rule(rule)?;
281        match &result.status {
282            RuleTestStatus::Pass => {
283                passed += 1;
284                total_tested += 1;
285            }
286            RuleTestStatus::Fail { .. } => {
287                failed += 1;
288                total_tested += 1;
289            }
290            RuleTestStatus::NoTests => {
291                no_tests += 1;
292            }
293        }
294        results.push(result);
295    }
296
297    Ok(TestSummary {
298        results,
299        total_tested,
300        passed,
301        failed,
302        no_tests,
303    })
304}
305
306#[cfg(test)]
307mod tests;