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 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
85fn 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
110fn 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 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 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
270pub 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;