260 lines
8.2 KiB
Rust
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,
|
|
})
|
|
} |