feat(vscode): close HTML tags as they are typed in .wrn files

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 21:16:47 +05:30
co-authored by Claude Opus 5
parent d9e8f5be82
commit e83f0366ef
6 changed files with 179 additions and 33 deletions
+10 -1
View File
@@ -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"
}
}
+26 -2
View File
@@ -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 };
+33 -1
View File
@@ -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 };
+54 -29
View File
@@ -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);
+6
View File
@@ -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<void> {
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);
@@ -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 {
<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');
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</div>"');
process.kill();
await process.exited;
});