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