Skip to content

Commit ca21002

Browse files
chaliyclaude
andauthored
feat(builtins): implement shopt builtin with nullglob enforcement (#254)
## Summary - Add `shopt` builtin for bash-specific shell options (`-s`, `-u`, `-q`, `-p` flags) - All standard bash shopt option names recognized (extglob, nullglob, globstar, etc.) - Enforce `nullglob` behavior: unmatched globs expand to nothing when enabled - nullglob applied in command args, for loops, and select loops ## Test plan - [x] 8 spec tests covering set/unset/query/print/invalid options/nullglob behavior - [x] All existing tests pass - [x] `cargo clippy` clean, `cargo fmt` clean Co-authored-by: Claude <noreply@anthropic.com>
1 parent b844983 commit ca21002

File tree

5 files changed

+331
-11
lines changed

5 files changed

+331
-11
lines changed

crates/bashkit/src/builtins/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ pub use strings::Strings;
104104
pub use system::{Hostname, Id, Uname, Whoami, DEFAULT_HOSTNAME, DEFAULT_USERNAME};
105105
pub use test::{Bracket, Test};
106106
pub use timeout::Timeout;
107-
pub use vars::{Eval, Local, Readonly, Set, Shift, Times, Unset};
107+
pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset};
108108
pub use wait::Wait;
109109
pub use wc::Wc;
110110

