From e83f0366ef6ba0498c858093452696342b13d50b Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 21:16:47 +0530 Subject: [PATCH] feat(vscode): close HTML tags as they are typed in .wrn files Co-Authored-By: Claude Opus 5 --- editors/vscode/package.json | 11 ++- editors/vscode/src/extension.bundle.cjs | 28 ++++++- editors/vscode/src/extension.js | 34 +++++++- editors/vscode/src/language-server.cjs | 83 ++++++++++++------- packages/language-server/src/server.ts | 6 ++ .../test/language-server.test.ts | 50 +++++++++++ 6 files changed, 179 insertions(+), 33 deletions(-) diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 27fb5f88..c543747e 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -134,6 +134,11 @@ "maximum": 240, "scope": "resource", "description": "Preferred WRNexus formatter line width before long tags are expanded." + }, + "wrnexus.html.autoClosingTags": { + "type": "boolean", + "default": true, + "description": "Automatically close HTML tags inside .wrn view blocks." } } }, @@ -141,6 +146,9 @@ "files.associations": { "*.wrn": "wrn" }, + "emmet.includeLanguages": { + "wrn": "html" + }, "[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": false, @@ -283,6 +291,7 @@ "@vscode/vsce": "^3.9.2" }, "dependencies": { - "vscode-languageclient": "^10.1.0" + "vscode-languageclient": "^10.1.0", + "vscode-html-languageservice": "^5.6.2" } } diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs index 48fa082d..3f238afd 100644 --- a/editors/vscode/src/extension.bundle.cjs +++ b/editors/vscode/src/extension.bundle.cjs @@ -1,4 +1,4 @@ -// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6 +// WRN editor extension source hash: 7721e499707f7fb9b0b52fe428de3033cd624fa508322ec795960af7b6d96da5 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 "use strict"; var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); @@ -22718,6 +22718,29 @@ async function recoverWrnLanguage(document) { console.warn("[wrnexus] unable to recover .wrn language association:", error instanceof Error ? error.message : String(error)); } } +function registerAutoCloseTags(context, client2) { + const listener = vscode.workspace.onDidChangeTextDocument(async (event) => { + if (event.document.languageId !== "wrn") + return; + if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) + return; + const change = event.contentChanges[0]; + if (!change || change.text !== ">" && change.text !== "/") + return; + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document !== event.document) + return; + const position = change.range.start.translate(0, change.text.length); + const snippet = await client2.sendRequest("wrn/tagComplete", { + textDocument: { uri: event.document.uri.toString() }, + position: { line: position.line, character: position.character } + }); + if (typeof snippet !== "string" || !snippet) + return; + await editor.insertSnippet(new vscode.SnippetString(snippet), position); + }); + context.subscriptions.push(listener); +} async function activate(context) { for (const document of vscode.workspace.textDocuments) recoverWrnLanguage(document); @@ -22730,6 +22753,7 @@ async function activate(context) { debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } } }, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] }); await client.start(); + registerAutoCloseTags(context, client); } async function deactivate() { const running = client; @@ -22737,4 +22761,4 @@ async function deactivate() { if (running) await running.stop(); } -module.exports = { activate, deactivate, recoverWrnLanguage }; +module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags }; diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js index be6a85dc..eb804e7e 100644 --- a/editors/vscode/src/extension.js +++ b/editors/vscode/src/extension.js @@ -23,6 +23,37 @@ async function recoverWrnLanguage(document) { } } +/** + * Auto-close tags as they are typed. + * + * LSP has no request for this, so the client watches document changes and asks + * the server whether the tag should close. The server owns the decision because + * void elements and already-closed tags must not be closed. + */ +function registerAutoCloseTags(context, client) { + const listener = vscode.workspace.onDidChangeTextDocument(async (event) => { + if (event.document.languageId !== "wrn") return; + if (!vscode.workspace.getConfiguration("wrnexus.html").get("autoClosingTags", true)) return; + + const change = event.contentChanges[0]; + if (!change || (change.text !== ">" && change.text !== "/")) return; + + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document !== event.document) return; + + const position = change.range.start.translate(0, change.text.length); + const snippet = await client.sendRequest("wrn/tagComplete", { + textDocument: { uri: event.document.uri.toString() }, + position: { line: position.line, character: position.character }, + }); + if (typeof snippet !== "string" || !snippet) return; + + await editor.insertSnippet(new vscode.SnippetString(snippet), position); + }); + + context.subscriptions.push(listener); +} + /** @param {vscode.ExtensionContext} context */ async function activate(context) { for (const document of vscode.workspace.textDocuments) void recoverWrnLanguage(document); @@ -43,6 +74,7 @@ async function activate(context) { { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] }, ); await client.start(); + registerAutoCloseTags(context, client); } async function deactivate() { @@ -51,4 +83,4 @@ async function deactivate() { if (running) await running.stop(); } -module.exports = { activate, deactivate, recoverWrnLanguage }; +module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags }; diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 15127e8c..df997994 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,8 +1,7 @@ #!/usr/bin/env node -// WRN editor language server source hash: 1317c4e318ab938fffab3dd5b65655b0e220a81c481c9fa92f23706ede60303c +// WRN editor language server source hash: 1bda85712569242c96adb5fe2512cc07a59d469c50791a892c5146ab61043da9 // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 -// @bun @bun-cjs -(function(exports, require, module, __filename, __dirname) {var __create = Object.create; +var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; @@ -195906,20 +195905,12 @@ function regionsFor(document) { regionCache.set(document.uri, { version: document.version, regions }); return regions; } -// packages/language-server/src/index.ts -function offsetAt2(text, position) { - const lines = text.split(/\r?\n/); - let offset = 0; - for (let line = 0;line < Math.min(position.line, lines.length); line++) - offset += (lines[line]?.length ?? 0) + 1; - return Math.min(text.length, offset + Math.max(0, position.character)); +function clearHtmlRegionCache(uri) { + if (uri) + regionCache.delete(uri); + else + regionCache.clear(); } -var semanticTokenTypes2 = ["variable"]; -var semanticTokenModifiers2 = ["declaration", "modification"]; -var semanticTokensLegend2 = { - tokenTypes: [...semanticTokenTypes2], - tokenModifiers: [...semanticTokenModifiers2] -}; // packages/language-server/src/html-service.ts var service = getLanguageService(); @@ -195936,7 +195927,7 @@ function markdown(value) { return ""; } function htmlCompletions(document, position) { - if (!isInsideHtml(document, offsetAt2(document.text, position))) + if (!isInsideHtml(document, offsetAt(document.text, position))) return []; const virtual = htmlDocument(document); const parsed = service.parseHTMLDocument(virtual); @@ -195951,7 +195942,7 @@ function htmlCompletions(document, position) { })); } function htmlHover(document, position) { - if (!isInsideHtml(document, offsetAt2(document.text, position))) + if (!isInsideHtml(document, offsetAt(document.text, position))) return null; const virtual = htmlDocument(document); const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual)); @@ -195964,12 +195955,51 @@ 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))) + 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; } +function selfClosingTagCompletion(text, offset) { + if (text.charAt(offset - 1) !== "/") + return null; + if (text.charAt(offset) === ">") + return null; + const tagStart = text.lastIndexOf("<", offset - 1); + if (tagStart < 0) + return null; + if (!/^<[A-Za-z][\w-]*/.test(text.slice(tagStart))) + return null; + let quote = null; + for (let i = tagStart + 1;i < offset - 1; i++) { + const ch = text[i]; + if (quote) { + if (ch === quote) + quote = null; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === "<" || ch === ">") + return null; + } + if (quote) + return null; + return ">"; +} +function htmlTagComplete(document, position) { + 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); +} function mergeCompletions(wrnexus, html) { const taken = new Set(wrnexus.map((item) => item.label)); return [ @@ -195978,15 +196008,6 @@ function mergeCompletions(wrnexus, html) { ]; } -// packages/language-server/src/html-regions.ts -var regionCache2 = new Map; -function clearHtmlRegionCache(uri) { - if (uri) - regionCache2.delete(uri); - else - regionCache2.clear(); -} - // packages/language-server/src/server.ts var documents = new Map; var diagnosticTimers = new Map; @@ -196192,6 +196213,11 @@ async function handle(message) { result(message.id, ranges ? { ranges } : null); break; } + case "wrn/tagComplete": { + const document = documents.get(params.textDocument.uri); + result(message.id, document ? htmlTagComplete(document, params.position) : null); + break; + } case "textDocument/definition": { const document = documents.get(params.textDocument.uri); result(message.id, document ? definitionLocation(document, params.position) : null); @@ -196388,4 +196414,3 @@ process.stdin.on("data", (chunk) => { } }); process.stdin.resume(); -})(exports, require, module, __filename, __dirname); diff --git a/packages/language-server/src/server.ts b/packages/language-server/src/server.ts index e3d95149..3d7fe93e 100644 --- a/packages/language-server/src/server.ts +++ b/packages/language-server/src/server.ts @@ -24,6 +24,7 @@ import { htmlFoldingRanges, htmlHover, htmlLinkedEditingRanges, + htmlTagComplete, mergeCompletions, } from "./html-service.ts"; import { clearHtmlRegionCache } from "./html-regions.ts"; @@ -242,6 +243,11 @@ async function handle(message: JsonRpc): Promise { result(message.id, ranges ? { ranges } : null); break; } + case "wrn/tagComplete": { + const document = documents.get(params.textDocument.uri); + result(message.id, document ? htmlTagComplete(document, params.position) : 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/language-server.test.ts b/packages/language-server/test/language-server.test.ts index a3a6299a..42cc24c9 100644 --- a/packages/language-server/test/language-server.test.ts +++ b/packages/language-server/test/language-server.test.ts @@ -371,3 +371,53 @@ test("advertises and serves folding ranges and linked editing ranges over the wi process.kill(); await process.exited; }); + +test("serves wrn/tagComplete 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:///tag-complete.wrn"; + const text = `page A { + view { +
+ } +} +`; + 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'); + + 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: "wrn/tagComplete", + params: { textDocument: { uri }, position: { line: 2, character: 9 } }, + }), + ); + await process.stdin.flush(); + await readUntil('"id":2'); + const reply = output.slice(output.indexOf('"id":2')); + expect(reply).toContain('"result":"$0
"'); + + process.kill(); + await process.exited; +});