feat(language-server): merge HTML completions and hover into one response
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,4 +1,12 @@
|
|||||||
import { getLanguageService, TextDocument as HtmlTextDocument } from "vscode-html-languageservice";
|
// The default `main` entrypoint is a UMD bundle whose internal AMD-style
|
||||||
|
// `require("./parser/htmlScanner")` calls survive bundling literally instead
|
||||||
|
// of being inlined, so a bundled language server fails at runtime with
|
||||||
|
// "Cannot find module './parser/htmlScanner'". The ESM entrypoint bundles
|
||||||
|
// cleanly, so import it explicitly.
|
||||||
|
import {
|
||||||
|
getLanguageService,
|
||||||
|
TextDocument as HtmlTextDocument,
|
||||||
|
} from "vscode-html-languageservice/lib/esm/htmlLanguageService.js";
|
||||||
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
|
import { isInsideHtml, virtualHtmlDocument } from "./html-regions.ts";
|
||||||
import { offsetAt, type Position, type TextDocument } from "./index.ts";
|
import { offsetAt, type Position, type TextDocument } from "./index.ts";
|
||||||
|
|
||||||
@@ -126,3 +134,27 @@ export function htmlTagComplete(document: TextDocument, position: Position): str
|
|||||||
|
|
||||||
return selfClosingTagCompletion(document.text, offset);
|
return selfClosingTagCompletion(document.text, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One completion list from both sources.
|
||||||
|
*
|
||||||
|
* WRNexus entries take the `0` sortText prefix so they rank above HTML without
|
||||||
|
* either list being filtered. An exact label collision resolves to the
|
||||||
|
* WRNexus entry: a component named `Table` is what the author meant.
|
||||||
|
*/
|
||||||
|
interface CompletionLike {
|
||||||
|
label: string;
|
||||||
|
kind?: number;
|
||||||
|
sortText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeCompletions(
|
||||||
|
wrnexus: CompletionLike[],
|
||||||
|
html: CompletionLike[],
|
||||||
|
): CompletionLike[] {
|
||||||
|
const taken = new Set(wrnexus.map((item) => item.label));
|
||||||
|
return [
|
||||||
|
...wrnexus.map((item) => ({ ...item, sortText: `0${item.sortText ?? item.label}` })),
|
||||||
|
...html.filter((item) => !taken.has(item.label)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
htmlToWrn,
|
htmlToWrn,
|
||||||
type TextDocument,
|
type TextDocument,
|
||||||
} from "./index.ts";
|
} from "./index.ts";
|
||||||
|
import { htmlCompletions, htmlHover, mergeCompletions } from "./html-service.ts";
|
||||||
|
import { clearHtmlRegionCache } from "./html-regions.ts";
|
||||||
|
|
||||||
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
||||||
const documents = new Map<string, TextDocument>();
|
const documents = new Map<string, TextDocument>();
|
||||||
@@ -111,7 +113,9 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
textDocumentSync: { openClose: true, change: 1, save: true },
|
textDocumentSync: { openClose: true, change: 1, save: true },
|
||||||
documentFormattingProvider: true,
|
documentFormattingProvider: true,
|
||||||
completionProvider: { triggerCharacters: ["<", "@", ":", "."] },
|
completionProvider: {
|
||||||
|
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
|
||||||
|
},
|
||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
definitionProvider: true,
|
definitionProvider: true,
|
||||||
referencesProvider: true,
|
referencesProvider: true,
|
||||||
@@ -172,6 +176,7 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
case "textDocument/didClose":
|
case "textDocument/didClose":
|
||||||
clearDiagnosticTimer(params.textDocument.uri);
|
clearDiagnosticTimer(params.textDocument.uri);
|
||||||
documents.delete(params.textDocument.uri);
|
documents.delete(params.textDocument.uri);
|
||||||
|
clearHtmlRegionCache(params.textDocument.uri);
|
||||||
send({
|
send({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "textDocument/publishDiagnostics",
|
method: "textDocument/publishDiagnostics",
|
||||||
@@ -195,9 +200,13 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/completion":
|
case "textDocument/completion": {
|
||||||
result(message.id, [...completionItems(), ...workspaceCompletionItems(workspaceRoot)]);
|
const document = documents.get(params.textDocument.uri);
|
||||||
|
const wrnexus = [...completionItems(), ...workspaceCompletionItems(workspaceRoot)];
|
||||||
|
const html = document ? htmlCompletions(document, params.position) : [];
|
||||||
|
result(message.id, html.length ? mergeCompletions(wrnexus, html) : wrnexus);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "textDocument/documentSymbol": {
|
case "textDocument/documentSymbol": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? documentSymbols(document) : []);
|
result(message.id, document ? documentSymbols(document) : []);
|
||||||
@@ -210,7 +219,8 @@ async function handle(message: JsonRpc): Promise<void> {
|
|||||||
}
|
}
|
||||||
case "textDocument/hover": {
|
case "textDocument/hover": {
|
||||||
const document = documents.get(params.textDocument.uri);
|
const document = documents.get(params.textDocument.uri);
|
||||||
result(message.id, document ? hover(document, params.position) : null);
|
const html = document ? htmlHover(document, params.position) : null;
|
||||||
|
result(message.id, html ?? (document ? hover(document, params.position) : null));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "textDocument/definition": {
|
case "textDocument/definition": {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { expect, test } from "bun:test";
|
import { expect, test } from "bun:test";
|
||||||
import { isInsideHtml, viewRegions, virtualHtmlDocument } from "../src/html-regions.ts";
|
import {
|
||||||
|
clearHtmlRegionCache,
|
||||||
|
isInsideHtml,
|
||||||
|
viewRegions,
|
||||||
|
virtualHtmlDocument,
|
||||||
|
} from "../src/html-regions.ts";
|
||||||
|
|
||||||
function doc(text: string): { uri: string; text: string; version?: number } {
|
function doc(text: string): { uri: string; text: string; version?: number } {
|
||||||
return { uri: `file:///${Math.random()}.wrn`, text };
|
return { uri: `file:///${Math.random()}.wrn`, text };
|
||||||
@@ -99,6 +104,22 @@ test("regions are cached per document version", () => {
|
|||||||
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(false);
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("clearHtmlRegionCache drops a closed document's cached regions", () => {
|
||||||
|
// A reopened document commonly restarts at version 1. Without clearing the
|
||||||
|
// cache on close, that version would match the stale entry from the prior
|
||||||
|
// session and serve regions scanned from the old text.
|
||||||
|
const first = doc(PAGE);
|
||||||
|
first.version = 1;
|
||||||
|
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
|
||||||
|
|
||||||
|
clearHtmlRegionCache(first.uri);
|
||||||
|
|
||||||
|
// Same uri, same version 1, but a document with no view block at all: if
|
||||||
|
// the cache had survived, this would still report true from the old scan.
|
||||||
|
const reopened = { uri: first.uri, text: "page A { }", version: 1 };
|
||||||
|
expect(isInsideHtml(reopened, PAGE.indexOf("<div"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("isInsideHtml distinguishes markup from surrounding code", () => {
|
test("isInsideHtml distinguishes markup from surrounding code", () => {
|
||||||
const source = doc(PAGE);
|
const source = doc(PAGE);
|
||||||
const markupOffset = PAGE.indexOf("<div");
|
const markupOffset = PAGE.indexOf("<div");
|
||||||
|
|||||||
@@ -111,9 +111,7 @@ test("does not misfire inside a quoted attribute value containing a slash", () =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
expect(
|
expect(htmlTagComplete(doc(singleQuoted), positionOf(singleQuoted, `src='/assets/`))).toBeNull();
|
||||||
htmlTagComplete(doc(singleQuoted), positionOf(singleQuoted, `src='/assets/`)),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("still completes a self-close after a preceding attribute", () => {
|
test("still completes a self-close after a preceding attribute", () => {
|
||||||
@@ -151,3 +149,26 @@ test("folding ranges stay inside view regions", () => {
|
|||||||
const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 1;
|
const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 1;
|
||||||
for (const range of ranges) expect(range.startLine).toBeGreaterThan(viewStartLine - 1);
|
for (const range of ranges) expect(range.startLine).toBeGreaterThan(viewStartLine - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { mergeCompletions } from "../src/html-service.ts";
|
||||||
|
|
||||||
|
test("merging ranks WRNexus entries above HTML and drops exact collisions", () => {
|
||||||
|
const merged = mergeCompletions(
|
||||||
|
[
|
||||||
|
{ label: "Card", kind: 7 },
|
||||||
|
{ label: "table", kind: 7 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "div", kind: 10, sortText: "1div" },
|
||||||
|
{ label: "table", kind: 10, sortText: "1table" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const labels = merged.map((item) => item.label);
|
||||||
|
expect(labels.filter((label) => label === "table")).toHaveLength(1);
|
||||||
|
expect(merged.find((item) => item.label === "Card")?.sortText?.startsWith("0")).toBe(true);
|
||||||
|
expect(merged.find((item) => item.label === "div")?.sortText?.startsWith("1")).toBe(true);
|
||||||
|
|
||||||
|
const sorted = [...merged].sort((a, b) => (a.sortText ?? "").localeCompare(b.sortText ?? ""));
|
||||||
|
expect(sorted[0]!.label).toBe("Card");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user