From e324c07a6610f8a4c59f52d28a505e7cdbc5202b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 10:02:52 -0500 Subject: [PATCH] Neutralize the overlay's doc ranges in one pass over the file renderShapeOverlay rebuilt the whole source text once per JSDoc range, so a file with many annotations cost time quadratic in its size. The ranges are leading trivia of declarations, equal or disjoint, so the text is now built once from the ranges in order. --- .../src/frontend/shape-inference/overlay.ts | 25 +++++++-- .../test/shape-overlay-render.test.ts | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/shape-overlay-render.test.ts diff --git a/packages/compiler/src/frontend/shape-inference/overlay.ts b/packages/compiler/src/frontend/shape-inference/overlay.ts index 2fa99de132..24fdcefd9b 100644 --- a/packages/compiler/src/frontend/shape-inference/overlay.ts +++ b/packages/compiler/src/frontend/shape-inference/overlay.ts @@ -654,15 +654,30 @@ export function renderShapeOverlay( else annotations++; } if (edits.length === 0 && part.suffix === "") continue; - let text = part.text; - for (const r of part.docRanges) - if (dropped?.has(r.id) !== true) - text = text.slice(0, r.lo) + neutralizeDocTags(text.slice(r.lo, r.hi)) + text.slice(r.hi); - files.set(file, applyEdits(text, edits) + part.suffix); + files.set(file, applyEdits(neutralizedDocRanges(part, dropped), edits) + part.suffix); } return { files, annotations, deadBranches }; } +/** The part's text with the package JSDoc neutralized in the leading trivia + * of the annotations a build kept. Each range is one declaration's leading + * trivia, so two ranges are equal or disjoint; the text is built once. */ +function neutralizedDocRanges(part: OverlayPart, dropped: ReadonlySet | undefined): string { + const ranges = part.docRanges + .filter((r) => dropped?.has(r.id) !== true) + .sort((a, b) => a.lo - b.lo || a.hi - b.hi); + if (ranges.length === 0) return part.text; + let out = ""; + let at = 0; + for (const r of ranges) { + const lo = Math.max(r.lo, at); + if (r.hi <= lo) continue; + out += part.text.slice(at, lo) + neutralizeDocTags(part.text.slice(lo, r.hi)); + at = r.hi; + } + return out + part.text.slice(at); +} + /** The `typeof` results the values can have. */ function typeofResults(values: ReadonlySet): Set { const out = new Set(); diff --git a/packages/compiler/test/shape-overlay-render.test.ts b/packages/compiler/test/shape-overlay-render.test.ts new file mode 100644 index 0000000000..8aa539a748 --- /dev/null +++ b/packages/compiler/test/shape-overlay-render.test.ts @@ -0,0 +1,53 @@ +/* renderShapeOverlay neutralizes the package JSDoc in the leading trivia of + * the annotations a build keeps, and leaves the trivia of dropped ones. */ +import { expect, test } from "vitest"; +import { + neutralizeDocTags, + OVERLAY_MARK, + renderShapeOverlay, +} from "../src/frontend/shape-inference/overlay.js"; +import type { ShapeOverlayPlan } from "../src/frontend/shape-inference/overlay.js"; + +test("each kept annotation's leading JSDoc is neutralized, once per range", () => { + const decls = [ + "/** @param {string} a */\nfunction f(a) {}\n", + "/** @type {number} */\nvar x = 1;\n", + "/** @returns {void} */\nfunction g() {}\n", + ]; + const text = decls.join(""); + const docRanges: { lo: number; hi: number; id: number }[] = []; + const edits: { pos: number; id: number; text: string }[] = []; + let lo = 0; + for (const decl of decls) { + const hi = lo + decl.indexOf("\n") + 1; + docRanges.push({ lo, hi, id: hi }); + // A declaration's first child starts at the same position: its range is + // the same trivia. + docRanges.push({ lo, hi, id: hi }); + edits.push({ pos: hi, id: hi, text: `/** @type {any} ${OVERLAY_MARK} #${hi} */ ` }); + lo += decl.length; + } + const plan: ShapeOverlayPlan = { + parts: new Map([["a.js", { text, edits, suffix: "", docRanges }]]), + }; + const dropped = docRanges[2]!.id; + const overlay = renderShapeOverlay(plan, { + annotations: new Map([["a.js", new Set([dropped])]]), + files: new Set(), + }); + const annotated = (decl: string, keep: boolean, at: number): string => { + const head = decl.slice(0, decl.indexOf("\n") + 1); + const rest = decl.slice(head.length); + return keep + ? `${neutralizeDocTags(head)}/** @type {any} ${OVERLAY_MARK} #${at} */ ${rest}` + : decl; + }; + expect(overlay.files.get("a.js")).toBe( + decls + .map((decl, i) => annotated(decl, docRanges[2 * i]!.id !== dropped, docRanges[2 * i]!.id)) + .join(""), + ); + expect(overlay.files.get("a.js")).toContain("/** @type {number} */"); + expect(overlay.files.get("a.js")).toContain("/** -param {string} a */"); + expect(overlay.annotations).toBe(2); +});