shout/shout-rs/src/matching.rs
Chris Wanstrath 1c7c0da4b7 Add Rust implementation of the shout test runner
Rewrites the shout CLI in Rust for better performance, with parallel
test execution via rayon and the same .shout file format semantics.
2026-04-02 13:28:48 -07:00

124 lines
3.7 KiB
Rust

use regex::Regex;
/// Check if a single line matches a pattern that may contain inline `...` wildcards.
pub fn match_line(pattern: &str, actual: &str) -> bool {
if !pattern.contains("...") {
return pattern == actual;
}
let parts: Vec<&str> = pattern.split("...").collect();
let escaped: Vec<String> = parts.iter().map(|p| regex::escape(p)).collect();
let re_str = format!("^{}$", escaped.join(".*"));
match Regex::new(&re_str) {
Ok(re) => re.is_match(actual),
Err(_) => false,
}
}
/// Match expected output against actual output, supporting multi-line `...` wildcards.
pub fn match_output(expected: &[String], actual: &[String]) -> bool {
do_match(expected, 0, actual, 0)
}
fn do_match(expected: &[String], ei: usize, actual: &[String], ai: usize) -> bool {
// Both exhausted — match
if ei == expected.len() && ai == actual.len() {
return true;
}
// Expected exhausted but actual remains — no match
if ei == expected.len() {
return false;
}
let exp = &expected[ei];
// Multi-line wildcard
if exp == "..." {
// Try matching zero or more actual lines
for skip in ai..=actual.len() {
if do_match(expected, ei + 1, actual, skip) {
return true;
}
}
return false;
}
// Actual exhausted but expected remains — no match
if ai == actual.len() {
return false;
}
// Line-level match (with possible inline wildcards)
if match_line(exp, &actual[ai]) {
return do_match(expected, ei + 1, actual, ai + 1);
}
false
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiffKind {
Equal,
Expected,
Actual,
Context,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffKind,
pub text: String,
}
pub fn diff(expected: &[String], actual: &[String]) -> Vec<DiffLine> {
let mut result = Vec::new();
let mut ei = 0;
let mut ai = 0;
while ei < expected.len() || ai < actual.len() {
if ei < expected.len() && expected[ei] == "..." {
// Find where the wildcard ends by looking at next expected line
let next_exp = if ei + 1 < expected.len() {
Some(&expected[ei + 1])
} else {
None
};
if next_exp.is_none() {
// ... at end matches everything remaining
result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() });
break;
}
// Skip actual lines until we find the next expected match
result.push(DiffLine { kind: DiffKind::Context, text: "...".to_string() });
ei += 1;
let next = next_exp.unwrap();
while ai < actual.len() && !match_line(next, &actual[ai]) {
ai += 1;
}
continue;
}
if ei < expected.len() && ai < actual.len() {
if match_line(&expected[ei], &actual[ai]) {
result.push(DiffLine { kind: DiffKind::Equal, text: actual[ai].clone() });
ei += 1;
ai += 1;
} else {
result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() });
result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() });
ei += 1;
ai += 1;
}
} else if ei < expected.len() {
result.push(DiffLine { kind: DiffKind::Expected, text: expected[ei].clone() });
ei += 1;
} else {
result.push(DiffLine { kind: DiffKind::Actual, text: actual[ai].clone() });
ai += 1;
}
}
result
}