From a04d5ee44d42fada3eaf0bef0ce93eb832d7a46c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 01:54:24 +0000 Subject: [PATCH 1/6] feat(jq): expose shell env vars to jaq runtime via `env` builtin jaq's built-in `env` function reads from std::env::vars() which doesn't see bashkit's virtual environment. Fix: temporarily expose ctx.env and ctx.variables to the process environment before running jaq, with an RAII drop guard for cleanup on all return paths. - Unskip jq_env spec test - Add jq_env_missing and jq_env_in_pipeline spec tests - Add test_jq_env_access and test_jq_env_missing_var unit tests https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- specs/009-implementation-status.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index b7d1a9d5..872d22a3 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -310,7 +310,10 @@ None currently tracked. - `--arg name value` and `--argjson name value` variable bindings - `--indent N` flag no longer eats the filter argument - `env` builtin now exposes bashkit shell env vars to jaq runtime +<<<<<<< HEAD - `input`/`inputs` iterators wired to shared input stream +======= +>>>>>>> aaf2160 (feat(jq): expose shell env vars to jaq runtime via `env` builtin) ### Curl Limitations From 3a91ded88d02fb67b629038f826600d1b06f2f00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 02:15:32 +0000 Subject: [PATCH 2/6] feat(jq): wire input/inputs iterators to shared input stream Replace per-value empty inputs iterator with a shared RcIter over all parsed JSON values. Main loop and filter's input/inputs functions consume from the same source, matching real jq behavior. - Unskip jq_input and jq_inputs spec tests - Add jq_input_with_dot, jq_inputs_empty spec tests - Add 3 unit tests (input_reads_next, inputs_collects_remaining, inputs_single_value) https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- specs/009-implementation-status.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 872d22a3..b7d1a9d5 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -310,10 +310,7 @@ None currently tracked. - `--arg name value` and `--argjson name value` variable bindings - `--indent N` flag no longer eats the filter argument - `env` builtin now exposes bashkit shell env vars to jaq runtime -<<<<<<< HEAD - `input`/`inputs` iterators wired to shared input stream -======= ->>>>>>> aaf2160 (feat(jq): expose shell env vars to jaq runtime via `env` builtin) ### Curl Limitations From 6beac5123b591257aeb5c04332fd6eba3a5f89a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 03:31:08 +0000 Subject: [PATCH 3/6] feat(bash): arithmetic exponentiation, base literals, mapfile builtin - Arithmetic ** (power) operator with correct precedence over * - Base#value literals (16#ff, 2#1010, 8#77) and 0x/077 prefixes - Unary operators: !, ~, - in arithmetic expressions - mapfile/readarray builtin with -t flag and custom array name - expand_arithmetic_vars tracks numeric literal context to avoid expanding hex digits as variable names - 13 new spec tests (10 arithmetic, 3 mapfile) https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- crates/bashkit/src/interpreter/mod.rs | 148 +++++++++++++++++- .../tests/spec_cases/bash/arithmetic.test.sh | 70 +++++++++ .../tests/spec_cases/bash/arrays.test.sh | 28 ++++ 3 files changed, 243 insertions(+), 3 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 53d22652..96ca58bc 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -2475,6 +2475,11 @@ impl Interpreter { return self.execute_getopts(&args, &command.redirects).await; } + // Handle `mapfile`/`readarray` - needs direct access to arrays + if name == "mapfile" || name == "readarray" { + return self.execute_mapfile(&args, stdin.as_deref()).await; + } + // Check for builtins if let Some(builtin) = self.builtins.get(name) { let ctx = builtins::Context { @@ -2854,6 +2859,53 @@ impl Interpreter { /// Execute the `getopts` builtin (POSIX option parsing). /// + /// Execute mapfile/readarray builtin — reads lines into an indexed array. + /// Handled inline because it needs direct access to self.arrays. + async fn execute_mapfile( + &mut self, + args: &[String], + stdin_data: Option<&str>, + ) -> Result { + let mut trim_trailing = false; // -t: strip trailing newlines + let mut array_name = "MAPFILE".to_string(); + let mut positional = Vec::new(); + + for arg in args { + match arg.as_str() { + "-t" => trim_trailing = true, + a if a.starts_with('-') => {} // skip unknown flags + _ => positional.push(arg.clone()), + } + } + + if let Some(name) = positional.first() { + array_name = name.clone(); + } + + let input = stdin_data.unwrap_or(""); + + // Clear existing array + self.arrays.remove(&array_name); + + // Split into lines and populate array + if !input.is_empty() { + let mut arr = HashMap::new(); + for (idx, line) in input.lines().enumerate() { + let value = if trim_trailing { + line.to_string() + } else { + format!("{}\n", line) + }; + arr.insert(idx, value); + } + if !arr.is_empty() { + self.arrays.insert(array_name, arr); + } + } + + Ok(ExecResult::ok(String::new())) + } + /// Usage: `getopts optstring name [args...]` /// /// Parses options from positional params (or `args`). @@ -4416,9 +4468,12 @@ impl Interpreter { fn expand_arithmetic_vars(&self, expr: &str) -> String { let mut result = String::new(); let mut chars = expr.chars().peekable(); + // Track whether we're in a numeric literal context (after # or 0x) + let mut in_numeric_literal = false; while let Some(ch) = chars.next() { if ch == '$' { + in_numeric_literal = false; // Handle $var syntax (common in arithmetic) let mut name = String::new(); while let Some(&c) = chars.peek() { @@ -4438,7 +4493,26 @@ impl Interpreter { } else { result.push(ch); } + } else if ch == '#' { + // base#value syntax: digits before # are base, chars after are literal digits + result.push(ch); + in_numeric_literal = true; + } else if in_numeric_literal && (ch.is_ascii_alphanumeric() || ch == '_') { + // Part of a base#value literal — don't expand as variable + result.push(ch); + } else if ch.is_ascii_digit() { + result.push(ch); + // Check for 0x/0X hex prefix + if ch == '0' { + if let Some(&next) = chars.peek() { + if next == 'x' || next == 'X' { + result.push(chars.next().unwrap()); + in_numeric_literal = true; + } + } + } } else if ch.is_ascii_alphabetic() || ch == '_' { + in_numeric_literal = false; // Could be a variable name let mut name = String::new(); name.push(ch); @@ -4457,6 +4531,7 @@ impl Interpreter { result.push_str(&value); } } else { + in_numeric_literal = false; result.push(ch); } } @@ -4688,17 +4763,28 @@ impl Interpreter { } } - // Multiplication/Division/Modulo (higher precedence) + // Multiplication/Division/Modulo (higher precedence, skip ** which is power) depth = 0; for i in (0..chars.len()).rev() { match chars[i] { '(' => depth += 1, ')' => depth -= 1, - '*' | '/' | '%' if depth == 0 => { + '*' if depth == 0 => { + // Skip ** (power operator handled below) + if i + 1 < chars.len() && chars[i + 1] == '*' { + continue; + } + if i > 0 && chars[i - 1] == '*' { + continue; + } + let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1); + let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1); + return left * right; + } + '/' | '%' if depth == 0 => { let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1); let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1); return match chars[i] { - '*' => left * right, '/' => { if right != 0 { left / right @@ -4720,6 +4806,62 @@ impl Interpreter { } } + // Exponentiation ** (right-associative, higher precedence than */%) + depth = 0; + for i in 0..chars.len() { + match chars[i] { + '(' => depth += 1, + ')' => depth -= 1, + '*' if depth == 0 && i + 1 < chars.len() && chars[i + 1] == '*' => { + let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1); + // Right-associative: parse from i+2 onward (may contain more **) + let right = self.parse_arithmetic_impl(&expr[i + 2..], arith_depth + 1); + return left.pow(right as u32); + } + _ => {} + } + } + + // Unary negation and bitwise NOT + if let Some(rest) = expr.strip_prefix('-') { + let rest = rest.trim(); + if !rest.is_empty() { + return -self.parse_arithmetic_impl(rest, arith_depth + 1); + } + } + if let Some(rest) = expr.strip_prefix('~') { + let rest = rest.trim(); + if !rest.is_empty() { + return !self.parse_arithmetic_impl(rest, arith_depth + 1); + } + } + if let Some(rest) = expr.strip_prefix('!') { + let rest = rest.trim(); + if !rest.is_empty() { + let val = self.parse_arithmetic_impl(rest, arith_depth + 1); + return if val == 0 { 1 } else { 0 }; + } + } + + // Base conversion: base#value (e.g., 16#ff = 255, 2#1010 = 10) + if let Some(hash_pos) = expr.find('#') { + let base_str = &expr[..hash_pos]; + let value_str = &expr[hash_pos + 1..]; + if let Ok(base) = base_str.parse::() { + if (2..=64).contains(&base) { + return i64::from_str_radix(value_str, base).unwrap_or(0); + } + } + } + + // Hex (0x...), octal (0...) literals + if expr.starts_with("0x") || expr.starts_with("0X") { + return i64::from_str_radix(&expr[2..], 16).unwrap_or(0); + } + if expr.starts_with('0') && expr.len() > 1 && expr.chars().all(|c| c.is_ascii_digit()) { + return i64::from_str_radix(&expr[1..], 8).unwrap_or(0); + } + // Parse as number expr.trim().parse().unwrap_or(0) } diff --git a/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh b/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh index 1efb533f..3f4f61dd 100644 --- a/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh @@ -201,3 +201,73 @@ echo $((1 || 0 && 0)) ### expect 1 ### end + +### arith_exponentiation +# ** power operator +echo $((2 ** 10)) +### expect +1024 +### end + +### arith_exponentiation_variable +# ** with variable +x=5; echo $(( x ** 2 )) +### expect +25 +### end + +### arith_base_hex +# Base conversion: 16#ff = 255 +echo $((16#ff)) +### expect +255 +### end + +### arith_base_binary +# Base conversion: 2#1010 = 10 +echo $((2#1010)) +### expect +10 +### end + +### arith_base_octal +# Base conversion: 8#77 = 63 +echo $((8#77)) +### expect +63 +### end + +### arith_hex_literal +# 0x hex literal +echo $((0xff)) +### expect +255 +### end + +### arith_octal_literal +# Octal literal +echo $((077)) +### expect +63 +### end + +### arith_unary_negate +# Unary negation +echo $((-5)) +### expect +-5 +### end + +### arith_bitwise_not +# Bitwise NOT +echo $((~0)) +### expect +-1 +### end + +### arith_logical_not +# Logical NOT +echo $((!0)) +### expect +1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/arrays.test.sh b/crates/bashkit/tests/spec_cases/bash/arrays.test.sh index 0bd3147c..728d3a5c 100644 --- a/crates/bashkit/tests/spec_cases/bash/arrays.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/arrays.test.sh @@ -154,3 +154,31 @@ b 2 3 ### end + +### mapfile_basic +### bash_diff: pipes don't create subshells in bashkit (stateless model) +# mapfile reads lines into array from pipe +printf 'a\nb\nc\n' | mapfile -t lines; echo ${#lines[@]}; echo ${lines[0]}; echo ${lines[1]}; echo ${lines[2]} +### expect +3 +a +b +c +### end + +### readarray_alias +### bash_diff: pipes don't create subshells in bashkit (stateless model) +# readarray is an alias for mapfile +printf 'x\ny\n' | readarray -t arr; echo ${arr[0]} ${arr[1]} +### expect +x y +### end + +### mapfile_default_name +### bash_diff: pipes don't create subshells in bashkit (stateless model) +# mapfile default array name is MAPFILE +printf 'hello\nworld\n' | mapfile -t; echo ${MAPFILE[0]}; echo ${MAPFILE[1]} +### expect +hello +world +### end From 1e40756ac2e7be8bfab1a430f7c8d8f1a632b712 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 04:24:40 +0000 Subject: [PATCH 4/6] feat(bash): arithmetic operators, trap, PIPESTATUS, subshell isolation, heredoc <<- Arithmetic evaluation: - Bitwise XOR (^), left/right shift (<<, >>) - Compound assignments (+=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=) - Pre/post increment/decrement (++x, x++, --x, x--) - Comma operator for multi-expression evaluation - Fix C-style for loop with <= and >= conditions Shell features: - trap builtin with EXIT handler support - PIPESTATUS array tracking per-command exit codes in pipelines - Subshell (...) variable isolation (changes don't leak to parent) - Heredoc tab-stripping with <<- syntax - ${!prefix*} / ${!prefix@} variable name prefix matching Parser fixes: - HereDocStrip token for <<- heredocs - Fix arithmetic for-loop condition reconstruction (<=, >= operators) - PrefixMatch WordPart for ${!prefix*} expansion https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- crates/bashkit/src/interpreter/mod.rs | 305 ++++++++++++++++-- crates/bashkit/src/parser/ast.rs | 5 + crates/bashkit/src/parser/lexer.rs | 3 + crates/bashkit/src/parser/mod.rs | 56 +++- crates/bashkit/src/parser/tokens.rs | 3 + .../tests/spec_cases/bash/arithmetic.test.sh | 134 ++++++++ .../spec_cases/bash/control-flow.test.sh | 18 ++ .../tests/spec_cases/bash/heredoc.test.sh | 11 + .../tests/spec_cases/bash/variables.test.sh | 30 ++ 9 files changed, 531 insertions(+), 34 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 96ca58bc..50559c90 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -169,6 +169,10 @@ pub struct Interpreter { output_emit_count: u64, /// Pending nounset (set -u) error message, consumed by execute_command. nounset_error: Option, + /// Trap handlers: signal/event name -> command string + traps: HashMap, + /// PIPESTATUS: exit codes of the last pipeline's commands + pipestatus: Vec, } impl Interpreter { @@ -337,6 +341,8 @@ impl Interpreter { output_callback: None, output_emit_count: 0, nounset_error: None, + traps: HashMap::new(), + pipestatus: Vec::new(), } } @@ -471,6 +477,19 @@ impl Interpreter { } } + // Run EXIT trap if registered + if let Some(trap_cmd) = self.traps.get("EXIT").cloned() { + if let Ok(trap_script) = Parser::new(&trap_cmd).parse() { + let emit_before = self.output_emit_count; + if let Ok(trap_result) = self.execute_command_sequence(&trap_script.commands).await + { + self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before); + stdout.push_str(&trap_result.stdout); + stderr.push_str(&trap_result.stderr); + } + } + } + Ok(ExecResult { stdout, stderr, @@ -568,7 +587,15 @@ impl Interpreter { } CompoundCommand::While(while_cmd) => self.execute_while(while_cmd).await, CompoundCommand::Until(until_cmd) => self.execute_until(until_cmd).await, - CompoundCommand::Subshell(commands) => self.execute_command_sequence(commands).await, + CompoundCommand::Subshell(commands) => { + // Subshells run in isolated variable scope + let saved_vars = self.variables.clone(); + let saved_exit = self.last_exit_code; + let result = self.execute_command_sequence(commands).await; + self.variables = saved_vars; + self.last_exit_code = saved_exit; + result + } CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await, CompoundCommand::Case(case_cmd) => self.execute_case(case_cmd).await, CompoundCommand::Arithmetic(expr) => self.execute_arithmetic_command(expr).await, @@ -1907,6 +1934,7 @@ impl Interpreter { async fn execute_pipeline(&mut self, pipeline: &Pipeline) -> Result { let mut stdin_data: Option = None; let mut last_result = ExecResult::ok(String::new()); + let mut pipe_statuses = Vec::new(); for (i, command) in pipeline.commands.iter().enumerate() { let is_last = i == pipeline.commands.len() - 1; @@ -1927,6 +1955,8 @@ impl Interpreter { } }; + pipe_statuses.push(result.exit_code); + if is_last { last_result = result; } else { @@ -1934,6 +1964,14 @@ impl Interpreter { } } + // Store PIPESTATUS array + self.pipestatus = pipe_statuses.clone(); + let mut ps_arr = HashMap::new(); + for (i, code) in pipe_statuses.iter().enumerate() { + ps_arr.insert(i, code.to_string()); + } + self.arrays.insert("PIPESTATUS".to_string(), ps_arr); + // Handle negation if pipeline.negated { last_result.exit_code = if last_result.exit_code == 0 { 1 } else { 0 }; @@ -2435,6 +2473,38 @@ impl Interpreter { return Ok(result); } + // Handle `trap` - register signal/event handlers + if name == "trap" { + if args.is_empty() { + // List traps + let mut output = String::new(); + for (sig, cmd) in &self.traps { + output.push_str(&format!("trap -- '{}' {}\n", cmd, sig)); + } + let mut result = ExecResult::ok(output); + result = self.apply_redirections(result, &command.redirects).await?; + return Ok(result); + } + if args.len() == 1 { + // trap '' or trap - : reset signal + let sig = args[0].to_uppercase(); + self.traps.remove(&sig); + } else { + let cmd = args[0].clone(); + for sig in &args[1..] { + let sig_upper = sig.to_uppercase(); + if cmd == "-" { + self.traps.remove(&sig_upper); + } else { + self.traps.insert(sig_upper, cmd.clone()); + } + } + } + let mut result = ExecResult::ok(String::new()); + result = self.apply_redirections(result, &command.redirects).await?; + return Ok(result); + } + // Handle `declare`/`typeset` - needs interpreter-level access to arrays if name == "declare" || name == "typeset" { return self @@ -3619,8 +3689,8 @@ impl Interpreter { let content = self.expand_word(&redirect.target).await?; stdin = Some(format!("{}\n", content)); } - RedirectKind::HereDoc => { - // << EOF - use the heredoc content as stdin + RedirectKind::HereDoc | RedirectKind::HereDocStrip => { + // << EOF / <<- EOF - use the heredoc content as stdin let content = self.expand_word(&redirect.target).await?; stdin = Some(content); } @@ -3765,7 +3835,10 @@ impl Interpreter { } } } - RedirectKind::Input | RedirectKind::HereString | RedirectKind::HereDoc => { + RedirectKind::Input + | RedirectKind::HereString + | RedirectKind::HereDoc + | RedirectKind::HereDocStrip => { // Input redirections handled in process_input_redirections } RedirectKind::DupInput => { @@ -3990,6 +4063,23 @@ impl Interpreter { let value = self.expand_variable(&var_name); result.push_str(&value); } + WordPart::PrefixMatch(prefix) => { + // ${!prefix*} - names of variables with given prefix + let mut names: Vec = self + .variables + .keys() + .filter(|k| k.starts_with(prefix.as_str())) + .cloned() + .collect(); + // Also check env + for k in self.env.keys() { + if k.starts_with(prefix.as_str()) && !names.contains(k) { + names.push(k.clone()); + } + } + names.sort(); + result.push_str(&names.join(" ")); + } WordPart::ArrayLength(name) => { // ${#arr[@]} - number of elements if let Some(arr) = self.assoc_arrays.get(name) { @@ -4418,30 +4508,141 @@ impl Interpreter { /// Evaluate arithmetic with assignment support (e.g. `X = X + 1`). /// Assignment must be handled before variable expansion so the LHS /// variable name is preserved. + /// Check if a string is a valid shell variable name + fn is_valid_var_name(s: &str) -> bool { + !s.is_empty() + && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !s.chars().next().unwrap_or('0').is_ascii_digit() + } + fn evaluate_arithmetic_with_assign(&mut self, expr: &str) -> i64 { let expr = expr.trim(); - // Check for assignment: VAR = expr (but not == comparison) - // Pattern: identifier followed by = (not ==) + // Handle comma operator (lowest precedence): evaluate all, return last + // But not inside parentheses + { + let mut depth = 0i32; + let chars: Vec = expr.chars().collect(); + for i in (0..chars.len()).rev() { + match chars[i] { + '(' => depth += 1, + ')' => depth -= 1, + ',' if depth == 0 => { + let left = &expr[..i]; + let right = &expr[i + 1..]; + self.evaluate_arithmetic_with_assign(left); + return self.evaluate_arithmetic_with_assign(right); + } + _ => {} + } + } + } + + // Handle pre-increment/pre-decrement: ++var, --var + if let Some(var_name) = expr.strip_prefix("++") { + let var_name = var_name.trim(); + if Self::is_valid_var_name(var_name) { + let val = self.expand_variable(var_name).parse::().unwrap_or(0) + 1; + self.variables.insert(var_name.to_string(), val.to_string()); + return val; + } + } + if let Some(var_name) = expr.strip_prefix("--") { + let var_name = var_name.trim(); + if Self::is_valid_var_name(var_name) { + let val = self.expand_variable(var_name).parse::().unwrap_or(0) - 1; + self.variables.insert(var_name.to_string(), val.to_string()); + return val; + } + } + + // Handle post-increment/post-decrement: var++, var-- + if let Some(var_name) = expr.strip_suffix("++") { + let var_name = var_name.trim(); + if Self::is_valid_var_name(var_name) { + let old_val = self.expand_variable(var_name).parse::().unwrap_or(0); + self.variables + .insert(var_name.to_string(), (old_val + 1).to_string()); + return old_val; + } + } + if let Some(var_name) = expr.strip_suffix("--") { + let var_name = var_name.trim(); + if Self::is_valid_var_name(var_name) { + let old_val = self.expand_variable(var_name).parse::().unwrap_or(0); + self.variables + .insert(var_name.to_string(), (old_val - 1).to_string()); + return old_val; + } + } + + // Check for compound assignments: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= + // and simple assignment: VAR = expr (but not == comparison) if let Some(eq_pos) = expr.find('=') { - // Make sure it's not == or != let before = &expr[..eq_pos]; let after_char = expr.as_bytes().get(eq_pos + 1); - if !before.ends_with('!') - && !before.ends_with('<') - && !before.ends_with('>') - && after_char != Some(&b'=') - { - let var_name = before.trim(); - // Verify LHS is a valid variable name - if !var_name.is_empty() - && var_name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_') - && !var_name.chars().next().unwrap_or('0').is_ascii_digit() - { + // Not == or != + if !before.ends_with('!') && after_char != Some(&b'=') { + // Detect compound operator: check multi-char ops first + let (var_name, op) = if let Some(s) = before.strip_suffix("<<") { + (s.trim(), "<<") + } else if let Some(s) = before.strip_suffix(">>") { + (s.trim(), ">>") + } else if let Some(s) = before.strip_suffix('+') { + (s.trim(), "+") + } else if let Some(s) = before.strip_suffix('-') { + (s.trim(), "-") + } else if let Some(s) = before.strip_suffix('*') { + (s.trim(), "*") + } else if let Some(s) = before.strip_suffix('/') { + (s.trim(), "/") + } else if let Some(s) = before.strip_suffix('%') { + (s.trim(), "%") + } else if let Some(s) = before.strip_suffix('&') { + (s.trim(), "&") + } else if let Some(s) = before.strip_suffix('|') { + (s.trim(), "|") + } else if let Some(s) = before.strip_suffix('^') { + (s.trim(), "^") + } else if !before.ends_with('<') && !before.ends_with('>') { + (before.trim(), "") + } else { + ("", "") + }; + + if Self::is_valid_var_name(var_name) { let rhs = &expr[eq_pos + 1..]; - let value = self.evaluate_arithmetic(rhs); + let rhs_val = self.evaluate_arithmetic(rhs); + let value = if op.is_empty() { + rhs_val + } else { + let lhs_val = self.expand_variable(var_name).parse::().unwrap_or(0); + match op { + "+" => lhs_val + rhs_val, + "-" => lhs_val - rhs_val, + "*" => lhs_val * rhs_val, + "/" => { + if rhs_val != 0 { + lhs_val / rhs_val + } else { + 0 + } + } + "%" => { + if rhs_val != 0 { + lhs_val % rhs_val + } else { + 0 + } + } + "&" => lhs_val & rhs_val, + "|" => lhs_val | rhs_val, + "^" => lhs_val ^ rhs_val, + "<<" => lhs_val << rhs_val, + ">>" => lhs_val >> rhs_val, + _ => rhs_val, + } + }; self.variables .insert(var_name.to_string(), value.to_string()); return value; @@ -4670,6 +4871,21 @@ impl Interpreter { } } + // Bitwise XOR (^) + depth = 0; + for i in (0..chars.len()).rev() { + match chars[i] { + '(' => depth += 1, + ')' => depth -= 1, + '^' if depth == 0 => { + let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1); + let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1); + return left ^ right; + } + _ => {} + } + } + // Bitwise AND (&) - but not && depth = 0; for i in (0..chars.len()).rev() { @@ -4725,7 +4941,7 @@ impl Interpreter { return if left >= right { 1 } else { 0 }; } '<' if depth == 0 - && (i + 1 >= chars.len() || chars[i + 1] != '=') + && (i + 1 >= chars.len() || (chars[i + 1] != '=' && chars[i + 1] != '<')) && (i == 0 || chars[i - 1] != '<') => { let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1); @@ -4733,7 +4949,7 @@ impl Interpreter { return if left < right { 1 } else { 0 }; } '>' if depth == 0 - && (i + 1 >= chars.len() || chars[i + 1] != '=') + && (i + 1 >= chars.len() || (chars[i + 1] != '=' && chars[i + 1] != '>')) && (i == 0 || chars[i - 1] != '>') => { let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1); @@ -4744,6 +4960,36 @@ impl Interpreter { } } + // Bitwise shift (<< >>) - but not <<= or heredoc contexts + depth = 0; + for i in (0..chars.len()).rev() { + match chars[i] { + '(' => depth += 1, + ')' => depth -= 1, + '<' if depth == 0 + && i > 0 + && chars[i - 1] == '<' + && (i < 2 || chars[i - 2] != '<') + && (i + 1 >= chars.len() || chars[i + 1] != '=') => + { + let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1); + let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1); + return left << right; + } + '>' if depth == 0 + && i > 0 + && chars[i - 1] == '>' + && (i < 2 || chars[i - 2] != '>') + && (i + 1 >= chars.len() || chars[i + 1] != '=') => + { + let left = self.parse_arithmetic_impl(&expr[..i - 1], arith_depth + 1); + let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1); + return left >> right; + } + _ => {} + } + } + // Addition/Subtraction depth = 0; for i in (0..chars.len()).rev() { @@ -4751,6 +4997,19 @@ impl Interpreter { '(' => depth += 1, ')' => depth -= 1, '+' | '-' if depth == 0 && i > 0 => { + // Skip ++/-- (handled elsewhere as increment/decrement) + if chars[i] == '+' && i + 1 < chars.len() && chars[i + 1] == '+' { + continue; + } + if chars[i] == '+' && i > 0 && chars[i - 1] == '+' { + continue; + } + if chars[i] == '-' && i + 1 < chars.len() && chars[i + 1] == '-' { + continue; + } + if chars[i] == '-' && i > 0 && chars[i - 1] == '-' { + continue; + } let left = self.parse_arithmetic_impl(&expr[..i], arith_depth + 1); let right = self.parse_arithmetic_impl(&expr[i + 1..], arith_depth + 1); return if chars[i] == '+' { diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 421fd09f..30a2bbde 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -294,6 +294,7 @@ impl fmt::Display for Word { } } WordPart::IndirectExpansion(name) => write!(f, "${{!{}}}", name)?, + WordPart::PrefixMatch(prefix) => write!(f, "${{!{}*}}", prefix)?, WordPart::ProcessSubstitution { commands, is_input } => { let prefix = if *is_input { "<" } else { ">" }; write!(f, "{}({:?})", prefix, commands)? @@ -343,6 +344,8 @@ pub enum WordPart { }, /// Indirect expansion `${!var}` - expands to value of variable named by var's value IndirectExpansion(String), + /// Prefix matching `${!prefix*}` or `${!prefix@}` - names of variables with given prefix + PrefixMatch(String), /// Process substitution <(cmd) or >(cmd) ProcessSubstitution { /// The commands to run @@ -413,6 +416,8 @@ pub enum RedirectKind { Input, /// << - here document HereDoc, + /// <<- - here document with leading tab stripping + HereDocStrip, /// <<< - here string HereString, /// >& - duplicate output fd diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 5bd528a0..a9342b7a 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -122,6 +122,9 @@ impl<'a> Lexer<'a> { if self.peek_char() == Some('<') { self.advance(); Some(Token::HereString) + } else if self.peek_char() == Some('-') { + self.advance(); + Some(Token::HereDocStrip) } else { Some(Token::HereDoc) } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 62fec285..94db879a 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -676,10 +676,13 @@ impl<'a> Parser<'a> { Some(tokens::Token::Word(w)) | Some(tokens::Token::LiteralWord(w)) | Some(tokens::Token::QuotedWord(w)) => { - if !current_expr.is_empty() - && !current_expr.ends_with(' ') - && !current_expr.ends_with('(') - { + // Don't add space when joining operator pairs like < + =3 → <=3 + let skip_space = current_expr.ends_with('<') + || current_expr.ends_with('>') + || current_expr.ends_with(' ') + || current_expr.ends_with('(') + || current_expr.is_empty(); + if !skip_space { current_expr.push(' '); } current_expr.push_str(w); @@ -1601,7 +1604,9 @@ impl<'a> Parser<'a> { target, }); } - Some(tokens::Token::HereDoc) => { + Some(tokens::Token::HereDoc) | Some(tokens::Token::HereDocStrip) => { + let strip_tabs = + matches!(self.current_token, Some(tokens::Token::HereDocStrip)); self.advance(); // Get the delimiter word and track if it was quoted // Quoted delimiters (single or double quotes) disable variable expansion @@ -1616,6 +1621,22 @@ impl<'a> Parser<'a> { // Read the here document content (reads until delimiter line) let content = self.lexer.read_heredoc(&delimiter); + // Strip leading tabs for <<- + let content = if strip_tabs { + let had_trailing_newline = content.ends_with('\n'); + let mut stripped: String = content + .lines() + .map(|l| l.trim_start_matches('\t')) + .collect::>() + .join("\n"); + if had_trailing_newline { + stripped.push('\n'); + } + stripped + } else { + content + }; + // Now advance to get the next token after the heredoc self.advance(); @@ -1627,9 +1648,15 @@ impl<'a> Parser<'a> { self.parse_word(content) }; + let kind = if strip_tabs { + RedirectKind::HereDocStrip + } else { + RedirectKind::HereDoc + }; + redirects.push(Redirect { fd: None, - kind: RedirectKind::HereDoc, + kind, target, }); @@ -2000,7 +2027,7 @@ impl<'a> Parser<'a> { chars.next(); // consume '!' let mut var_name = String::new(); while let Some(&c) = chars.peek() { - if c == '}' || c == '[' { + if c == '}' || c == '[' || c == '*' || c == '@' { break; } var_name.push(chars.next().unwrap()); @@ -2031,16 +2058,23 @@ impl<'a> Parser<'a> { chars.next(); // consume '}' parts.push(WordPart::IndirectExpansion(var_name)); } else { - // ${!prefix*} or ${!prefix@} - prefix matching (not fully supported) - // For now, consume until } and treat as variable + // ${!prefix*} or ${!prefix@} - prefix matching + let mut suffix = String::new(); while let Some(&c) = chars.peek() { if c == '}' { chars.next(); break; } - var_name.push(chars.next().unwrap()); + suffix.push(chars.next().unwrap()); + } + // Strip trailing * or @ + if suffix.ends_with('*') || suffix.ends_with('@') { + let full_prefix = + format!("{}{}", var_name, &suffix[..suffix.len() - 1]); + parts.push(WordPart::PrefixMatch(full_prefix)); + } else { + parts.push(WordPart::Variable(format!("!{}{}", var_name, suffix))); } - parts.push(WordPart::Variable(format!("!{}", var_name))); } } else { // Read variable name diff --git a/crates/bashkit/src/parser/tokens.rs b/crates/bashkit/src/parser/tokens.rs index a5d4810c..c8e38376 100644 --- a/crates/bashkit/src/parser/tokens.rs +++ b/crates/bashkit/src/parser/tokens.rs @@ -47,6 +47,9 @@ pub enum Token { /// Here document (<<) HereDoc, + /// Here document with tab stripping (<<-) + HereDocStrip, + /// Here string (<<<) HereString, diff --git a/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh b/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh index 3f4f61dd..0ee8378f 100644 --- a/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/arithmetic.test.sh @@ -271,3 +271,137 @@ echo $((!0)) ### expect 1 ### end + +### arith_bitwise_xor +# Bitwise XOR +echo $((5 ^ 3)) +### expect +6 +### end + +### arith_shift_left +# Left shift +echo $((1 << 4)) +### expect +16 +### end + +### arith_shift_right +# Right shift +echo $((16 >> 2)) +### expect +4 +### end + +### arith_compound_add_assign +# Compound += assignment +x=10; echo $(( x += 5 )) +### expect +15 +### end + +### arith_compound_sub_assign +# Compound -= assignment +x=10; echo $(( x -= 3 )) +### expect +7 +### end + +### arith_compound_mul_assign +# Compound *= assignment +x=4; echo $(( x *= 3 )) +### expect +12 +### end + +### arith_compound_and_assign +# Compound &= assignment +x=7; echo $(( x &= 3 )) +### expect +3 +### end + +### arith_compound_or_assign +# Compound |= assignment +x=5; echo $(( x |= 2 )) +### expect +7 +### end + +### arith_compound_xor_assign +# Compound ^= assignment +x=5; echo $(( x ^= 3 )) +### expect +6 +### end + +### arith_compound_shl_assign +# Compound <<= assignment +x=1; echo $(( x <<= 4 )) +### expect +16 +### end + +### arith_compound_shr_assign +# Compound >>= assignment +x=16; echo $(( x >>= 2 )) +### expect +4 +### end + +### arith_pre_increment +# Pre-increment ++var +x=5; echo $(( ++x )); echo $x +### expect +6 +6 +### end + +### arith_post_increment +# Post-increment var++ +x=5; echo $(( x++ )); echo $x +### expect +5 +6 +### end + +### arith_pre_decrement +# Pre-decrement --var +x=5; echo $(( --x )); echo $x +### expect +4 +4 +### end + +### arith_post_decrement +# Post-decrement var-- +x=5; echo $(( x-- )); echo $x +### expect +5 +4 +### end + +### arith_comma_operator +# Comma operator (evaluate all, return last) +echo $(( x=3, y=4, x+y )) +### expect +7 +### end + +### arith_compare_le +# Less than or equal +echo $((3 <= 5)); echo $((5 <= 5)); echo $((6 <= 5)) +### expect +1 +1 +0 +### end + +### arith_compare_ge +# Greater than or equal +echo $((5 >= 3)); echo $((5 >= 5)); echo $((4 >= 5)) +### expect +1 +1 +0 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh b/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh index ca6bc119..544306aa 100644 --- a/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh @@ -229,3 +229,21 @@ redirected ### expect hello ### end + +### arith_for_le +# C-style for loop with <= condition +for ((i=1; i<=3; i++)); do echo $i; done +### expect +1 +2 +3 +### end + +### arith_for_ge +# C-style for loop with >= (countdown) +for ((i=3; i>=1; i--)); do echo $i; done +### expect +3 +2 +1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh index 6a33b30b..aafdd656 100644 --- a/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/heredoc.test.sh @@ -82,3 +82,14 @@ EOF ### expect value: 42, cmd: hi, math: 84 ### end + +### heredoc_tab_strip +# <<- strips leading tabs from content and delimiter +cat <<-EOF + hello + world + EOF +### expect +hello +world +### end diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index 684d3c24..c6a68502 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -308,3 +308,33 @@ PERSIST=yes; echo $PERSIST ### expect yes ### end + +### var_prefix_match +# ${!prefix*} - names of variables with given prefix +MYVAR1=a; MYVAR2=b; MYVAR3=c; echo ${!MYVAR*} +### expect +MYVAR1 MYVAR2 MYVAR3 +### end + +### var_subshell_isolation +# Subshell variable changes don't leak to parent +X=orig; (X=changed; echo $X); echo $X +### expect +changed +orig +### end + +### var_pipestatus +# PIPESTATUS array tracks per-command exit codes +false | true | false; echo ${PIPESTATUS[0]}-${PIPESTATUS[1]}-${PIPESTATUS[2]} +### expect +1-0-1 +### end + +### var_trap_exit +# trap EXIT handler runs on script completion +trap 'echo goodbye' EXIT; echo hello +### expect +hello +goodbye +### end From 62290f72ac0a46af45fe449e48e4441a006b57bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 05:46:21 +0000 Subject: [PATCH 5/6] feat(bash): pipefail, trap ERR, ${var@op}, printf -v, read -a/-n/-d - set -o pipefail: pipeline returns rightmost non-zero exit code - trap ERR: fires on non-zero exit from semicolon-separated commands - ${var@Q/@U/@u/@L/@A/@a/@E}: parameter transformation operators - printf -v varname: assign formatted output to variable - read -a arr: read words into indexed array - read -n N: read at most N characters - read -d delim: custom delimiter - set -o/+o long option names (pipefail, errexit, nounset, etc.) - Fix cargo vet exemption versions for dependency bumps - Update 009-implementation-status.md: trap partially implemented, 800 bash test cases (up from 744) --- crates/bashkit/src/builtins/printf.rs | 29 +++- crates/bashkit/src/builtins/read.rs | 84 +++++++++-- crates/bashkit/src/builtins/vars.rs | 44 +++++- crates/bashkit/src/interpreter/mod.rs | 136 ++++++++++++++++++ crates/bashkit/src/parser/ast.rs | 5 + crates/bashkit/src/parser/mod.rs | 21 +++ .../spec_cases/bash/control-flow.test.sh | 24 ++++ .../tests/spec_cases/bash/printf.test.sh | 14 ++ .../spec_cases/bash/read-builtin.test.sh | 24 ++++ .../tests/spec_cases/bash/variables.test.sh | 70 +++++++++ specs/009-implementation-status.md | 35 +++-- supply-chain/config.toml | 16 +-- 12 files changed, 450 insertions(+), 52 deletions(-) diff --git a/crates/bashkit/src/builtins/printf.rs b/crates/bashkit/src/builtins/printf.rs index 4826ceec..addbbfbb 100644 --- a/crates/bashkit/src/builtins/printf.rs +++ b/crates/bashkit/src/builtins/printf.rs @@ -19,15 +19,30 @@ impl Builtin for Printf { return Ok(ExecResult::ok(String::new())); } - let format = &ctx.args[0]; - let args = &ctx.args[1..]; + let mut args_iter = ctx.args.iter(); + let mut var_name: Option = None; + + // Check for -v varname flag + let format = loop { + match args_iter.next() { + Some(arg) if arg == "-v" => { + if let Some(vname) = args_iter.next() { + var_name = Some(vname.clone()); + } + } + Some(arg) => break arg.clone(), + None => return Ok(ExecResult::ok(String::new())), + } + }; + + let args: Vec = args_iter.cloned().collect(); let mut arg_index = 0; let mut output = String::new(); // Bash printf repeats the format string until all args are consumed loop { let start_index = arg_index; - output.push_str(&format_string(format, args, &mut arg_index)); + output.push_str(&format_string(&format, &args, &mut arg_index)); // If no args were consumed or we've used all args, stop if arg_index == start_index || arg_index >= args.len() { @@ -35,7 +50,13 @@ impl Builtin for Printf { } } - Ok(ExecResult::ok(output)) + if let Some(name) = var_name { + // -v: assign to variable instead of printing + ctx.variables.insert(name, output); + Ok(ExecResult::ok(String::new())) + } else { + Ok(ExecResult::ok(output)) + } } } diff --git a/crates/bashkit/src/builtins/read.rs b/crates/bashkit/src/builtins/read.rs index cc11195c..bca8f57d 100644 --- a/crates/bashkit/src/builtins/read.rs +++ b/crates/bashkit/src/builtins/read.rs @@ -20,21 +20,60 @@ impl Builtin for Read { // Parse flags let mut raw_mode = false; // -r: don't interpret backslashes + let mut array_mode = false; // -a: read into array + let mut delimiter = None::; // -d: custom delimiter + let mut nchars = None::; // -n: read N chars let mut prompt = None::; // -p prompt let mut var_args = Vec::new(); let mut args_iter = ctx.args.iter(); while let Some(arg) = args_iter.next() { if arg.starts_with('-') && arg.len() > 1 { - for flag in arg[1..].chars() { + let mut chars = arg[1..].chars(); + while let Some(flag) = chars.next() { match flag { 'r' => raw_mode = true, + 'a' => array_mode = true, + 'd' => { + // -d delim: use first char of next arg as delimiter + let rest: String = chars.collect(); + let delim_str = if rest.is_empty() { + args_iter.next().map(|s| s.as_str()).unwrap_or("") + } else { + &rest + }; + delimiter = delim_str.chars().next(); + break; + } + 'n' => { + let rest: String = chars.collect(); + let n_str = if rest.is_empty() { + args_iter.next().map(|s| s.as_str()).unwrap_or("0") + } else { + &rest + }; + nchars = n_str.parse().ok(); + break; + } 'p' => { - // -p takes next arg as prompt - if let Some(p) = args_iter.next() { - prompt = Some(p.clone()); + let rest: String = chars.collect(); + prompt = Some(if rest.is_empty() { + args_iter.next().cloned().unwrap_or_default() + } else { + rest + }); + break; + } + 't' | 's' | 'u' | 'e' | 'i' => { + // -t timeout, -s silent, -u fd: accept and ignore + if matches!(flag, 't' | 'u') { + let rest: String = chars.collect(); + if rest.is_empty() { + args_iter.next(); + } + break; } } - _ => {} // ignore unknown flags + _ => {} } } } else { @@ -43,8 +82,14 @@ impl Builtin for Read { } let _ = prompt; // prompt is for interactive use, ignored in non-interactive - // Get first line - let line = if raw_mode { + // Extract input based on delimiter or nchars + let line = if let Some(n) = nchars { + // -n N: read at most N chars + input.chars().take(n).collect::() + } else if let Some(delim) = delimiter { + // -d delim: read until delimiter + input.split(delim).next().unwrap_or("").to_string() + } else if raw_mode { // -r: treat backslashes literally input.lines().next().unwrap_or("").to_string() } else { @@ -61,13 +106,6 @@ impl Builtin for Read { result }; - // If no variable names given, use REPLY - let var_names: Vec<&str> = if var_args.is_empty() { - vec!["REPLY"] - } else { - var_args - }; - // Split line by IFS (default: space, tab, newline) let ifs = ctx.env.get("IFS").map(|s| s.as_str()).unwrap_or(" \t\n"); let words: Vec<&str> = if ifs.is_empty() { @@ -79,6 +117,24 @@ impl Builtin for Read { .collect() }; + if array_mode { + // -a: read all words into array variable + let arr_name = var_args.first().copied().unwrap_or("REPLY"); + // Store as _ARRAY__ for the interpreter to pick up + ctx.variables.insert( + format!("_ARRAY_READ_{}", arr_name), + words.join("\x1F"), // unit separator as delimiter + ); + return Ok(ExecResult::ok(String::new())); + } + + // If no variable names given, use REPLY + let var_names: Vec<&str> = if var_args.is_empty() { + vec!["REPLY"] + } else { + var_args + }; + // Assign words to variables for (i, var_name) in var_names.iter().enumerate() { if i == var_names.len() - 1 { diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs index 0137ca48..de39e547 100644 --- a/crates/bashkit/src/builtins/vars.rs +++ b/crates/bashkit/src/builtins/vars.rs @@ -25,12 +25,29 @@ impl Builtin for Unset { /// set builtin - set/display shell options and positional parameters /// -/// Currently supports: -/// - `set -e` - exit on error (stored but not enforced yet) -/// - `set -x` - trace mode (stored but not enforced yet) +/// Supports: +/// - `set -e` / `set +e` - errexit +/// - `set -u` / `set +u` - nounset +/// - `set -x` / `set +x` - xtrace +/// - `set -o option` / `set +o option` - long option names /// - `set --` - set positional parameters pub struct Set; +/// Map long option names to their SHOPT_* variable names +fn option_name_to_var(name: &str) -> Option<&'static str> { + match name { + "errexit" => Some("SHOPT_e"), + "nounset" => Some("SHOPT_u"), + "xtrace" => Some("SHOPT_x"), + "verbose" => Some("SHOPT_v"), + "pipefail" => Some("SHOPT_pipefail"), + "noclobber" => Some("SHOPT_C"), + "noglob" => Some("SHOPT_f"), + "noexec" => Some("SHOPT_n"), + _ => None, + } +} + #[async_trait] impl Builtin for Set { async fn execute(&self, ctx: Context<'_>) -> Result { @@ -43,13 +60,25 @@ impl Builtin for Set { return Ok(ExecResult::ok(output)); } - for arg in ctx.args.iter() { + let mut i = 0; + while i < ctx.args.len() { + let arg = &ctx.args[i]; if arg == "--" { - // Set positional parameters (would need call stack access) - // For now, just consume remaining args break; + } else if (arg.starts_with('-') || arg.starts_with('+')) + && arg.len() > 1 + && (arg.as_bytes()[1] == b'o' && arg.len() == 2) + { + // -o option_name / +o option_name + let enable = arg.starts_with('-'); + i += 1; + if i < ctx.args.len() { + if let Some(var) = option_name_to_var(&ctx.args[i]) { + ctx.variables + .insert(var.to_string(), if enable { "1" } else { "0" }.to_string()); + } + } } else if arg.starts_with('-') || arg.starts_with('+') { - // Shell options - store in variables for now let enable = arg.starts_with('-'); for opt in arg.chars().skip(1) { let opt_name = format!("SHOPT_{}", opt); @@ -57,6 +86,7 @@ impl Builtin for Set { .insert(opt_name, if enable { "1" } else { "0" }.to_string()); } } + i += 1; } Ok(ExecResult::ok(String::new())) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 50559c90..a248557b 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -121,6 +121,8 @@ pub struct ShellOptions { /// Print commands before execution (set -x) - stored but not enforced #[allow(dead_code)] pub xtrace: bool, + /// Return rightmost non-zero exit code from pipeline (set -o pipefail) + pub pipefail: bool, } /// Interpreter state. @@ -465,6 +467,15 @@ impl Interpreter { break; } + // Run ERR trap on non-zero exit (unless in conditional chain) + if exit_code != 0 { + let suppressed = matches!(command, Command::List(_)) + || matches!(command, Command::Pipeline(p) if p.negated); + if !suppressed { + self.run_err_trap(&mut stdout, &mut stderr).await; + } + } + // errexit (set -e): stop on non-zero exit for top-level simple commands. // List commands handle errexit internally (with && / || chain awareness). // Negated pipelines (! cmd) explicitly handle the exit code. @@ -1972,6 +1983,13 @@ impl Interpreter { } self.arrays.insert("PIPESTATUS".to_string(), ps_arr); + // pipefail: return rightmost non-zero exit code from pipeline + if self.is_pipefail() { + if let Some(&nonzero) = pipe_statuses.iter().rev().find(|&&c| c != 0) { + last_result.exit_code = nonzero; + } + } + // Handle negation if pipeline.negated { last_result.exit_code = if last_result.exit_code == 0 { 1 } else { 0 }; @@ -2004,6 +2022,16 @@ impl Interpreter { }); } + // Check if first command in a semicolon-separated list failed => ERR trap + // Only fire if the first rest operator is semicolon (not &&/||) + let first_op_is_semicolon = list + .rest + .first() + .is_some_and(|(op, _)| matches!(op, ListOperator::Semicolon)); + if exit_code != 0 && first_op_is_semicolon { + self.run_err_trap(&mut stdout, &mut stderr).await; + } + // Track if the list contains any && or || operators // If so, failures within the list are "handled" by those operators let has_conditional_operators = list @@ -2080,6 +2108,11 @@ impl Interpreter { control_flow, }); } + + // ERR trap: fire on non-zero exit after semicolon commands (not &&/||) + if exit_code != 0 && !current_is_conditional { + self.run_err_trap(&mut stdout, &mut stderr).await; + } } } @@ -2586,6 +2619,25 @@ impl Interpreter { } }; + // Post-process: read -a populates array from marker variable + let markers: Vec<(String, String)> = self + .variables + .iter() + .filter(|(k, _)| k.starts_with("_ARRAY_READ_")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + for (marker, value) in markers { + let arr_name = marker.strip_prefix("_ARRAY_READ_").unwrap(); + let mut arr = HashMap::new(); + for (i, word) in value.split('\x1F').enumerate() { + if !word.is_empty() { + arr.insert(i, word.to_string()); + } + } + self.arrays.insert(arr_name.to_string(), arr); + self.variables.remove(&marker); + } + // Handle output redirections return self.apply_redirections(result, &command.redirects).await; } @@ -4120,6 +4172,65 @@ impl Interpreter { result.push_str(&path_str); } } + WordPart::Transformation { name, operator } => { + let value = self.expand_variable(name); + let transformed = match operator { + 'Q' => { + // Quote for reuse as input + format!("'{}'", value.replace('\'', "'\\''")) + } + 'E' => { + // Expand backslash escape sequences + value + .replace("\\n", "\n") + .replace("\\t", "\t") + .replace("\\\\", "\\") + } + 'P' => { + // Prompt string expansion (simplified) + value.clone() + } + 'A' => { + // Assignment statement form + format!("{}='{}'", name, value.replace('\'', "'\\''")) + } + 'K' => { + // Display as key-value pairs (for assoc arrays, same as value for scalars) + value.clone() + } + 'a' => { + // Attribute flags for the variable + let mut attrs = String::new(); + if self.variables.contains_key(&format!("_READONLY_{}", name)) { + attrs.push('r'); + } + if self.env.contains_key(name.as_str()) { + attrs.push('x'); + } + attrs + } + 'u' | 'U' => { + // Uppercase (u = first char, U = all) + if *operator == 'U' { + value.to_uppercase() + } else { + let mut chars = value.chars(); + match chars.next() { + Some(first) => { + first.to_uppercase().collect::() + chars.as_str() + } + None => String::new(), + } + } + } + 'L' => { + // Lowercase all + value.to_lowercase() + } + _ => value.clone(), + }; + result.push_str(&transformed); + } } is_first_part = false; } @@ -5291,6 +5402,31 @@ impl Interpreter { .unwrap_or(false) } + /// Check if pipefail (`set -o pipefail`) is active. + fn is_pipefail(&self) -> bool { + self.options.pipefail + || self + .variables + .get("SHOPT_pipefail") + .map(|v| v == "1") + .unwrap_or(false) + } + + /// Run ERR trap if registered. Appends trap output to stdout/stderr. + async fn run_err_trap(&mut self, stdout: &mut String, stderr: &mut String) { + if let Some(trap_cmd) = self.traps.get("ERR").cloned() { + if let Ok(trap_script) = Parser::new(&trap_cmd).parse() { + let emit_before = self.output_emit_count; + if let Ok(trap_result) = self.execute_command_sequence(&trap_script.commands).await + { + self.maybe_emit_output(&trap_result.stdout, &trap_result.stderr, emit_before); + stdout.push_str(&trap_result.stdout); + stderr.push_str(&trap_result.stderr); + } + } + } + } + /// Set a local variable in the current call frame #[allow(dead_code)] fn set_local(&mut self, name: &str, value: &str) { diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 30a2bbde..85ef4ef5 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -299,6 +299,9 @@ impl fmt::Display for Word { let prefix = if *is_input { "<" } else { ">" }; write!(f, "{}({:?})", prefix, commands)? } + WordPart::Transformation { name, operator } => { + write!(f, "${{{}@{}}}", name, operator)? + } } } Ok(()) @@ -353,6 +356,8 @@ pub enum WordPart { /// True for <(cmd), false for >(cmd) is_input: bool, }, + /// Parameter transformation `${var@op}` where op is Q, E, P, A, K, a, u, U, L + Transformation { name: String, operator: char }, } /// Parameter expansion operators diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 94db879a..1354bfe6 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -2358,6 +2358,27 @@ impl<'a> Parser<'a> { operand: String::new(), }); } + '@' => { + // Parameter transformation ${var@op} + chars.next(); // consume '@' + if let Some(&op) = chars.peek() { + chars.next(); // consume operator + // Consume closing } + if chars.peek() == Some(&'}') { + chars.next(); + } + parts.push(WordPart::Transformation { + name: var_name, + operator: op, + }); + } else { + // No operator, treat as variable + if chars.peek() == Some(&'}') { + chars.next(); + } + parts.push(WordPart::Variable(var_name)); + } + } '}' => { chars.next(); if !var_name.is_empty() { diff --git a/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh b/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh index 544306aa..9eb52d59 100644 --- a/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/control-flow.test.sh @@ -247,3 +247,27 @@ for ((i=3; i>=1; i--)); do echo $i; done 2 1 ### end + +### trap_err +# trap ERR fires on non-zero exit +trap 'echo ERR' ERR; false; echo after +### expect +ERR +after +### end + +### trap_err_not_on_success +# trap ERR does not fire on success +trap 'echo ERR' ERR; true; echo ok +### expect +ok +### end + +### trap_multiple +# Multiple traps can coexist +trap 'echo BYE' EXIT; trap 'echo ERR' ERR; false; echo done +### expect +ERR +done +BYE +### end diff --git a/crates/bashkit/tests/spec_cases/bash/printf.test.sh b/crates/bashkit/tests/spec_cases/bash/printf.test.sh index fdb3478f..b2c0932c 100644 --- a/crates/bashkit/tests/spec_cases/bash/printf.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/printf.test.sh @@ -183,3 +183,17 @@ printf "(%s)\n" "${arr[@]}" ### expect (only) ### end + +### printf_v_flag +# printf -v assigns to variable +printf -v result "%d + %d = %d" 3 4 7; echo "$result" +### expect +3 + 4 = 7 +### end + +### printf_v_formatted +# printf -v with padding +printf -v padded "%05d" 42; echo "$padded" +### expect +00042 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh b/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh index 0f2bbc68..2d45f2e5 100644 --- a/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/read-builtin.test.sh @@ -42,3 +42,27 @@ echo "one two three four" | { read a b; echo "$a|$b"; } ### expect one|two three four ### end + +### read_array +# read -a reads words into indexed array +read -a arr <<< "one two three" +echo "${arr[0]} ${arr[1]} ${arr[2]}" +### expect +one two three +### end + +### read_array_length +# read -a array length +read -a arr <<< "a b c d" +echo ${#arr[@]} +### expect +4 +### end + +### read_nchars +# read -n N reads N characters +read -n 3 var <<< "hello" +echo "$var" +### expect +hel +### end diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index c6a68502..80da605b 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -338,3 +338,73 @@ trap 'echo goodbye' EXIT; echo hello hello goodbye ### end + +### var_pipefail +# set -o pipefail returns rightmost non-zero exit code +set -o pipefail; false | true; echo $? +### expect +1 +### end + +### var_pipefail_all_success +# pipefail with all-success pipeline returns 0 +set -o pipefail; true | true; echo $? +### expect +0 +### end + +### var_pipefail_last_fails +# pipefail with last command failing +set -o pipefail; true | false; echo $? +### expect +1 +### end + +### var_pipefail_disable +# set +o pipefail disables pipefail +set -o pipefail; set +o pipefail; false | true; echo $? +### expect +0 +### end + +### var_error_if_unset_set +# ${var:?message} succeeds when set +x=hello; echo ${x:?should not error} +### expect +hello +### end + +### var_transform_quote +# ${var@Q} quotes for reuse +x='hello world'; echo ${x@Q} +### expect +'hello world' +### end + +### var_transform_uppercase_all +# ${var@U} uppercases all +x=hello; echo ${x@U} +### expect +HELLO +### end + +### var_transform_uppercase_first +# ${var@u} uppercases first char +x=hello; echo ${x@u} +### expect +Hello +### end + +### var_transform_lowercase +# ${var@L} lowercases all +x=HELLO; echo ${x@L} +### expect +hello +### end + +### var_transform_assign +# ${var@A} shows assignment form +x=hello; echo ${x@A} +### expect +x='hello' +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index b7d1a9d5..e7e2eff1 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -16,7 +16,6 @@ stateless, virtual execution model or pose security risks. | Feature | Rationale | Threat ID | |---------|-----------|-----------| | `exec` builtin | Cannot replace shell process in sandbox; breaks containment | TM-ESC-005 | -| `trap` builtin | Stateless model - no persistent handlers; no signal sources in virtual environment | - | | Background execution (`&`) | Stateless model - no persistent processes between commands | TM-ESC-007 | | Job control (`bg`, `fg`, `jobs`) | Requires process state; interactive feature | - | | Symlink following | Prevents symlink loop attacks and sandbox escape | TM-DOS-011 | @@ -39,10 +38,7 @@ traversal is blocked. This prevents: - Symlink-based sandbox escapes (e.g., `link -> /etc/passwd`) **Security Exclusions**: `exec` is excluded because it would replace the shell -process, breaking sandbox containment. `trap` is excluded because signal -handlers require persistent state (conflicts with stateless model) and there -are no signal sources in the virtual environment. Scripts should use exit-code-based error -handling instead. +process, breaking sandbox containment. **bash/sh Commands**: The `bash` and `sh` commands are implemented as virtual re-invocations of the Bashkit interpreter, NOT external process spawning. This @@ -91,7 +87,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | `set` | Implemented | Set options and positional parameters | | `shift` | Implemented | Shift positional parameters | | `times` | Implemented | Display process times (returns zeros in virtual mode) | -| `trap` | **Excluded** | See [Intentionally Unimplemented](#intentionally-unimplemented-features) | +| `trap` | Implemented | EXIT, ERR handlers; signal traps stored but no signal delivery in virtual mode | | `unset` | Implemented | Remove variables and functions | ### Pipelines and Lists @@ -107,22 +103,22 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1105 (1095 pass, 10 skip) +**Total spec test cases:** 1161 (1156 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 744 | Yes | 739 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 800 | Yes | 795 | 5 | `bash_spec_tests` in CI | | AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g | | Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect | | Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E | -| JQ | 114 | Yes | 109 | 5 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | -| **Total** | **1105** | **Yes** | **1095** | **10** | | +| JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | +| **Total** | **1161** | **Yes** | **1156** | **5** | | ### Bash Spec Tests Breakdown | File | Cases | Notes | |------|-------|-------| -| arithmetic.test.sh | 29 | includes logical operators | +| arithmetic.test.sh | 57 | includes logical, bitwise, compound assign, increment/decrement | | arrays.test.sh | 20 | includes indices, `${arr[@]}` / `${arr[*]}` expansion | | background.test.sh | 4 | | | bash-command.test.sh | 34 | bash/sh re-invocation | @@ -132,7 +128,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | command-not-found.test.sh | 17 | unknown command handling | | conditional.test.sh | 17 | `[[ ]]` conditionals, `=~` regex, BASH_REMATCH | | command-subst.test.sh | 14 | includes backtick substitution (1 skipped) | -| control-flow.test.sh | 32 | if/elif/else, for, while, case | +| control-flow.test.sh | 37 | if/elif/else, for, while, case, trap ERR | | cuttr.test.sh | 32 | cut and tr commands, `-z` zero-terminated | | date.test.sh | 38 | format specifiers, `-d` relative/compound/epoch, `-R`, `-I`, `%N` (2 skipped) | | diff.test.sh | 4 | line diffs | @@ -152,7 +148,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | paste.test.sh | 4 | line merging with `-s` serial and `-d` delimiter | | path.test.sh | 14 | | | pipes-redirects.test.sh | 19 | includes stderr redirects | -| printf.test.sh | 24 | format specifiers, array expansion | +| printf.test.sh | 26 | format specifiers, array expansion, `-v` variable assignment | | procsub.test.sh | 6 | | | sleep.test.sh | 6 | | | sortuniq.test.sh | 32 | sort and uniq, `-z` zero-terminated, `-m` merge | @@ -160,16 +156,16 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | test-operators.test.sh | 17 | file/string tests | | time.test.sh | 11 | Wall-clock only (user/sys always 0) | | timeout.test.sh | 17 | | -| variables.test.sh | 44 | includes special vars, prefix env assignments | +| variables.test.sh | 58 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}` | | wc.test.sh | 35 | word count (5 skipped) | | type.test.sh | 15 | `type`, `which`, `hash` builtins | | declare.test.sh | 10 | `declare`/`typeset`, `-i`, `-r`, `-x`, `-a`, `-p` | | ln.test.sh | 5 | `ln -s`, `-f`, symlink creation | | eval-bugs.test.sh | 4 | regression tests for eval/script bugs | | script-exec.test.sh | 10 | script execution by path, $PATH search, exit codes | -| heredoc.test.sh | 9 | heredoc variable expansion, quoted delimiters, file redirects | -| string-ops.test.sh | 15 | string replacement (prefix/suffix anchored), `${var:?}`, case conversion | -| read-builtin.test.sh | 7 | `read` builtin, IFS splitting, `-r` flag, here-string input | +| heredoc.test.sh | 10 | heredoc variable expansion, quoted delimiters, file redirects, `<<-` tab strip | +| string-ops.test.sh | 14 | string replacement (prefix/suffix anchored), `${var:?}`, case conversion | +| read-builtin.test.sh | 10 | `read` builtin, IFS splitting, `-r`, `-a` (array), `-n` (nchars), here-string | ## Shell Features @@ -198,9 +194,10 @@ Features that may be added in the future (not intentionally excluded): | Prefix env assignments | `VAR=val cmd` temporarily sets env for cmd | Array prefix assignments not in env | | `local` | Declaration | Proper scoping in nested functions | | `return` | Basic usage | Return value propagation | -| Heredocs | Basic | Variable expansion inside | +| Heredocs | Basic, `<<-` tab strip, variable expansion | — | | Arrays | Indexing, `[@]`/`[*]` as separate args, `${!arr[@]}`, `+=`, slice `${arr[@]:1:2}`, assoc `declare -A`, compound init `declare -A m=([k]=v)` | — | -| `echo -n` | Flag parsed | Trailing newline handling | +| `trap` | EXIT, ERR handlers | No signal delivery in virtual mode (INT, TERM stored but not triggered) | +| `set -o pipefail` | Pipeline returns rightmost non-zero exit code | — | | `time` | Wall-clock timing | User/sys CPU time (always 0) | | `timeout` | Basic usage | `-k` kill timeout | | `bash`/`sh` | `-c`, `-n`, script files, stdin, `--version`, `--help` | `-e` (exit on error), `-x` (trace), `-o`, login shell | diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 2a778009..3e450dc5 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -607,7 +607,7 @@ version = "0.1.34" criteria = "safe-to-deploy" [[exemptions.js-sys]] -version = "0.3.88" +version = "0.3.89" criteria = "safe-to-deploy" [[exemptions.leb128fmt]] @@ -1195,7 +1195,7 @@ version = "0.13.5" criteria = "safe-to-deploy" [[exemptions.tempfile]] -version = "3.25.0" +version = "3.26.0" criteria = "safe-to-run" [[exemptions.testing_table]] @@ -1379,23 +1379,23 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" criteria = "safe-to-run" [[exemptions.wasm-bindgen]] -version = "0.2.111" +version = "0.2.112" criteria = "safe-to-deploy" [[exemptions.wasm-bindgen-futures]] -version = "0.4.61" +version = "0.4.62" criteria = "safe-to-deploy" [[exemptions.wasm-bindgen-macro]] -version = "0.2.111" +version = "0.2.112" criteria = "safe-to-deploy" [[exemptions.wasm-bindgen-macro-support]] -version = "0.2.111" +version = "0.2.112" criteria = "safe-to-deploy" [[exemptions.wasm-bindgen-shared]] -version = "0.2.111" +version = "0.2.112" criteria = "safe-to-deploy" [[exemptions.wasm-encoder]] @@ -1415,7 +1415,7 @@ version = "0.244.0" criteria = "safe-to-deploy" [[exemptions.web-sys]] -version = "0.3.88" +version = "0.3.89" criteria = "safe-to-deploy" [[exemptions.web-time]] From 7ff8d6dfe6ea28585f81fb911144f67a764b4ec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 14:05:03 +0000 Subject: [PATCH 6/6] fix(bash): local variable scoping with dynamic scope lookup Variable assignments now check call_stack frames before writing to globals, matching bash's dynamic scoping. Also uses set_variable() in all arithmetic assignment paths (=, +=, ++, -- etc). https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- crates/bashkit/src/interpreter/mod.rs | 52 +++++++++++-------- .../tests/spec_cases/bash/functions.test.sh | 44 ++++++++++++++++ 2 files changed, 73 insertions(+), 23 deletions(-) diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index a248557b..916433af 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1102,8 +1102,7 @@ impl Interpreter { rhs_value }; - self.variables - .insert(var_name.to_string(), final_value.to_string()); + self.set_variable(var_name.to_string(), final_value.to_string()); return final_value; } } @@ -1113,16 +1112,14 @@ impl Interpreter { let var_name = stripped.trim(); let current = self.evaluate_arithmetic(var_name); let new_value = current + 1; - self.variables - .insert(var_name.to_string(), new_value.to_string()); + self.set_variable(var_name.to_string(), new_value.to_string()); return new_value; } if let Some(stripped) = expr.strip_prefix("--") { let var_name = stripped.trim(); let current = self.evaluate_arithmetic(var_name); let new_value = current - 1; - self.variables - .insert(var_name.to_string(), new_value.to_string()); + self.set_variable(var_name.to_string(), new_value.to_string()); return new_value; } @@ -1131,16 +1128,14 @@ impl Interpreter { let var_name = stripped.trim(); let current = self.evaluate_arithmetic(var_name); let new_value = current + 1; - self.variables - .insert(var_name.to_string(), new_value.to_string()); + self.set_variable(var_name.to_string(), new_value.to_string()); return current; // Return old value for post-increment } if let Some(stripped) = expr.strip_suffix("--") { let var_name = stripped.trim(); let current = self.evaluate_arithmetic(var_name); let new_value = current - 1; - self.variables - .insert(var_name.to_string(), new_value.to_string()); + self.set_variable(var_name.to_string(), new_value.to_string()); return current; // Return old value for post-decrement } @@ -2188,10 +2183,9 @@ impl Interpreter { } else if assignment.append { // VAR+=value - append to variable let existing = self.expand_variable(&assignment.name); - self.variables - .insert(assignment.name.clone(), existing + &value); + self.set_variable(assignment.name.clone(), existing + &value); } else { - self.variables.insert(assignment.name.clone(), value); + self.set_variable(assignment.name.clone(), value); } } AssignmentValue::Array(words) => { @@ -2443,7 +2437,7 @@ impl Interpreter { let value = &arg[eq_pos + 1..]; frame.locals.insert(var_name.to_string(), value.to_string()); } else { - // Just declare without value + // Just declare without value — empty string (bash behavior) frame.locals.insert(arg.to_string(), String::new()); } } @@ -4312,7 +4306,7 @@ impl Interpreter { ParameterOp::AssignDefault => { // ${var:=default} - assign default if unset/empty if value.is_empty() { - self.variables.insert(name.to_string(), operand.to_string()); + self.set_variable(name.to_string(), operand.to_string()); operand.to_string() } else { value.to_string() @@ -4654,7 +4648,7 @@ impl Interpreter { let var_name = var_name.trim(); if Self::is_valid_var_name(var_name) { let val = self.expand_variable(var_name).parse::().unwrap_or(0) + 1; - self.variables.insert(var_name.to_string(), val.to_string()); + self.set_variable(var_name.to_string(), val.to_string()); return val; } } @@ -4662,7 +4656,7 @@ impl Interpreter { let var_name = var_name.trim(); if Self::is_valid_var_name(var_name) { let val = self.expand_variable(var_name).parse::().unwrap_or(0) - 1; - self.variables.insert(var_name.to_string(), val.to_string()); + self.set_variable(var_name.to_string(), val.to_string()); return val; } } @@ -4672,8 +4666,7 @@ impl Interpreter { let var_name = var_name.trim(); if Self::is_valid_var_name(var_name) { let old_val = self.expand_variable(var_name).parse::().unwrap_or(0); - self.variables - .insert(var_name.to_string(), (old_val + 1).to_string()); + self.set_variable(var_name.to_string(), (old_val + 1).to_string()); return old_val; } } @@ -4681,8 +4674,7 @@ impl Interpreter { let var_name = var_name.trim(); if Self::is_valid_var_name(var_name) { let old_val = self.expand_variable(var_name).parse::().unwrap_or(0); - self.variables - .insert(var_name.to_string(), (old_val - 1).to_string()); + self.set_variable(var_name.to_string(), (old_val - 1).to_string()); return old_val; } } @@ -4754,8 +4746,7 @@ impl Interpreter { _ => rhs_val, } }; - self.variables - .insert(var_name.to_string(), value.to_string()); + self.set_variable(var_name.to_string(), value.to_string()); return value; } } @@ -5252,6 +5243,21 @@ impl Interpreter { s.to_string() } + /// Set a variable, respecting dynamic scoping. + /// If the variable is declared `local` in any active call frame, update that frame. + /// Otherwise, set in global variables. + fn set_variable(&mut self, name: String, value: String) { + for frame in self.call_stack.iter_mut().rev() { + if let std::collections::hash_map::Entry::Occupied(mut e) = + frame.locals.entry(name.clone()) + { + e.insert(value); + return; + } + } + self.variables.insert(name, value); + } + fn expand_variable(&self, name: &str) -> String { // Check for special parameters (POSIX required) match name { diff --git a/crates/bashkit/tests/spec_cases/bash/functions.test.sh b/crates/bashkit/tests/spec_cases/bash/functions.test.sh index 07889a43..bc15bbfc 100644 --- a/crates/bashkit/tests/spec_cases/bash/functions.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/functions.test.sh @@ -115,3 +115,47 @@ a b c ### end + +### func_local_nested_write +# Writes in nested function update local frame (dynamic scoping) +outer() { + local x=outer + inner + echo "after inner: $x" +} +inner() { + x=modified +} +outer +### expect +after inner: modified +### end + +### func_local_no_value +# local x (no value) initializes to empty (bash behavior) +x=global +wrapper() { + local x + echo "in wrapper: [$x]" +} +wrapper +echo "after: $x" +### expect +in wrapper: [] +after: global +### end + +### func_local_assign_stays_local +# local declaration + assignment stays local +x=global +outer() { + local x + x=local_val + echo "in outer: $x" +} +outer +echo "after outer: $x" +### expect +in outer: local_val +after outer: global +### end