1use std::collections::{BTreeMap, BTreeSet};
21
22use crate::engine::CompiledRule;
23use crate::error::RulesError;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Expectation {
28 Match,
30 NoMatch,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FailureKind {
37 ExpectedMatch,
39 UnexpectedMatch,
41 Unannotated,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct TestFailure {
48 pub line: usize,
50 pub kind: FailureKind,
51}
52
53impl TestFailure {
54 pub fn describe(&self) -> String {
56 let what = match self.kind {
57 FailureKind::ExpectedMatch => "expected a match (// ruleid:) but found none",
58 FailureKind::UnexpectedMatch => "matched, but // ok: said it should not",
59 FailureKind::Unannotated => "matched, but no // ruleid: annotated it",
60 };
61 format!("line {}: {what}", self.line + 1)
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct InlineTestReport {
68 pub rule_id: String,
69 pub passed: bool,
71 pub checked: usize,
73 pub matches: usize,
75 pub failures: Vec<TestFailure>,
76}
77
78pub fn run_inline_test(rule: &CompiledRule, source: &str) -> Result<InlineTestReport, RulesError> {
81 let expectations = parse_annotations(source, rule.id());
82
83 let matched_lines: BTreeSet<usize> =
84 rule.run(source)?.iter().map(|m| m.span.start_row).collect();
85
86 let mut failures = Vec::new();
87
88 for (&line, &expectation) in &expectations {
90 match expectation {
91 Expectation::Match if !matched_lines.contains(&line) => failures.push(TestFailure {
92 line,
93 kind: FailureKind::ExpectedMatch,
94 }),
95 Expectation::NoMatch if matched_lines.contains(&line) => failures.push(TestFailure {
96 line,
97 kind: FailureKind::UnexpectedMatch,
98 }),
99 _ => {}
100 }
101 }
102
103 for &line in &matched_lines {
105 if expectations.get(&line) != Some(&Expectation::Match) {
106 if !expectations.contains_key(&line) {
109 failures.push(TestFailure {
110 line,
111 kind: FailureKind::Unannotated,
112 });
113 }
114 }
115 }
116
117 failures.sort_by_key(|f| (f.line, f.kind as u8));
118
119 Ok(InlineTestReport {
120 rule_id: rule.id().to_string(),
121 passed: failures.is_empty(),
122 checked: expectations.len(),
123 matches: matched_lines.len(),
124 failures,
125 })
126}
127
128fn parse_annotations(source: &str, rule_id: &str) -> BTreeMap<usize, Expectation> {
132 let mut out = BTreeMap::new();
133 for (i, line) in source.lines().enumerate() {
134 let trimmed = line.trim_start();
135 let Some(rest) = trimmed
137 .strip_prefix("//")
138 .or_else(|| trimmed.strip_prefix('#'))
139 else {
140 continue;
141 };
142 let rest = rest.trim_start();
143 let (expectation, ids) = if let Some(ids) = rest.strip_prefix("ruleid:") {
144 (Expectation::Match, ids)
145 } else if let Some(ids) = rest.strip_prefix("ok:") {
146 (Expectation::NoMatch, ids)
147 } else {
148 continue;
149 };
150 if ids.split(',').map(str::trim).any(|id| id == rule_id) {
151 out.insert(i + 1, expectation);
154 }
155 }
156 out
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::model::Rule;
163
164 fn rule(toml: &str) -> CompiledRule {
165 CompiledRule::compile(&Rule::from_toml_str(toml).unwrap()).unwrap()
166 }
167
168 const NO_FOO: &str = r#"
169 id = "no-foo"
170 language = "typescript"
171 message = "no foo"
172 [rule]
173 pattern = "foo()"
174 "#;
175
176 #[test]
177 fn passing_fixture_reports_no_failures() {
178 let r = rule(NO_FOO);
179 let src = "// ruleid: no-foo\nfoo();\n// ok: no-foo\nbar();\n";
180 let report = run_inline_test(&r, src).unwrap();
181 assert!(report.passed, "failures: {:?}", report.failures);
182 assert_eq!(report.checked, 2);
183 assert_eq!(report.matches, 1);
184 }
185
186 #[test]
187 fn false_negative_is_reported() {
188 let r = rule(NO_FOO);
190 let src = "// ruleid: no-foo\nbar();\n";
191 let report = run_inline_test(&r, src).unwrap();
192 assert!(!report.passed);
193 assert_eq!(report.failures[0].kind, FailureKind::ExpectedMatch);
194 }
195
196 #[test]
197 fn false_positive_on_ok_line_is_reported() {
198 let r = rule(NO_FOO);
199 let src = "// ok: no-foo\nfoo();\n";
200 let report = run_inline_test(&r, src).unwrap();
201 assert!(!report.passed);
202 assert_eq!(report.failures[0].kind, FailureKind::UnexpectedMatch);
203 }
204
205 #[test]
206 fn unannotated_match_is_a_false_positive() {
207 let r = rule(NO_FOO);
208 let src = "foo();\n";
209 let report = run_inline_test(&r, src).unwrap();
210 assert!(!report.passed);
211 assert_eq!(report.failures[0].kind, FailureKind::Unannotated);
212 }
213
214 #[test]
215 fn annotations_for_other_rules_are_ignored() {
216 let r = rule(NO_FOO);
217 let src = "// ruleid: other-rule\nbar();\n// ruleid: no-foo\nfoo();\n";
220 let report = run_inline_test(&r, src).unwrap();
221 assert!(report.passed, "failures: {:?}", report.failures);
222 assert_eq!(report.checked, 1);
223 }
224
225 #[test]
226 fn python_hash_comments_work() {
227 let r = rule(
228 r#"
229 id = "call-print"
230 language = "python"
231 [rule]
232 pattern = "print($X)"
233 "#,
234 );
235 let src = "# ruleid: call-print\nprint(x)\n# ok: call-print\ny = 1\n";
236 let report = run_inline_test(&r, src).unwrap();
237 assert!(report.passed, "failures: {:?}", report.failures);
238 }
239}