diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index fdd76129..f03ddab8 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -104,7 +104,7 @@ pub use strings::Strings; pub use system::{Hostname, Id, Uname, Whoami, DEFAULT_HOSTNAME, DEFAULT_USERNAME}; pub use test::{Bracket, Test}; pub use timeout::Timeout; -pub use vars::{Eval, Local, Readonly, Set, Shift, Times, Unset}; +pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset}; pub use wait::Wait; pub use wc::Wc; diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs index de39e547..d5f0c57c 100644 --- a/crates/bashkit/src/builtins/vars.rs +++ b/crates/bashkit/src/builtins/vars.rs @@ -235,3 +235,207 @@ impl Builtin for Eval { Ok(ExecResult::ok(String::new())) } } + +/// Known shopt option names. Maps to SHOPT_* variables. +const SHOPT_OPTIONS: &[&str] = &[ + "autocd", + "cdspell", + "checkhash", + "checkjobs", + "checkwinsize", + "cmdhist", + "compat31", + "compat32", + "compat40", + "compat41", + "compat42", + "compat43", + "compat44", + "direxpand", + "dirspell", + "dotglob", + "execfail", + "expand_aliases", + "extdebug", + "extglob", + "extquote", + "failglob", + "force_fignore", + "globasciiranges", + "globstar", + "gnu_errfmt", + "histappend", + "histreedit", + "histverify", + "hostcomplete", + "huponexit", + "inherit_errexit", + "interactive_comments", + "lastpipe", + "lithist", + "localvar_inherit", + "localvar_unset", + "login_shell", + "mailwarn", + "no_empty_cmd_completion", + "nocaseglob", + "nocasematch", + "nullglob", + "progcomp", + "progcomp_alias", + "promptvars", + "restricted_shell", + "shift_verbose", + "sourcepath", + "xpg_echo", +]; + +/// shopt builtin - set/unset bash-specific shell options. +/// +/// Usage: +/// - `shopt` - list all options with on/off status +/// - `shopt -s opt` - set (enable) option +/// - `shopt -u opt` - unset (disable) option +/// - `shopt -q opt` - query option (exit code only, no output) +/// - `shopt -p [opt]` - print in reusable `shopt -s/-u` format +/// - `shopt opt` - show status of specific option +/// +/// Options stored as SHOPT_ variables ("1" = on, absent/other = off). +pub struct Shopt; + +#[async_trait] +impl Builtin for Shopt { + async fn execute(&self, ctx: Context<'_>) -> Result { + if ctx.args.is_empty() { + // List all options with their status + let mut output = String::new(); + for opt in SHOPT_OPTIONS { + let key = format!("SHOPT_{}", opt); + let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); + output.push_str(&format!("{:<32}{}\n", opt, if on { "on" } else { "off" })); + } + return Ok(ExecResult::ok(output)); + } + + let mut mode: Option = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print + let mut opts: Vec = Vec::new(); + + for arg in ctx.args { + if arg.starts_with('-') && opts.is_empty() { + for ch in arg.chars().skip(1) { + match ch { + 's' | 'u' | 'q' | 'p' => mode = Some(ch), + _ => { + return Ok(ExecResult::err( + format!("bash: shopt: -{}: invalid option\n", ch), + 2, + )); + } + } + } + } else { + opts.push(arg.to_string()); + } + } + + match mode { + Some('s') => { + // Set options + for opt in &opts { + if !SHOPT_OPTIONS.contains(&opt.as_str()) { + return Ok(ExecResult::err( + format!("bash: shopt: {}: invalid shell option name\n", opt), + 1, + )); + } + ctx.variables + .insert(format!("SHOPT_{}", opt), "1".to_string()); + } + Ok(ExecResult::ok(String::new())) + } + Some('u') => { + // Unset options + for opt in &opts { + if !SHOPT_OPTIONS.contains(&opt.as_str()) { + return Ok(ExecResult::err( + format!("bash: shopt: {}: invalid shell option name\n", opt), + 1, + )); + } + ctx.variables.remove(&format!("SHOPT_{}", opt)); + } + Ok(ExecResult::ok(String::new())) + } + Some('q') => { + // Query: exit 0 if all named options are on, 1 otherwise + let all_on = opts.iter().all(|opt| { + let key = format!("SHOPT_{}", opt); + ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false) + }); + Ok(ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: if all_on { 0 } else { 1 }, + control_flow: crate::interpreter::ControlFlow::None, + }) + } + Some('p') => { + // Print in reusable format + let mut output = String::new(); + let list = if opts.is_empty() { + SHOPT_OPTIONS + .iter() + .map(|s| s.to_string()) + .collect::>() + } else { + opts.clone() + }; + for opt in &list { + let key = format!("SHOPT_{}", opt); + let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); + output.push_str(&format!("shopt {} {}\n", if on { "-s" } else { "-u" }, opt)); + } + Ok(ExecResult::ok(output)) + } + None => { + // No flag: show status of named options + if opts.is_empty() { + // Same as listing all + let mut output = String::new(); + for opt in SHOPT_OPTIONS { + let key = format!("SHOPT_{}", opt); + let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); + output.push_str(&format!("{:<32}{}\n", opt, if on { "on" } else { "off" })); + } + return Ok(ExecResult::ok(output)); + } + let mut output = String::new(); + let mut any_invalid = false; + for opt in &opts { + if !SHOPT_OPTIONS.contains(&opt.as_str()) { + output.push_str(&format!( + "bash: shopt: {}: invalid shell option name\n", + opt + )); + any_invalid = true; + continue; + } + let key = format!("SHOPT_{}", opt); + let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false); + output.push_str(&format!("{:<32}{}\n", opt, if on { "on" } else { "off" })); + } + if any_invalid { + Ok(ExecResult { + stdout: String::new(), + stderr: output, + exit_code: 1, + control_flow: crate::interpreter::ControlFlow::None, + }) + } else { + Ok(ExecResult::ok(output)) + } + } + _ => Ok(ExecResult::ok(String::new())), + } + } +} diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 97240901..0e5a59af 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -312,6 +312,7 @@ impl Interpreter { builtins.insert("xargs".to_string(), Box::new(builtins::Xargs)); builtins.insert("tee".to_string(), Box::new(builtins::Tee)); builtins.insert("watch".to_string(), Box::new(builtins::Watch)); + builtins.insert("shopt".to_string(), Box::new(builtins::Shopt)); // Merge custom builtins (override defaults if same name) for (name, builtin) in custom_builtins { @@ -716,7 +717,14 @@ impl Interpreter { if self.contains_glob_chars(&item) { let glob_matches = self.expand_glob(&item).await?; if glob_matches.is_empty() { - vals.push(item); + let nullglob = self + .variables + .get("SHOPT_nullglob") + .map(|v| v == "1") + .unwrap_or(false); + if !nullglob { + vals.push(item); + } } else { vals.extend(glob_matches); } @@ -837,7 +845,14 @@ impl Interpreter { if self.contains_glob_chars(&item) { let glob_matches = self.expand_glob(&item).await?; if glob_matches.is_empty() { - values.push(item); + let nullglob = self + .variables + .get("SHOPT_nullglob") + .map(|v| v == "1") + .unwrap_or(false); + if !nullglob { + values.push(item); + } } else { values.extend(glob_matches); } @@ -2641,8 +2656,17 @@ impl Interpreter { if self.contains_glob_chars(&item) { let glob_matches = self.expand_glob(&item).await?; if glob_matches.is_empty() { - // No matches - keep original pattern (bash behavior) - args.push(item); + // nullglob: unmatched globs expand to nothing + let nullglob = self + .variables + .get("SHOPT_nullglob") + .map(|v| v == "1") + .unwrap_or(false); + if !nullglob { + // Default: keep original pattern (bash behavior) + args.push(item); + } + // With nullglob: skip (produce nothing) } else { args.extend(glob_matches); } diff --git a/crates/bashkit/tests/spec_cases/bash/variables.test.sh b/crates/bashkit/tests/spec_cases/bash/variables.test.sh index 8fb3f213..ec30ff59 100644 --- a/crates/bashkit/tests/spec_cases/bash/variables.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/variables.test.sh @@ -580,3 +580,95 @@ echo hello ### expect hello ### end + +### shopt_set_and_query +# shopt -s sets option, -q queries it +shopt -s nullglob +shopt -q nullglob && echo "on" || echo "off" +shopt -u nullglob +shopt -q nullglob && echo "on" || echo "off" +### expect +on +off +### end + +### shopt_print_format +# shopt -p prints in reusable format +### bash_diff +shopt -s extglob +shopt -p extglob +shopt -u extglob +shopt -p extglob +### expect +shopt -s extglob +shopt -u extglob +### end + +### shopt_invalid_option +# shopt rejects invalid option names +shopt -s nonexistent_option +echo "exit:$?" +### expect +exit:1 +### end +### exit_code: 1 + +### shopt_show_specific +# shopt shows status of named option +### bash_diff +shopt nullglob +shopt -s nullglob +shopt nullglob +### expect +nullglob off +nullglob on +### end + +### shopt_nullglob_no_matches +# shopt -s nullglob: unmatched globs expand to nothing +### bash_diff +shopt -s nullglob +for f in /tmp/nonexistent_pattern_xyz_*.txt; do + echo "found: $f" +done +echo "done" +### expect +done +### end + +### shopt_nullglob_off_keeps_pattern +# Without nullglob, unmatched globs keep the pattern +for f in /tmp/nonexistent_pattern_xyz_*.txt; do + echo "found: $f" +done +echo "done" +### expect +found: /tmp/nonexistent_pattern_xyz_*.txt +done +### end + +### shopt_nullglob_with_matches +# nullglob doesn't affect globs that have matches +### bash_diff +echo "test" > /tmp/shopt_test1.txt +echo "test" > /tmp/shopt_test2.txt +shopt -s nullglob +count=0 +for f in /tmp/shopt_test*.txt; do + count=$((count + 1)) +done +echo "count:$count" +### expect +count:2 +### end + +### shopt_multiple_options +# shopt -s can set multiple options +### bash_diff +shopt -s nullglob extglob +shopt -q nullglob && echo "null:on" || echo "null:off" +shopt -q extglob && echo "ext:on" || echo "ext:off" +### expect +null:on +ext:on +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index dd85226d..ec686efe 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:** 1297 (1292 pass, 5 skip) +**Total spec test cases:** 1305 (1300 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 879 | Yes | 874 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 887 | Yes | 882 | 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** | **1297** | **Yes** | **1292** | **5** | | +| **Total** | **1305** | **Yes** | **1300** | **5** | | ### 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 | | time.test.sh | 11 | Wall-clock only (user/sys always 0) | | timeout.test.sh | 17 | | -| variables.test.sh | 78 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS, `set -x` xtrace | +| variables.test.sh | 86 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS, `set -x` xtrace, `shopt` builtin, nullglob | | 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` | @@ -207,7 +207,7 @@ Features that may be added in the future (not intentionally excluded): ### Implemented -**93 core builtins + 3 feature-gated = 96 total** +**94 core builtins + 3 feature-gated = 97 total** `echo`, `printf`, `cat`, `nl`, `cd`, `pwd`, `true`, `false`, `exit`, `test`, `[`, `export`, `set`, `unset`, `local`, `source`, `.`, `read`, `shift`, `break`, @@ -215,7 +215,7 @@ Features that may be added in the future (not intentionally excluded): `basename`, `dirname`, `mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`, `chown`, `ln`, `wc`, `sort`, `uniq`, `cut`, `tr`, `paste`, `column`, `diff`, `comm`, `date`, `wait`, `curl`, `wget`, `timeout`, `command`, `getopts`, -`type`, `which`, `hash`, `declare`, `typeset`, `let`, `kill`, +`type`, `which`, `hash`, `declare`, `typeset`, `let`, `kill`, `shopt`, `time` (keyword), `whoami`, `hostname`, `uname`, `id`, `ls`, `rmdir`, `find`, `xargs`, `tee`, `:` (colon), `eval`, `readonly`, `times`, `bash`, `sh`, `od`, `xxd`, `hexdump`, `strings`,