Compare commits

..

2 Commits

5 changed files with 131 additions and 168 deletions

97
Cargo.lock generated
View File

@ -2,112 +2,15 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "rayon"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "shout"
version = "0.0.18"
dependencies = [
"libc",
"rayon",
"regex",
]

View File

@ -5,5 +5,3 @@ edition = "2024"
[dependencies]
libc = "0.2"
rayon = "1"
regex = "1"

View File

@ -597,8 +597,6 @@ fn main() {
};
if opts.parallel {
use rayon::prelude::*;
// Pre-assign ports
let file_ports: Vec<(PathBuf, u16)> = files
.iter()
@ -606,16 +604,19 @@ fn main() {
.map(|(i, f)| (f.clone(), opts.port_from + i as u16))
.collect();
let par_results: Vec<TestResult> = file_ports
.par_iter()
.map(|(f, port)| {
let r = run_one(f, *port, &opts, timeout_ms, &cwd);
let mut par_results: Vec<Option<TestResult>> = file_ports.iter().map(|_| None).collect();
std::thread::scope(|s| {
let handles: Vec<_> = file_ports
.iter()
.map(|(f, port)| s.spawn(|| run_one(f, *port, &opts, timeout_ms, &cwd)))
.collect();
for (slot, handle) in par_results.iter_mut().zip(handles) {
let r = handle.join().expect("thread panicked");
print_error_dot(&r);
r
})
.collect();
results.extend(par_results);
*slot = Some(r);
}
});
results.extend(par_results.into_iter().map(|r| r.unwrap()));
let _ = writeln!(stdout());
} else {
for file_path in &files {

View File

@ -1,5 +1,3 @@
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("...") {
@ -7,12 +5,27 @@ pub fn match_line(pattern: &str, actual: &str) -> bool {
}
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,
let mut pos = 0;
for (i, part) in parts.iter().enumerate() {
if i == 0 {
if !actual.starts_with(part) {
return false;
}
pos = part.len();
} else if i == parts.len() - 1 {
if !actual[pos..].ends_with(part) {
return false;
}
} else {
match actual[pos..].find(part) {
Some(idx) => pos += idx + part.len(),
None => return false,
}
}
}
true
}
/// Match expected output against actual output, supporting multi-line `...` wildcards.

View File

@ -3,8 +3,6 @@ use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
use regex::Regex;
use crate::parse::{self, ShoutFile};
#[derive(Debug, Clone)]
@ -64,26 +62,80 @@ fn build_script(commands: &[parse::Command], verbose: bool) -> String {
lines.join("\n") + "\n"
}
fn strip_ansi(line: &str) -> String {
// Same regex as the TS version
let re = Regex::new(r"[\x1b\x9b][\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]").unwrap();
re.replace_all(line, "").to_string()
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
for fc in chars.by_ref() {
if ('@'..='~').contains(&fc) {
break;
}
}
}
} else if c == '\u{009b}' {
for fc in chars.by_ref() {
if ('@'..='~').contains(&fc) {
break;
}
}
} else {
result.push(c);
}
}
result
}
/// Parse the suffix of a sentinel starting right after SENTINEL_PREFIX.
/// Expected format: `{exit_code}_{index}__`
/// Returns (exit_code, index, bytes_consumed) or None.
fn parse_sentinel_suffix(s: &str) -> Option<(i32, usize, usize)> {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i == 0 || b.get(i) != Some(&b'_') {
return None;
}
let exit_code: i32 = s[..i].parse().ok()?;
i += 1;
let j = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i == j || !s[i..].starts_with("__") {
return None;
}
let index: usize = s[j..i].parse().ok()?;
Some((exit_code, index, i + 2))
}
/// Find the next sentinel in `s` at or after byte offset `from`.
/// Returns (start, exit_code, index, end) or None.
fn find_sentinel(s: &str, from: usize) -> Option<(usize, i32, usize, usize)> {
let mut search = from;
while let Some(rel) = s[search..].find(SENTINEL_PREFIX) {
let abs = search + rel;
let after = abs + SENTINEL_PREFIX.len();
if let Some((exit_code, index, len)) = parse_sentinel_suffix(&s[after..]) {
return Some((abs, exit_code, index, after + len));
}
search = abs + 1;
}
None
}
fn parse_sentinel_output(raw: &str, command_count: usize) -> (Vec<Vec<String>>, Vec<i32>) {
let mut outputs = Vec::new();
let mut exit_codes = Vec::new();
let sentinel_re = Regex::new(&format!(r"{}(\d+)_(\d+)__", regex::escape(SENTINEL_PREFIX))).unwrap();
let mut remaining = raw;
let mut pos = 0;
for _i in 0..command_count {
if let Some(m) = sentinel_re.find(remaining) {
let caps = sentinel_re.captures(&remaining[m.start()..]).unwrap();
let exit_code: i32 = caps[1].parse().unwrap_or(1);
let before = &remaining[..m.start()];
if let Some((start, exit_code, _idx, end)) = find_sentinel(raw, pos) {
let before = &raw[pos..start];
let mut lines: Vec<String> = before.split('\n').map(|s| s.to_string()).collect();
// Remove leading empty line (from printf \n prefix)
@ -99,16 +151,13 @@ fn parse_sentinel_output(raw: &str, command_count: usize) -> (Vec<Vec<String>>,
outputs.push(lines);
exit_codes.push(exit_code);
// Skip past sentinel
let after = &remaining[m.end()..];
remaining = if after.starts_with('\n') {
&after[1..]
} else {
after
};
pos = end;
if raw.as_bytes().get(pos) == Some(&b'\n') {
pos += 1;
}
} else {
// No sentinel found — rest is output for this command
let mut lines: Vec<String> = remaining.split('\n').map(|s| s.to_string()).collect();
let mut lines: Vec<String> = raw[pos..].split('\n').map(|s| s.to_string()).collect();
if !lines.is_empty() && lines[0].is_empty() {
lines.remove(0);
}
@ -316,8 +365,6 @@ pub fn run_file(
let mut sentinels_reported: usize = 0;
let mut last_sentinel_end: usize = 0;
let sentinel_re = Regex::new(&format!(r"{}(\d+)_(\d+)__", regex::escape(SENTINEL_PREFIX))).unwrap();
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
@ -331,33 +378,34 @@ pub fn run_file(
// Stream command results as they come in
if let Some(on_result) = on_command_result {
for caps in sentinel_re.captures_iter(&accumulated[last_sentinel_end..]) {
let idx: usize = caps[2].parse().unwrap_or(0);
if idx >= sentinels_reported {
let exit_code: i32 = caps[1].parse().unwrap_or(1);
let sentinel_match = caps.get(0).unwrap();
let abs_start = last_sentinel_end + sentinel_match.start();
let abs_end = last_sentinel_end + sentinel_match.end();
let output_slice = &accumulated[last_sentinel_end..abs_start];
let mut lines: Vec<String> = output_slice.split('\n').map(|s| s.to_string()).collect();
if !lines.is_empty() && lines[0].is_empty() {
lines.remove(0);
}
lines = parse::trim_trailing_empty(&lines);
if lines.len() == 1 && lines[0].is_empty() {
lines.clear();
}
let result = CommandResult {
command: file.commands[idx].clone(),
actual: lines.iter().map(|l| strip_ansi(l)).collect(),
exit_code,
};
on_result(idx, &result);
sentinels_reported = idx + 1;
last_sentinel_end = abs_end;
if accumulated.as_bytes().get(last_sentinel_end) == Some(&b'\n') {
last_sentinel_end += 1;
loop {
if let Some((start, exit_code, idx, end)) = find_sentinel(&accumulated, last_sentinel_end) {
if idx >= sentinels_reported {
let output_slice = &accumulated[last_sentinel_end..start];
let mut lines: Vec<String> = output_slice.split('\n').map(|s| s.to_string()).collect();
if !lines.is_empty() && lines[0].is_empty() {
lines.remove(0);
}
lines = parse::trim_trailing_empty(&lines);
if lines.len() == 1 && lines[0].is_empty() {
lines.clear();
}
let result = CommandResult {
command: file.commands[idx].clone(),
actual: lines.iter().map(|l| strip_ansi(l)).collect(),
exit_code,
};
on_result(idx, &result);
sentinels_reported = idx + 1;
last_sentinel_end = end;
if accumulated.as_bytes().get(last_sentinel_end) == Some(&b'\n') {
last_sentinel_end += 1;
}
} else {
last_sentinel_end = end + 1;
}
} else {
break;
}
}
}