diff --git a/CHANGELOG.md b/CHANGELOG.md index 59421276b329..40baa4c42bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Make sure `contain-*` utility variables resolve to a valid value ([#13521](https://github.com/tailwindlabs/tailwindcss/pull/13521)) +- Support unbalanced parentheses and braces in quotes in arbitrary values and variants ([#13608](https://github.com/tailwindlabs/tailwindcss/pull/13608)) ### Changed diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index 8984212cb363..28b2b5d7e657 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -1031,5 +1031,40 @@ it('should parse arbitrary properties that are important and using stacked arbit }) it('should not parse compound group with a non-compoundable variant', () => { - expect(run('group-*:flex')).toMatchInlineSnapshot(`null`) + let utilities = new Utilities() + utilities.static('flex', () => []) + + let variants = new Variants() + variants.compound('group', () => {}) + + expect(run('group-*:flex', { utilities, variants })).toMatchInlineSnapshot(`null`) +}) + +it('should parse a variant containing an arbitrary string with unbalanced parens, brackets, curlies and other quotes', () => { + let utilities = new Utilities() + utilities.static('flex', () => []) + + let variants = new Variants() + variants.functional('string', () => {}) + + expect(run(`string-['}[("\\'']:flex`, { utilities, variants })).toMatchInlineSnapshot(` + { + "important": false, + "kind": "static", + "negative": false, + "root": "flex", + "variants": [ + { + "compounds": true, + "kind": "functional", + "modifier": null, + "root": "string", + "value": { + "kind": "arbitrary", + "value": "'}[("\\''", + }, + }, + ], + } + `) }) diff --git a/packages/tailwindcss/src/utils/segment.test.ts b/packages/tailwindcss/src/utils/segment.test.ts index 9cfa91f86ab3..e9bd8c49d8bc 100644 --- a/packages/tailwindcss/src/utils/segment.test.ts +++ b/packages/tailwindcss/src/utils/segment.test.ts @@ -21,6 +21,30 @@ it('should not split inside of curlies', () => { expect(segment('a:{b:c}:d', ':')).toEqual(['a', '{b:c}', 'd']) }) +it('should not split inside of double quotes', () => { + expect(segment('a:"b:c":d', ':')).toEqual(['a', '"b:c"', 'd']) +}) + +it('should not split inside of single quotes', () => { + expect(segment("a:'b:c':d", ':')).toEqual(['a', "'b:c'", 'd']) +}) + +it('should not crash when double quotes are unbalanced', () => { + expect(segment('a:"b:c:d', ':')).toEqual(['a', '"b:c:d']) +}) + +it('should not crash when single quotes are unbalanced', () => { + expect(segment("a:'b:c:d", ':')).toEqual(['a', "'b:c:d"]) +}) + +it('should skip escaped double quotes', () => { + expect(segment(String.raw`a:"b:c\":d":e`, ':')).toEqual(['a', String.raw`"b:c\":d"`, 'e']) +}) + +it('should skip escaped single quotes', () => { + expect(segment(String.raw`a:'b:c\':d':e`, ':')).toEqual(['a', String.raw`'b:c\':d'`, 'e']) +}) + it('should split by the escape sequence which is escape as well', () => { expect(segment('a\\b\\c\\d', '\\')).toEqual(['a', 'b', 'c', 'd']) expect(segment('a\\(b\\c)\\d', '\\')).toEqual(['a', '(b\\c)', 'd']) diff --git a/packages/tailwindcss/src/utils/segment.ts b/packages/tailwindcss/src/utils/segment.ts index cbb7115f0667..018485dbb94f 100644 --- a/packages/tailwindcss/src/utils/segment.ts +++ b/packages/tailwindcss/src/utils/segment.ts @@ -5,6 +5,8 @@ const OPEN_PAREN = 0x28 const CLOSE_PAREN = 0x29 const OPEN_BRACKET = 0x5b const CLOSE_BRACKET = 0x5d +const DOUBLE_QUOTE = 0x22 +const SINGLE_QUOTE = 0x27 // This is a shared buffer that is used to keep track of the current nesting level // of parens, brackets, and braces. It is used to determine if a character is at @@ -30,10 +32,11 @@ export function segment(input: string, separator: string) { let stackPos = 0 let parts: string[] = [] let lastPos = 0 + let len = input.length let separatorCode = separator.charCodeAt(0) - for (let idx = 0; idx < input.length; idx++) { + for (let idx = 0; idx < len; idx++) { let char = input.charCodeAt(idx) if (stackPos === 0 && char === separatorCode) { @@ -47,6 +50,25 @@ export function segment(input: string, separator: string) { // The next character is escaped, so we skip it. idx += 1 break + // Strings should be handled as-is until the end of the string. No need to + // worry about balancing parens, brackets, or curlies inside a string. + case SINGLE_QUOTE: + case DOUBLE_QUOTE: + // Ensure we don't go out of bounds. + while (++idx < len) { + let nextChar = input.charCodeAt(idx) + + // The next character is escaped, so we skip it. + if (nextChar === BACKSLASH) { + idx += 1 + continue + } + + if (nextChar === char) { + break + } + } + break case OPEN_PAREN: closingBracketStack[stackPos] = CLOSE_PAREN stackPos++