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); +});