From 7f66d5cf821be36d574f5f1cdf0fd679a2138b35 Mon Sep 17 00:00:00 2001 From: djunehor Date: Mon, 10 Nov 2025 20:30:00 +0100 Subject: [PATCH] feat: add literalBrackets option to handle square brackets in file paths --- README.md | 20 +++++++---- src/glob.ts | 18 ++++++++++ test/square-brackets.ts | 73 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/square-brackets.ts diff --git a/README.md b/README.md index 642254fd..fe7116eb 100644 --- a/README.md +++ b/README.md @@ -435,11 +435,11 @@ share the previously loaded cache. is used as the starting point for absolute patterns that start with `/`, (but not drive letters or UNC paths on Windows). - To start absolute and non-absolute patterns in the same path, - you can use `{root:''}`. However, be aware that on Windows - systems, a pattern like `x:/*` or `//host/share/*` will - _always_ start in the `x:/` or `//host/share` directory, - regardless of the `root` setting. + To start absolute and non-absolute patterns in the same path, + you can use `{root:''}`. However, be aware that on Windows + systems, a pattern like `x:/*` or `//host/share/*` will + _always_ start in the `x:/` or `//host/share` directory, + regardless of the `root` setting. > [!NOTE] This _doesn't_ necessarily limit the walk to the > `root` directory, and doesn't affect the cwd starting point @@ -477,6 +477,15 @@ share the previously loaded cache. Only has effect on the {@link hasMagic} function, no effect on glob pattern matching itself. +- `literalBrackets` Treat square brackets `[` and `]` literally instead + of as character classes. When set to true, patterns containing literal + square brackets in filenames will be automatically escaped. + + For example, with `literalBrackets: true`, the pattern + `'src/app/api/[id]/route.js'` will match the literal folder named `[id]` + instead of treating `[id]` as a character class matching any single + character 'i' or 'd'. + - `dotRelative` Prepend all relative path strings with `./` (or `.\` on Windows). @@ -664,7 +673,6 @@ share the previously loaded cache. > already be added before its ancestor, if multiple or braced > patterns are used. - ## Glob Primer Much more information about glob pattern expansion can be found diff --git a/src/glob.ts b/src/glob.ts index 6beadfe2..12c00705 100644 --- a/src/glob.ts +++ b/src/glob.ts @@ -127,6 +127,18 @@ export interface GlobOptions { */ magicalBraces?: boolean + /** + * Treat square brackets `[` and `]` literally instead of as character + * classes. When set to true, patterns containing literal square brackets + * in filenames will be automatically escaped. + * + * For example, with `literalBrackets: true`, the pattern + * `'src/app/api/[id]/route.js'` will match the literal folder named `[id]` + * instead of treating `[id]` as a character class matching any single + * character 'i' or 'd'. + */ + literalBrackets?: boolean + /** * Add a `/` character to directory matches. Note that this requires * additional stat calls in some cases. @@ -382,6 +394,7 @@ export class Glob implements GlobOptions { follow: boolean ignore?: string | string[] | IgnoreLike magicalBraces: boolean + literalBrackets: boolean mark?: boolean matchBase: boolean maxDepth: number @@ -441,6 +454,7 @@ export class Glob implements GlobOptions { this.cwd = opts.cwd || '' this.root = opts.root this.magicalBraces = !!opts.magicalBraces + this.literalBrackets = !!opts.literalBrackets this.nobrace = !!opts.nobrace this.noext = !!opts.noext this.realpath = !!opts.realpath @@ -471,6 +485,10 @@ export class Glob implements GlobOptions { pattern = pattern.map(p => p.replace(/\\/g, '/')) } + if (this.literalBrackets) { + pattern = pattern.map(p => p.replace(/\[/g, '\\[').replace(/\]/g, '\\]')) + } + if (this.matchBase) { if (opts.noglobstar) { throw new TypeError('base matching requires globstar') diff --git a/test/square-brackets.ts b/test/square-brackets.ts new file mode 100644 index 00000000..e7064af9 --- /dev/null +++ b/test/square-brackets.ts @@ -0,0 +1,73 @@ +import t from 'tap' +import { glob } from '../dist/esm/index.js' + +t.test('square brackets in folder names', async t => { + // Set up test files in directories with square brackets + const cwd = t.testdir({ + 'app': { + 'api': { + '[id]': { + 'route.spec.js': 'export const test = true;' + }, + '[slug]': { + 'page.spec.js': 'export const test = true;' + }, + 'normal': { + 'file.spec.js': 'export const test = true;' + } + } + } + }) + + t.test('escaped brackets should match literal brackets in folders', async t => { + const results = await glob('app/api/\\[id\\]/*.spec.js', { cwd }) + t.equal(results.length, 1) + t.match(results[0], /\[id\]\/route\.spec\.js$/) + }) + + t.test('unescaped brackets should not match literal bracket folders', async t => { + const results = await glob('app/api/[id]/*.spec.js', { cwd }) + t.equal(results.length, 0) + }) + + t.test('wildcard should match all directories including bracketed ones', async t => { + const results = await glob('app/api/*/*.spec.js', { cwd }) + t.equal(results.length, 3) // [id], [slug], and normal + }) + + t.test('globstar should find all spec files', async t => { + const results = await glob('**/*.spec.js', { cwd }) + t.equal(results.length, 3) + }) + + t.test('literalBrackets option should auto-escape brackets', async t => { + const results = await glob('app/api/[id]/*.spec.js', { cwd, literalBrackets: true }) + t.equal(results.length, 1) + t.match(results[0], /\[id\]\/route\.spec\.js$/) + }) + + t.test('literalBrackets should work with multiple patterns', async t => { + const patterns = ['app/api/[id]/*.spec.js', 'app/api/[slug]/*.spec.js'] + const results = await glob(patterns, { cwd, literalBrackets: true }) + t.equal(results.length, 2) + t.match(results[0], /\[id\]\/route\.spec\.js$/) + t.match(results[1], /\[slug\]\/page\.spec\.js$/) + }) + + t.test('literalBrackets should not affect normal brackets used as character classes', async t => { + // Create files that would match character classes + const testCwd = t.testdir({ + 'i': { 'test.js': 'content' }, + 'd': { 'test.js': 'content' }, + 'normal': { 'test.js': 'content' } + }) + + // Without literalBrackets, [id] should match directories named 'i' or 'd' + const results = await glob('[id]/test.js', { cwd: testCwd }) + t.equal(results.length, 2) // Should match 'i' and 'd' directories + + // With literalBrackets, [id] should be treated literally and match nothing + const literalResults = await glob('[id]/test.js', { cwd: testCwd, literalBrackets: true }) + t.equal(literalResults.length, 0) // Should not match any directory + }) +}) \ No newline at end of file