From 7301849a7f2179affa9a0b7492edd0c37f669988 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 20:34:24 +0530 Subject: [PATCH] feat(language-server): add offset-preserving virtual HTML document --- packages/language-server/src/html-regions.ts | 116 ++++++++++++++++++ .../language-server/test/html-regions.test.ts | 109 ++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/language-server/src/html-regions.ts create mode 100644 packages/language-server/test/html-regions.test.ts diff --git a/packages/language-server/src/html-regions.ts b/packages/language-server/src/html-regions.ts new file mode 100644 index 00000000..076f8c91 --- /dev/null +++ b/packages/language-server/src/html-regions.ts @@ -0,0 +1,116 @@ +import type { TextDocument } from "./index.ts"; + +export interface HtmlRegion { + start: number; + end: number; +} + +/** + * Byte ranges of the markup inside each `view { }` block. + * + * This is a tolerant scanner rather than the parser: completion fires while + * the document is being typed, which is exactly when it does not parse. + */ +export function viewRegions(text: string): HtmlRegion[] { + const regions: HtmlRegion[] = []; + const pattern = /\bview\s*\{/g; + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text))) { + const bodyStart = match.index + match[0].length; + const end = matchingBrace(text, bodyStart); + regions.push({ start: bodyStart, end }); + pattern.lastIndex = end; + } + return regions; +} + +/** + * Offset of the brace closing the block that starts at `from`, or the end of + * the text when it is never closed (an unterminated block is normal mid-edit). + * + * Quotes are only tracked inside a tag, never in text content: `

it's

` + * would otherwise open a string that never closes and swallow the rest of the + * file. + */ +function matchingBrace(text: string, from: number): number { + let depth = 1; + let inTag = false; + let quote: string | null = null; + + for (let index = from; index < text.length; index += 1) { + const char = text[index]!; + + if (quote) { + if (char === quote) quote = null; + continue; + } + if (inTag && (char === '"' || char === "'")) { + quote = char; + continue; + } + if (char === "<") inTag = true; + else if (char === ">") inTag = false; + else if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return text.length; +} + +/** + * A parallel document containing only the markup. + * + * Everything outside a view block becomes whitespace of the same length, and + * newlines are preserved, so an offset in the source is the same offset here. + * That removes the need for a mapping table entirely. + */ +export function virtualHtmlDocument(document: TextDocument): { + uri: string; + languageId: "html"; + text: string; +} { + const source = document.text; + const keep = new Array(source.length).fill(false); + for (const region of viewRegions(source)) { + for (let index = region.start; index < region.end; index += 1) keep[index] = true; + } + + let text = ""; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]!; + text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " "; + } + + return { uri: `${document.uri}.html`, languageId: "html", text }; +} + +export function isInsideHtml(document: TextDocument, offset: number): boolean { + return regionsFor(document).some((region) => offset >= region.start && offset <= region.end); +} + +/** + * Regions for a document, cached by uri and version. + * + * A single keystroke produces a burst of completion, hover, and tag-close + * requests; without this each one rescans the file. + */ +const regionCache = new Map(); + +function regionsFor(document: TextDocument): HtmlRegion[] { + const version = document.version ?? -1; + const cached = regionCache.get(document.uri); + if (cached && cached.version === version) return cached.regions; + + const regions = viewRegions(document.text); + regionCache.set(document.uri, { version, regions }); + return regions; +} + +/** Drops a document's cached regions. Call when a document closes. */ +export function clearHtmlRegionCache(uri?: string): void { + if (uri) regionCache.delete(uri); + else regionCache.clear(); +} diff --git a/packages/language-server/test/html-regions.test.ts b/packages/language-server/test/html-regions.test.ts new file mode 100644 index 00000000..bf13dd24 --- /dev/null +++ b/packages/language-server/test/html-regions.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; +import { isInsideHtml, viewRegions, virtualHtmlDocument } from "../src/html-regions.ts"; + +function doc(text: string): { uri: string; text: string; version?: number } { + return { uri: `file:///${Math.random()}.wrn`, text }; +} + +const PAGE = `page Home { + view { +
hello
+ } +} +`; + +test("the virtual document preserves length and newline offsets", () => { + // This is what makes position mapping unnecessary. If it breaks, every + // feature reports positions off by some amount instead of failing loudly. + const source = doc(PAGE); + const virtual = virtualHtmlDocument(source); + + expect(virtual.text.length).toBe(source.text.length); + expect(virtual.languageId).toBe("html"); + for (let i = 0; i < source.text.length; i += 1) { + if (source.text[i] === "\n") expect(virtual.text[i]).toBe("\n"); + } +}); + +test("markup survives into the virtual document and everything else is blanked", () => { + const virtual = virtualHtmlDocument(doc(PAGE)); + expect(virtual.text).toContain('
hello
'); + expect(virtual.text).not.toContain("page Home"); + expect(virtual.text).not.toContain("view"); +}); + +test("an apostrophe in text content does not swallow later regions", () => { + // A scanner treating ' as a string delimiter anywhere considers the rest of + // the file one open string and loses every later region. + const source = `page A { + view { +

it's fine

+ } +} +component B { + view { + second + } +} +`; + expect(viewRegions(source)).toHaveLength(2); + expect(virtualHtmlDocument(doc(source)).text).toContain("second"); +}); + +test("interpolation braces nest without ending the region early", () => { + const source = `page A { + view { +
after
+ } +} +`; + const regions = viewRegions(source); + expect(regions).toHaveLength(1); + expect(virtualHtmlDocument(doc(source)).text).toContain("after"); +}); + +test("unparseable mid-edit markup still yields a region", () => { + // Completion fires exactly when the document does not parse. + const source = `page A { + view { +
{ + const source = `page A { + functions { + function go() {} + } +} +`; + expect(viewRegions(source)).toEqual([]); + expect(virtualHtmlDocument(doc(source)).text.trim()).toBe(""); +}); + +test("regions are cached per document version", () => { + // One keystroke fans out into completion, hover, and tag-close requests. + const first = doc(PAGE); + first.version = 1; + expect(isInsideHtml(first, PAGE.indexOf(" { + const source = doc(PAGE); + const markupOffset = PAGE.indexOf("