From cd36e56d28ef72fd48afabf5c6c8130d1a6abeb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 07:20:03 +0000 Subject: [PATCH] feat(interpreter): implement glob options (dotglob, nocaseglob, failglob, noglob, globstar) Enforce shopt glob options during glob expansion: - dotglob: include dotfiles in * matches - nocaseglob: case-insensitive glob matching - failglob: error on unmatched globs - nullglob: already worked, now uses shared helper - noglob (set -f): skip glob expansion entirely - globstar: gate ** recursive matching on shopt setting Also refactors glob expansion call sites to use shared expand_glob_item helper, reducing code duplication. Adds 13 spec tests for glob options. https://claude.ai/code/session_012rzB3FRw7yoQWCG1mxyW7J --- crates/bashkit/src/interpreter/mod.rs | 192 ++++++++++++------ .../spec_cases/bash/glob-options.test.sh | 146 +++++++++++++ .../tests/spec_cases/bash/globs.test.sh | 3 +- specs/009-implementation-status.md | 7 +- 4 files changed, 287 insertions(+), 61 deletions(-) create mode 100644 crates/bashkit/tests/spec_cases/bash/glob-options.test.sh diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index d0c4d807..47144c6d 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -724,22 +724,15 @@ impl Interpreter { for expanded in fields { let brace_expanded = self.expand_braces(&expanded); for item in brace_expanded { - if self.contains_glob_chars(&item) { - let glob_matches = self.expand_glob(&item).await?; - if glob_matches.is_empty() { - let nullglob = self - .variables - .get("SHOPT_nullglob") - .map(|v| v == "1") - .unwrap_or(false); - if !nullglob { - vals.push(item); - } - } else { - vals.extend(glob_matches); + match self.expand_glob_item(&item).await { + Ok(items) => vals.extend(items), + Err(pat) => { + self.last_exit_code = 1; + return Ok(ExecResult::err( + format!("-bash: no match: {}\n", pat), + 1, + )); } - } else { - vals.push(item); } } } @@ -852,22 +845,15 @@ impl Interpreter { for expanded in fields { let brace_expanded = self.expand_braces(&expanded); for item in brace_expanded { - if self.contains_glob_chars(&item) { - let glob_matches = self.expand_glob(&item).await?; - if glob_matches.is_empty() { - let nullglob = self - .variables - .get("SHOPT_nullglob") - .map(|v| v == "1") - .unwrap_or(false); - if !nullglob { - values.push(item); - } - } else { - values.extend(glob_matches); + match self.expand_glob_item(&item).await { + Ok(items) => values.extend(items), + Err(pat) => { + self.last_exit_code = 1; + return Ok(ExecResult::err( + format!("-bash: no match: {}\n", pat), + 1, + )); } - } else { - values.push(item); } } } @@ -2076,6 +2062,11 @@ impl Interpreter { /// Simple glob pattern matching with support for *, ?, and [...] fn glob_match(&self, value: &str, pattern: &str) -> bool { + self.glob_match_impl(value, pattern, false) + } + + /// Glob match with optional case-insensitive mode + fn glob_match_impl(&self, value: &str, pattern: &str, nocase: bool) -> bool { let mut value_chars = value.chars().peekable(); let mut pattern_chars = pattern.chars().peekable(); @@ -2093,14 +2084,14 @@ impl Interpreter { while value_chars.peek().is_some() { let remaining_value: String = value_chars.clone().collect(); let remaining_pattern: String = pattern_chars.clone().collect(); - if self.glob_match(&remaining_value, &remaining_pattern) { + if self.glob_match_impl(&remaining_value, &remaining_pattern, nocase) { return true; } value_chars.next(); } // Also try with empty match let remaining_pattern: String = pattern_chars.collect(); - return self.glob_match("", &remaining_pattern); + return self.glob_match_impl("", &remaining_pattern, nocase); } (Some('?'), Some(_)) => { pattern_chars.next(); @@ -2109,7 +2100,10 @@ impl Interpreter { (Some('?'), None) => return false, (Some('['), Some(v)) => { pattern_chars.next(); // consume '[' - if let Some(matched) = self.match_bracket_expr(&mut pattern_chars, v) { + let match_char = if nocase { v.to_ascii_lowercase() } else { v }; + if let Some(matched) = + self.match_bracket_expr(&mut pattern_chars, match_char, nocase) + { if matched { value_chars.next(); } else { @@ -2122,7 +2116,12 @@ impl Interpreter { } (Some('['), None) => return false, (Some(p), Some(v)) => { - if p == v { + let matches = if nocase { + p.eq_ignore_ascii_case(&v) + } else { + p == v + }; + if matches { pattern_chars.next(); value_chars.next(); } else { @@ -2140,6 +2139,7 @@ impl Interpreter { &self, pattern_chars: &mut std::iter::Peekable>, value_char: char, + nocase: bool, ) -> Option { let mut chars_in_class = Vec::new(); let mut negate = false; @@ -2182,7 +2182,12 @@ impl Interpreter { } } - let matched = chars_in_class.contains(&value_char); + let matched = if nocase { + let lc = value_char.to_ascii_lowercase(); + chars_in_class.iter().any(|&c| c.to_ascii_lowercase() == lc) + } else { + chars_in_class.contains(&value_char) + }; Some(if negate { !matched } else { matched }) } @@ -2692,25 +2697,12 @@ impl Interpreter { // Step 2: For each brace-expanded item, do glob expansion for item in brace_expanded { - if self.contains_glob_chars(&item) { - let glob_matches = self.expand_glob(&item).await?; - if glob_matches.is_empty() { - // 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); + match self.expand_glob_item(&item).await { + Ok(items) => args.extend(items), + Err(pat) => { + self.last_exit_code = 1; + return Ok(ExecResult::err(format!("-bash: no match: {}\n", pat), 1)); } - } else { - args.push(item); } } } @@ -6187,14 +6179,82 @@ impl Interpreter { s.contains('*') || s.contains('?') || s.contains('[') } + /// Check if dotglob shopt is enabled + fn is_dotglob(&self) -> bool { + self.variables + .get("SHOPT_dotglob") + .map(|v| v == "1") + .unwrap_or(false) + } + + /// Check if nocaseglob shopt is enabled + fn is_nocaseglob(&self) -> bool { + self.variables + .get("SHOPT_nocaseglob") + .map(|v| v == "1") + .unwrap_or(false) + } + + /// Check if noglob (set -f) is enabled + fn is_noglob(&self) -> bool { + self.variables + .get("SHOPT_f") + .map(|v| v == "1") + .unwrap_or(false) + } + + /// Check if failglob shopt is enabled + fn is_failglob(&self) -> bool { + self.variables + .get("SHOPT_failglob") + .map(|v| v == "1") + .unwrap_or(false) + } + + /// Check if globstar shopt is enabled + fn is_globstar(&self) -> bool { + self.variables + .get("SHOPT_globstar") + .map(|v| v == "1") + .unwrap_or(false) + } + + /// Expand glob for a single item, applying noglob/failglob/nullglob. + /// Returns Err(pattern) if failglob triggers, Ok(items) otherwise. + async fn expand_glob_item(&self, item: &str) -> std::result::Result, String> { + if !self.contains_glob_chars(item) || self.is_noglob() { + return Ok(vec![item.to_string()]); + } + let glob_matches = self.expand_glob(item).await.unwrap_or_default(); + if glob_matches.is_empty() { + if self.is_failglob() { + return Err(item.to_string()); + } + let nullglob = self + .variables + .get("SHOPT_nullglob") + .map(|v| v == "1") + .unwrap_or(false); + if nullglob { + Ok(vec![]) + } else { + Ok(vec![item.to_string()]) + } + } else { + Ok(glob_matches) + } + } + /// Expand a glob pattern against the filesystem async fn expand_glob(&self, pattern: &str) -> Result> { - // Check for ** (recursive glob) - if pattern.contains("**") { + // Check for ** (recursive glob) — only when globstar is enabled + if pattern.contains("**") && self.is_globstar() { return self.expand_glob_recursive(pattern).await; } let mut matches = Vec::new(); + let dotglob = self.is_dotglob(); + let nocase = self.is_nocaseglob(); // Split pattern into directory and filename parts let path = Path::new(pattern); @@ -6233,9 +6293,17 @@ impl Interpreter { Err(_) => return Ok(matches), }; + // Check if pattern explicitly starts with dot + let pattern_starts_with_dot = file_pattern.starts_with('.'); + // Match each entry against the pattern for entry in entries { - if self.glob_match(&entry.name, &file_pattern) { + // Skip dotfiles unless dotglob is set or pattern explicitly starts with '.' + if entry.name.starts_with('.') && !dotglob && !pattern_starts_with_dot { + continue; + } + + if self.glob_match_impl(&entry.name, &file_pattern, nocase) { // Construct the full path let full_path = if path.is_absolute() { dir.join(&entry.name).to_string_lossy().to_string() @@ -6264,6 +6332,8 @@ impl Interpreter { async fn expand_glob_recursive(&self, pattern: &str) -> Result> { let is_absolute = pattern.starts_with('/'); let components: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect(); + let dotglob = self.is_dotglob(); + let nocase = self.is_nocaseglob(); // Find the ** component let star_star_idx = match components.iter().position(|&c| c == "**") { @@ -6300,6 +6370,9 @@ impl Interpreter { // ** alone matches all files recursively if let Ok(entries) = self.fs.read_dir(dir).await { for entry in entries { + if entry.name.starts_with('.') && !dotglob { + continue; + } if !entry.metadata.file_type.is_dir() { matches.push(dir.join(&entry.name).to_string_lossy().to_string()); } @@ -6307,9 +6380,14 @@ impl Interpreter { } } else if after_pattern.len() == 1 { // Single pattern after **: match files in this directory + let pat = after_pattern[0]; + let pattern_starts_with_dot = pat.starts_with('.'); if let Ok(entries) = self.fs.read_dir(dir).await { for entry in entries { - if self.glob_match(&entry.name, after_pattern[0]) { + if entry.name.starts_with('.') && !dotglob && !pattern_starts_with_dot { + continue; + } + if self.glob_match_impl(&entry.name, pat, nocase) { matches.push(dir.join(&entry.name).to_string_lossy().to_string()); } } diff --git a/crates/bashkit/tests/spec_cases/bash/glob-options.test.sh b/crates/bashkit/tests/spec_cases/bash/glob-options.test.sh new file mode 100644 index 00000000..9a91ef00 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/glob-options.test.sh @@ -0,0 +1,146 @@ +### dotglob_off_default +# By default, * does not match dotfiles +mkdir -p /tmp/dg_test +cd /tmp/dg_test +touch a b .hidden +echo * +### expect +a b +### end + +### dotglob_on +# With dotglob, * matches dotfiles +### bash_diff +mkdir -p /tmp/dg_on +cd /tmp/dg_on +touch a b .hidden +shopt -s dotglob +echo * +### expect +.hidden a b +### end + +### dotglob_explicit_dot +# Pattern starting with . always matches dotfiles regardless of dotglob +mkdir -p /tmp/dg_dot +cd /tmp/dg_dot +touch .foo .bar visible +echo .* +### expect +.bar .foo +### end + +### nocaseglob_off +# Without nocaseglob, glob is case-sensitive +mkdir -p /tmp/ncg_off +cd /tmp/ncg_off +touch ABC abc +echo [a]* +### expect +abc +### end + +### nocaseglob_on +# With nocaseglob, glob is case-insensitive +### bash_diff +mkdir -p /tmp/ncg_on +cd /tmp/ncg_on +touch ABC abc +shopt -s nocaseglob +echo [a]* +### expect +ABC abc +### end + +### nullglob_off +# Without nullglob, unmatched glob stays literal +mkdir -p /tmp/ng_off +cd /tmp/ng_off +echo *.nonexistent +### expect +*.nonexistent +### end + +### nullglob_on +# With nullglob, unmatched glob expands to nothing +mkdir -p /tmp/ng_on +cd /tmp/ng_on +shopt -s nullglob +for f in *.nonexistent; do echo "got: $f"; done +echo "done" +### expect +done +### end + +### failglob_on +# With failglob, unmatched glob is an error +### bash_diff +mkdir -p /tmp/fg_on +cd /tmp/fg_on +shopt -s failglob +echo *.nonexistent 2>/dev/null +echo "exit:$?" +### expect +exit:1 +### end + +### noglob_set_f +# set -f disables glob expansion +mkdir -p /tmp/ng_setf +cd /tmp/ng_setf +touch a b c +set -f +echo * +### expect +* +### end + +### noglob_restored +# set +f re-enables glob expansion +mkdir -p /tmp/ng_restore +cd /tmp/ng_restore +touch x y z +set -f +echo * +set +f +echo * +### expect +* +x y z +### end + +### dotglob_toggle +# dotglob can be toggled off +### bash_diff +mkdir -p /tmp/dg_toggle +cd /tmp/dg_toggle +touch a .hidden +shopt -s dotglob +echo * +shopt -u dotglob +echo * +### expect +.hidden a +a +### end + +### globstar_off_default +# Without globstar, ** is treated as regular * +mkdir -p /tmp/gs_off/sub +touch /tmp/gs_off/top.txt /tmp/gs_off/sub/deep.txt +cd /tmp/gs_off +echo ** +### expect +sub top.txt +### end + +### globstar_on +# With globstar, ** matches recursively +### bash_diff +mkdir -p /tmp/gs_on/sub +touch /tmp/gs_on/top.txt /tmp/gs_on/sub/deep.txt +shopt -s globstar +echo /tmp/gs_on/**/*.txt +### expect +/tmp/gs_on/sub/deep.txt /tmp/gs_on/top.txt +### end diff --git a/crates/bashkit/tests/spec_cases/bash/globs.test.sh b/crates/bashkit/tests/spec_cases/bash/globs.test.sh index 9f791b9d..031fe504 100644 --- a/crates/bashkit/tests/spec_cases/bash/globs.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/globs.test.sh @@ -37,7 +37,8 @@ echo a > /x1.txt; echo b > /x2.txt; echo /x[12].txt ### glob_recursive ### bash_diff: Bashkit VFS has files, real bash CI filesystem does not -# Recursive glob with ** +# Recursive glob with ** (requires globstar) +shopt -s globstar mkdir -p /recur/sub1/deep mkdir -p /recur/sub2 echo a > /recur/f1.txt diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index d2867078..9c136588 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:** 1411 (1406 pass, 5 skip) +**Total spec test cases:** 1424 (1419 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 993 | Yes | 988 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 1006 | Yes | 1001 | 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** | **1411** | **Yes** | **1406** | **5** | | +| **Total** | **1424** | **Yes** | **1419** | **5** | | ### Bash Spec Tests Breakdown @@ -139,6 +139,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | find.test.sh | 10 | file search | | functions.test.sh | 26 | local dynamic scoping, nested writes, FUNCNAME call stack, `caller` builtin | | getopts.test.sh | 9 | POSIX option parsing, combined flags, silent mode | +| glob-options.test.sh | 13 | dotglob, nocaseglob, failglob, nullglob, noglob, globstar | | globs.test.sh | 12 | for-loop glob expansion, recursive `**` | | headtail.test.sh | 14 | | | herestring.test.sh | 8 | 1 skipped |