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 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
95fn 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
120fn 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 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 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
281pub 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;