From 1d7496f2ef1e3d8c28f866655fef12bad29d4ab4 Mon Sep 17 00:00:00 2001 From: Seyed Mostafa Hashemian Date: Tue, 10 Nov 2026 15:13:55 +0100 Subject: [PATCH] perf(parser): parse unclosed brackets and emphasis openers in linear time --- bench/emphasis-runs.test.cjs | 8 ++++++++ markdown/parserInlineMatchers.ts | 35 ++++++++++++++++++++++++++++++-- markdown/parserInlineUtils.ts | 28 ++++++++++++++++++------- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/bench/emphasis-runs.test.cjs b/bench/emphasis-runs.test.cjs index 6a26d69..d600bf1 100644 --- a/bench/emphasis-runs.test.cjs +++ b/bench/emphasis-runs.test.cjs @@ -22,3 +22,11 @@ for (const opener of ["*a ", "_a "]) { assert.ok(took < BUDGET_MS, `${JSON.stringify(opener)}×10000 took ${took} ms`); }); } + +test(`"["×25000 parses inside ${BUDGET_MS} ms`, () => { + const source = "[".repeat(25000); + const started = performance.now(); + E.parse(source); + const took = Math.round(performance.now() - started); + assert.ok(took < BUDGET_MS, `"["×25000 took ${took} ms`); +}); diff --git a/markdown/parserInlineMatchers.ts b/markdown/parserInlineMatchers.ts index 2fbf20d..8064d51 100644 --- a/markdown/parserInlineMatchers.ts +++ b/markdown/parserInlineMatchers.ts @@ -511,6 +511,19 @@ function skipCodeSpan(text: string, cursor: number): number { return close === -1 ? cursor + ticks : close + ticks; } +const MAX_CACHED_EMPHASIS = 4; +const failedOpenersCache = new Map; underscore: Set }>(); + +function getFailedOpeners(text: string): { star: Set; underscore: Set } { + let entry = failedOpenersCache.get(text); + if (!entry) { + if (failedOpenersCache.size >= MAX_CACHED_EMPHASIS) failedOpenersCache.clear(); + entry = { star: new Set(), underscore: new Set() }; + failedOpenersCache.set(text, entry); + } + return entry; +} + function matchStackedEmphasis( text: string, pos: number, @@ -526,6 +539,13 @@ function matchStackedEmphasis( if (isWhitespaceCode(text.charCodeAt(pos + openLen))) return null; // opener can't precede space if (!isStar && pos > 0 && isWordCode(text.charCodeAt(pos - 1))) return null; // no intraword _ + const failedSet = isStar ? getFailedOpeners(text).star : getFailedOpeners(text).underscore; + if (failedSet.has(pos)) return null; + if (text.indexOf(marker, pos + openLen) === -1) { + failedSet.add(pos); + return null; + } + // Zero-alloc fast path for the dominant single-close case; `extraSegments` // only materializes when a run closes in parts ("***a** b*"). let firstContent: string | null = null; @@ -536,6 +556,7 @@ function matchStackedEmphasis( let cursor = segStart; let matchEnd = -1; let depth = 0; + const openersSeen: number[] = []; // The next "\\" and "`" at or after `cursor`, rescanned only once `cursor` passes // them (`cursor` never moves back). Rescanning at every candidate made a run of // unclosed openers cubic: "*a "×10000 took 45831 ms, 754 ms cached (node 22). @@ -572,6 +593,7 @@ function matchStackedEmphasis( if (canClose && cursor > segStart && depth > 0) { depth -= 1; // pairs with an inner opener; stays in the content + openersSeen.pop(); cursor += runLen; continue; } @@ -590,11 +612,20 @@ function matchStackedEmphasis( segStart = cursor; continue; } - if (canOpen) depth += 1; + if (canOpen) { + depth += 1; + openersSeen.push(cursor); + } cursor += runLen; } - if (firstContent === null || firstContent.length === 0) return null; + if (firstContent === null || firstContent.length === 0) { + failedSet.add(pos); + for (let i = 0; i < openersSeen.length; i++) { + failedSet.add(openersSeen[i]); + } + return null; + } let node = wrapEmphasis(marker, firstTake, parseInline(firstContent)); let consumed = firstTake; diff --git a/markdown/parserInlineUtils.ts b/markdown/parserInlineUtils.ts index 5f4abc8..07be362 100644 --- a/markdown/parserInlineUtils.ts +++ b/markdown/parserInlineUtils.ts @@ -37,16 +37,30 @@ export function appendChar(nodes: InlineNode[], ch: string): void { } } +const MAX_CACHED_BRACKETS = 4; +const bracketCache = new Map>(); + export function findClosingBracket(text: string, openPos: number): number { - let depth = 0; - for (let i = openPos; i < text.length; i++) { - if (text[i] === '[') depth++; - else if (text[i] === ']') { - depth--; - if (depth === 0) return i; + if (text.indexOf(']', openPos) === -1) return -1; + let map = bracketCache.get(text); + if (!map) { + if (bracketCache.size >= MAX_CACHED_BRACKETS) bracketCache.clear(); + map = new Map(); + const stack: number[] = []; + const len = text.length; + for (let i = 0; i < len; i++) { + const code = text.charCodeAt(i); + if (code === 91) { + stack.push(i); + } else if (code === 93) { + if (stack.length > 0) { + map.set(stack.pop()!, i); + } + } } + bracketCache.set(text, map); } - return -1; + return map.get(openPos) ?? -1; } export function slugify(text: string): string {