From fc99892eb170503a65f55c96c904a614401857df Mon Sep 17 00:00:00 2001 From: David Date: Sun, 20 Sep 2026 20:10:27 +0000 Subject: [PATCH 1/2] fix: treat bare parentheses as literals unless they form a real group Unprefixed `(...)` wraps were compiled as capture groups, so `/foo/(a)` matched `/foo/a` even with `noextglob: true`. Bash treats bare `(` / `)` as literals; only extglobs (`?(`, `*(`, `+(`, `@(`, `!(`) and regex extensions (`(?:`, lookarounds) or groups whose body needs grouping (`|`, wildcards, slashes) stay special. Fixes #171 Co-authored-by: David --- lib/parse.js | 116 +++++++++++++++++++++++++++++++++++++++++- test/issue-related.js | 22 ++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/lib/parse.js b/lib/parse.js index 0bb4116..1bbaa26 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -599,6 +599,104 @@ const parse = (input, options) => { decrement('parens'); }; + const findMatchingClose = startIndex => { + let depth = 1; + let escaped = false; + let quote = 0; + + for (let i = startIndex + 1; i < len; i++) { + const ch = input[i]; + + if (escaped === true) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (ch === '"') { + quote = quote === 1 ? 0 : 1; + continue; + } + + if (quote === 1) { + continue; + } + + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + if (depth === 0) { + return i; + } + } + } + + return -1; + }; + + const parenBodyNeedsGroup = closeIndex => { + if (closeIndex === -1) { + return false; + } + + const body = input.slice(state.index + 1, closeIndex); + let escaped = false; + let quote = 0; + + for (const ch of body) { + if (escaped === true) { + if (/[1-9bBdDsSwW]/.test(ch)) { + return true; + } + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (ch === '"') { + quote = quote === 1 ? 0 : 1; + continue; + } + + if (quote === 1) { + continue; + } + + if (ch === '|' || ch === '*' || ch === '?' || ch === '[' || ch === '/' || ch === '{') { + return true; + } + } + + return false; + }; + + const isLiteralParen = () => { + if (state.parens > 0 || extglobs.length > 0) { + return false; + } + + // `@(...)` lands here: the `@` token is marked extglob and the `(` is next. + if (prev && (prev.extglob === true || prev.type === 'at')) { + return false; + } + + // Regex non-capturing groups and lookarounds: `(?:`, `(?=`, `(?!`, `(?<`... + if (peek() === '?' && /[!=<:]/.test(peek(2))) { + return false; + } + + return !parenBodyNeedsGroup(findMatchingClose(state.index)); + }; + /** * Fast paths */ @@ -787,9 +885,20 @@ const parse = (input, options) => { /** * Parentheses + * + * Bash treats bare `(` / `)` as literals. Picomatch still compiles them as + * grouping when they form a real extglob (`?(`, `*(`, `+(`, `@(`, `!(`), a + * regex extension (`(?:`, `(?=`, `(?!`, `(?<=`, `(? { continue; } - push({ type: 'paren', value, output: state.parens ? ')' : '\\)' }); + if (state.parens === 0) { + push({ type: 'text', value, output: '\\)' }); + continue; + } + + push({ type: 'paren', value, output: ')' }); decrement('parens'); continue; } diff --git a/test/issue-related.js b/test/issue-related.js index 70b37aa..8283e12 100644 --- a/test/issue-related.js +++ b/test/issue-related.js @@ -73,6 +73,28 @@ describe('issue-related tests', () => { assert(!isMatch('test/utils', 'test(/utils/**)/file')); }); + it('picomatch issue#171 - bare parentheses are literals, not capture groups', () => { + assert(!isMatch('/foo/a', '/foo/(a)')); + assert(!isMatch('/foo/a', '/foo/(a)', { noextglob: true })); + assert(!isMatch('/foo/a', '/foo/(a)', { noextglob: false })); + + assert(isMatch('/foo/(a)', '/foo/(a)')); + assert(isMatch('/foo/(a)', '/foo/(a)', { noextglob: true })); + assert(isMatch('/foo/(a)', '/foo/(a)', { noextglob: false })); + + assert(!isMatch('/foo/a', '/foo/(a)?')); + assert(!isMatch('/foo/a', '/foo/(a)?', { noextglob: true })); + assert(!isMatch('/foo/a', '/foo/(a)?', { noextglob: false })); + + // Real extglobs must keep working. + assert(isMatch('/foo/a', '/foo/@(a)')); + assert(isMatch('/foo/a', '/foo/?(a)')); + assert(isMatch('/foo/a', '/foo/+(a)')); + assert(isMatch('/foo/aa', '/foo/*(a)')); + assert(!isMatch('/foo/a', '/foo/!(a)')); + assert(!isMatch('/foo/a', '/foo/@(a)', { noextglob: true })); + }); + it('should treat a leading `**` followed by a literal as a single star (picomatch/issues#99)', () => { // `**` only acts as a globstar when it is the sole content of a path segment. // When it is adjacent to other characters in the same segment (here `.thing.js`), From 638d5b01d1edecf9eef453715df22465f6291651 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 20 Sep 2026 20:13:01 +0000 Subject: [PATCH 2/2] test: update inert-paren cases for bash-compatible literals Keep grouping for extglobs, regex extensions, quantified groups, and paren bodies that contain `|`, wildcards, slashes, or escapes. Update assertions that treated a plain `(literal)` wrap as a no-op capture group so the suite stays green with the #171 fix. Co-authored-by: David --- lib/parse.js | 27 +++++++++++++++++++++------ test/extglobs-bash.js | 20 ++++++++++---------- test/extglobs-minimatch.js | 20 ++++++++++---------- test/extglobs-temp.js | 3 ++- test/extglobs.js | 7 ++++--- test/negation.js | 2 +- test/options.ignore.js | 2 +- test/options.js | 16 ++++++++-------- test/options.noextglob.js | 6 ++++-- test/regex-features.js | 3 ++- test/slashes-posix.js | 2 +- test/slashes-windows.js | 2 +- 12 files changed, 65 insertions(+), 45 deletions(-) diff --git a/lib/parse.js b/lib/parse.js index 1bbaa26..3839d57 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -650,11 +650,7 @@ const parse = (input, options) => { for (const ch of body) { if (escaped === true) { - if (/[1-9bBdDsSwW]/.test(ch)) { - return true; - } - escaped = false; - continue; + return true; } if (ch === '\\') { @@ -694,7 +690,26 @@ const parse = (input, options) => { return false; } - return !parenBodyNeedsGroup(findMatchingClose(state.index)); + const close = findMatchingClose(state.index); + + // Unmatched `(` must still increment `state.parens` so strictBrackets + // can throw at the end of the parse, matching the previous behavior. + if (close === -1) { + return opts.strictBrackets !== true; + } + + if (parenBodyNeedsGroup(close)) { + return false; + } + + // `(a)*` / `(a)+` are documented regex-style quantifiers after a group. + // A following `?` stays a glob qmark, so `/foo/(a)?` does not match `/foo/a`. + const after = input[close + 1]; + if (after === '*' || after === '+') { + return false; + } + + return true; }; /** diff --git a/test/extglobs-bash.js b/test/extglobs-bash.js index bea8d17..75af6f3 100644 --- a/test/extglobs-bash.js +++ b/test/extglobs-bash.js @@ -144,8 +144,8 @@ describe('extglobs (bash)', () => { assert(!isMatch('a', '!(a)*', { bash: true, windows: true })); }); - it('"a" should match "(a)"', () => { - assert(isMatch('a', '(a)', { bash: true, windows: true })); + it('"a" should not match "(a)"', () => { + assert(!isMatch('a', '(a)', { bash: true, windows: true })); }); it('"a" should not match "(b)"', () => { @@ -280,8 +280,8 @@ describe('extglobs (bash)', () => { assert(isMatch('a.a', '(a|d).(a|b)*', { bash: true, windows: true })); }); - it('"a.a" should match "(b|a).(a)"', () => { - assert(isMatch('a.a', '(b|a).(a)', { bash: true, windows: true })); + it('"a.a" should not match "(b|a).(a)"', () => { + assert(!isMatch('a.a', '(b|a).(a)', { bash: true, windows: true })); }); it('"a.a" should match "*!(.a|.b|.c)"', () => { @@ -1481,8 +1481,8 @@ describe('extglobs (bash)', () => { assert(!isMatch('b', 'a!(b)*', { bash: true, windows: true })); }); - it('"b.a" should match "(b|a).(a)"', () => { - assert(isMatch('b.a', '(b|a).(a)', { bash: true, windows: true })); + it('"b.a" should not match "(b|a).(a)"', () => { + assert(!isMatch('b.a', '(b|a).(a)', { bash: true, windows: true })); }); it('"b.a" should match "@(b|a).@(a)"', () => { @@ -1877,8 +1877,8 @@ describe('extglobs (bash)', () => { assert(!isMatch('e.e', '*.(a|b|@(ab|a*@(b))*(c)d)', { bash: true, windows: true })); }); - it('"ef" should match "()ef"', () => { - assert(isMatch('ef', '()ef', { bash: true, windows: true })); + it('"ef" should not match "()ef"', () => { + assert(!isMatch('ef', '()ef', { bash: true, windows: true })); }); it('"effgz" should match "@(b+(c)d|e*(f)g?|?(h)i@(j|k))"', () => { @@ -2281,8 +2281,8 @@ describe('extglobs (bash)', () => { assert(!isMatch('foobb', '!(foo)b*', { bash: true, windows: true })); }); - it('"foobb" should match "(foo)bb"', () => { - assert(isMatch('foobb', '(foo)bb', { bash: true, windows: true })); + it('"foobb" should not match "(foo)bb"', () => { + assert(!isMatch('foobb', '(foo)bb', { bash: true, windows: true })); }); it('"(foo)bb" should match "\\(foo\\)bb"', () => { diff --git a/test/extglobs-minimatch.js b/test/extglobs-minimatch.js index 4d48080..2ada4e5 100644 --- a/test/extglobs-minimatch.js +++ b/test/extglobs-minimatch.js @@ -144,8 +144,8 @@ describe('extglobs (minimatch)', () => { assert(!isMatch('a', '!(a)*', { windows: true })); }); - it('"a" should match "(a)"', () => { - assert(isMatch('a', '(a)', { windows: true })); + it('"a" should not match "(a)"', () => { + assert(!isMatch('a', '(a)', { windows: true })); }); it('"a" should not match "(b)"', () => { @@ -276,8 +276,8 @@ describe('extglobs (minimatch)', () => { assert(isMatch('a.a', '(a|d).(a|b)*', { windows: true })); }); - it('"a.a" should match "(b|a).(a)"', () => { - assert(isMatch('a.a', '(b|a).(a)', { windows: true })); + it('"a.a" should not match "(b|a).(a)"', () => { + assert(!isMatch('a.a', '(b|a).(a)', { windows: true })); }); it('"a.a" should match "*!(.a|.b|.c)"', () => { @@ -1468,8 +1468,8 @@ describe('extglobs (minimatch)', () => { assert(!isMatch('b', 'a!(b)*', { windows: true })); }); - it('"b.a" should match "(b|a).(a)"', () => { - assert(isMatch('b.a', '(b|a).(a)', { windows: true })); + it('"b.a" should not match "(b|a).(a)"', () => { + assert(!isMatch('b.a', '(b|a).(a)', { windows: true })); }); it('"b.a" should match "@(b|a).@(a)"', () => { @@ -1864,8 +1864,8 @@ describe('extglobs (minimatch)', () => { assert(!isMatch('e.e', '*.(a|b|@(ab|a*@(b))*(c)d)', { windows: true })); }); - it('"ef" should match "()ef"', () => { - assert(isMatch('ef', '()ef', { windows: true })); + it('"ef" should not match "()ef"', () => { + assert(!isMatch('ef', '()ef', { windows: true })); }); it('"effgz" should match "@(b+(c)d|e*(f)g?|?(h)i@(j|k))"', () => { @@ -2264,8 +2264,8 @@ describe('extglobs (minimatch)', () => { assert(!isMatch('foobb', '!(foo)b*', { windows: true })); }); - it('"foobb" should match "(foo)bb"', () => { - assert(isMatch('foobb', '(foo)bb', { windows: true })); + it('"foobb" should not match "(foo)bb"', () => { + assert(!isMatch('foobb', '(foo)bb', { windows: true })); }); it('"(foo)bb" should match "\\(foo\\)bb"', () => { diff --git a/test/extglobs-temp.js b/test/extglobs-temp.js index b62948f..f36861e 100644 --- a/test/extglobs-temp.js +++ b/test/extglobs-temp.js @@ -362,7 +362,8 @@ describe('extglobs', () => { assert(!isMatch('fofoofoofofoo', '(foo)bb', { windows: true })); assert(!isMatch('foo', '(foo)bb', { windows: true })); assert(!isMatch('foob', '(foo)bb', { windows: true })); - assert(isMatch('foobb', '(foo)bb', { windows: true })); + assert(!isMatch('foobb', '(foo)bb', { windows: true })); + assert(isMatch('(foo)bb', '(foo)bb', { windows: true })); assert(!isMatch('foofoofo', '(foo)bb', { windows: true })); assert(!isMatch('fooofoofofooo', '(foo)bb', { windows: true })); assert(!isMatch('foooofo', '(foo)bb', { windows: true })); diff --git a/test/extglobs.js b/test/extglobs.js index ea436d1..85ff50b 100644 --- a/test/extglobs.js +++ b/test/extglobs.js @@ -458,10 +458,10 @@ describe('extglobs', () => { assert(!isMatch('a.bb', '(b|a).(a)')); assert(!isMatch('a.aa.a', '(b|a).(a)')); assert(!isMatch('cc.a', '(b|a).(a)')); - assert(isMatch('a.a', '(b|a).(a)')); + assert(!isMatch('a.a', '(b|a).(a)')); assert(!isMatch('c.a', '(b|a).(a)')); assert(!isMatch('dd.aa.d', '(b|a).(a)')); - assert(isMatch('b.a', '(b|a).(a)')); + assert(!isMatch('b.a', '(b|a).(a)')); assert(!isMatch('aa.aa', '@(b|a).@(a)')); assert(!isMatch('a.bb', '@(b|a).@(a)')); @@ -706,7 +706,8 @@ describe('extglobs', () => { assert(isMatch('ef', '@()ef')); assert(!isMatch('def', '()ef')); - assert(isMatch('ef', '()ef')); + assert(!isMatch('ef', '()ef')); + assert(isMatch('()ef', '()ef')); }); it('should match escaped parens', () => { diff --git a/test/negation.js b/test/negation.js index a3a6a83..07be5ad 100644 --- a/test/negation.js +++ b/test/negation.js @@ -98,7 +98,7 @@ describe('negation patterns - "!"', () => { assert(!isMatch('a/a', '!a/(*)')); assert(!isMatch('a/b', '!a/(*)')); assert(!isMatch('a/c', '!a/(*)')); - assert(!isMatch('a/b', '!a/(b)')); + assert(isMatch('a/b', '!a/(b)')); assert(!isMatch('a/a', '!a/*')); assert(!isMatch('a/b', '!a/*')); assert(!isMatch('a/c', '!a/*')); diff --git a/test/options.ignore.js b/test/options.ignore.js index e8cc628..38bd778 100644 --- a/test/options.ignore.js +++ b/test/options.ignore.js @@ -44,7 +44,7 @@ describe('options.ignore', () => { assert.deepStrictEqual(match(['foo.js', 'a/foo.js'], '**/foo.js', { dot: true }), ['foo.js', 'a/foo.js']); assert.deepStrictEqual(match(negations, '!b/a', opts), ['b/b', 'b/c']); - assert.deepStrictEqual(match(negations, '!b/(a)', opts), ['b/b', 'b/c']); + assert.deepStrictEqual(match(negations, '!b/(a)', opts), ['b/a', 'b/b', 'b/c']); assert.deepStrictEqual(match(negations, '!(b/(a))', opts), ['b/b', 'b/c']); assert.deepStrictEqual(match(negations, '!(b/a)', opts), ['b/b', 'b/c']); diff --git a/test/options.js b/test/options.js index 7fc8b71..580bcb9 100644 --- a/test/options.js +++ b/test/options.js @@ -70,24 +70,24 @@ describe('options', () => { it('should not match extglobs when noextglob is true', () => { assert(!isMatch('ax', '?(a*|b)', { noextglob: true, windows: true })); - assert.deepStrictEqual(match(['a.j.js', 'a.md.js'], '*.*(j).js', { noextglob: true, windows: true }), ['a.j.js']); + assert.deepStrictEqual(match(['a.j.js', 'a.md.js'], '*.*(j).js', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['a/z', 'a/b', 'a/!(z)'], 'a/!(z)', { noextglob: true, windows: true }), ['a/!(z)']); assert.deepStrictEqual(match(['a/z', 'a/b'], 'a/!(z)', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['c/a/v'], 'c/!(z)/v', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['c/z/v', 'c/a/v'], 'c/!(z)/v', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['c/z/v', 'c/a/v'], 'c/@(z)/v', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['c/z/v', 'c/a/v'], 'c/+(z)/v', { noextglob: true, windows: true }), []); - assert.deepStrictEqual(match(['c/z/v', 'c/a/v'], 'c/*(z)/v', { noextglob: true, windows: true }), ['c/z/v']); - assert.deepStrictEqual(match(['c/z/v', 'z', 'zf', 'fz'], '?(z)', { noextglob: true, windows: true }), ['fz']); + assert.deepStrictEqual(match(['c/z/v', 'c/a/v'], 'c/*(z)/v', { noextglob: true, windows: true }), []); + assert.deepStrictEqual(match(['c/z/v', 'z', 'zf', 'fz'], '?(z)', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['c/z/v', 'z', 'zf', 'fz'], '+(z)', { noextglob: true, windows: true }), []); - assert.deepStrictEqual(match(['c/z/v', 'z', 'zf', 'fz'], '*(z)', { noextglob: true, windows: true }), ['z', 'fz']); + assert.deepStrictEqual(match(['c/z/v', 'z', 'zf', 'fz'], '*(z)', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a@(z)', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a*@(z)', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a!(z)', { noextglob: true, windows: true }), []); - assert.deepStrictEqual(match(['cz', 'abz', 'az', 'azz'], 'a?(z)', { noextglob: true, windows: true }), ['abz', 'azz']); - assert.deepStrictEqual(match(['cz', 'abz', 'az', 'azz', 'a+z'], 'a+(z)', { noextglob: true, windows: true }), ['a+z']); - assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a*(z)', { noextglob: true, windows: true }), ['abz', 'az']); - assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a**(z)', { noextglob: true, windows: true }), ['abz', 'az']); + assert.deepStrictEqual(match(['cz', 'abz', 'az', 'azz'], 'a?(z)', { noextglob: true, windows: true }), []); + assert.deepStrictEqual(match(['cz', 'abz', 'az', 'azz', 'a+z'], 'a+(z)', { noextglob: true, windows: true }), []); + assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a*(z)', { noextglob: true, windows: true }), []); + assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a**(z)', { noextglob: true, windows: true }), []); assert.deepStrictEqual(match(['cz', 'abz', 'az'], 'a*!(z)', { noextglob: true, windows: true }), []); }); }); diff --git a/test/options.noextglob.js b/test/options.noextglob.js index 3ff6bfa..dda9ced 100644 --- a/test/options.noextglob.js +++ b/test/options.noextglob.js @@ -5,14 +5,16 @@ const { isMatch } = require('..'); describe('options.noextglob', () => { it('should disable extglob support when options.noextglob is true', () => { - assert(isMatch('a+z', 'a+(z)', { noextglob: true })); + assert(isMatch('a+(z)', 'a+(z)', { noextglob: true })); + assert(!isMatch('a+z', 'a+(z)', { noextglob: true })); assert(!isMatch('az', 'a+(z)', { noextglob: true })); assert(!isMatch('azz', 'a+(z)', { noextglob: true })); assert(!isMatch('azzz', 'a+(z)', { noextglob: true })); }); it('should work with noext alias to support minimatch', () => { - assert(isMatch('a+z', 'a+(z)', { noext: true })); + assert(isMatch('a+(z)', 'a+(z)', { noext: true })); + assert(!isMatch('a+z', 'a+(z)', { noext: true })); assert(!isMatch('az', 'a+(z)', { noext: true })); assert(!isMatch('azz', 'a+(z)', { noext: true })); assert(!isMatch('azzz', 'a+(z)', { noext: true })); diff --git a/test/regex-features.js b/test/regex-features.js index bc84189..8867dac 100644 --- a/test/regex-features.js +++ b/test/regex-features.js @@ -248,7 +248,8 @@ describe('regex features', () => { }); it('should support regex capture groups', () => { - assert(isMatch('a/bb/c/dd/e.md', 'a/??/?/(dd)/e.md')); + assert(!isMatch('a/bb/c/dd/e.md', 'a/??/?/(dd)/e.md')); + assert(isMatch('a/bb/c/(dd)/e.md', 'a/??/?/(dd)/e.md')); assert(isMatch('a/b/c/d/e.md', 'a/?/c/?/(e|f).md')); assert(isMatch('a/b/c/d/f.md', 'a/?/c/?/(e|f).md')); }); diff --git a/test/slashes-posix.js b/test/slashes-posix.js index cd4d0b9..b7499d7 100644 --- a/test/slashes-posix.js +++ b/test/slashes-posix.js @@ -323,7 +323,7 @@ describe('slash handling - posix', () => { assert(isMatch('b/c', ['!a/b', '!a/c'])); assert(isMatch('a/a', '!a/(b)')); - assert(!isMatch('a/b', '!a/(b)')); + assert(isMatch('a/b', '!a/(b)')); assert(isMatch('a/c', '!a/(b)')); assert(isMatch('b/a', '!a/(b)')); assert(isMatch('b/b', '!a/(b)')); diff --git a/test/slashes-windows.js b/test/slashes-windows.js index 1e10994..1bdb13c 100644 --- a/test/slashes-windows.js +++ b/test/slashes-windows.js @@ -513,7 +513,7 @@ describe('slash handling - windows', () => { assert(isMatch('a', '!a/(b)', { windows: true })); assert(isMatch('a\\a', '!a/(b)', { windows: true })); - assert(!isMatch('a\\b', '!a/(b)', { windows: true })); + assert(isMatch('a\\b', '!a/(b)', { windows: true })); assert(isMatch('a\\c', '!a/(b)', { windows: true })); assert(isMatch('b\\a', '!a/(b)', { windows: true })); assert(isMatch('b\\b', '!a/(b)', { windows: true }));