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::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 fire, every
106/// should-not-match case must not. A metric fires only when the file reaches
107/// `min_lines` (if set) and its value exceeds the threshold, matching the
108/// scanner so a below-floor should-match case is a genuine failure.
109fn test_metric_rule(
110    rule: &Rule,
111    metric: Metric,
112    threshold: f64,
113    tests: &RuleTests,
114) -> Result<RuleTestResult, TestError> {
115    let lang = rule.language.ast_grep_langs()[0];
116    let fires = |content: &str| {
117        if let Some(floor) = rule.min_lines {
118            if content.lines().count() < floor {
119                return false;
120            }
121        }
122        let root = lang.ast_grep(content);
123        // Fixtures are self-contained metric cases: count everything, so a
124        // fixture that exercises the metric is not silently emptied by
125        // `#[cfg(test)]` exclusion.
126        metric::exceeds(
127            metric::compute(metric, &rule.language, &root, content, None),
128            threshold,
129        )
130    };
131
132    let mut failures = Vec::new();
133
134    for (label, content) in metric_cases(rule, &tests.should_match, &tests.should_match_files)? {
135        if !fires(&content) {
136            failures.push(TestFailure {
137                kind: TestFailureKind::ShouldMatchDidNot,
138                snippet: label,
139            });
140        }
141    }
142
143    for (label, content) in
144        metric_cases(rule, &tests.should_not_match, &tests.should_not_match_files)?
145    {
146        if fires(&content) {
147            failures.push(TestFailure {
148                kind: TestFailureKind::ShouldNotMatchDid,
149                snippet: label,
150            });
151        }
152    }
153
154    let status = if failures.is_empty() {
155        RuleTestStatus::Pass
156    } else {
157        RuleTestStatus::Fail { failures }
158    };
159
160    Ok(RuleTestResult {
161        rule_id: rule.id.clone(),
162        status,
163    })
164}
165
166fn test_one_rule(rule: &Rule) -> Result<RuleTestResult, TestError> {
167    // A cross-file rule needs a whole project to mean anything. A snippet can
168    // never exercise it, and compiling its (null) `rule` to ast-grep would fail,
169    // so `slopguard test` reports it as untested.
170    if rule.is_cross_file() {
171        return Ok(RuleTestResult {
172            rule_id: rule.id.clone(),
173            status: RuleTestStatus::NoTests,
174        });
175    }
176
177    let tests = match &rule.tests {
178        Some(t) => t,
179        None => {
180            return Ok(RuleTestResult {
181                rule_id: rule.id.clone(),
182                status: RuleTestStatus::NoTests,
183            });
184        }
185    };
186
187    if let Some((metric, threshold)) = rule.metric_spec() {
188        return test_metric_rule(rule, metric, threshold, tests);
189    }
190    if rule.is_metric() {
191        return Err(TestError::CompileError {
192            id: rule.id.clone(),
193            reason: "metric rule is missing a threshold".to_string(),
194        });
195    }
196
197    let lang = rule.language.ast_grep_langs()[0];
198    let ast_grep_rule = AstGrepRule {
199        id: rule.id.as_str(),
200        language: lang,
201        severity: &rule.severity,
202        message: &rule.message,
203        note: rule.note.as_deref(),
204        rule: &rule.rule,
205        files: None,
206        ignores: None,
207    };
208    let config = compile_ast_grep_rule(&ast_grep_rule).map_err(|e| TestError::CompileError {
209        id: rule.id.clone(),
210        reason: e.to_string(),
211    })?;
212    let collection =
213        RuleCollection::try_new(vec![config]).map_err(|e| TestError::CompileError {
214            id: rule.id.clone(),
215            reason: e.to_string(),
216        })?;
217
218    let mut failures = Vec::new();
219
220    for snippet in &tests.should_match {
221        if count_matches(snippet, lang, &collection) == 0 {
222            failures.push(TestFailure {
223                kind: TestFailureKind::ShouldMatchDidNot,
224                snippet: snippet.clone(),
225            });
226        }
227    }
228
229    for snippet in &tests.should_not_match {
230        if count_matches(snippet, lang, &collection) > 0 {
231            failures.push(TestFailure {
232                kind: TestFailureKind::ShouldNotMatchDid,
233                snippet: snippet.clone(),
234            });
235        }
236    }
237
238    let status = if failures.is_empty() {
239        RuleTestStatus::Pass
240    } else {
241        RuleTestStatus::Fail { failures }
242    };
243
244    Ok(RuleTestResult {
245        rule_id: rule.id.clone(),
246        status,
247    })
248}
249
250/// Test all rules' inline test cases. Returns a summary with per-rule results.
251pub fn test_rules(rules: &[Rule]) -> Result<TestSummary, TestError> {
252    let mut results = Vec::with_capacity(rules.len());
253    let mut passed = 0;
254    let mut failed = 0;
255    let mut no_tests = 0;
256    let mut total_tested = 0;
257
258    for rule in rules {
259        let result = test_one_rule(rule)?;
260        match &result.status {
261            RuleTestStatus::Pass => {
262                passed += 1;
263                total_tested += 1;
264            }
265            RuleTestStatus::Fail { .. } => {
266                failed += 1;
267                total_tested += 1;
268            }
269            RuleTestStatus::NoTests => {
270                no_tests += 1;
271            }
272        }
273        results.push(result);
274    }
275
276    Ok(TestSummary {
277        results,
278        total_tested,
279        passed,
280        failed,
281        no_tests,
282    })
283}
284
285#[cfg(test)]
286mod tests;