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
80fn 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
105fn 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 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 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
250pub 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;