Skip to content

fix: treat ? and + after a closing extglob as glob tokens - #191

Open
Jaybhade wants to merge 1 commit into
micromatch:masterfrom
Jaybhade:fix/extglob-trailing-metachars
Open

fix: treat ? and + after a closing extglob as glob tokens#191
Jaybhade wants to merge 1 commit into
micromatch:masterfrom
Jaybhade:fix/extglob-trailing-metachars

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 4, 2026

Copy link
Copy Markdown

A ? or + that follows a closing extglob is passed straight through to the generated regex, where it binds to the preceding group as a quantifier instead of being treated as a glob token.

picomatch.makeRe('@(a|b)?')  // ^(?:(a|b)?)$        the group becomes optional
picomatch.makeRe('*(a)?')    // ^(?:(?=.)(?:a)*?)$  the star becomes lazy
picomatch.makeRe('+(a)?')    // ^(?:(?=.)(?:a)+?)$
picomatch.makeRe('?(a)+')    // $^

The last one is the worst case. (?:a)?+ is not valid JavaScript, so compileRe catches the SyntaxError and falls back to /$^/. Nothing is thrown and nothing is logged; ?(a)+, *(a)+ and +(a)+ simply match nothing at all.

picomatch.isMatch('a+', '?(a)+')  // false, bash matches
picomatch.isMatch('b',  '@(a|b)?')  // true, bash does not match
picomatch.isMatch('ab', '@(a|b)?')  // false, bash matches

For reference:

$ shopt -s extglob
$ [[ a+ == ?(a)+ ]]   && echo match   # match
$ [[ b == @(a|b)? ]]  && echo match   # no output
$ [[ ab == @(a|b)? ]] && echo match   # match

Why * is already correct

The star handler in parse.js only uses regex semantics after a closing paren or bracket when the caller asks for it:

if (prev && (prev.type === 'bracket' || prev.type === 'paren') && opts.regex === true) {

which is what the regex option is documented to control:

Use regular expression rules for + (instead of matching literal +), and for stars that follow closing parentheses or brackets (as in )* and ]*).

So @(a|b)* is right today. ? and + never got that check, so they always take the regex branch. This PR applies the same rule to them: after a closing extglob, ? is a single character wildcard and + is a literal plus, unless options.regex is true.

Scope

Only extglobs change. (a|b)? and (a|b)+ are ordinary capture groups and keep their quantifiers, so this does not touch the ambiguity raised in #101, which is about plain parens and looked to be blocked on how many tests depend on the current behaviour. There is a test pinning that.

@(...) needed a little care. Unlike the other four, it is tokenized as an at token followed by plain parens, so its closing ) is indistinguishable from a capture group's. The patch records the paren depth of a group opened by @ and marks the matching ) as an extglob, which is what lets @(a|b)? and (a|b)? be told apart.

!(...) composed with a trailing token still diverges from bash for a separate reason. Its negative lookahead is only $-anchored when the extglob ends the pattern, so !(a)? rejects anything starting with a rather than anything that is exactly a. That is the same mechanism as #154 and #93 and I have left it alone.

Verification

npm test: 1985 passing, up from 1977. The four new bug-covering tests fail on main; the four guard tests (* unchanged, ?(/+( still opening a nested extglob, regex: true opt-out, plain groups unchanged) pass both before and after. npm run lint is clean.

I found this by differentially testing against bash 3.2 with shopt -s extglob, generating pattern/subject pairs from a small extglob grammar. Subjects are slash-free so bash's fnmatch semantics line up, and I excluded the two known divergences (a leading ! is picomatch negation, and [!...] is #187). Over 20,000 seeded pairs:

disagreements with bash distinct patterns
4.0.5 927 404
this PR 40 20

The 40 that remain are other classes, mostly an extglob that can match empty following a * (*?(a), *@(a|)) and the !(...) anchoring above. Happy to look at those separately.

I also ran micromatch's suite against a picomatch carrying the equivalent change, since it is the largest consumer: 1954 passing, unchanged.

A metacharacter following a closing extglob paren was emitted straight
into the regex, where it acted as a quantifier on the preceding group:

  @(a|b)?  ->  /^(?:(a|b)?)$/       group made optional
  *(a)?    ->  /^(?:(?=.)(?:a)*?)$/ star made lazy
  ?(a)+    ->  /$^/                 invalid regex, matches nothing

The star handler already applies the intended rule, using regex
semantics after a closing paren or bracket only when options.regex is
true. Apply the same rule to ? and +, so ? is a single character
wildcard and + is a literal plus, as in bash.

Groups opened by @ are tokenized as plain parens rather than through
extglobOpen/extglobClose, so track their paren depth to tell @(a|b)
apart from a bare (a|b) capture group. Quantifiers on plain groups are
unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant