feat(language-server): add tag folding and linked editing

This commit is contained in:
2026-08-18 21:10:57 +05:30
parent bd0c1317ff
commit d9e8f5be82
5 changed files with 149 additions and 2 deletions
+24 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// WRN editor language server source hash: 6a4f4c8e370f4063c863f89ca0dd57923fdb2b940a3e63980984ed2ff45f7af0
// WRN editor language server source hash: 1317c4e318ab938fffab3dd5b65655b0e220a81c481c9fa92f23706ede60303c
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
// @bun @bun-cjs
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
@@ -195960,6 +195960,16 @@ function htmlHover(document, position) {
const contents = markdown(result.contents);
return contents ? { contents } : null;
}
function htmlFoldingRanges(document) {
return service.getFoldingRanges(htmlDocument(document)).map((range) => ({ startLine: range.startLine, endLine: range.endLine }));
}
function htmlLinkedEditingRanges(document, position) {
if (!isInsideHtml(document, offsetAt2(document.text, position)))
return null;
const virtual = htmlDocument(document);
const ranges = service.findLinkedEditingRanges(virtual, position, service.parseHTMLDocument(virtual));
return ranges && ranges.length ? ranges : null;
}
function mergeCompletions(wrnexus, html) {
const taken = new Set(wrnexus.map((item) => item.label));
return [
@@ -196066,6 +196076,8 @@ async function handle(message) {
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"]
},
hoverProvider: true,
foldingRangeProvider: true,
linkedEditingRangeProvider: true,
definitionProvider: true,
referencesProvider: true,
renameProvider: { prepareProvider: true },
@@ -196169,6 +196181,17 @@ async function handle(message) {
result(message.id, html ?? (document ? hover(document, params.position) : null));
break;
}
case "textDocument/foldingRange": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? htmlFoldingRanges(document) : []);
break;
}
case "textDocument/linkedEditingRange": {
const document = documents.get(params.textDocument.uri);
const ranges = document ? htmlLinkedEditingRanges(document, params.position) : null;
result(message.id, ranges ? { ranges } : null);
break;
}
case "textDocument/definition": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? definitionLocation(document, params.position) : null);
@@ -77,6 +77,22 @@ export function htmlFoldingRanges(
.map((range) => ({ startLine: range.startLine, endLine: range.endLine }));
}
/** Ranges of the opening and closing tag names, so renaming one renames both. */
export function htmlLinkedEditingRanges(
document: TextDocument,
position: Position,
): Array<{ start: Position; end: Position }> | null {
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
const virtual = htmlDocument(document);
const ranges = service.findLinkedEditingRanges(
virtual,
position,
service.parseHTMLDocument(virtual),
);
return ranges && ranges.length ? ranges : null;
}
/**
* Detects `<Name attr="x" /` just before `position` and completes the `>`.
*
+20 -1
View File
@@ -19,7 +19,13 @@ import {
htmlToWrn,
type TextDocument,
} from "./index.ts";
import { htmlCompletions, htmlHover, mergeCompletions } from "./html-service.ts";
import {
htmlCompletions,
htmlFoldingRanges,
htmlHover,
htmlLinkedEditingRanges,
mergeCompletions,
} from "./html-service.ts";
import { clearHtmlRegionCache } from "./html-regions.ts";
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
@@ -117,6 +123,8 @@ async function handle(message: JsonRpc): Promise<void> {
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
},
hoverProvider: true,
foldingRangeProvider: true,
linkedEditingRangeProvider: true,
definitionProvider: true,
referencesProvider: true,
renameProvider: { prepareProvider: true },
@@ -223,6 +231,17 @@ async function handle(message: JsonRpc): Promise<void> {
result(message.id, html ?? (document ? hover(document, params.position) : null));
break;
}
case "textDocument/foldingRange": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? htmlFoldingRanges(document) : []);
break;
}
case "textDocument/linkedEditingRange": {
const document = documents.get(params.textDocument.uri);
const ranges = document ? htmlLinkedEditingRanges(document, params.position) : null;
result(message.id, ranges ? { ranges } : null);
break;
}
case "textDocument/definition": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? definitionLocation(document, params.position) : null);
@@ -3,6 +3,7 @@ import {
htmlCompletions,
htmlFoldingRanges,
htmlHover,
htmlLinkedEditingRanges,
htmlTagComplete,
} from "../src/html-service.ts";
@@ -172,3 +173,25 @@ test("merging ranks WRNexus entries above HTML and drops exact collisions", () =
const sorted = [...merged].sort((a, b) => (a.sortText ?? "").localeCompare(b.sortText ?? ""));
expect(sorted[0]!.label).toBe("Card");
});
test("linked editing returns both the opening and closing tag names", () => {
const text = `page A {
view {
<div>x</div>
}
}
`;
const ranges = htmlLinkedEditingRanges(doc(text), positionOf(text, "<di"));
expect(ranges).not.toBeNull();
expect(ranges).toHaveLength(2);
});
test("linked editing returns null outside a view block", () => {
const text = `page A {
functions {
function go() { }
}
}
`;
expect(htmlLinkedEditingRanges(doc(text), positionOf(text, "func"))).toBeNull();
});
@@ -305,3 +305,69 @@ test("didClose drops the region cache so a reopened document at the same version
process.kill();
await process.exited;
});
test("advertises and serves folding ranges and linked editing ranges over the wire", async () => {
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
const uri = "file:///fold.wrn";
const text = `page A {
view {
<div>x</div>
}
}
`;
const reader = process.stdout.getReader();
let output = "";
async function readUntil(marker: string): Promise<void> {
while (!output.includes(marker)) {
const chunk = await reader.read();
if (chunk.done) break;
output += new TextDecoder().decode(chunk.value);
}
}
process.stdin.write(packet({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
await process.stdin.flush();
await readUntil('"id":1');
const initReply = output.slice(output.indexOf('"id":1'));
expect(initReply).toContain('"foldingRangeProvider":true');
expect(initReply).toContain('"linkedEditingRangeProvider":true');
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text } },
}),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 2,
method: "textDocument/foldingRange",
params: { textDocument: { uri } },
}),
);
await process.stdin.flush();
await readUntil('"id":2');
const foldReply = output.slice(output.indexOf('"id":2'));
expect(foldReply).toContain('"result":[');
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 3,
method: "textDocument/linkedEditingRange",
params: { textDocument: { uri }, position: { line: 2, character: 6 } },
}),
);
await process.stdin.flush();
await readUntil('"id":3');
const linkedReply = output.slice(output.indexOf('"id":3'));
expect(linkedReply).toContain('"ranges"');
process.kill();
await process.exited;
});