diff --git a/src/main.rs b/src/main.rs index cdb7335..9b12ba9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -299,6 +299,8 @@ fn run_one( let mut user_env: Vec<(String, String)> = vec![]; let mut setup_commands: Vec = vec![]; let mut teardown_commands: Vec = parsed.teardown_commands.clone(); + let mut setup_defs: Vec<(String, String)> = vec![]; + let mut user_defs: Vec<(String, String)> = vec![]; for d in &parsed.directives { match d { @@ -322,8 +324,14 @@ fn run_one( } }; for sd in &setup_parsed.directives { - if let Directive::Env { key, value, .. } = sd { - setup_env.push((key.clone(), value.clone())); + match sd { + Directive::Env { key, value, .. } => { + setup_env.push((key.clone(), value.clone())); + } + Directive::Def { name, body, .. } => { + setup_defs.push((name.clone(), body.clone())); + } + _ => {} } } setup_commands.extend(setup_parsed.commands); @@ -332,9 +340,21 @@ fn run_one( Directive::Env { key, value, .. } => { user_env.push((key.clone(), value.clone())); } + Directive::Def { name, body, .. } => { + user_defs.push((name.clone(), body.clone())); + } } } + // Merge defs: setup first, then user overrides + let mut defs: std::collections::HashMap = std::collections::HashMap::new(); + for (name, body) in &setup_defs { + defs.insert(name.clone(), body.clone()); + } + for (name, body) in &user_defs { + defs.insert(name.clone(), body.clone()); + } + // Merge env: setup first, then user overrides let mut env_vars: Vec<(String, String)> = vec![]; env_vars.extend(setup_env.clone()); @@ -349,10 +369,22 @@ fn run_one( env_vars.push(("PORT".to_string(), port.to_string())); } + // Apply macro substitutions + let apply_defs = |commands: &mut Vec| { + for cmd in commands.iter_mut() { + if let Some(body) = defs.get(&cmd.command) { + cmd.command = body.clone(); + } + } + }; + let mut user_commands = parsed.commands.clone(); + apply_defs(&mut setup_commands); + apply_defs(&mut user_commands); + // Merge commands: setup + user + teardown let mut merged_commands = Vec::new(); merged_commands.extend(setup_commands.clone()); - merged_commands.extend(parsed.commands.clone()); + merged_commands.extend(user_commands.clone()); merged_commands.extend(teardown_commands.clone()); let merged = ShoutFile { @@ -363,7 +395,7 @@ fn run_one( }; let setup_len = setup_commands.len(); - let user_len = parsed.commands.len(); + let user_len = user_commands.len(); let run_opts = RunOptions { clean_env: opts.clean_env, diff --git a/src/parse.rs b/src/parse.rs index c617f22..84d14c0 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -25,6 +25,7 @@ pub enum ExitCode { pub enum Directive { Setup { path: String, line: usize }, Env { key: String, value: String, line: usize }, + Def { name: String, body: String, line: usize }, } #[derive(Debug, Clone)] @@ -109,6 +110,29 @@ fn parse_env_directive(path: &str, line: &str, line_num: usize) -> Result<(Strin } } +/// Parse `@def name body` with backslash continuation support. +/// Returns (name, body, number_of_lines_consumed). +fn parse_def_directive(path: &str, rest: &str, line_num: usize, lines: &[&str]) -> Result<(String, String, usize), ParseError> { + let rest = rest.trim(); + let space = rest.find(' ').ok_or_else(|| { + ParseError(format!("{}:{}: @def requires a name and body", path, line_num)) + })?; + let name = rest[..space].to_string(); + let mut body = rest[space + 1..].to_string(); + let mut consumed = 1; + + // Handle backslash continuation + while body.ends_with('\\') { + body.pop(); // remove trailing backslash + consumed += 1; + if consumed <= lines.len() { + body.push_str(lines[consumed - 1].trim_start()); + } + } + + Ok((name, body, consumed)) +} + fn finalize_command(cmd: &mut Command) { let trimmed = trim_trailing_empty(&cmd.expected); let (expected, exit_code) = parse_exit_code(&trimmed); @@ -128,10 +152,13 @@ pub fn parse_setup(path: &str, content: &str) -> Result { let mut teardown_commands = Vec::new(); let mut directives = Vec::new(); - for (i, &line) in raw_lines.iter().enumerate() { + let mut i = 0; + while i < raw_lines.len() { + let line = raw_lines[i]; let line_num = i + 1; if line.is_empty() || line.starts_with('#') { + i += 1; continue; } @@ -139,6 +166,11 @@ pub fn parse_setup(path: &str, content: &str) -> Result { 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("@def ") { + let (name, body, lines_consumed) = parse_def_directive(path, rest, line_num, &raw_lines[i..])?; + directives.push(Directive::Def { name, body, line: line_num }); + i += lines_consumed; + continue; } 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))); @@ -163,6 +195,7 @@ pub fn parse_setup(path: &str, content: &str) -> Result { exit_code: ExitCode::Default, }); } + i += 1; } Ok(ShoutFile { @@ -187,7 +220,9 @@ pub fn parse(path: &str, content: &str) -> Result { let mut current: Option = None; let mut seen_command = false; - for (i, &line) in raw_lines.iter().enumerate() { + let mut i = 0; + while i < raw_lines.len() { + let line = raw_lines[i]; let line_num = i + 1; // Directives (before first command) @@ -198,6 +233,11 @@ pub fn parse(path: &str, content: &str) -> Result { 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("@def ") { + let (name, body, lines_consumed) = parse_def_directive(path, rest, line_num, &raw_lines[i..])?; + directives.push(Directive::Def { name, body, line: line_num }); + i += lines_consumed; + continue; } 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))); @@ -215,6 +255,7 @@ pub fn parse(path: &str, content: &str) -> Result { } else { return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line))); } + i += 1; continue; } @@ -244,6 +285,7 @@ pub fn parse(path: &str, content: &str) -> Result { } else if let Some(ref mut cmd) = current { cmd.expected.push(line.to_string()); } + i += 1; } if let Some(ref mut cmd) = current {