From 1695c66ca31e86c6b6c414293e1d10482ca0bd3a Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 11 Sep 2026 14:55:43 +0100 Subject: [PATCH] fix: apply prepend as a string on generated regexes (#110) The option was documented as a boolean but stored as a string on the beginning-of-string token, and that string was only copied into the regex after the parser backtracked. Initialize parser output from it, skip fast paths that would drop it, and document the option as a string. When the tokenizer recursively parses a suffix after a negated extglob (for example `!(*.d).ts`), do not pass prepend back into parse() or the prefix is applied twice and files that should be excluded start matching. Closes #110 --- .verb.md | 2 +- README.md | 2 +- lib/parse.js | 8 +++++--- lib/picomatch.js | 3 ++- test/options.prepend.js | 40 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 test/options.prepend.js diff --git a/.verb.md b/.verb.md index b49d5cfc..12d8c235 100644 --- a/.verb.md +++ b/.verb.md @@ -118,7 +118,7 @@ The following options may be used with the main `picomatch()` function or any of | [onMatch](#optionsonMatch) | `function` | `undefined` | Function to be called on matched items. | | [onResult](#optionsonResult) | `function` | `undefined` | Function to be called on all items, regardless of whether or not they are matched or ignored. | | `posix` | `boolean` | `false` | Support POSIX character classes ("posix brackets"). | -| `prepend` | `boolean` | `undefined` | String to prepend to the generated regex used for matching. | +| `prepend` | `string` | `undefined` | String to prepend to the generated regex used for matching. | | `regex` | `boolean` | `false` | Use regular expression rules for `+` (instead of matching literal `+`), and for stars that follow closing parentheses or brackets (as in `)*` and `]*`). | | `strictBrackets` | `boolean` | `undefined` | Throw an error if brackets, braces, or parens are imbalanced. | | `strictSlashes` | `boolean` | `undefined` | When true, picomatch won't match trailing slashes with single stars. | diff --git a/README.md b/README.md index 158a8ba6..31b8b56f 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ The following options may be used with the main `picomatch()` function or any of | [onMatch](#optionsonMatch) | `function` | `undefined` | Function to be called on matched items. | | [onResult](#optionsonResult) | `function` | `undefined` | Function to be called on all items, regardless of whether or not they are matched or ignored. | | `posix` | `boolean` | `false` | Support POSIX character classes ("posix brackets"). | -| `prepend` | `boolean` | `undefined` | String to prepend to the generated regex used for matching. | +| `prepend` | `string` | `undefined` | String to prepend to the generated regex used for matching. | | `regex` | `boolean` | `false` | Use regular expression rules for `+` (instead of matching literal `+`), and for stars that follow closing parentheses or brackets (as in `)*` and `]*`). | | `strictBrackets` | `boolean` | `undefined` | Throw an error if brackets, braces, or parens are imbalanced. | | `strictSlashes` | `boolean` | `undefined` | When true, picomatch won't match trailing slashes with single stars. | diff --git a/lib/parse.js b/lib/parse.js index 0bb4116d..87c84a73 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -415,7 +415,7 @@ const parse = (input, options) => { start: 0, dot: opts.dot === true, consumed: '', - output: '', + output: bos.output, prefix: '', backtrack: false, negated: false, @@ -585,7 +585,8 @@ const parse = (input, options) => { // Suitable patterns: `/!(*.d).ts`, `/!(*.d).{ts,tsx}`, `**/!(*-dbg).@(js)`. // // Disabling the `fastpaths` option due to a problem with parsing strings as `.ts` in the pattern like `**/!(*.d).ts`. - const expression = parse(rest, { ...options, fastpaths: false }).output; + // Clear prepend so the suffix is not prefixed again; the outer bos token already holds it. + const expression = parse(rest, { ...options, fastpaths: false, prepend: '' }).output; output = token.close = `)${expression})${extglobStar})`; } @@ -603,7 +604,8 @@ const parse = (input, options) => { * Fast paths */ - if (opts.fastpaths !== false && !/(^[*!]|[/()[\]{}"])/.test(input)) { + // Fast paths skip tokenization, which is where options.prepend is applied. + if (opts.fastpaths !== false && !opts.prepend && !/(^[*!]|[/()[\]{}"])/.test(input)) { let backslashes = false; let output = input.replace(REGEX_SPECIAL_CHARS_BACKREF, (m, esc, chars, first, rest, index) => { diff --git a/lib/picomatch.js b/lib/picomatch.js index 9c2bfd5c..091a12ab 100644 --- a/lib/picomatch.js +++ b/lib/picomatch.js @@ -309,7 +309,8 @@ picomatch.makeRe = (input, options = {}, returnOutput = false, returnState = fal let parsed = { negated: false, fastpaths: true }; - if (options.fastpaths !== false && (input[0] === '.' || input[0] === '*')) { + // Fast paths skip tokenization, which is where options.prepend is applied. + if (options.fastpaths !== false && !options.prepend && (input[0] === '.' || input[0] === '*')) { parsed.output = parse.fastpaths(input, options); } diff --git a/test/options.prepend.js b/test/options.prepend.js new file mode 100644 index 00000000..18a631ed --- /dev/null +++ b/test/options.prepend.js @@ -0,0 +1,40 @@ +'use strict'; + +const assert = require('assert'); +const { isMatch, makeRe, parse } = require('..'); + +describe('options.prepend', () => { + it('should prepend a string to the parsed regex source (issue #110)', () => { + const state = parse('abc', { prepend: 'foo' }); + assert.strictEqual(state.tokens[0].output, 'foo'); + assert.strictEqual(state.output, 'fooabc'); + }); + + it('should prepend a string to the generated matcher regex', () => { + assert.strictEqual(makeRe('abc', { prepend: 'foo' }).source, '^(?:fooabc)$'); + assert(isMatch('fooabc', 'ab*', { prepend: 'foo' })); + assert(!isMatch('abc', 'ab*', { prepend: 'foo' })); + }); + + it('should prepend a string when compiling common fast-path patterns', () => { + const re = makeRe('*.js', { prepend: 'foo' }); + assert.ok(re.source.indexOf('^(?:foo') === 0); + assert(isMatch('fooa.js', '*.js', { prepend: 'foo' })); + assert(!isMatch('a.js', '*.js', { prepend: 'foo' })); + }); + + it('should not double-prepend when the parser rebuilds output after backtracking', () => { + const re = makeRe('[[:alpha:]]', { prepend: 'foo' }); + assert.ok(re.source.indexOf('^(?:foo') === 0); + assert.strictEqual(re.source.split('foo').length - 1, 1); + assert(isMatch('fooa', '[[:alpha:]]', { prepend: 'foo' })); + assert(!isMatch('a', '[[:alpha:]]', { prepend: 'foo' })); + }); + + it('should not re-apply prepend when parsing a suffix after a negated extglob', () => { + const state = parse('!(*.d).ts', { prepend: 'foo' }); + assert.strictEqual(state.output.split('foo').length - 1, 1); + assert(isMatch('foofile.ts', '!(*.d).ts', { prepend: 'foo' })); + assert(!isMatch('foofile.d.ts', '!(*.d).ts', { prepend: 'foo' })); + }); +});