feat(language-server): add offset-preserving virtual HTML document

This commit is contained in:
2026-08-18 20:34:24 +05:30
parent 7d481df652
commit 7301849a7f
2 changed files with 225 additions and 0 deletions
@@ -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: `<p>it's</p>`
* 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<boolean>(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<string, { version: number; regions: HtmlRegion[] }>();
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();
}
@@ -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 {
<div class="card">hello</div>
}
}
`;
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('<div class="card">hello</div>');
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 {
<p>it's fine</p>
}
}
component B {
view {
<span>second</span>
}
}
`;
expect(viewRegions(source)).toHaveLength(2);
expect(virtualHtmlDocument(doc(source)).text).toContain("<span>second</span>");
});
test("interpolation braces nest without ending the region early", () => {
const source = `page A {
view {
<div class={cond ? "a" : "b"} data-x={{ a: 1 }}>after</div>
}
}
`;
const regions = viewRegions(source);
expect(regions).toHaveLength(1);
expect(virtualHtmlDocument(doc(source)).text).toContain("after</div>");
});
test("unparseable mid-edit markup still yields a region", () => {
// Completion fires exactly when the document does not parse.
const source = `page A {
view {
<div class="
}
}
`;
expect(viewRegions(source).length).toBe(1);
});
test("a file with no view block yields no regions and a fully blank document", () => {
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("<div"))).toBe(true);
// Same version, mutated text: the cached regions are reused, proving the
// scan did not run again.
first.text = "page A { }";
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
first.version = 2;
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(false);
});
test("isInsideHtml distinguishes markup from surrounding code", () => {
const source = doc(PAGE);
const markupOffset = PAGE.indexOf("<div");
const keywordOffset = PAGE.indexOf("page");
expect(isInsideHtml(source, markupOffset)).toBe(true);
expect(isInsideHtml(source, keywordOffset)).toBe(false);
});