From cc65fcd208d23b6a054badcef6aaf59ec3f8caa0 Mon Sep 17 00:00:00 2001 From: r74tech Date: Wed, 30 Sep 2026 21:04:51 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20span=E3=82=92=E3=81=BE=E3=81=9F?= =?UTF-8?q?=E3=81=90=E8=A3=85=E9=A3=BE=E8=A8=98=E5=8F=B7=E3=81=A7=E3=83=91?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=81=8C=E6=8C=87=E6=95=B0=E7=9A=84=E3=81=AB?= =?UTF-8?q?=E9=81=85=E3=81=8F=E3=81=AA=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/parser/rules/inline/span/index.ts | 47 +++++++++++++++++++ .../parser/span-unclosed-formatting.test.ts | 14 ++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/unit/parser/span-unclosed-formatting.test.ts diff --git a/packages/parser/src/parser/rules/inline/span/index.ts b/packages/parser/src/parser/rules/inline/span/index.ts index f8080ced..8c5e05ab 100644 --- a/packages/parser/src/parser/rules/inline/span/index.ts +++ b/packages/parser/src/parser/rules/inline/span/index.ts @@ -11,6 +11,43 @@ import { buildSpanElements } from "./elements"; import { parseCloseSpan, parseSpanOpener } from "./syntax"; import { parseSpanContent } from "./content"; +/** + * Span openers already known to have no closing tag under an identical scope. + * + * Formatting that spans across `[[/span]]` can make a span consume later + * paragraphs, and every failed attempt is retried by each enclosing rule. + * Without this cache, `[[span]]**a[[/span]][[span]]b**[[/span]]` repeated + * n times fails 2^n - 1 times at only n distinct positions. + */ +const failedSpans = new WeakMap>(); +const scopeRefIds = new WeakMap(); +let nextScopeRefId = 1; + +function refId(value: object | undefined): number { + if (value === undefined) return 0; + let id = scopeRefIds.get(value); + if (id === undefined) { + id = nextScopeRefId++; + scopeRefIds.set(value, id); + } + return id; +} + +/** Null when the scope carries mutable table state, which makes failures non-repeatable. */ +function failureKey(ctx: ParseContext): string | null { + const scope = ctx.scope; + if (scope.tableFormatting) return null; + return [ + ctx.pos, + scope.inlineEnd ?? -1, + scope.suppressEmailLinks === true ? 1 : 0, + refId(scope.blockCloseCondition), + refId(scope.excludedBlockNames), + scope.divClosesBudget ?? -1, + scope.footnoteBlockParsed ? 1 : 0, + ].join(":"); +} + export const spanRule: InlineRule = { name: "span", startTokens: ["BLOCK_OPEN"], @@ -26,8 +63,18 @@ export const spanRule: InlineRule = { return { success: false }; } + const key = failureKey(ctx); + const failed = failedSpans.get(ctx.tokens); + if (key !== null && failed?.has(key)) { + return { success: false }; + } + const content = parseSpanContent(ctx, opener.pos, opener.blockName); if (!content.foundClose) { + if (key !== null) { + if (failed) failed.add(key); + else failedSpans.set(ctx.tokens, new Set([key])); + } ctx.diagnostics.push({ severity: "warning", code: "unclosed-block", diff --git a/tests/unit/parser/span-unclosed-formatting.test.ts b/tests/unit/parser/span-unclosed-formatting.test.ts new file mode 100644 index 00000000..5c69c9e0 --- /dev/null +++ b/tests/unit/parser/span-unclosed-formatting.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { parse } from "@wdprlib/parser"; +import { renderToHtml } from "@wdprlib/render"; + +describe("span with formatting across its closing tag", () => { + // Each unit leaves `**` open across [[/span]]; before failed spans were cached, + // parsing retried every unclosed span 2^n - 1 times. + test("parses repeated units without exponential retries", () => { + const unit = '[[span class="a"]]**X[[/span]][[span class="b"]]:** [[/span]]\n\n'; + const ast = parse(unit.repeat(40)).ast; + + expect(renderToHtml(ast)).toContain("X"); + }, 5_000); +}); From ed57ae4aaca3b75688e28d797840c88be9ebfc6a Mon Sep 17 00:00:00 2001 From: r74tech Date: Tue, 13 Oct 2026 01:07:44 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20span=E3=81=AE=E5=A4=B1=E6=95=97?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=E3=82=92=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=81=A8=E3=83=90=E3=83=BC=E3=82=B8=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AB=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../parser/src/parser/rules/inline/span/index.ts | 7 ++++++- tests/unit/parser/span-unclosed-formatting.test.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/parser/src/parser/rules/inline/span/index.ts b/packages/parser/src/parser/rules/inline/span/index.ts index 8c5e05ab..71e95ec6 100644 --- a/packages/parser/src/parser/rules/inline/span/index.ts +++ b/packages/parser/src/parser/rules/inline/span/index.ts @@ -33,11 +33,16 @@ function refId(value: object | undefined): number { return id; } -/** Null when the scope carries mutable table state, which makes failures non-repeatable. */ +/** + * Null when the scope carries mutable table state, which makes failures non-repeatable. + * Settings and version are included because callers may parse one token array more than once. + */ function failureKey(ctx: ParseContext): string | null { const scope = ctx.scope; if (scope.tableFormatting) return null; return [ + refId(ctx.settings), + ctx.version, ctx.pos, scope.inlineEnd ?? -1, scope.suppressEmailLinks === true ? 1 : 0, diff --git a/tests/unit/parser/span-unclosed-formatting.test.ts b/tests/unit/parser/span-unclosed-formatting.test.ts index 5c69c9e0..e67db7ef 100644 --- a/tests/unit/parser/span-unclosed-formatting.test.ts +++ b/tests/unit/parser/span-unclosed-formatting.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { parse } from "@wdprlib/parser"; +import { createSettings } from "@wdprlib/ast"; +import { Parser, parse, tokenize } from "@wdprlib/parser"; import { renderToHtml } from "@wdprlib/render"; describe("span with formatting across its closing tag", () => { @@ -12,3 +13,14 @@ describe("span with formatting across its closing tag", () => { expect(renderToHtml(ast)).toContain("X"); }, 5_000); }); + +describe("span failures cached for one token array", () => { + // Disabled HTML consumes the span close, so a draft failure must not apply to a page parse. + test("are not reused under different settings", () => { + const tokens = tokenize("prefix [[span]] [[html]] [[/span]] [[/html]]"); + new Parser(tokens, { settings: createSettings("draft") }).parse(); + const { ast } = new Parser(tokens, { settings: createSettings("page") }).parse(); + + expect(renderToHtml(ast)).toContain(""); + }); +});