shout/shout-rs/src/parse.rs
Chris Wanstrath 175899001a 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

261 lines
8.2 KiB
Rust

use std::fmt;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Command {
pub line: usize,
pub raw: String,
pub command: String,
pub expected: Vec<String>,
pub exit_code: ExitCode,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ExitCode {
/// No explicit exit code — expects 0
Default,
/// Expect a specific exit code
Code(i32),
/// Expect any non-zero exit code ([*])
Any,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Directive {
Setup { path: String, line: usize },
Env { key: String, value: String, line: usize },
}
#[derive(Debug, Clone)]
pub struct ShoutFile {
pub path: String,
pub commands: Vec<Command>,
pub directives: Vec<Directive>,
pub teardown_commands: Vec<Command>,
}
#[derive(Debug)]
pub struct ParseError(pub String);
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
pub fn trim_trailing_empty(lines: &[String]) -> Vec<String> {
let mut end = lines.len();
while end > 0 && lines[end - 1].is_empty() {
end -= 1;
}
lines[..end].to_vec()
}
/// Strip trailing `# comment` from a command line, respecting quotes.
fn strip_comment(line: &str) -> &str {
let mut in_single = false;
let mut in_double = false;
for (i, ch) in line.char_indices() {
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'#' if !in_single && !in_double => return line[..i].trim_end(),
_ => {}
}
}
line
}
/// A line like `$# ...` or `$ # ...` — a comment, not a real command.
pub fn is_comment_line(line: &str) -> bool {
line.starts_with("$#")
|| (line.starts_with("$ ") && strip_comment(&line[2..]).is_empty())
}
fn parse_exit_code(lines: &[String]) -> (Vec<String>, ExitCode) {
if lines.is_empty() {
return (vec![], ExitCode::Default);
}
let last = &lines[lines.len() - 1];
// Match [N] or [*]
if last.starts_with('[') && last.ends_with(']') {
let inner = &last[1..last.len() - 1];
if inner == "*" {
return (lines[..lines.len() - 1].to_vec(), ExitCode::Any);
}
if let Ok(code) = inner.parse::<i32>() {
return (lines[..lines.len() - 1].to_vec(), ExitCode::Code(code));
}
}
(lines.to_vec(), ExitCode::Default)
}
fn parse_env_directive(path: &str, line: &str, line_num: usize) -> Result<(String, String), ParseError> {
let rest = line[5..].trim();
match rest.find('=') {
Some(eq) if eq > 0 => {
let key = rest[..eq].to_string();
let value = rest[eq + 1..].to_string();
Ok((key, value))
}
_ => Err(ParseError(format!(
"{}:{}: malformed @env directive (expected KEY=VALUE): {}",
path, line_num, line
))),
}
}
fn finalize_command(cmd: &mut Command) {
let trimmed = trim_trailing_empty(&cmd.expected);
let (expected, exit_code) = parse_exit_code(&trimmed);
cmd.expected = trim_trailing_empty(&expected);
cmd.exit_code = exit_code;
}
pub fn parse_setup(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
let mut raw_lines: Vec<&str> = content.split('\n').collect();
// Remove trailing empty line
if raw_lines.last() == Some(&"") {
raw_lines.pop();
}
let mut commands = Vec::new();
let mut teardown_commands = Vec::new();
let mut directives = Vec::new();
for (i, &line) in raw_lines.iter().enumerate() {
let line_num = i + 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(rest) = line.strip_prefix("@env ") {
let _ = rest; // parsed below
let (key, value) = parse_env_directive(path, line, line_num)?;
directives.push(Directive::Env { key, value, line: line_num });
} else if let Some(rest) = line.strip_prefix("@teardown ") {
if rest.trim().is_empty() {
return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
}
teardown_commands.push(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(rest).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
} else if line.starts_with("@setup ") {
return Err(ParseError(format!("{}:{}: @setup not allowed in setup files", path, line_num)));
} else if line.starts_with('@') {
return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line)));
} else {
commands.push(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(line).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
}
}
Ok(ShoutFile {
path: path.to_string(),
commands,
directives,
teardown_commands,
})
}
pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
let mut raw_lines: Vec<&str> = content.split('\n').collect();
// Remove trailing newline
if raw_lines.last() == Some(&"") {
raw_lines.pop();
}
let mut commands = Vec::new();
let mut teardown_commands = Vec::new();
let mut directives = Vec::new();
let mut current: Option<Command> = None;
let mut seen_command = false;
for (i, &line) in raw_lines.iter().enumerate() {
let line_num = i + 1;
// Directives (before first command)
if !seen_command && line.starts_with('@') {
if let Some(rest) = line.strip_prefix("@setup ") {
let setup_path = rest.trim();
if setup_path.is_empty() {
return Err(ParseError(format!("{}:{}: @setup requires a file path", path, line_num)));
}
directives.push(Directive::Setup { path: setup_path.to_string(), line: line_num });
} else if let Some(rest) = line.strip_prefix("@teardown ") {
if rest.trim().is_empty() {
return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
}
teardown_commands.push(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(rest).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
} else if line.starts_with("@env ") {
let (key, value) = parse_env_directive(path, line, line_num)?;
directives.push(Directive::Env { key, value, line: line_num });
} else {
return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line)));
}
continue;
}
if is_comment_line(line) {
seen_command = true;
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
current = None;
}
} else if line.starts_with("\\$ ") && current.is_some() {
// Escaped dollar-space: literal expected output starting with "$ "
current.as_mut().unwrap().expected.push(line[1..].to_string());
} else if line.starts_with("$ ") {
seen_command = true;
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
}
current = Some(Command {
line: line_num,
raw: line.to_string(),
command: strip_comment(&line[2..]).to_string(),
expected: vec![],
exit_code: ExitCode::Default,
});
} else if let Some(ref mut cmd) = current {
cmd.expected.push(line.to_string());
}
}
if let Some(ref mut cmd) = current {
finalize_command(cmd);
commands.push(cmd.clone());
}
Ok(ShoutFile {
path: path.to_string(),
commands,
directives,
teardown_commands,
})
}