Skip to content

Commit 9e32c33

Browse files
chaliyclaude
andauthored
feat(interpreter): implement glob options (dotglob, nocaseglob, failglob, noglob, globstar) (#269)
## Summary - Enforces shopt glob options during glob expansion: `dotglob`, `nocaseglob`, `failglob`, `noglob` (`set -f`), `globstar` - Adds case-insensitive matching to `glob_match` and `match_bracket_expr` - Gates `**` recursive glob on `shopt -s globstar` (bash-compatible behavior) - Refactors three glob expansion call sites into shared `expand_glob_item` helper - Adds 13 spec tests covering all glob options ## Test plan - [x] All 1424 spec tests pass (1419 pass, 5 skip) - [x] `cargo clippy` clean - [x] `cargo fmt` clean - [x] Existing `glob_recursive` test updated to use `shopt -s globstar` Co-authored-by: Claude <noreply@anthropic.com>
1 parent 60ad9a4 commit 9e32c33

File tree

4 files changed

+287
-61
lines changed

4 files changed

+287
-61
lines changed

crates/bashkit/src/interpreter/mod.rs

Lines changed: 135 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -724,22 +724,15 @@ impl Interpreter {
724724
for expanded in fields {
725725
let brace_expanded = self.expand_braces(&expanded);
726726
for item in brace_expanded {
727-
if self.contains_glob_chars(&item) {
728-
let glob_matches = self.expand_glob(&item).await?;
729-
if glob_matches.is_empty() {
730-
let nullglob = self
731-
.variables
732-
.get("SHOPT_nullglob")
733-
.map(|v| v == "1")
734-
.unwrap_or(false);
735-
if !nullglob {
736-
vals.push(item);
737-
}
738-
} else {
739-
vals.extend(glob_matches);
727+
match self.expand_glob_item(&item).await {
728+
Ok(items) => vals.extend(items),
729+
Err(pat) => {
730+
self.last_exit_code = 1;
731+
return Ok(ExecResult::err(
732+
format!("-bash: no match: {}\n", pat),
733+
1,
734+
));
740735
}
741-
} else {
742-
vals.push(item);
743736
}
744737
}
745738
}
@@ -852,22 +845,15 @@ impl Interpreter {
852845
for expanded in fields {
853846
let brace_expanded = self.expand_braces(&expanded);
854847
for item in brace_expanded {
855-
if self.contains_glob_chars(&item) {
856-
let glob_matches = self.expand_glob(&item).await?;
857-
if glob_matches.is_empty() {
858-
let nullglob = self
859-
.variables
860-
.get("SHOPT_nullglob")
861-
.map(|v| v == "1")
862-
.unwrap_or(false);
863-
if !nullglob {
864-
values.push(item);
865-
}
866-
} else {
867-
values.extend(glob_matches);
848+
match self.expand_glob_item(&item).await {
849+
Ok(items) => values.extend(items),
850+
Err(pat) => {
851+
self.last_exit_code = 1;
852+
return Ok(ExecResult::err(
853+
format!("-bash: no match: {}\n", pat),
854+
1,
855+
));
868856
}
869-
} else {
870-
values.push(item);
871857
}
872858
}
873859
}
@@ -2076,6 +2062,11 @@ impl Interpreter {
20762062

20772063
/// Simple glob pattern matching with support for *, ?, and [...]
20782064
fn glob_match(&self, value: &str, pattern: &str) -> bool {
2065+
self.glob_match_impl(value, pattern, false)
2066+
}
2067+
2068+
/// Glob match with optional case-insensitive mode
2069+
fn glob_match_impl(&self, value: &str, pattern: &str, nocase: bool) -> bool {
20792070
let mut value_chars = value.chars().peekable();
20802071
let mut pattern_chars = pattern.chars().peekable();
20812072

@@ -2093,14 +2084,14 @@ impl Interpreter {
20932084
while value_chars.peek().is_some() {
20942085
let remaining_value: String = value_chars.clone().collect();
20952086
let remaining_pattern: String = pattern_chars.clone().collect();
2096-
if self.glob_match(&remaining_value, &remaining_pattern) {
2087+
if self.glob_match_impl(&remaining_value, &remaining_pattern, nocase) {
20972088
return true;
20982089
}
20992090
value_chars.next();
21002091
}
21012092
// Also try with empty match
21022093
let remaining_pattern: String = pattern_chars.collect();
2103-
return self.glob_match("", &remaining_pattern);
2094+
return self.glob_match_impl("", &remaining_pattern, nocase);
21042095
}
21052096
(Some('?'), Some(_)) => {
21062097
pattern_chars.next();
@@ -2109,7 +2100,10 @@ impl Interpreter {
21092100
(Some('?'), None) => return false,
21102101
(Some('['), Some(v)) => {
21112102
pattern_chars.next(); // consume '['
2112-
if let Some(matched) = self.match_bracket_expr(&mut pattern_chars, v) {
2103+
let match_char = if nocase { v.to_ascii_lowercase() } else { v };
2104+
if let Some(matched) =
2105+
self.match_bracket_expr(&mut pattern_chars, match_char, nocase)
2106+
{
21132107
if matched {
21142108
value_chars.next();
21152109
} else {
@@ -2122,7 +2116,12 @@ impl Interpreter {
21222116
}
21232117
(Some('['), None) => return false,
21242118
(Some(p), Some(v)) => {
2125-
if p == v {
2119+
let matches = if nocase {
2120+
p.eq_ignore_ascii_case(&v)
2121+
} else {
2122+
p == v
2123+
};
2124+
if matches {
21262125
pattern_chars.next();
21272126
value_chars.next();
21282127
} else {
@@ -2140,6 +2139,7 @@ impl Interpreter {
21402139
&self,
21412140
pattern_chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
21422141
value_char: char,
2142+
nocase: bool,
21432143
) -> Option<bool> {
21442144
let mut chars_in_class = Vec::new();
21452145
let mut negate = false;
@@ -2182,7 +2182,12 @@ impl Interpreter {
21822182
}
21832183
}
21842184

2185-
let matched = chars_in_class.contains(&value_char);
2185+
let matched = if nocase {
2186+
let lc = value_char.to_ascii_lowercase();
2187+
chars_in_class.iter().any(|&c| c.to_ascii_lowercase() == lc)
2188+
} else {
2189+
chars_in_class.contains(&value_char)
2190+
};
21862191
Some(if negate { !matched } else { matched })
21872192
}
21882193

@@ -2692,25 +2697,12 @@ impl Interpreter {
26922697

26932698
// Step 2: For each brace-expanded item, do glob expansion
26942699
for item in brace_expanded {
2695-
if self.contains_glob_chars(&item) {
2696-
let glob_matches = self.expand_glob(&item).await?;
2697-
if glob_matches.is_empty() {
2698-
// nullglob: unmatched globs expand to nothing
2699-
let nullglob = self
2700-
.variables
2701-
.get("SHOPT_nullglob")
2702-
.map(|v| v == "1")
2703-
.unwrap_or(false);
2704-
if !nullglob {
2705-
// Default: keep original pattern (bash behavior)
2706-
args.push(item);
2707-
}
2708-
// With nullglob: skip (produce nothing)
2709-
} else {
2710-
args.extend(glob_matches);
2700+
match self.expand_glob_item(&item).await {
2701+
Ok(items) => args.extend(items),
2702+
Err(pat) => {
2703+
self.last_exit_code = 1;
2704+
return Ok(ExecResult::err(format!("-bash: no match: {}\n", pat), 1));
27112705
}
2712-
} else {
2713-
args.push(item);
27142706
}
27152707
}
27162708
}
@@ -6187,14 +6179,82 @@ impl Interpreter {
61876179
s.contains('*') || s.contains('?') || s.contains('[')
61886180
}
61896181

6182+
/// Check if dotglob shopt is enabled
6183+
fn is_dotglob(&self) -> bool {
6184+
self.variables
6185+
.get("SHOPT_dotglob")
6186+
.map(|v| v == "1")
6187+
.unwrap_or(false)
6188+
}
6189+
6190+
/// Check if nocaseglob shopt is enabled
6191+
fn is_nocaseglob(&self) -> bool {
6192+
self.variables
6193+
.get("SHOPT_nocaseglob")
6194+
.map(|v| v == "1")
6195+
.unwrap_or(false)
6196+
}
6197+
6198+
/// Check if noglob (set -f) is enabled
6199+
fn is_noglob(&self) -> bool {
6200+
self.variables
6201+
.get("SHOPT_f")
6202+
.map(|v| v == "1")
6203+
.unwrap_or(false)
6204+
}
6205+
6206+
/// Check if failglob shopt is enabled
6207+
fn is_failglob(&self) -> bool {
6208+
self.variables
6209+
.get("SHOPT_failglob")
6210+
.map(|v| v == "1")
6211+
.unwrap_or(false)
6212+
}
6213+
6214+
/// Check if globstar shopt is enabled
6215+
fn is_globstar(&self) -> bool {
6216+
self.variables
6217+
.get("SHOPT_globstar")
6218+
.map(|v| v == "1")
6219+
.unwrap_or(false)
6220+
}
6221+
6222+
/// Expand glob for a single item, applying noglob/failglob/nullglob.
6223+
/// Returns Err(pattern) if failglob triggers, Ok(items) otherwise.
6224+
async fn expand_glob_item(&self, item: &str) -> std::result::Result<Vec<String>, String> {
6225+
if !self.contains_glob_chars(item) || self.is_noglob() {
6226+
return Ok(vec![item.to_string()]);
6227+
}
6228+
let glob_matches = self.expand_glob(item).await.unwrap_or_default();
6229+
if glob_matches.is_empty() {
6230+
if self.is_failglob() {
6231+
return Err(item.to_string());
6232+
}
6233+
let nullglob = self
6234+
.variables
6235+
.get("SHOPT_nullglob")
6236+
.map(|v| v == "1")
6237+
.unwrap_or(false);
6238+
if nullglob {
6239+
Ok(vec![])
6240+
} else {
6241+
Ok(vec![item.to_string()])
6242+
}
6243+
} else {
6244+
Ok(glob_matches)
6245+
}
6246+
}
6247+
61906248
/// Expand a glob pattern against the filesystem
61916249
async fn expand_glob(&self, pattern: &str) -> Result<Vec<String>> {
6192-
// Check for ** (recursive glob)
6193-
if pattern.contains("**") {
6250+
// Check for ** (recursive glob) — only when globstar is enabled
6251+
if pattern.contains("**") && self.is_globstar() {
61946252
return self.expand_glob_recursive(pattern).await;
61956253
}
61966254

61976255
let mut matches = Vec::new();
6256+
let dotglob = self.is_dotglob();
6257+
let nocase = self.is_nocaseglob();
61986258

61996259
// Split pattern into directory and filename parts
62006260
let path = Path::new(pattern);
@@ -6233,9 +6293,17 @@ impl Interpreter {
62336293
Err(_) => return Ok(matches),
62346294
};
62356295

6296+
// Check if pattern explicitly starts with dot
6297+
let pattern_starts_with_dot = file_pattern.starts_with('.');
6298+
62366299
// Match each entry against the pattern
62376300
for entry in entries {
6238-
if self.glob_match(&entry.name, &file_pattern) {
6301+
// Skip dotfiles unless dotglob is set or pattern explicitly starts with '.'
6302+
if entry.name.starts_with('.') && !dotglob && !pattern_starts_with_dot {
6303+
continue;
6304+
}
6305+
6306+
if self.glob_match_impl(&entry.name, &file_pattern, nocase) {
62396307
// Construct the full path
62406308
let full_path = if path.is_absolute() {
62416309
dir.join(&entry.name).to_string_lossy().to_string()
@@ -6264,6 +6332,8 @@ impl Interpreter {
62646332
async fn expand_glob_recursive(&self, pattern: &str) -> Result<Vec<String>> {
62656333
let is_absolute = pattern.starts_with('/');
62666334
let components: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
6335+
let dotglob = self.is_dotglob();
6336+
let nocase = self.is_nocaseglob();
62676337

62686338
// Find the ** component
62696339
let star_star_idx = match components.iter().position(|&c| c == "**") {
@@ -6300,16 +6370,24 @@ impl Interpreter {
63006370
// ** alone matches all files recursively
63016371
if let Ok(entries) = self.fs.read_dir(dir).await {
63026372
for entry in entries {
6373+
if entry.name.starts_with('.') && !dotglob {
6374+
continue;
6375+
}
63036376
if !entry.metadata.file_type.is_dir() {
63046377
matches.push(dir.join(&entry.name).to_string_lossy().to_string());
63056378
}
63066379
}
63076380
}
63086381
} else if after_pattern.len() == 1 {
63096382
// Single pattern after **: match files in this directory
6383+
let pat = after_pattern[0];
6384+
let pattern_starts_with_dot = pat.starts_with('.');
63106385
if let Ok(entries) = self.fs.read_dir(dir).await {
63116386
for entry in entries {
6312-
if self.glob_match(&entry.name, after_pattern[0]) {
6387+
if entry.name.starts_with('.') && !dotglob && !pattern_starts_with_dot {
6388+
continue;
6389+
}
6390+
if self.glob_match_impl(&entry.name, pat, nocase) {
63136391
matches.push(dir.join(&entry.name).to_string_lossy().to_string());
63146392
}
63156393
}

0 commit comments

Comments
 (0)