diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 9e52253..487938e 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -205,6 +205,15 @@ for sandbox security reasons. See the compliance spec for details. | `${var^}` | ❌ | - | Uppercase first | | `${var,}` | ❌ | - | Lowercase first | +### Prefix Environment Assignments + +| Syntax | Status | Example | Description | +|--------|--------|---------|-------------| +| `VAR=val cmd` | ✅ | `TOKEN=abc printenv TOKEN` | Temporary env for command | +| Multiple prefix | ✅ | `A=1 B=2 cmd` | Multiple vars in one command | +| No persist | ✅ | `X=1 cmd; echo $X` | Var not set after command | +| Assignment-only | ✅ | `X=1` (no cmd) | Persists in shell variables | + ### Command Substitution | Syntax | Status | Example | diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index db0e001..378c51d 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1766,6 +1766,15 @@ impl Interpreter { command: &SimpleCommand, stdin: Option, ) -> Result { + // Save old variable values before applying prefix assignments. + // If there's a command, these assignments are temporary (bash behavior: + // `VAR=value cmd` sets VAR only for cmd's duration). + let var_saves: Vec<(String, Option)> = command + .assignments + .iter() + .map(|a| (a.name.clone(), self.variables.get(&a.name).cloned())) + .collect(); + // Process variable assignments first for assignment in &command.assignments { match &assignment.value { @@ -1837,11 +1846,64 @@ impl Interpreter { let name = self.expand_word(&command.name).await?; - // If name is empty, this is an assignment-only command + // If name is empty, this is an assignment-only command - keep permanently if name.is_empty() { return Ok(ExecResult::ok(String::new())); } + // Has a command: prefix assignments are temporary (bash behavior). + // Inject scalar prefix assignments into self.env so builtins/functions + // can see them via ctx.env (e.g., `MYVAR=hello printenv MYVAR`). + let mut env_saves: Vec<(String, Option)> = Vec::new(); + for assignment in &command.assignments { + if assignment.index.is_none() { + if let Some(value) = self.variables.get(&assignment.name).cloned() { + let old = self.env.insert(assignment.name.clone(), value); + env_saves.push((assignment.name.clone(), old)); + } + } + } + + // Dispatch to the appropriate handler + let result = self.execute_dispatched_command(&name, command, stdin).await; + + // Restore env (prefix assignments are command-scoped) + for (name, old) in env_saves { + match old { + Some(v) => { + self.env.insert(name, v); + } + None => { + self.env.remove(&name); + } + } + } + + // Restore variables (prefix assignments don't persist when there's a command) + for (name, old) in var_saves { + match old { + Some(v) => { + self.variables.insert(name, v); + } + None => { + self.variables.remove(&name); + } + } + } + + result + } + + /// Execute a command after name resolution and prefix assignment setup. + /// + /// Handles argument expansion, stdin processing, and dispatch to + /// functions, special builtins, regular builtins, or command-not-found. + async fn execute_dispatched_command( + &mut self, + name: &str, + command: &SimpleCommand, + stdin: Option, + ) -> Result { // Expand arguments with brace and glob expansion let mut args: Vec = Vec::new(); for word in &command.args { @@ -1906,13 +1968,13 @@ impl Interpreter { }; // Check for functions first - if let Some(func_def) = self.functions.get(&name).cloned() { + if let Some(func_def) = self.functions.get(name).cloned() { // Check function depth limit self.counters.push_function(&self.limits)?; // Push call frame with positional parameters self.call_stack.push(CallFrame { - name: name.clone(), + name: name.to_string(), locals: HashMap::new(), positional: args.clone(), }); @@ -1972,7 +2034,7 @@ impl Interpreter { // Handle `bash` and `sh` specially - execute scripts using the interpreter if name == "bash" || name == "sh" { return self - .execute_shell(&name, &args, stdin, &command.redirects) + .execute_shell(name, &args, stdin, &command.redirects) .await; } @@ -1987,7 +2049,7 @@ impl Interpreter { } // Check for builtins - if let Some(builtin) = self.builtins.get(name.as_str()) { + if let Some(builtin) = self.builtins.get(name) { let ctx = builtins::Context { args: &args, env: &self.env, diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 1f63345..089ca55 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -1687,6 +1687,79 @@ mod tests { assert_eq!(result.stdout, "1 2 3\n"); } + #[tokio::test] + async fn test_prefix_assignment_visible_in_env() { + let mut bash = Bash::new(); + // VAR=value command should make VAR visible in the command's environment + let result = bash.exec("MYVAR=hello printenv MYVAR").await.unwrap(); + assert_eq!(result.stdout, "hello\n"); + } + + #[tokio::test] + async fn test_prefix_assignment_temporary() { + let mut bash = Bash::new(); + // Prefix assignment should NOT persist after the command + bash.exec("MYVAR=hello printenv MYVAR").await.unwrap(); + let result = bash.exec("echo ${MYVAR:-unset}").await.unwrap(); + assert_eq!(result.stdout, "unset\n"); + } + + #[tokio::test] + async fn test_prefix_assignment_does_not_clobber_existing_env() { + let mut bash = Bash::new(); + // Set up existing env var + let result = bash + .exec("EXISTING=original; export EXISTING; EXISTING=temp printenv EXISTING") + .await + .unwrap(); + assert_eq!(result.stdout, "temp\n"); + } + + #[tokio::test] + async fn test_prefix_assignment_multiple_vars() { + let mut bash = Bash::new(); + // Multiple prefix assignments on same command + let result = bash.exec("A=one B=two printenv A").await.unwrap(); + assert_eq!(result.stdout, "one\n"); + assert_eq!(result.exit_code, 0); + } + + #[tokio::test] + async fn test_prefix_assignment_empty_value() { + let mut bash = Bash::new(); + // Empty value is still set in environment + let result = bash.exec("MYVAR= printenv MYVAR").await.unwrap(); + assert_eq!(result.stdout, "\n"); + assert_eq!(result.exit_code, 0); + } + + #[tokio::test] + async fn test_prefix_assignment_not_found_without_prefix() { + let mut bash = Bash::new(); + // printenv for a var that was never set should fail + let result = bash.exec("printenv NONEXISTENT").await.unwrap(); + assert_eq!(result.stdout, ""); + assert_eq!(result.exit_code, 1); + } + + #[tokio::test] + async fn test_prefix_assignment_does_not_persist_in_variables() { + let mut bash = Bash::new(); + // After prefix assignment with command, var should not be in shell scope + bash.exec("TMPVAR=gone echo ok").await.unwrap(); + let result = bash.exec("echo \"${TMPVAR:-unset}\"").await.unwrap(); + assert_eq!(result.stdout, "unset\n"); + } + + #[tokio::test] + async fn test_assignment_only_persists() { + let mut bash = Bash::new(); + // Assignment without a command should persist (not a prefix assignment) + bash.exec("PERSIST=yes").await.unwrap(); + let result = bash.exec("echo $PERSIST").await.unwrap(); + assert_eq!(result.stdout, "yes\n"); + } + #[tokio::test] async fn test_printf_string() { let mut bash = Bash::new(); diff --git a/crates/bashkit/tests/proptest_differential.rs b/crates/bashkit/tests/proptest_differential.rs index 91f4188..c023ccd 100644 --- a/crates/bashkit/tests/proptest_differential.rs +++ b/crates/bashkit/tests/proptest_differential.rs @@ -215,6 +215,22 @@ fn function_strategy() -> impl Strategy { ) } +/// Generate a prefix assignment command (VAR=value command) +fn prefix_assignment_strategy() -> impl Strategy { + prop_oneof![ + // Single prefix assignment with printenv + (var_name_strategy(), safe_value_strategy()) + .prop_map(|(name, value)| format!("{}={} printenv {}", name, value, name)), + // Single prefix assignment with echo (variable visible via $VAR) + (var_name_strategy(), safe_value_strategy()) + .prop_map(|(name, value)| format!("{}={} echo done", name, value)), + // Prefix assignment then check it doesn't persist + (var_name_strategy(), safe_value_strategy()).prop_map(|(name, value)| { + format!("{}={} echo done; echo ${{{}:-unset}}", name, value, name) + }), + ] +} + /// Generate a complete valid bash script fn valid_script_strategy() -> impl Strategy { prop_oneof![ @@ -229,6 +245,7 @@ fn valid_script_strategy() -> impl Strategy { 3 => command_subst_strategy(), 3 => logical_ops_strategy(), 2 => function_strategy(), + 3 => prefix_assignment_strategy(), ] } @@ -446,6 +463,30 @@ proptest! { ); } + /// Prefix assignments should produce identical output + #[test] + fn prefix_assignments_match_bash(script in prefix_assignment_strategy()) { + let (bash_out, bash_exit) = run_real_bash(&script); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let (bashkit_out, bashkit_exit) = rt.block_on(run_bashkit(&script)); + + prop_assert_eq!( + &bashkit_out, &bash_out, + "Output mismatch for script: {}\nBashkit: {:?}\nBash: {:?}", + script, bashkit_out, bash_out + ); + prop_assert_eq!( + bashkit_exit, bash_exit, + "Exit code mismatch for script: {}", + script + ); + } + /// Multi-statement scripts should produce identical output #[test] fn multi_statement_matches_bash(script in multi_statement_strategy()) { @@ -494,6 +535,19 @@ async fn differential_edge_cases() { "X=hello; Y=world; echo $X $Y", "X=hello; Y=world; echo $X $Y", ), + // Prefix environment assignments + ("prefix assign visible", "X=hello printenv X"), + ( + "prefix assign temporary", + "X=hello printenv X; echo ${X:-unset}", + ), + ( + "prefix assign no clobber", + "X=original; X=temp echo done; echo $X", + ), + ("prefix assign empty", "X= printenv X"), + ("multiple prefix assigns", "A=1 B=2 printenv A"), + ("assignment only persists", "X=persist; echo $X"), // Arithmetic ("echo $((1 + 2))", "echo $((1 + 2))"), ("echo $((10 - 3))", "echo $((10 - 3))"), diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index 66eec4f..684d3c2 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -264,3 +264,47 @@ X=value; ref=X; echo ${!ref} ### expect value ### end + +### prefix_assign_visible_in_env +# Prefix assignment visible to command via printenv +MYVAR=hello printenv MYVAR +### expect +hello +### end + +### prefix_assign_multiple +# Multiple prefix assignments visible to command +A=one B=two printenv A +### expect +one +### end + +### prefix_assign_temporary +# Prefix assignment does not persist after command +TMPVAR=gone printenv TMPVAR; echo ${TMPVAR:-unset} +### expect +gone +unset +### end + +### prefix_assign_no_clobber +# Prefix assignment does not overwrite pre-existing var permanently +X=original; X=temp echo done; echo $X +### expect +done +original +### end + +### prefix_assign_empty_value +# Prefix assignment with empty value is still set (exit code 0) +MYVAR= printenv MYVAR > /dev/null; echo $? +### expect +0 +### end + +### prefix_assign_only_no_command +# Assignment without command persists (not a prefix assignment) +PERSIST=yes; echo $PERSIST +### expect +yes +### end diff --git a/specs/005-builtins.md b/specs/005-builtins.md index 0388cda..4e2d2af 100644 --- a/specs/005-builtins.md +++ b/specs/005-builtins.md @@ -128,6 +128,14 @@ Bash::builder() - `printenv` - Print environment variable values - `history` - Command history (virtual mode: limited, no persistent history) +#### Prefix Environment Assignments + +Bash supports `VAR=value command` syntax where the assignment is temporary and +scoped to the command's environment. Bashkit implements this: prefix assignments +are injected into `ctx.env` for the command's duration, then both `env` and +`variables` are restored. Assignment-only commands (`VAR=value` with no command) +persist in shell variables as usual. + #### Pipeline Control - `xargs` - Build commands from stdin (`-I REPL`, `-n MAX`, `-d DELIM`) - `tee` - Write to files and stdout (`-a` append) diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index abb59ba..3632678 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -107,17 +107,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 962 +**Total spec test cases:** 968 | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 588 | Yes | 524 | 64 | `bash_spec_tests` in CI | +| Bash (core) | 594 | Yes | 530 | 64 | `bash_spec_tests` in CI | | AWK | 89 | Yes | 72 | 17 | loops, arrays, -v, ternary, field assign | | Grep | 63 | Yes | 58 | 5 | now with -z, -r, -a, -b, -H, -h, -f, -P | | Sed | 68 | Yes | 56 | 12 | hold space, change, regex ranges, -E | | JQ | 97 | Yes | 87 | 10 | reduce, walk, regex funcs | | Python | 57 | Yes | 51 | 6 | **Experimental.** VFS bridging, pathlib, env vars | -| **Total** | **962** | **Yes** | **848** | **114** | | +| **Total** | **968** | **Yes** | **854** | **114** | | ### Bash Spec Tests Breakdown @@ -157,7 +157,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | test-operators.test.sh | 17 | file/string tests (2 skipped) | | time.test.sh | 11 | Wall-clock only (user/sys always 0) | | timeout.test.sh | 16 | | -| variables.test.sh | 38 | includes special vars | +| variables.test.sh | 44 | includes special vars, prefix env assignments | | wc.test.sh | 20 | word count (5 skipped) | ## Shell Features @@ -181,6 +181,7 @@ Features that may be added in the future (not intentionally excluded): | Feature | What Works | What's Missing | |---------|------------|----------------| +| 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 |