Skip to main content

harn_rules/
testing.rs

1//! A small rule-test harness (#2842).
2//!
3//! Run a rule against an **annotated fixture** and check that its matches line
4//! up with inline `// ruleid:` / `// ok:` comments — the Semgrep convention,
5//! adapted to be language-agnostic:
6//!
7//! ```text
8//!   // ruleid: no-foo
9//!   foo();              // <- must match `no-foo`
10//!   // ok: no-foo
11//!   bar();              // <- must NOT match `no-foo`
12//!   baz();              // <- no annotation: must NOT match either
13//! ```
14//!
15//! An annotation comment sits on its **own line** and targets the **next**
16//! line. The check is strict: every match must be covered by a `// ruleid:`
17//! (an un-annotated match is a false positive), and every `// ruleid:` line
18//! must match (a missing match is a false negative).
19
20use std::collections::{BTreeMap, BTreeSet};
21
22use crate::engine::CompiledRule;
23use crate::error::RulesError;
24
25/// What an annotation asserts about the line it targets.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Expectation {
28    /// `// ruleid: <id>` — the targeted line must match the rule.
29    Match,
30    /// `// ok: <id>` — the targeted line must not match.
31    NoMatch,
32}
33
34/// Why a fixture line failed its expectation.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FailureKind {
37    /// `// ruleid:` annotated this line, but the rule did not match.
38    ExpectedMatch,
39    /// `// ok:` annotated this line, but the rule matched it.
40    UnexpectedMatch,
41    /// The rule matched this line, but nothing annotated it (false positive).
42    Unannotated,
43}
44
45/// One failed expectation in a fixture.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct TestFailure {
48    /// 0-based fixture line.
49    pub line: usize,
50    pub kind: FailureKind,
51}
52
53impl TestFailure {
54    /// A human-readable, 1-based description.
55    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/// The outcome of running one rule against one annotated fixture.
66#[derive(Debug, Clone)]
67pub struct InlineTestReport {
68    pub rule_id: String,
69    /// True when there are no failures.
70    pub passed: bool,
71    /// Number of annotations checked.
72    pub checked: usize,
73    /// Number of matches the rule produced.
74    pub matches: usize,
75    pub failures: Vec<TestFailure>,
76}
77
78/// Run `rule` against `source` and compare its matches with the fixture's
79/// inline `// ruleid:` / `// ok:` annotations.
80pub 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    // Every annotation must hold.
89    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    // Every match must be acknowledged by a `// ruleid:` (no false positives).
104    for &line in &matched_lines {
105        if expectations.get(&line) != Some(&Expectation::Match) {
106            // A `// ok:` match is already reported as UnexpectedMatch above;
107            // only flag the genuinely un-annotated ones here.
108            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
128/// Parse `// ruleid:` / `// ok:` annotations that apply to `rule_id`. Each
129/// annotation is on its own line and targets the **next** line (0-based). An
130/// id list (`// ruleid: a, b`) applies if `rule_id` is among them.
131fn 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        // Accept `//` (C-family) or `#` (Python/Ruby/shell) line comments.
136        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            // Targets the next line; a trailing annotation at EOF targets
152            // nothing and is harmless.
153            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        // `// ruleid:` on a line that does not actually match.
189        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        // The annotation names a different rule, so it does not apply here;
218        // and the matching line below is annotated for `no-foo`.
219        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}