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
2 changes: 1 addition & 1 deletion crates/bashkit/src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
204 changes: 204 additions & 0 deletions crates/bashkit/src/builtins/vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name> variables ("1" = on, absent/other = off).
pub struct Shopt;

#[async_trait]
impl Builtin for Shopt {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
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<char> = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print
let mut opts: Vec<String> = 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::<Vec<_>>()
} 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())),
}
}
}
32 changes: 28 additions & 4 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
92 changes: 92 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/variables.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading