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(
108 rule: &Rule,
109 metric: Metric,
110 threshold: f64,
111 tests: &RuleTests,
112) -> Result<RuleTestResult, TestError> {
113 let lang = rule.language.ast_grep_langs()[0];
114 let measure = |content: &str| {
115 let root = lang.ast_grep(content);
116 metric::compute(metric, &rule.language, &root, content)
117 };
118
119 let mut failures = Vec::new();
120
121 for (label, content) in metric_cases(rule, &tests.should_match, &tests.should_match_files)? {
122 if !metric::exceeds(measure(&content), threshold) {
123 failures.push(TestFailure {
124 kind: TestFailureKind::ShouldMatchDidNot,
125 snippet: label,
126 });
127 }
128 }
129
130 for (label, content) in
131 metric_cases(rule, &tests.should_not_match, &tests.should_not_match_files)?
132 {
133 if metric::exceeds(measure(&content), threshold) {
134 failures.push(TestFailure {
135 kind: TestFailureKind::ShouldNotMatchDid,
136 snippet: label,
137 });
138 }
139 }
140
141 let status = if failures.is_empty() {
142 RuleTestStatus::Pass
143 } else {
144 RuleTestStatus::Fail { failures }
145 };
146
147 Ok(RuleTestResult {
148 rule_id: rule.id.clone(),
149 status,
150 })
151}
152
153fn test_one_rule(rule: &Rule) -> Result<RuleTestResult, TestError> {
154 let tests = match &rule.tests {
155 Some(t) => t,
156 None => {
157 return Ok(RuleTestResult {
158 rule_id: rule.id.clone(),
159 status: RuleTestStatus::NoTests,
160 });
161 }
162 };
163
164 if let Some((metric, threshold)) = rule.metric_spec() {
165 return test_metric_rule(rule, metric, threshold, tests);
166 }
167 if rule.is_metric() {
168 return Err(TestError::CompileError {
169 id: rule.id.clone(),
170 reason: "metric rule is missing a threshold".to_string(),
171 });
172 }
173
174 let lang = rule.language.ast_grep_langs()[0];
175 let ast_grep_rule = AstGrepRule {
176 id: rule.id.as_str(),
177 language: lang,
178 severity: &rule.severity,
179 message: &rule.message,
180 note: rule.note.as_deref(),
181 rule: &rule.rule,
182 files: None,
183 ignores: None,
184 };
185 let config = compile_ast_grep_rule(&ast_grep_rule).map_err(|e| TestError::CompileError {
186 id: rule.id.clone(),
187 reason: e.to_string(),
188 })?;
189 let collection =
190 RuleCollection::try_new(vec![config]).map_err(|e| TestError::CompileError {
191 id: rule.id.clone(),
192 reason: e.to_string(),
193 })?;
194
195 let mut failures = Vec::new();
196
197 for snippet in &tests.should_match {
198 if count_matches(snippet, lang, &collection) == 0 {
199 failures.push(TestFailure {
200 kind: TestFailureKind::ShouldMatchDidNot,
201 snippet: snippet.clone(),
202 });
203 }
204 }
205
206 for snippet in &tests.should_not_match {
207 if count_matches(snippet, lang, &collection) > 0 {
208 failures.push(TestFailure {
209 kind: TestFailureKind::ShouldNotMatchDid,
210 snippet: snippet.clone(),
211 });
212 }
213 }
214
215 let status = if failures.is_empty() {
216 RuleTestStatus::Pass
217 } else {
218 RuleTestStatus::Fail { failures }
219 };
220
221 Ok(RuleTestResult {
222 rule_id: rule.id.clone(),
223 status,
224 })
225}
226
227pub fn test_rules(rules: &[Rule]) -> Result<TestSummary, TestError> {
229 let mut results = Vec::with_capacity(rules.len());
230 let mut passed = 0;
231 let mut failed = 0;
232 let mut no_tests = 0;
233 let mut total_tested = 0;
234
235 for rule in rules {
236 let result = test_one_rule(rule)?;
237 match &result.status {
238 RuleTestStatus::Pass => {
239 passed += 1;
240 total_tested += 1;
241 }
242 RuleTestStatus::Fail { .. } => {
243 failed += 1;
244 total_tested += 1;
245 }
246 RuleTestStatus::NoTests => {
247 no_tests += 1;
248 }
249 }
250 results.push(result);
251 }
252
253 Ok(TestSummary {
254 results,
255 total_tested,
256 passed,
257 failed,
258 no_tests,
259 })
260}
261
262#[cfg(test)]
263mod tests {
264 use std::fs::{create_dir, write};
265
266 use tempfile::tempdir;
267
268 use crate::rule::{load_builtin_rules, load_custom_rules, parse_rule};
269
270 use super::*;
271
272 #[test]
273 fn test_rule_pass() {
274 let rule = parse_rule(
275 r#"
276id: test-pass
277language: rust
278severity: error
279message: "No unwrap"
280rule:
281 kind: call_expression
282 regex: '\.unwrap\(\)\s*$'
283tests:
284 should_match:
285 - "fn f() { foo().unwrap(); }"
286 should_not_match:
287 - "fn f() { foo()?; }"
288"#,
289 )
290 .unwrap();
291
292 let result = test_one_rule(&rule).unwrap();
293 assert_eq!(result.rule_id, RuleId::from("test-pass"));
294 assert_eq!(result.status, RuleTestStatus::Pass);
295 }
296
297 #[test]
298 fn test_rule_fail_should_match() {
299 let rule = parse_rule(
300 r#"
301id: test-fail-match
302language: rust
303severity: error
304message: "No unwrap"
305rule:
306 kind: call_expression
307 regex: '\.unwrap\(\)\s*$'
308tests:
309 should_match:
310 - "fn f() { foo()?; }"
311 should_not_match:
312 - "fn f() { bar()?; }"
313"#,
314 )
315 .unwrap();
316
317 let result = test_one_rule(&rule).unwrap();
318 assert!(matches!(result.status, RuleTestStatus::Fail { .. }));
319 if let RuleTestStatus::Fail { failures } = &result.status {
320 assert_eq!(failures.len(), 1);
321 assert_eq!(failures[0].kind, TestFailureKind::ShouldMatchDidNot);
322 }
323 }
324
325 #[test]
326 fn test_rule_fail_should_not_match() {
327 let rule = parse_rule(
328 r#"
329id: test-fail-not-match
330language: rust
331severity: error
332message: "No unwrap"
333rule:
334 kind: call_expression
335 regex: '\.unwrap\(\)\s*$'
336tests:
337 should_match:
338 - "fn f() { foo().unwrap(); }"
339 should_not_match:
340 - "fn f() { bar().unwrap(); }"
341"#,
342 )
343 .unwrap();
344
345 let result = test_one_rule(&rule).unwrap();
346 assert!(matches!(result.status, RuleTestStatus::Fail { .. }));
347 if let RuleTestStatus::Fail { failures } = &result.status {
348 assert_eq!(failures.len(), 1);
349 assert_eq!(failures[0].kind, TestFailureKind::ShouldNotMatchDid);
350 }
351 }
352
353 #[test]
354 fn test_rule_no_tests() {
355 let rule = parse_rule(
356 r#"
357id: no-tests
358language: rust
359severity: error
360message: "No unwrap"
361rule:
362 kind: call_expression
363 regex: '\.unwrap\(\)\s*$'
364"#,
365 )
366 .unwrap();
367
368 let result = test_one_rule(&rule).unwrap();
369 assert_eq!(result.status, RuleTestStatus::NoTests);
370 }
371
372 #[test]
373 fn test_rules_summary() {
374 let pass_rule = parse_rule(
375 r#"
376id: passes
377language: rust
378severity: error
379message: "m"
380rule:
381 kind: call_expression
382 regex: '\.unwrap\(\)\s*$'
383tests:
384 should_match:
385 - "fn f() { foo().unwrap(); }"
386 should_not_match:
387 - "fn f() { foo()?; }"
388"#,
389 )
390 .unwrap();
391
392 let fail_rule = parse_rule(
393 r#"
394id: fails
395language: rust
396severity: error
397message: "m"
398rule:
399 kind: call_expression
400 regex: '\.unwrap\(\)\s*$'
401tests:
402 should_match:
403 - "fn f() { foo()?; }"
404 should_not_match:
405 - "fn f() { bar()?; }"
406"#,
407 )
408 .unwrap();
409
410 let no_test_rule = parse_rule(
411 r#"
412id: notested
413language: rust
414severity: error
415message: "m"
416rule:
417 kind: call_expression
418 regex: '\.unwrap\(\)\s*$'
419"#,
420 )
421 .unwrap();
422
423 let summary = test_rules(&[pass_rule, fail_rule, no_test_rule]).unwrap();
424 assert_eq!(summary.total_tested, 2);
425 assert_eq!(summary.passed, 1);
426 assert_eq!(summary.failed, 1);
427 assert_eq!(summary.no_tests, 1);
428 }
429
430 fn rust_lines(n: usize) -> String {
432 (0..n).map(|i| format!("fn f{i}() {{}}\n")).collect()
433 }
434
435 fn yaml_scalar(source: &str) -> String {
438 format!("{source:?}")
439 }
440
441 #[test]
442 fn metric_rule_test_pass_inline() {
443 let rule = parse_rule(&format!(
444 r#"
445id: metric-pass
446language: rust
447severity: warning
448category: slop
449metric: file_lines
450threshold: 5
451message: "m"
452tests:
453 should_match:
454 - {}
455 should_not_match:
456 - {}
457"#,
458 yaml_scalar(&rust_lines(8)),
459 yaml_scalar(&rust_lines(3))
460 ))
461 .unwrap();
462
463 let result = test_one_rule(&rule).unwrap();
464 assert_eq!(result.status, RuleTestStatus::Pass);
465 }
466
467 #[test]
468 fn metric_rule_test_fail_should_match() {
469 let rule = parse_rule(&format!(
470 r#"
471id: metric-fail-match
472language: rust
473severity: warning
474category: slop
475metric: file_lines
476threshold: 20
477message: "m"
478tests:
479 should_match:
480 - {}
481"#,
482 yaml_scalar(&rust_lines(3))
483 ))
484 .unwrap();
485
486 let result = test_one_rule(&rule).unwrap();
487 match &result.status {
488 RuleTestStatus::Fail { failures } => {
489 assert_eq!(failures.len(), 1);
490 assert_eq!(failures[0].kind, TestFailureKind::ShouldMatchDidNot);
491 }
492 other => panic!("expected a failure, got {other:?}"),
493 }
494 }
495
496 #[test]
497 fn metric_rule_test_fail_should_not_match() {
498 let rule = parse_rule(&format!(
499 r#"
500id: metric-fail-not-match
501language: rust
502severity: warning
503category: slop
504metric: file_lines
505threshold: 2
506message: "m"
507tests:
508 should_not_match:
509 - {}
510"#,
511 yaml_scalar(&rust_lines(6))
512 ))
513 .unwrap();
514
515 let result = test_one_rule(&rule).unwrap();
516 match &result.status {
517 RuleTestStatus::Fail { failures } => {
518 assert_eq!(failures.len(), 1);
519 assert_eq!(failures[0].kind, TestFailureKind::ShouldNotMatchDid);
520 }
521 other => panic!("expected a failure, got {other:?}"),
522 }
523 }
524
525 fn fixture_rule_dir(threshold: usize) -> tempfile::TempDir {
527 let dir = tempdir().unwrap();
528 let fixtures = dir.path().join("fixtures");
529 create_dir(&fixtures).unwrap();
530 write(fixtures.join("big.rs"), rust_lines(20)).unwrap();
531 write(fixtures.join("small.rs"), rust_lines(3)).unwrap();
532 write(
533 dir.path().join("metric.yml"),
534 format!(
535 r#"
536id: custom-metric
537language: rust
538severity: warning
539category: slop
540metric: file_lines
541threshold: {threshold}
542message: "m"
543tests:
544 should_match_files:
545 - "fixtures/big.rs"
546 should_not_match_files:
547 - "fixtures/small.rs"
548"#
549 ),
550 )
551 .unwrap();
552 dir
553 }
554
555 #[test]
556 fn metric_rule_test_from_fixture_files() {
557 let dir = fixture_rule_dir(10);
558 let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
559 let summary = test_rules(&rules).unwrap();
560 assert_eq!(summary.passed, 1);
561 assert_eq!(summary.failed, 0);
562
563 let dir = fixture_rule_dir(100);
566 let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
567 let summary = test_rules(&rules).unwrap();
568 assert_eq!(summary.failed, 1);
569 match &summary.results[0].status {
570 RuleTestStatus::Fail { failures } => {
571 assert_eq!(failures.len(), 1);
572 assert_eq!(failures[0].snippet, "fixtures/big.rs");
573 }
574 other => panic!("expected a failure, got {other:?}"),
575 }
576 }
577
578 #[test]
579 fn metric_rule_missing_fixture_errors() {
580 let dir = tempdir().unwrap();
581 write(
582 dir.path().join("metric.yml"),
583 r#"
584id: missing-fixture
585language: rust
586severity: warning
587category: slop
588metric: file_lines
589threshold: 10
590message: "m"
591tests:
592 should_match_files:
593 - "fixtures/gone.rs"
594"#,
595 )
596 .unwrap();
597 let rules = load_custom_rules(&[dir.path().to_path_buf()]).unwrap();
598 let err = test_rules(&rules).unwrap_err();
599 assert!(matches!(err, TestError::Fixture { .. }), "{err:?}");
600 }
601
602 #[test]
603 fn all_builtin_metric_rules_pass() {
604 let rules: Vec<Rule> = load_builtin_rules()
605 .unwrap()
606 .into_iter()
607 .filter(|r| r.is_metric())
608 .collect();
609 assert!(!rules.is_empty(), "there should be builtin metric rules");
610 let summary = test_rules(&rules).unwrap();
611 assert_eq!(summary.failed, 0, "{:?}", summary.results);
612 assert_eq!(summary.no_tests, 0, "{:?}", summary.results);
613 }
614}