From fbef260b626dfab4c101ccd397eb66d55b4b91b7 Mon Sep 17 00:00:00 2001 From: Wenjix Date: Mon, 26 Oct 2026 23:13:36 -0700 Subject: [PATCH] fix: replace three quadratic regexes with linear scans Three hotspots retried a pattern from every input position, so a long malformed tail cost O(n^2) on the parse thread: - text-fine dedent() dropped the trailing blank run with /\n\s*$/; scanning backward over whitespace keeps the same semantics. - recipe parseAttributes() matched each attribute with /([a-z][a-z0-9-]*)\s*=\s*(["'])(.*?)\2/g; a long run of name characters backtracked at every position. A hand scanner parses name/=/quoted-value directly and keeps the same error codes, duplicate detection and single-line-value rule. - studio parameter source ranges located "name =" with a regex anchored at the prefix end; it still tried every offset. A backward scan finds the same start. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/recipe/src/parser.ts | 34 ++++++++++++++++++++++++------- packages/studio/src/parameters.ts | 22 ++++++++++++++++---- packages/text-fine/src/surface.ts | 8 +++++++- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/recipe/src/parser.ts b/packages/recipe/src/parser.ts index 584cfd112..49f239996 100644 --- a/packages/recipe/src/parser.ts +++ b/packages/recipe/src/parser.ts @@ -143,19 +143,39 @@ function parseAttributes( offset: number, ): Readonly> { const attributes: Record = {}; - const attribute = /([a-z][a-z0-9-]*)\s*=\s*(["'])(.*?)\2/gu; + // Scanned by hand: the regex this replaced backtracked through one character class per + // position and went quadratic on a long run of name characters with no "=". let cursor = 0; - for (const match of raw.matchAll(attribute)) { - const start = match.index ?? 0; - if (raw.slice(cursor, start).trim().length > 0) { + while (cursor < raw.length) { + while (cursor < raw.length && /\s/u.test(raw[cursor]!)) cursor++; + if (cursor >= raw.length) break; + const start = cursor; + if (!(raw[cursor]! >= "a" && raw[cursor]! <= "z")) { + fail(sourceName, "SVS_SHEET_ATTRIBUTE", "Malformed attribute.", offset + cursor); + } + while (cursor < raw.length && /[a-z0-9-]/u.test(raw[cursor]!)) cursor++; + const name = raw.slice(start, cursor); + while (cursor < raw.length && /\s/u.test(raw[cursor]!)) cursor++; + if (raw[cursor] !== "=") { + fail(sourceName, "SVS_SHEET_ATTRIBUTE", "Malformed attribute.", offset + cursor); + } + cursor++; + while (cursor < raw.length && /\s/u.test(raw[cursor]!)) cursor++; + const quote = raw[cursor]; + if (quote !== '"' && quote !== "'") { + fail(sourceName, "SVS_SHEET_ATTRIBUTE", "Malformed attribute.", offset + cursor); + } + const close = raw.indexOf(quote, cursor + 1); + // Line terminators never matched the old (.*?)\2 body, so a quoted value spanning lines + // was malformed rather than a short value. + if (close === -1 || /[\n\r\u2028\u2029]/u.test(raw.slice(cursor + 1, close))) { fail(sourceName, "SVS_SHEET_ATTRIBUTE", "Malformed attribute.", offset + cursor); } - const name = match[1] as string; if (Object.hasOwn(attributes, name)) { fail(sourceName, "SVS_SHEET_ATTRIBUTE_DUPLICATE", `Duplicate sheet attribute ${name}.`, offset + start); } - attributes[name] = match[3] as string; - cursor = start + match[0].length; + attributes[name] = raw.slice(cursor + 1, close); + cursor = close + 1; } if (raw.slice(cursor).trim().length > 0) { fail(sourceName, "SVS_SHEET_ATTRIBUTE", "Malformed attribute.", offset + cursor); diff --git a/packages/studio/src/parameters.ts b/packages/studio/src/parameters.ts index 8c844dda1..fe8de2254 100644 --- a/packages/studio/src/parameters.ts +++ b/packages/studio/src/parameters.ts @@ -247,10 +247,24 @@ function attributeGroup(file: StudioSourceFile, target: AuthorElement, draft: St if (target.references[name] !== undefined) { writable = false; value[name] = target.references[name]!; continue; } value[name] = target.attributes[name] ?? target.references[name] ?? ""; const before = file.text.slice(target.range.start, range.start); - const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const prefix = new RegExp(`\\s+${escaped}\\s*=\\s*["']$`, "u").exec(before); - if (!prefix) throw new Error(`Cannot locate scalar attribute ${name}.`); - ranges[name] = { start: prefix.index, end: range.end - target.range.start + 1 }; + // The tail must end `+ name * = * quote`. Scanned backward from the end: a + // regex anchored at the end still retried from every position and went quadratic on long + // whitespace runs. + const located = ((): number | undefined => { + let i = before.length - 1; + if (i < 0 || (before[i] !== '"' && before[i] !== "'")) return undefined; + while (i > 0 && /\s/u.test(before[i - 1]!)) i--; + if (i === 0 || before[i - 1] !== "=") return undefined; + i--; + while (i > 0 && /\s/u.test(before[i - 1]!)) i--; + if (i < name.length || before.slice(i - name.length, i) !== name) return undefined; + i -= name.length; + if (i === 0 || !/\s/u.test(before[i - 1]!)) return undefined; + while (i > 0 && /\s/u.test(before[i - 1]!)) i--; + return i; + })(); + if (located === undefined) throw new Error(`Cannot locate scalar attribute ${name}.`); + ranges[name] = { start: located, end: range.end - target.range.start + 1 }; } const schema = parameterRecordSchema(declaration.schema, value); for (const [name, held] of Object.entries(value)) { diff --git a/packages/text-fine/src/surface.ts b/packages/text-fine/src/surface.ts index aedc97205..0f1868689 100644 --- a/packages/text-fine/src/surface.ts +++ b/packages/text-fine/src/surface.ts @@ -521,7 +521,13 @@ export const decodeTypographyPathMotionSurface: StructuredSurfaceHandler = ({ el }; function dedent(value: string): string { - const lines = value.replace(/^\n/u, "").replace(/\n\s*$/u, "").split("\n"); + let text = value.startsWith("\n") ? value.slice(1) : value; + // The trailing run is dropped only when it begins with a newline — what /\n\s*$/ did, but + // scanned backward: the regex retried the tail from every position and went quadratic. + let end = text.length; + while (end > 0 && /\s/u.test(text[end - 1]!)) end--; + if (end < text.length && text[end] === "\n") text = text.slice(0, end); + const lines = text.split("\n"); const indentation = lines.filter((line) => line.trim()).reduce((min, line) => Math.min(min, /^\s*/u.exec(line)?.[0].length ?? 0), Number.POSITIVE_INFINITY); return lines.map((line) => line.slice(Number.isFinite(indentation) ? indentation : 0)).join("\n"); }