diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 47144c6d..f58e94b7 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1864,6 +1864,8 @@ impl Interpreter { let mut script_file: Option = None; let mut script_args: Vec = Vec::new(); let mut noexec = false; // -n flag: syntax check only + // Shell options to set before executing the script + let mut shell_opts: Vec<(&str, &str)> = Vec::new(); let mut idx = 0; while idx < args.len() { @@ -1884,7 +1886,10 @@ impl Interpreter { Options:\n\ \t-c string\tExecute commands from string\n\ \t-n\t\tCheck syntax without executing (noexec)\n\ - \t-e\t\tExit on error (accepted, limited support)\n\ + \t-e\t\tExit on error (errexit)\n\ + \t-x\t\tPrint commands before execution (xtrace)\n\ + \t-u\t\tError on unset variables (nounset)\n\ + \t-o option\tSet option by name\n\ \t--version\tShow version\n\ \t--help\t\tShow this help\n", shell_name @@ -1906,20 +1911,59 @@ impl Interpreter { break; } "-n" => { - // Noexec: parse only, don't execute noexec = true; idx += 1; } - // Accept but ignore these options. These are recognized for - // compatibility with scripts that set them, but not enforced - // in virtual mode: - // -e (errexit): would need per-command exit code checking - // -v (verbose): would need input echoing - // -u (nounset): would need unset variable detection - // -o (option): would need set -o pipeline + "-e" => { + shell_opts.push(("SHOPT_e", "1")); + idx += 1; + } + "-x" => { + shell_opts.push(("SHOPT_x", "1")); + idx += 1; + } + "-u" => { + shell_opts.push(("SHOPT_u", "1")); + idx += 1; + } + "-v" => { + shell_opts.push(("SHOPT_v", "1")); + idx += 1; + } + "-f" => { + shell_opts.push(("SHOPT_f", "1")); + idx += 1; + } + "-o" => { + idx += 1; + if idx >= args.len() { + return Ok(ExecResult::err( + format!("{}: -o: option requires an argument\n", shell_name), + 2, + )); + } + let opt = &args[idx]; + match opt.as_str() { + "errexit" => shell_opts.push(("SHOPT_e", "1")), + "nounset" => shell_opts.push(("SHOPT_u", "1")), + "xtrace" => shell_opts.push(("SHOPT_x", "1")), + "verbose" => shell_opts.push(("SHOPT_v", "1")), + "pipefail" => shell_opts.push(("SHOPT_pipefail", "1")), + "noglob" => shell_opts.push(("SHOPT_f", "1")), + "noclobber" => shell_opts.push(("SHOPT_C", "1")), + _ => { + return Ok(ExecResult::err( + format!("{}: set: {}: invalid option name\n", shell_name, opt), + 2, + )); + } + } + idx += 1; + } + // Accept but don't act on these: // -i (interactive): not applicable in virtual mode // -s (stdin): read from stdin (implicit behavior) - "-e" | "-x" | "-v" | "-u" | "-o" | "-i" | "-s" => { + "-i" | "-s" => { idx += 1; } "--" => { @@ -1937,12 +1981,36 @@ impl Interpreter { idx += 1; } s if s.starts_with('-') && s.len() > 1 => { - // Combined short options like -ne, -ev - for ch in s.chars().skip(1) { - if ch == 'n' { - noexec = true; + // Combined short options like -ne, -ev, -euxo + let chars: Vec = s.chars().skip(1).collect(); + let mut ci = 0; + while ci < chars.len() { + match chars[ci] { + 'n' => noexec = true, + 'e' => shell_opts.push(("SHOPT_e", "1")), + 'x' => shell_opts.push(("SHOPT_x", "1")), + 'u' => shell_opts.push(("SHOPT_u", "1")), + 'v' => shell_opts.push(("SHOPT_v", "1")), + 'f' => shell_opts.push(("SHOPT_f", "1")), + 'o' => { + // -o in combined form: next arg is option name + idx += 1; + if idx < args.len() { + match args[idx].as_str() { + "errexit" => shell_opts.push(("SHOPT_e", "1")), + "nounset" => shell_opts.push(("SHOPT_u", "1")), + "xtrace" => shell_opts.push(("SHOPT_x", "1")), + "verbose" => shell_opts.push(("SHOPT_v", "1")), + "pipefail" => shell_opts.push(("SHOPT_pipefail", "1")), + "noglob" => shell_opts.push(("SHOPT_f", "1")), + "noclobber" => shell_opts.push(("SHOPT_C", "1")), + _ => {} + } + } + } + _ => {} // Ignore unknown } - // Ignore other options + ci += 1; } idx += 1; } @@ -2030,9 +2098,26 @@ impl Interpreter { positional: positional_args, }); + // Save and apply shell options (-e, -x, -u, -o pipefail, etc.) + let mut saved_opts: Vec<(String, Option)> = Vec::new(); + for (var, val) in &shell_opts { + let prev = self.variables.get(*var).cloned(); + saved_opts.push((var.to_string(), prev)); + self.variables.insert(var.to_string(), val.to_string()); + } + // Execute the script let result = self.execute(&script).await; + // Restore shell options + for (var, prev) in saved_opts { + if let Some(val) = prev { + self.variables.insert(var, val); + } else { + self.variables.remove(&var); + } + } + // Pop the call frame self.call_stack.pop(); diff --git a/crates/bashkit/tests/spec_cases/bash/bash-flags.test.sh b/crates/bashkit/tests/spec_cases/bash/bash-flags.test.sh new file mode 100644 index 00000000..992739af --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/bash-flags.test.sh @@ -0,0 +1,108 @@ +### bash_e_errexit +# bash -e enables errexit for subshell +bash -e -c 'false; echo "should not reach"' +echo "exit:$?" +### expect +exit:1 +### end + +### bash_e_errexit_success +# bash -e with successful commands runs normally +bash -e -c 'echo hello; echo world' +### expect +hello +world +### end + +### bash_e_errexit_conditional +# bash -e doesn't exit on conditional failure +bash -e -c 'if false; then echo y; else echo n; fi; echo ok' +### expect +n +ok +### end + +### bash_x_xtrace +# bash -x enables xtrace output +### bash_diff +bash -x -c 'echo hello' 2>/dev/null +### expect +hello +### end + +### bash_u_nounset +# bash -u enables nounset checking +### bash_diff +bash -u -c 'echo $UNDEFINED_VAR_XYZ' 2>/dev/null +echo "exit:$?" +### expect +exit:1 +### end + +### bash_o_errexit +# bash -o errexit enables errexit +bash -o errexit -c 'false; echo "should not reach"' +echo "exit:$?" +### expect +exit:1 +### end + +### bash_o_pipefail +# bash -o pipefail enables pipefail +### bash_diff +bash -o pipefail -c 'false | true; echo "exit:$?"' +### expect +exit:1 +### end + +### bash_o_nounset +# bash -o nounset enables nounset +### bash_diff +bash -o nounset -c 'echo $UNDEFINED_XYZ' 2>/dev/null +echo "exit:$?" +### expect +exit:1 +### end + +### bash_combined_eu +# bash -eu combines errexit and nounset +bash -eu -c 'false; echo "nope"' +echo "exit:$?" +### expect +exit:1 +### end + +### bash_e_does_not_leak +# bash -e doesn't affect parent shell +bash -e -c 'false' +false +echo "still running" +### expect +still running +### end + +### bash_o_invalid_option +# bash -o with invalid option returns error +bash -o invalidopt -c 'echo hi' 2>/dev/null +echo "exit:$?" +### expect +exit:2 +### end + +### bash_o_noglob +# bash -o noglob disables glob expansion in subshell +mkdir -p /tmp/bng_test +touch /tmp/bng_test/a /tmp/bng_test/b +bash -o noglob -c 'echo /tmp/bng_test/*' +### expect +/tmp/bng_test/* +### end + +### bash_f_noglob +# bash -f disables glob expansion +mkdir -p /tmp/bf_test +touch /tmp/bf_test/x /tmp/bf_test/y +bash -f -c 'echo /tmp/bf_test/*' +### expect +/tmp/bf_test/* +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 9c136588..2b34c221 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1424 (1419 pass, 5 skip) +**Total spec test cases:** 1437 (1432 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 1006 | Yes | 1001 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 1019 | Yes | 1014 | 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 | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | | Python | 57 | Yes | 57 | 0 | embedded Python (Monty) | -| **Total** | **1424** | **Yes** | **1419** | **5** | | +| **Total** | **1437** | **Yes** | **1432** | **5** | | ### Bash Spec Tests Breakdown @@ -123,6 +123,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | arrays.test.sh | 27 | indices, `${arr[@]}` / `${arr[*]}`, negative indexing `${arr[-1]}` | | background.test.sh | 4 | | | bash-command.test.sh | 34 | bash/sh re-invocation | +| bash-flags.test.sh | 13 | bash `-e`, `-x`, `-u`, `-f`, `-o option` flags | | brace-expansion.test.sh | 21 | {a,b,c}, {1..5}, for-loop brace expansion | | column.test.sh | 10 | column alignment | | command.test.sh | 9 | `command -v`, `-V`, function bypass | @@ -207,7 +208,7 @@ Features that may be added in the future (not intentionally excluded): | `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 | +| `bash`/`sh` | `-c`, `-n`, `-e`, `-x`, `-u`, `-f`, `-o option`, script files, stdin, `--version`, `--help` | Login shell | ## Builtins