crates/bashkit/src/builtins/vars.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,207 @@ impl Builtin for Eval {
235235
Ok(ExecResult::ok(String::new()))
236236
}
237237
}
238+
239+
/// Known shopt option names. Maps to SHOPT_* variables.
240+
const SHOPT_OPTIONS: &[&str] = &[
241+
"autocd",
242+
"cdspell",
243+
"checkhash",
244+
"checkjobs",
245+
"checkwinsize",
246+
"cmdhist",
247+
"compat31",
248+
"compat32",
249+
"compat40",
250+
"compat41",
251+
"compat42",
252+
"compat43",
253+
"compat44",
254+
"direxpand",
255+
"dirspell",
256+
"dotglob",
257+
"execfail",
258+
"expand_aliases",
259+
"extdebug",
260+
"extglob",
261+
"extquote",
262+
"failglob",
263+
"force_fignore",
264+
"globasciiranges",
265+
"globstar",
266+
"gnu_errfmt",
267+
"histappend",
268+
"histreedit",
269+
"histverify",
270+
"hostcomplete",
271+
"huponexit",
272+
"inherit_errexit",
273+
"interactive_comments",
274+
"lastpipe",
275+
"lithist",
276+
"localvar_inherit",
277+
"localvar_unset",
278+
"login_shell",
279+
"mailwarn",
280+
"no_empty_cmd_completion",
281+
"nocaseglob",
282+
"nocasematch",
283+
"nullglob",
284+
"progcomp",
285+
"progcomp_alias",
286+
"promptvars",
287+
"restricted_shell",
288+
"shift_verbose",
289+
"sourcepath",
290+
"xpg_echo",
291+
];
292+
293+
/// shopt builtin - set/unset bash-specific shell options.
294+
///
295+
/// Usage:
296+
/// - `shopt` - list all options with on/off status
297+
/// - `shopt -s opt` - set (enable) option
298+
/// - `shopt -u opt` - unset (disable) option
299+
/// - `shopt -q opt` - query option (exit code only, no output)
300+
/// - `shopt -p [opt]` - print in reusable `shopt -s/-u` format
301+
/// - `shopt opt` - show status of specific option
302+
///
303+
/// Options stored as SHOPT_<name> variables ("1" = on, absent/other = off).
304+
pub struct Shopt;
305+
306+
#[async_trait]
307+
impl Builtin for Shopt {
308+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
309+
if ctx.args.is_empty() {
310+
// List all options with their status
311+
let mut output = String::new();
312+
for opt in SHOPT_OPTIONS {
313+
let key = format!("SHOPT_{}", opt);
314+
let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false);
315+
output.push_str(&format!("{:<32}{}\n", opt, if on { "on" } else { "off" }));
316+
}
317+
return Ok(ExecResult::ok(output));
318+
}
319+
320+
let mut mode: Option<char> = None; // 's'=set, 'u'=unset, 'q'=query, 'p'=print
321+
let mut opts: Vec<String> = Vec::new();
322+
323+
for arg in ctx.args {
324+
if arg.starts_with('-') && opts.is_empty() {
325+
for ch in arg.chars().skip(1) {
326+
match ch {
327+
's' | 'u' | 'q' | 'p' => mode = Some(ch),
328+
_ => {
329+
return Ok(ExecResult::err(
330+
format!("bash: shopt: -{}: invalid option\n", ch),
331+
2,
332+
));
333+
}
334+
}
335+
}
336+
} else {
337+
opts.push(arg.to_string());
338+
}
339+
}
340+
341+
match mode {
342+
Some('s') => {
343+
// Set options
344+
for opt in &opts {
345+
if !SHOPT_OPTIONS.contains(&opt.as_str()) {
346+
return Ok(ExecResult::err(
347+
format!("bash: shopt: {}: invalid shell option name\n", opt),
348+
1,
349+
));
350+
}
351+
ctx.variables
352+
.insert(format!("SHOPT_{}", opt), "1".to_string());
353+
}
354+
Ok(ExecResult::ok(String::new()))
355+
}
356+
Some('u') => {
357+
// Unset options
358+
for opt in &opts {
359+
if !SHOPT_OPTIONS.contains(&opt.as_str()) {
360+
return Ok(ExecResult::err(
361+
format!("bash: shopt: {}: invalid shell option name\n", opt),
362+
1,
363+
));
364+
}
365+
ctx.variables.remove(&format!("SHOPT_{}", opt));
366+
}
367+
Ok(ExecResult::ok(String::new()))
368+
}
369+
Some('q') => {
370+
// Query: exit 0 if all named options are on, 1 otherwise
371+
let all_on = opts.iter().all(|opt| {
372+
let key = format!("SHOPT_{}", opt);
373+
ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false)
374+
});
375+
Ok(ExecResult {
376+
stdout: String::new(),
377+
stderr: String::new(),
378+
exit_code: if all_on { 0 } else { 1 },
379+
control_flow: crate::interpreter::ControlFlow::None,
380+
})
381+
}
382+
Some('p') => {
383+
// Print in reusable format
384+
let mut output = String::new();
385+
let list = if opts.is_empty() {
386+
SHOPT_OPTIONS
387+
.iter()
388+
.map(|s| s.to_string())
389+
.collect::<Vec<_>>()
390+
} else {
391+
opts.clone()
392+
};
393+
for opt in &list {
394+
let key = format!("SHOPT_{}", opt);
395+
let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false);
396+
output.push_str(&format!("shopt {} {}\n", if on { "-s" } else { "-u" }, opt));
397+
}
398+
Ok(ExecResult::ok(output))
399+
}
400+
None => {
401+
// No flag: show status of named options
402+
if opts.is_empty() {
403+
// Same as listing all
404+
let mut output = String::new();
405+
for opt in SHOPT_OPTIONS {
406+
let key = format!("SHOPT_{}", opt);
407+
let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false);
408+
output.push_str(&format!("{:<32}{}\n", opt, if on { "on" } else { "off" }));
409+
}
410+
return Ok(ExecResult::ok(output));
411+
}
412+
let mut output = String::new();
413+
let mut any_invalid = false;
414+
for opt in &opts {
415+
if !SHOPT_OPTIONS.contains(&opt.as_str()) {
416+
output.push_str(&format!(
417+
"bash: shopt: {}: invalid shell option name\n",
418+
opt
419+
));
420+
any_invalid = true;
421+
continue;
422+
}
423+
let key = format!("SHOPT_{}", opt);
424+
let on = ctx.variables.get(&key).map(|v| v == "1").unwrap_or(false);
425+
output.push_str(&format!("{:<32}{}\n", opt, if on { "on" } else { "off" }));
426+
}
427+
if any_invalid {
428+
Ok(ExecResult {
429+
stdout: String::new(),
430+
stderr: output,
431+
exit_code: 1,
432+
control_flow: crate::interpreter::ControlFlow::None,
433+
})
434+
} else {
435+
Ok(ExecResult::ok(output))
436+
}
437+
}
438+
_ => Ok(ExecResult::ok(String::new())),
439+
}
440+
}
441+
}

