From ca828dfee1c98f4d558f9bf6e4404a5ba3495c89 Mon Sep 17 00:00:00 2001 From: Fadhlan Date: Fri, 4 Sep 2026 08:27:08 +0700 Subject: [PATCH] fix(partial-matcher): let `**` match zero path segments `getPartialMatcher` decides whether a directory is worth crawling. It walked the pattern and the input in lockstep, so `**` could only ever stand for one or more segments: once its matcher rejected an input part, the whole pattern was abandoned. That is wrong whenever the segment after `**` is the one that matches. With `dot: false` picomatch's `**` does not match `.a`, so `**/.a/*.txt` never descended into `.a` and the glob returned nothing, while fast-glob returns the file. Track the pattern and input positions separately so a `**` that does not consume the current segment falls through to the next pattern part instead of ending the walk. Fixes #187 --- src/utils.ts | 38 ++++++++++++++++++++---------- test/index.test.ts | 12 ++++++++++ test/utils/partial-matcher.test.ts | 27 +++++++++++++++++++++ 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index a5d44ba..bf50018 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,7 +20,8 @@ export function getPartialMatcher(patterns: string[], options: PartialMatcherOpt const patternsCount = patterns.length; const patternsParts: string[][] = Array(patternsCount); const matchers: Matcher[][] = Array(patternsCount); - let i: number, j: number; + const globstar = !options.noglobstar; + let i: number, j: number, k: number; for (i = 0; i < patternsCount; i++) { const parts = splitPattern(patterns[i]); @@ -45,14 +46,18 @@ export function getPartialMatcher(patterns: string[], options: PartialMatcherOpt return true; } + const inputPartsCount = inputParts.length; + for (i = 0; i < patternsCount; i++) { const patternParts = patternsParts[i]; const matcher = matchers[i]; - const inputPatternCount = inputParts.length; - const minParts = Math.min(inputPatternCount, patternParts.length); + const patternPartsCount = patternParts.length; + // `j` walks the pattern, `k` walks the input. They only go out of sync when a + // `**` matches zero path segments, which is the one case where the two differ. j = 0; - while (j < minParts) { + k = 0; + while (j < patternPartsCount && k < inputPartsCount) { const part = patternParts[j]; // handling slashes in parts is very hard, not even fast-glob does it @@ -62,20 +67,29 @@ export function getPartialMatcher(patterns: string[], options: PartialMatcherOpt return true; } - if (!matcher[j](inputParts[j])) { - break; + if (globstar && part === '**') { + // unlike popular belief, `**` doesn't return true in *all* cases + // some examples are when matching it to `.a` with dot: false or `..` + // so it needs to match to consume one or more segments and return early + if (matcher[j](inputParts[k])) { + return true; + } + + // `**` also matches zero segments, so the rest of the pattern still has to + // be tried against the same input part. Without this, a pattern like + // `**/.a/*.ts` would never crawl into `.a` when `dot` is false. + j++; + continue; } - // unlike popular belief, `**` doesn't return true in *all* cases - // some examples are when matching it to `.a` with dot: false or `..` - // so it needs to match to return early - if (!options.noglobstar && part === '**') { - return true; + if (!matcher[j](inputParts[k])) { + break; } j++; + k++; } - if (j === inputPatternCount) { + if (k === inputPartsCount) { return true; } } diff --git a/test/index.test.ts b/test/index.test.ts index 1cc9d39..9034046 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -207,6 +207,18 @@ test('leading ../', async () => { assert.deepEqual(files.sort(), ['../b/a.txt', '../b/b.txt']); }); +// `**` also matches zero path segments, so `**/.a/**` has to reach `.a` at the +// root even though `dot` is false and `**` on its own doesn't match `.a`. +test('leading ** before a dot directory', async () => { + const files = await glob('**/.a/a/*.txt', { cwd, expandDirectories: false }); + assert.deepEqual(files.sort(), ['.a/a/a.txt']); +}); + +test('** before a dot directory with a trailing **', async () => { + const files = await glob('**/.deep/a/**/*.txt', { cwd, expandDirectories: false }); + assert.deepEqual(files.sort(), ['.deep/a/a/a.txt']); +}); + test('leading ../ with only dirs', async () => { const files = await glob('../.a/*', { cwd: path.join(cwd, 'a'), onlyDirectories: true, expandDirectories: false }); assert.deepEqual(files.sort(), ['../.a/a/']); diff --git a/test/utils/partial-matcher.test.ts b/test/utils/partial-matcher.test.ts index 342b340..daa494b 100644 --- a/test/utils/partial-matcher.test.ts +++ b/test/utils/partial-matcher.test.ts @@ -140,4 +140,31 @@ describe('getPartialMatcher', () => { assert.ok(matcher('src/utils')); assert.ok(!matcher('src/gaming')); }); + + test('leading ** matches zero segments', () => { + // `**` matching nothing is what makes `**/.a` match `.a` at the root. + // With `dot: false` the `**` matcher itself rejects `.a`, so the rest of + // the pattern has to be tried against the same input part. + const matcher = getPartialMatcher(['**/.a/*.txt']); + assert.ok(matcher('.a')); + assert.ok(matcher('.a/b.txt')); + assert.ok(matcher('src/.a')); + assert.ok(!matcher('.b')); + }); + + test('** matches zero segments in the middle of a pattern', () => { + const matcher = getPartialMatcher(['test/**/.a/*.txt']); + assert.ok(matcher('test')); + assert.ok(matcher('test/.a')); + assert.ok(matcher('test/utils/.a')); + assert.ok(!matcher('test/.b')); + }); + + test('** matching zero segments does not create false positives', () => { + const matcher = getPartialMatcher(['a/**/.b/c']); + assert.ok(matcher('a')); + assert.ok(matcher('a/.b')); + assert.ok(!matcher('.b')); + assert.ok(!matcher('a/.c')); + }); });