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
42 changes: 18 additions & 24 deletions crates/bashkit/src/interpreter/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1836,25 +1836,31 @@ impl Interpreter {
return value.to_string();
}

// Use glob_match for patterns with bracket expressions or extglob
if Self::has_unescaped_char(pattern, '[') || self.contains_unescaped_extglob(pattern) {
// A lone `*` matches the empty string (shortest) or everything
// (longest), on either side: `${x#*}` and `${x%*}` keep `x` whole.
if pattern == "*" {
return if longest {
String::new()
} else {
value.to_string()
};
}

// The literal fast paths below handle one `*`. Anything else (`?`,
// brackets, extglob, several stars) goes through glob_match.
if Self::has_unescaped_char(pattern, '[')
|| Self::has_unescaped_char(pattern, '?')
|| self.contains_unescaped_extglob(pattern)
|| Self::find_unescaped_char(pattern, '*')
.is_some_and(|star| Self::find_unescaped_char(&pattern[star + 1..], '*').is_some())
{
return self.remove_pattern_glob(value, pattern, prefix, longest);
}

let literal_pattern = Self::unescape_pattern_literal(pattern);

if prefix {
// Remove from beginning
if pattern == "*" {
if longest {
return String::new();
} else if !value.is_empty() {
return value.chars().skip(1).collect();
} else {
return value.to_string();
}
}

// Check if pattern contains *
if let Some(star_pos) = Self::find_unescaped_char(pattern, '*') {
let prefix_part = &pattern[..star_pos];
Expand Down Expand Up @@ -1901,18 +1907,6 @@ impl Interpreter {
}
} else {
// Remove from end (suffix)
if pattern == "*" {
if longest {
return String::new();
} else if !value.is_empty() {
let mut s = value.to_string();
s.pop();
return s;
} else {
return value.to_string();
}
}

// Check if pattern contains *
if let Some(star_pos) = Self::find_unescaped_char(pattern, '*') {
let prefix_part = &pattern[..star_pos];
Expand Down
31 changes: 31 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/remove-pattern-glob.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ${x#pat} / ${x%pat} match the full glob grammar: a lone `*` matches the
# empty string for the shortest forms, `?` matches one character, and several
# `*` combine.

### remove_lone_star
x=abc; e=
echo "${x#*}|${x%*}|${x##*}|${x%%*}|[${e#*}]"
### expect
abc|abc|||[]
### end

### remove_question_mark
x=abc
echo "${x#?}|${x%?}|${x#*?}|${x%?*}|${x##?}|${x#??}"
### expect
bc|ab|bc|ab|bc|c
### end

### remove_multi_star
p=/a/b.c/d.tar.gz
echo "${p#*/*/}|${p##*.*.}|${p%/*/*}|${p#/?/}|${p%%.*}|${p##*/}"
### expect
b.c/d.tar.gz|gz|/a|b.c/d.tar.gz|/a/b|d.tar.gz
### end

### remove_star_from_variable
y='a*b'; q='*'
echo "${y#$q}|${y#"$q"}|${y%$q}"
### expect
a*b|a*b|a*b
### end
1 change: 1 addition & 0 deletions knowledge/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2026-10-07

* **Fix**: `${x#pat}`/`${x%pat}` follow the glob grammar. A lone `*` matches empty for the short forms (`${x#*}` and `${x%*}` keep `x`; they dropped a character), and patterns with `?` or several `*` (`${x%?}`, `${p#*/*/}`, `${p##*.*.}`) now match instead of being compared as literal text. Single-`*` patterns keep their linear fast path.
* **Fix**: Quoted or escaped glob characters are literal everywhere. `echo a\*`, `a"*"` and `a'?'` no longer glob; `case`/`[[ == ]]` patterns match `"$x"`, `'a*'`, `a\*` and `\[s\]` literally; `"*"zz*` with no match prints `*zz*` (escapes no longer leak); `"$x"zz*` no longer reads `$xzz`; `'a'$x` expands and `'a'*` globs; `a{1,2}"*"` still brace-expands. The lexer escapes quoted glob characters in mixed words (the existing `QuotedGlobWord` convention, now with quote markers); `expand_word` returns unescaped text for non-glob uses; patterns use `expand_pattern_word`. Known gap: data from an unquoted expansion inside such a word loses `\` before glob characters when nothing matches.
* **Fix**: A backslash outside quotes keeps `$` and `` ` `` literal. `echo \$HOME` printed the home directory; it now prints `$HOME`, as do `a\$x`, `\${x}` and `x=\$y` (the lexer emits the same NUL literal marker as quoted `"\$"`). Found while working gap telemetry.
* **Fix**: `$'\x1e'` and `$'\x1f'` bytes (record/unit separators) are kept as data. They collided with the lexer's quote-boundary markers and were silently dropped, which broke the common `SEP=$'\x1f'` composite-key idiom for associative arrays (`v["$section"$'\x1f'"$key"]`). Decoded ANSI-C content now NUL-escapes those bytes like `$`. Found by gap telemetry (`config_ini_merge` replay printed nothing).
Expand Down
Loading