crates/bashkit/src/interpreter/mod.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,7 @@ impl Interpreter {
312312
builtins.insert("xargs".to_string(), Box::new(builtins::Xargs));
313313
builtins.insert("tee".to_string(), Box::new(builtins::Tee));
314314
builtins.insert("watch".to_string(), Box::new(builtins::Watch));
315+
builtins.insert("shopt".to_string(), Box::new(builtins::Shopt));
315316

316317
// Merge custom builtins (override defaults if same name)
317318
for (name, builtin) in custom_builtins {
@@ -716,7 +717,14 @@ impl Interpreter {
716717
if self.contains_glob_chars(&item) {
717718
let glob_matches = self.expand_glob(&item).await?;
718719
if glob_matches.is_empty() {
719-
vals.push(item);
720+
let nullglob = self
721+
.variables
722+
.get("SHOPT_nullglob")
723+
.map(|v| v == "1")
724+
.unwrap_or(false);
725+
if !nullglob {
726+
vals.push(item);
727+
}
720728
} else {
721729
vals.extend(glob_matches);
722730
}
@@ -837,7 +845,14 @@ impl Interpreter {
837845
if self.contains_glob_chars(&item) {
838846
let glob_matches = self.expand_glob(&item).await?;
839847
if glob_matches.is_empty() {
840-
values.push(item);
848+
let nullglob = self
849+
.variables
850+
.get("SHOPT_nullglob")
851+
.map(|v| v == "1")
852+
.unwrap_or(false);
853+
if !nullglob {
854+
values.push(item);
855+
}
841856
} else {
842857
values.extend(glob_matches);
843858
}
@@ -2641,8 +2656,17 @@ impl Interpreter {
26412656
if self.contains_glob_chars(&item) {
26422657
let glob_matches = self.expand_glob(&item).await?;
26432658
if glob_matches.is_empty() {
2644-
// No matches - keep original pattern (bash behavior)
2645-
args.push(item);
2659+
// nullglob: unmatched globs expand to nothing
2660+
let nullglob = self
2661+
.variables
2662+
.get("SHOPT_nullglob")
2663+
.map(|v| v == "1")
2664+
.unwrap_or(false);
2665+
if !nullglob {
2666+
// Default: keep original pattern (bash behavior)
2667+
args.push(item);
2668+
}
2669+
// With nullglob: skip (produce nothing)
26462670
} else {
26472671
args.extend(glob_matches);
26482672
}

crates/bashkit/tests/spec_cases/bash/variables.test.sh

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,3 +580,95 @@ echo hello
580580
### expect
581581
hello
582582
### end
583+
584+
### shopt_set_and_query
585+
# shopt -s sets option, -q queries it
586+
shopt -s nullglob
587+
shopt -q nullglob && echo "on" || echo "off"
588+
shopt -u nullglob
589+
shopt -q nullglob && echo "on" || echo "off"
590+
### expect
591+
on
592+
off
593+
### end
594+
595+
### shopt_print_format
596+
# shopt -p prints in reusable format
597+
### bash_diff
598+
shopt -s extglob
599+
shopt -p extglob
600+
shopt -u extglob
601+
shopt -p extglob
602+
### expect
603+
shopt -s extglob
604+
shopt -u extglob
605+
### end
606+
607+
### shopt_invalid_option
608+
# shopt rejects invalid option names
609+
shopt -s nonexistent_option
610+
echo "exit:$?"
611+
### expect
612+
exit:1
613+
### end
614+
### exit_code: 1
615+
616+
### shopt_show_specific
617+
# shopt shows status of named option
618+
### bash_diff
619+
shopt nullglob
620+
shopt -s nullglob
621+
shopt nullglob
622+
### expect
623+
nullglob off
624+
nullglob on
625+
### end
626+
627+
### shopt_nullglob_no_matches
628+
# shopt -s nullglob: unmatched globs expand to nothing
629+
### bash_diff
630+
shopt -s nullglob
631+
for f in /tmp/nonexistent_pattern_xyz_*.txt; do
632+
echo "found: $f"
633+
done
634+
echo "done"
635+
### expect
636+
done
637+
### end
638+
639+
### shopt_nullglob_off_keeps_pattern
640+
# Without nullglob, unmatched globs keep the pattern
641+
for f in /tmp/nonexistent_pattern_xyz_*.txt; do
642+
echo "found: $f"
643+
done
644+
echo "done"
645+
### expect
646+
found: /tmp/nonexistent_pattern_xyz_*.txt
647+
done
648+
### end
649+
650+
### shopt_nullglob_with_matches
651+
# nullglob doesn't affect globs that have matches
652+
### bash_diff
653+
echo "test" > /tmp/shopt_test1.txt
654+
echo "test" > /tmp/shopt_test2.txt
655+
shopt -s nullglob
656+
count=0
657+
for f in /tmp/shopt_test*.txt; do
658+
count=$((count + 1))
659+
done
660+
echo "count:$count"
661+
### expect
662+
count:2
663+
### end
664+
665+
### shopt_multiple_options
666+
# shopt -s can set multiple options
667+
### bash_diff
668+
shopt -s nullglob extglob
669+
shopt -q nullglob && echo "null:on" || echo "null:off"
670+
shopt -q extglob && echo "ext:on" || echo "ext:off"
671+
### expect
672+
null:on
673+
ext:on
674+
### end

0 commit comments

Comments
 (0)