From d9e8f5be82e380aba98e4177369b825f440c6d84 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 21:10:57 +0530 Subject: [PATCH] feat(language-server): add tag folding and linked editing --- editors/vscode/src/language-server.cjs | 25 ++++++- packages/language-server/src/html-service.ts | 16 +++++ packages/language-server/src/server.ts | 21 +++++- .../language-server/test/html-service.test.ts | 23 +++++++ .../test/language-server.test.ts | 66 +++++++++++++++++++ 5 files changed, 149 insertions(+), 2 deletions(-) diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 0bb13dab..15127e8c 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -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); diff --git a/packages/language-server/src/html-service.ts b/packages/language-server/src/html-service.ts index 1cc9cfa3..dfcfafd0 100644 --- a/packages/language-server/src/html-service.ts +++ b/packages/language-server/src/html-service.ts @@ -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 ``. * diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index 89eb4ef8..e3d95149 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -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 { triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"], }, hoverProvider: true, + foldingRangeProvider: true, + linkedEditingRangeProvider: true, definitionProvider: true, referencesProvider: true, renameProvider: { prepareProvider: true }, @@ -223,6 +231,17 @@ async function handle(message: JsonRpc): Promise { 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); diff --git a/packages/language-server/test/html-service.test.ts b/packages/language-server/test/html-service.test.ts index 8aa02e92..fbb437bc 100644 --- a/packages/language-server/test/html-service.test.ts +++ b/packages/language-server/test/html-service.test.ts @@ -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 { +
x
+ } +} +`; + const ranges = htmlLinkedEditingRanges(doc(text), positionOf(text, " { + const text = `page A { + functions { + function go() { } + } +} +`; + expect(htmlLinkedEditingRanges(doc(text), positionOf(text, "func"))).toBeNull(); +}); diff --git a/packages/language-server/test/language-server.test.ts b/packages/language-server/test/language-server.test.ts index 4ec65bd9..a3a6299a 100644 --- a/packages/language-server/test/language-server.test.ts +++ b/packages/language-server/test/language-server.test.ts @@ -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 { +
x
+ } +} +`; + const reader = process.stdout.getReader(); + let output = ""; + async function readUntil(marker: string): Promise { + 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; +});