feat(language-server): answer HTML completion, hover, folding, and tag close
This commit is contained in:
@@ -458,6 +458,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@wrnexus/syntax": "workspace:*",
|
||||||
"@wrnexus/typecheck": "workspace:*",
|
"@wrnexus/typecheck": "workspace:*",
|
||||||
|
"vscode-html-languageservice": "^5.6.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/mcp": {
|
"packages/mcp": {
|
||||||
@@ -967,6 +968,8 @@
|
|||||||
|
|
||||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
|
||||||
|
|
||||||
|
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||||
|
|
||||||
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
|
||||||
|
|
||||||
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
|
||||||
@@ -1387,6 +1390,14 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||||
|
|
||||||
|
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
|
||||||
|
|
||||||
|
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||||
|
|
||||||
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
|
||||||
|
|
||||||
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/syntax": "workspace:*",
|
"@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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user