Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/parser/src/parser/rules/inline/span/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,48 @@ 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<ParseContext["tokens"], Set<string>>();
const scopeRefIds = new WeakMap<object, number>();
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.
* 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,
refId(scope.blockCloseCondition),
refId(scope.excludedBlockNames),
scope.divClosesBudget ?? -1,
scope.footnoteBlockParsed ? 1 : 0,
].join(":");
}

export const spanRule: InlineRule = {
name: "span",
startTokens: ["BLOCK_OPEN"],
Expand All @@ -26,8 +68,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",
Expand Down
26 changes: 26 additions & 0 deletions tests/unit/parser/span-unclosed-formatting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, test } from "bun:test";
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", () => {
// 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);
});

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("<span>");
});
});
Loading