diff --git a/crates/bashkit/src/interpreter/expansion.rs b/crates/bashkit/src/interpreter/expansion.rs index 300140bec..6eb8dc095 100644 --- a/crates/bashkit/src/interpreter/expansion.rs +++ b/crates/bashkit/src/interpreter/expansion.rs @@ -1836,8 +1836,24 @@ 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); } @@ -1845,16 +1861,6 @@ impl Interpreter { 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]; @@ -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]; diff --git a/crates/bashkit/tests/spec_cases/bash/remove-pattern-glob.test.sh b/crates/bashkit/tests/spec_cases/bash/remove-pattern-glob.test.sh new file mode 100644 index 000000000..d99f16ea7 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/remove-pattern-glob.test.sh @@ -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 diff --git a/knowledge/log.md b/knowledge/log.md index 24e45428f..ef7dd0388 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -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).