Compare commits
No commits in common. "5ae67ba391c2005f50452ad2c3f3e90e75417499" and "faa4a4ce9e3a7bf9cc4ec36d952a0092736e5d2d" have entirely different histories.
5ae67ba391
...
faa4a4ce9e
97
Cargo.lock
generated
97
Cargo.lock
generated
|
|
@ -2,15 +2,112 @@
|
|||
# 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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,3 +5,5 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
rayon = "1"
|
||||
regex = "1"
|
||||
|
|
|
|||
23
src/main.rs
23
src/main.rs
|
|
@ -597,6 +597,8 @@ fn main() {
|
|||
};
|
||||
|
||||
if opts.parallel {
|
||||
use rayon::prelude::*;
|
||||
|
||||
// Pre-assign ports
|
||||
let file_ports: Vec<(PathBuf, u16)> = files
|
||||
.iter()
|
||||
|
|
@ -604,19 +606,16 @@ fn main() {
|
|||
.map(|(i, f)| (f.clone(), opts.port_from + i as u16))
|
||||
.collect();
|
||||
|
||||
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");
|
||||
let par_results: Vec<TestResult> = file_ports
|
||||
.par_iter()
|
||||
.map(|(f, port)| {
|
||||
let r = run_one(f, *port, &opts, timeout_ms, &cwd);
|
||||
print_error_dot(&r);
|
||||
*slot = Some(r);
|
||||
}
|
||||
});
|
||||
results.extend(par_results.into_iter().map(|r| r.unwrap()));
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.extend(par_results);
|
||||
let _ = writeln!(stdout());
|
||||
} else {
|
||||
for file_path in &files {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
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("...") {
|
||||
|
|
@ -5,27 +7,12 @@ pub fn match_line(pattern: &str, actual: &str) -> bool {
|
|||
}
|
||||
|
||||
let parts: Vec<&str> = pattern.split("...").collect();
|
||||
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,
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Match expected output against actual output, supporting multi-line `...` wildcards.
|
||||
|
|
|
|||
150
src/run.rs
150
src/run.rs
|
|
@ -3,6 +3,8 @@ use std::process::{Command, Stdio};
|
|||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::parse::{self, ShoutFile};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -62,80 +64,26 @@ fn build_script(commands: &[parse::Command], verbose: bool) -> String {
|
|||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
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 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 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 mut pos = 0;
|
||||
|
||||
let sentinel_re = Regex::new(&format!(r"{}(\d+)_(\d+)__", regex::escape(SENTINEL_PREFIX))).unwrap();
|
||||
|
||||
let mut remaining = raw;
|
||||
|
||||
for _i in 0..command_count {
|
||||
if let Some((start, exit_code, _idx, end)) = find_sentinel(raw, pos) {
|
||||
let before = &raw[pos..start];
|
||||
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()];
|
||||
let mut lines: Vec<String> = before.split('\n').map(|s| s.to_string()).collect();
|
||||
|
||||
// Remove leading empty line (from printf \n prefix)
|
||||
|
|
@ -151,13 +99,16 @@ fn parse_sentinel_output(raw: &str, command_count: usize) -> (Vec<Vec<String>>,
|
|||
outputs.push(lines);
|
||||
exit_codes.push(exit_code);
|
||||
|
||||
pos = end;
|
||||
if raw.as_bytes().get(pos) == Some(&b'\n') {
|
||||
pos += 1;
|
||||
}
|
||||
// Skip past sentinel
|
||||
let after = &remaining[m.end()..];
|
||||
remaining = if after.starts_with('\n') {
|
||||
&after[1..]
|
||||
} else {
|
||||
after
|
||||
};
|
||||
} else {
|
||||
// No sentinel found — rest is output for this command
|
||||
let mut lines: Vec<String> = raw[pos..].split('\n').map(|s| s.to_string()).collect();
|
||||
let mut lines: Vec<String> = remaining.split('\n').map(|s| s.to_string()).collect();
|
||||
if !lines.is_empty() && lines[0].is_empty() {
|
||||
lines.remove(0);
|
||||
}
|
||||
|
|
@ -365,6 +316,8 @@ 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() {
|
||||
|
|
@ -378,34 +331,33 @@ pub fn run_file(
|
|||
|
||||
// Stream command results as they come in
|
||||
if let Some(on_result) = on_command_result {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user