use std::collections::{BTreeMap, BTreeSet};
use crate::engine::CompiledRule;
use crate::error::RulesError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Expectation {
Match,
NoMatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureKind {
ExpectedMatch,
UnexpectedMatch,
Unannotated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TestFailure {
pub line: usize,
pub kind: FailureKind,
}
impl TestFailure {
pub fn describe(&self) -> String {
let what = match self.kind {
FailureKind::ExpectedMatch => "expected a match (// ruleid:) but found none",
FailureKind::UnexpectedMatch => "matched, but // ok: said it should not",
FailureKind::Unannotated => "matched, but no // ruleid: annotated it",
};
format!("line {}: {what}", self.line + 1)
}
}
#[derive(Debug, Clone)]
pub struct InlineTestReport {
pub rule_id: String,
pub passed: bool,
pub checked: usize,
pub matches: usize,
pub failures: Vec<TestFailure>,
}
pub fn run_inline_test(rule: &CompiledRule, source: &str) -> Result<InlineTestReport, RulesError> {
let expectations = parse_annotations(source, rule.id());
let matched_lines: BTreeSet<usize> =
rule.run(source)?.iter().map(|m| m.span.start_row).collect();
let mut failures = Vec::new();
for (&line, &expectation) in &expectations {
match expectation {
Expectation::Match if !matched_lines.contains(&line) => failures.push(TestFailure {
line,
kind: FailureKind::ExpectedMatch,
}),
Expectation::NoMatch if matched_lines.contains(&line) => failures.push(TestFailure {
line,
kind: FailureKind::UnexpectedMatch,
}),
_ => {}
}
}
for &line in &matched_lines {
if expectations.get(&line) != Some(&Expectation::Match) {
if !expectations.contains_key(&line) {
failures.push(TestFailure {
line,
kind: FailureKind::Unannotated,
});
}
}
}
failures.sort_by_key(|f| (f.line, f.kind as u8));
Ok(InlineTestReport {
rule_id: rule.id().to_string(),
passed: failures.is_empty(),
checked: expectations.len(),
matches: matched_lines.len(),
failures,
})
}
fn parse_annotations(source: &str, rule_id: &str) -> BTreeMap<usize, Expectation> {
let mut out = BTreeMap::new();
for (i, line) in source.lines().enumerate() {
let trimmed = line.trim_start();
let Some(rest) = trimmed
.strip_prefix("//")
.or_else(|| trimmed.strip_prefix('#'))
else {
continue;
};
let rest = rest.trim_start();
let (expectation, ids) = if let Some(ids) = rest.strip_prefix("ruleid:") {
(Expectation::Match, ids)
} else if let Some(ids) = rest.strip_prefix("ok:") {
(Expectation::NoMatch, ids)
} else {
continue;
};
if ids.split(',').map(str::trim).any(|id| id == rule_id) {
out.insert(i + 1, expectation);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Rule;
fn rule(toml: &str) -> CompiledRule {
CompiledRule::compile(&Rule::from_toml_str(toml).unwrap()).unwrap()
}
const NO_FOO: &str = r#"
id = "no-foo"
language = "typescript"
message = "no foo"
[rule]
pattern = "foo()"
"#;
#[test]
fn passing_fixture_reports_no_failures() {
let r = rule(NO_FOO);
let src = "// ruleid: no-foo\nfoo();\n// ok: no-foo\nbar();\n";
let report = run_inline_test(&r, src).unwrap();
assert!(report.passed, "failures: {:?}", report.failures);
assert_eq!(report.checked, 2);
assert_eq!(report.matches, 1);
}
#[test]
fn false_negative_is_reported() {
let r = rule(NO_FOO);
let src = "// ruleid: no-foo\nbar();\n";
let report = run_inline_test(&r, src).unwrap();
assert!(!report.passed);
assert_eq!(report.failures[0].kind, FailureKind::ExpectedMatch);
}
#[test]
fn false_positive_on_ok_line_is_reported() {
let r = rule(NO_FOO);
let src = "// ok: no-foo\nfoo();\n";
let report = run_inline_test(&r, src).unwrap();
assert!(!report.passed);
assert_eq!(report.failures[0].kind, FailureKind::UnexpectedMatch);
}
#[test]
fn unannotated_match_is_a_false_positive() {
let r = rule(NO_FOO);
let src = "foo();\n";
let report = run_inline_test(&r, src).unwrap();
assert!(!report.passed);
assert_eq!(report.failures[0].kind, FailureKind::Unannotated);
}
#[test]
fn annotations_for_other_rules_are_ignored() {
let r = rule(NO_FOO);
let src = "// ruleid: other-rule\nbar();\n// ruleid: no-foo\nfoo();\n";
let report = run_inline_test(&r, src).unwrap();
assert!(report.passed, "failures: {:?}", report.failures);
assert_eq!(report.checked, 1);
}
#[test]
fn python_hash_comments_work() {
let r = rule(
r#"
id = "call-print"
language = "python"
[rule]
pattern = "print($X)"
"#,
);
let src = "# ruleid: call-print\nprint(x)\n# ok: call-print\ny = 1\n";
let report = run_inline_test(&r, src).unwrap();
assert!(report.passed, "failures: {:?}", report.failures);
}
}