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 rule needs a whole project to mean anything. A snippet can
173    // never exercise it, and compiling its (null) `rule` to ast-grep would fail,
174    // so `slopguard test` reports it as untested.
175    if rule.is_cross_file() {
176        return Ok(RuleTestResult {
177            rule_id: rule.id.clone(),
178            status: RuleTestStatus::NoTests,
179        });
180    }
181
182    let tests = match &rule.tests {
183        Some(t) => t,
184        None => {
185            return Ok(RuleTestResult {
186                rule_id: rule.id.clone(),
187                status: RuleTestStatus::NoTests,
188            });
189        }
190    };
191
192    if let Some((metric, threshold)) = rule.metric_spec() {
193        return test_metric_rule(rule, metric, threshold, tests);
194    }
195    if rule.is_metric() {
196        return Err(TestError::CompileError {
197            id: rule.id.clone(),
198            reason: "metric rule is missing a threshold".to_string(),
199        });
200    }
201
202    let lang = rule.language.ast_grep_langs()[0];
203    let ast_grep_rule = AstGrepRule {
204        id: rule.id.as_str(),
205        language: lang,
206        severity: &rule.severity,
207        message: &rule.message,
208        note: rule.note.as_deref(),
209        rule: &rule.rule,
210        constraints: (!rule.constraints.is_null()).then_some(&rule.constraints),
211        fix: None,
212        files: None,
213        ignores: None,
214    };
215    let config = compile_ast_grep_rule(&ast_grep_rule).map_err(|e| TestError::CompileError {
216        id: rule.id.clone(),
217        reason: e.to_string(),
218    })?;
219    let collection =
220        RuleCollection::try_new(vec![config]).map_err(|e| TestError::CompileError {
221            id: rule.id.clone(),
222            reason: e.to_string(),
223        })?;
224
225    let mut failures = Vec::new();
226
227    for snippet in &tests.should_match {
228        if count_matches(snippet, lang, &collection) == 0 {
229            failures.push(TestFailure {
230                kind: TestFailureKind::ShouldMatchDidNot,
231                snippet: snippet.clone(),
232            });
233        }
234    }
235
236    for snippet in &tests.should_not_match {
237        if count_matches(snippet, lang, &collection) > 0 {
238            failures.push(TestFailure {
239                kind: TestFailureKind::ShouldNotMatchDid,
240                snippet: snippet.clone(),
241            });
242        }
243    }
244
245    for case in &tests.should_fix {
246        let actual = fix_snippet(rule, &case.before).map_err(|e| TestError::CompileError {
247            id: rule.id.clone(),
248            reason: e.to_string(),
249        })?;
250        if actual != case.after {
251            failures.push(TestFailure {
252                kind: TestFailureKind::FixMismatch { actual },
253                snippet: case.before.clone(),
254            });
255        }
256    }
257
258    let status = if failures.is_empty() {
259        RuleTestStatus::Pass
260    } else {
261        RuleTestStatus::Fail { failures }
262    };
263
264    Ok(RuleTestResult {
265        rule_id: rule.id.clone(),
266        status,
267    })
268}
269
270/// Test all rules' inline test cases. Returns a summary with per-rule results.
271pub fn test_rules(rules: &[Rule]) -> Result<TestSummary, TestError> {
272    let mut results = Vec::with_capacity(rules.len());
273    let mut passed = 0;
274    let mut failed = 0;
275    let mut no_tests = 0;
276    let mut total_tested = 0;
277
278    for rule in rules {
279        let result = test_one_rule(rule)?;
280        match &result.status {
281            RuleTestStatus::Pass => {
282                passed += 1;
283                total_tested += 1;
284            }
285            RuleTestStatus::Fail { .. } => {
286                failed += 1;
287                total_tested += 1;
288            }
289            RuleTestStatus::NoTests => {
290                no_tests += 1;
291            }
292        }
293        results.push(result);
294    }
295
296    Ok(TestSummary {
297        results,
298        total_tested,
299        passed,
300        failed,
301        no_tests,
302    })
303}
304
305#[cfg(test)]
306mod tests;