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