Add macro definition and substitution support

This commit is contained in:
Chris Wanstrath 2026-04-02 15:48:30 -07:00
parent aba7f9676d
commit 6869628353
2 changed files with 80 additions and 6 deletions

View File

@ -299,6 +299,8 @@ fn run_one(
let mut user_env: Vec<(String, String)> = vec![]; let mut user_env: Vec<(String, String)> = vec![];
let mut setup_commands: Vec<Command> = vec![]; let mut setup_commands: Vec<Command> = vec![];
let mut teardown_commands: Vec<Command> = parsed.teardown_commands.clone(); let mut teardown_commands: Vec<Command> = 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 { for d in &parsed.directives {
match d { match d {
@ -322,8 +324,14 @@ fn run_one(
} }
}; };
for sd in &setup_parsed.directives { for sd in &setup_parsed.directives {
if let Directive::Env { key, value, .. } = sd { match sd {
setup_env.push((key.clone(), value.clone())); 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); setup_commands.extend(setup_parsed.commands);
@ -332,9 +340,21 @@ fn run_one(
Directive::Env { key, value, .. } => { Directive::Env { key, value, .. } => {
user_env.push((key.clone(), value.clone())); 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<String, String> = 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 // Merge env: setup first, then user overrides
let mut env_vars: Vec<(String, String)> = vec![]; let mut env_vars: Vec<(String, String)> = vec![];
env_vars.extend(setup_env.clone()); env_vars.extend(setup_env.clone());
@ -349,10 +369,22 @@ fn run_one(
env_vars.push(("PORT".to_string(), port.to_string())); env_vars.push(("PORT".to_string(), port.to_string()));
} }
// Apply macro substitutions
let apply_defs = |commands: &mut Vec<Command>| {
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 // Merge commands: setup + user + teardown
let mut merged_commands = Vec::new(); let mut merged_commands = Vec::new();
merged_commands.extend(setup_commands.clone()); merged_commands.extend(setup_commands.clone());
merged_commands.extend(parsed.commands.clone()); merged_commands.extend(user_commands.clone());
merged_commands.extend(teardown_commands.clone()); merged_commands.extend(teardown_commands.clone());
let merged = ShoutFile { let merged = ShoutFile {
@ -363,7 +395,7 @@ fn run_one(
}; };
let setup_len = setup_commands.len(); let setup_len = setup_commands.len();
let user_len = parsed.commands.len(); let user_len = user_commands.len();
let run_opts = RunOptions { let run_opts = RunOptions {
clean_env: opts.clean_env, clean_env: opts.clean_env,

View File

@ -25,6 +25,7 @@ pub enum ExitCode {
pub enum Directive { pub enum Directive {
Setup { path: String, line: usize }, Setup { path: String, line: usize },
Env { key: String, value: String, line: usize }, Env { key: String, value: String, line: usize },
Def { name: String, body: String, line: usize },
} }
#[derive(Debug, Clone)] #[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) { fn finalize_command(cmd: &mut Command) {
let trimmed = trim_trailing_empty(&cmd.expected); let trimmed = trim_trailing_empty(&cmd.expected);
let (expected, exit_code) = parse_exit_code(&trimmed); let (expected, exit_code) = parse_exit_code(&trimmed);
@ -128,10 +152,13 @@ pub fn parse_setup(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
let mut teardown_commands = Vec::new(); let mut teardown_commands = Vec::new();
let mut directives = 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; let line_num = i + 1;
if line.is_empty() || line.starts_with('#') { if line.is_empty() || line.starts_with('#') {
i += 1;
continue; continue;
} }
@ -139,6 +166,11 @@ pub fn parse_setup(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
let _ = rest; // parsed below let _ = rest; // parsed below
let (key, value) = parse_env_directive(path, line, line_num)?; let (key, value) = parse_env_directive(path, line, line_num)?;
directives.push(Directive::Env { key, value, 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 ") { } else if let Some(rest) = line.strip_prefix("@teardown ") {
if rest.trim().is_empty() { if rest.trim().is_empty() {
return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num))); return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
@ -163,6 +195,7 @@ pub fn parse_setup(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
exit_code: ExitCode::Default, exit_code: ExitCode::Default,
}); });
} }
i += 1;
} }
Ok(ShoutFile { Ok(ShoutFile {
@ -187,7 +220,9 @@ pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
let mut current: Option<Command> = None; let mut current: Option<Command> = None;
let mut seen_command = false; 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; let line_num = i + 1;
// Directives (before first command) // Directives (before first command)
@ -198,6 +233,11 @@ pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
return Err(ParseError(format!("{}:{}: @setup requires a file path", path, line_num))); return Err(ParseError(format!("{}:{}: @setup requires a file path", path, line_num)));
} }
directives.push(Directive::Setup { path: setup_path.to_string(), line: 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 ") { } else if let Some(rest) = line.strip_prefix("@teardown ") {
if rest.trim().is_empty() { if rest.trim().is_empty() {
return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num))); return Err(ParseError(format!("{}:{}: @teardown requires a command", path, line_num)));
@ -215,6 +255,7 @@ pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
} else { } else {
return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line))); return Err(ParseError(format!("{}:{}: unknown directive: {}", path, line_num, line)));
} }
i += 1;
continue; continue;
} }
@ -244,6 +285,7 @@ pub fn parse(path: &str, content: &str) -> Result<ShoutFile, ParseError> {
} else if let Some(ref mut cmd) = current { } else if let Some(ref mut cmd) = current {
cmd.expected.push(line.to_string()); cmd.expected.push(line.to_string());
} }
i += 1;
} }
if let Some(ref mut cmd) = current { if let Some(ref mut cmd) = current {