diff --git a/bun.lock b/bun.lock index f7ec1f3c..2da44696 100644 --- a/bun.lock +++ b/bun.lock @@ -458,6 +458,7 @@ "dependencies": { "@wrnexus/syntax": "workspace:*", "@wrnexus/typecheck": "workspace:*", + "vscode-html-languageservice": "^5.6.2", }, }, "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=="], + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"], "@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=="], + "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"], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], diff --git a/packages/language-server/package.json b/packages/language-server/package.json index 2d31c506..b82b0821 100644 --- a/packages/language-server/package.json +++ b/packages/language-server/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@wrnexus/syntax": "workspace:*", - "@wrnexus/typecheck": "workspace:*" + "@wrnexus/typecheck": "workspace:*", + "vscode-html-languageservice": "^5.6.2" } } diff --git a/packages/language-server/src/html-service.ts b/packages/language-server/src/html-service.ts new file mode 100644 index 00000000..76681db1 --- /dev/null +++ b/packages/language-server/src/html-service.ts @@ -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 ``. + * + * `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed + * `/` when it opens an end tag (``) 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); +} diff --git a/packages/language-server/test/html-service.test.ts b/packages/language-server/test/html-service.test.ts new file mode 100644 index 00000000..b191088d --- /dev/null +++ b/packages/language-server/test/html-service.test.ts @@ -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 { + 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 { +
x
+ } +} +`; + expect(htmlHover(doc(text), positionOf(text, " { + const open = `page A { + view { +
+ } +} +`; + expect(htmlTagComplete(doc(open), positionOf(open, "
"))).toContain("
"); + + const void_ = `page A { + view { +
+ } +} +`; + expect(htmlTagComplete(doc(void_), positionOf(void_, "
"))).toBeNull(); +}); + +test("completes a self-closing component tag", () => { + const text = `page A { + view { + "); +}); + +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 { +
    +
  • one
  • +
+ } +} +`; + 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); +});