Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 100 additions & 15 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1864,6 +1864,8 @@ impl Interpreter {
let mut script_file: Option<String> = None;
let mut script_args: Vec<String> = 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() {
Expand All @@ -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
Expand All @@ -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;
}
"--" => {
Expand All @@ -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<char> = 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;
}
Expand Down Expand Up @@ -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<String>)> = 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();

Expand Down
108 changes: 108 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/bash-flags.test.sh
Original file line number Diff line number Diff line change
@@ -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
9 changes: 5 additions & 4 deletions specs/009-implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
Loading