diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 57f7b97..cbe1945 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -3077,3 +3077,24 @@ and (min-width: 768px) { }`) }) }) }) + +describe('unmatched parentheses performance', () => { + test.each(['media', 'container'])('@%s with many unclosed "(" parses in linear time', (name) => { + const css = `@${name} ${'('.repeat(32_000)}{}` + const start = performance.now() + parse(css) + // Quadratic behavior took ~6s at this size; linear takes a few ms + expect(performance.now() - start).toBeLessThan(1000) + }) + + test('a matched feature after unclosed "(" is still parsed', () => { + const ast = parse('@media ((min-width: 100px) {}') + const features: string[] = [] + walk(ast, (node) => { + if (node.type_name === 'Feature') { + features.push(node.text) + } + }) + expect(features).toContain('(min-width: 100px)') + }) +}) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index f0cb1dd..f21f115 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -104,17 +104,37 @@ export class ConditionParser { private paren_content_end = 0 private paren_close_end = 0 + // A failed scan means every '(' still open at the end of the range can never match (the + // range end is fixed), so remember them: callers that step past an unmatched '(' and retry + // at the next one would otherwise rescan the rest of the range each time, which is O(n^2) + // on input like `@media ((((((…`. Only valid for the `end` it was collected under. + private unmatched_opens: Set | null = null + private unmatched_opens_end = 0 + private scan_matching_paren(): boolean { let depth = 1 let content_end = this.lexer.pos let close_end = this.lexer.token_end + if (this.unmatched_opens !== null && this.unmatched_opens_end === this.end) { + if (this.unmatched_opens.has(this.lexer.token_start)) { + this.paren_content_end = content_end + this.paren_close_end = close_end + return false + } + } + + // Stack of open-paren positions; only needed to record the unmatched ones on failure + const open_stack: number[] = [this.lexer.token_start] + while (this.lexer.pos < this.end && depth > 0) { const token_type = this.next_token() if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { depth++ + open_stack.push(this.lexer.token_start) } else if (token_type === TOKEN_RIGHT_PAREN) { depth-- + open_stack.pop() if (depth === 0) { content_end = this.lexer.token_start close_end = this.lexer.token_end @@ -126,7 +146,18 @@ export class ConditionParser { this.paren_content_end = content_end this.paren_close_end = close_end - return depth === 0 + + if (depth !== 0) { + if (this.unmatched_opens === null || this.unmatched_opens_end !== this.end) { + this.unmatched_opens = new Set() + this.unmatched_opens_end = this.end + } + for (const pos of open_stack) { + this.unmatched_opens.add(pos) + } + return false + } + return true } // Parse feature value via the shared ValueNodeParser, so calc()/env()/var() get full children.