feat(language-server): add offset-preserving virtual HTML document
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user