shout/shout-rs/src/parse.rs

260 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,
})
}