feat(language-server): answer HTML completion, hover, folding, and tag close

This commit is contained in:
2026-08-18 20:43:47 +05:30
parent 6074d19c43
commit 609224591c
4 changed files with 239 additions and 1 deletions
+2 -1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@wrnexus/syntax": "workspace:*",
"@wrnexus/typecheck": "workspace:*"
"@wrnexus/typecheck": "workspace:*",
"vscode-html-languageservice": "^5.6.2"
}
}
@@ -0,0 +1,103 @@
import { getLanguageService, TextDocument as HtmlTextDocument } from "vscode-html-languageservice";
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
import { offsetAt, type Position, type TextDocument } from "./index.ts";
export interface HtmlCompletionItem {
label: string;
kind: number;
detail?: string;
documentation?: string;
sortText?: string;
insertText?: string;
}
const service = getLanguageService();
/** The virtual document as the HTML service's own document type. */
function htmlDocument(document: TextDocument) {
const virtual = virtualHtmlDocument(document);
return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
}
function markdown(value: unknown): string {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "value" in value) {
return String((value as { value: unknown }).value);
}
return "";
}
/**
* HTML completions for a position inside a view block.
*
* Every item carries the `1` sortText prefix so the server can rank WRNexus
* entries above these without filtering either list.
*/
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
const virtual = htmlDocument(document);
const parsed = service.parseHTMLDocument(virtual);
const list = service.doComplete(virtual, position, parsed);
return list.items.map((item) => ({
label: item.label,
kind: typeof item.kind === "number" ? item.kind : 1,
detail: item.detail,
documentation: markdown(item.documentation),
sortText: `1${item.sortText ?? item.label}`,
insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined,
}));
}
export function htmlHover(document: TextDocument, position: Position): { contents: string } | null {
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
const virtual = htmlDocument(document);
const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual));
if (!result) return null;
const contents = markdown(result.contents);
return contents ? { contents } : null;
}
export function htmlFoldingRanges(
document: TextDocument,
): Array<{ startLine: number; endLine: number }> {
return service
.getFoldingRanges(htmlDocument(document))
.map((range) => ({ startLine: range.startLine, endLine: range.endLine }));
}
/**
* Detects `<Name attr="x" /` just before `position` and completes the `>`.
*
* `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed
* `/` when it opens an end tag (`</`); it has no notion of a self-closing
* start tag, since plain HTML has no such elements outside its fixed void-element
* list. WRNexus components (`<Card />`) are exactly that case, so we complete
* it ourselves rather than relying on the library.
*/
function selfClosingTagCompletion(text: string, offset: number): string | null {
if (text.charAt(offset - 1) !== "/") return null;
if (text.charAt(offset) === ">") return null;
const before = text.slice(0, offset);
return /<[A-Za-z][\w-]*(?:\s[^<>]*)?\/$/.test(before) ? ">" : null;
}
/**
* The snippet that closes the tag being typed, or null.
*
* Void elements and already-closed tags return null, which is why this decision
* belongs here rather than in the editor client.
*/
export function htmlTagComplete(document: TextDocument, position: Position): string | null {
const offset = offsetAt(document.text, position);
if (!isInsideHtml(document, offset)) return null;
const virtual = htmlDocument(document);
const result = service.doTagComplete(virtual, position, service.parseHTMLDocument(virtual));
if (result) return result;
return selfClosingTagCompletion(document.text, offset);
}
@@ -0,0 +1,123 @@
import { expect, test } from "bun:test";
import {
htmlCompletions,
htmlFoldingRanges,
htmlHover,
htmlTagComplete,
} from "../src/html-service.ts";
function doc(text: string) {
return { uri: "file:///Page.wrn", text };
}
function positionOf(text: string, needle: string) {
const offset = text.indexOf(needle) + needle.length;
const before = text.slice(0, offset);
const lines = before.split("\n");
return { line: lines.length - 1, character: lines[lines.length - 1]!.length };
}
test("suggests HTML tags inside a view block", () => {
const text = `page A {
view {
<
}
}
`;
const items = htmlCompletions(doc(text), positionOf(text, " <"));
expect(items.some((item) => item.label === "div")).toBe(true);
expect(items.every((item) => item.sortText?.startsWith("1"))).toBe(true);
});
test("suggests attributes inside a tag", () => {
const text = `page A {
view {
<input
}
}
`;
const items = htmlCompletions(doc(text), positionOf(text, "<input "));
expect(items.some((item) => item.label === "type")).toBe(true);
});
test("returns nothing outside a view block", () => {
const text = `page A {
functions {
function go() { }
}
}
`;
expect(htmlCompletions(doc(text), positionOf(text, "function go() "))).toEqual([]);
});
test("hovers a tag inside a view block and nothing outside one", () => {
const text = `page A {
view {
<div>x</div>
}
}
`;
expect(htmlHover(doc(text), positionOf(text, "<di"))).not.toBeNull();
const code = `page A {
functions {
function go() { }
}
}
`;
expect(htmlHover(doc(code), positionOf(code, "func"))).toBeNull();
});
test("closes an open tag and leaves void elements alone", () => {
const open = `page A {
view {
<div>
}
}
`;
expect(htmlTagComplete(doc(open), positionOf(open, "<div>"))).toContain("</div>");
const void_ = `page A {
view {
<br>
}
}
`;
expect(htmlTagComplete(doc(void_), positionOf(void_, "<br>"))).toBeNull();
});
test("completes a self-closing component tag", () => {
const text = `page A {
view {
<Card /
}
}
`;
expect(htmlTagComplete(doc(text), positionOf(text, "<Card /"))).toBe(">");
});
test("returns no tag completion outside a view block", () => {
const text = `page A {
functions {
function go() { }
}
}
`;
expect(htmlTagComplete(doc(text), positionOf(text, "function go() "))).toBeNull();
});
test("folding ranges stay inside view regions", () => {
const text = `page A {
view {
<ul>
<li>one</li>
</ul>
}
}
`;
const ranges = htmlFoldingRanges(doc(text));
expect(ranges.length).toBeGreaterThan(0);
const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 1;
for (const range of ranges) expect(range.startLine).toBeGreaterThan(viewStartLine - 1);
});