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.
This commit is contained in:
Chris Tate
2026-10-25 10:02:52 -05:00
parent d9c1b89b5d
commit e324c07a66
2 changed files with 73 additions and 5 deletions
@@ -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<number> | 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<Atom>): Set<string> {
const out = new Set<string>();
@@ -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);
});