Compare commits

...
Author SHA1 Message Date
Clintchiz d70e89230b chore(release): publish language server 0.8.10
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-18 22:38:47 +05:30
ClintchizandClaude Opus 5 7e6d8c3bc8 test(language-server): add end-to-end verification for HTML editing support
Adds a scratch page (kept intentionally, per controller ruling on Task 8)
and one end-to-end test that drives the real language server over LSP
stdio against that page's real text, verifying completion (with the
seo-block negative case tested via a simulated '<' keystroke and
mutation-verified against html-regions.ts), hover, folding ranges,
linked editing, and wrn/tagComplete all work together on realistic
content.

Regenerates routes.gen.ts and wrnexus.generated.d.ts for the new page's
route, required by check:generated-types.

Two checks from the original brief (auto-close-tag insertion and Emmet
Tab-expansion) require a live VS Code Extension Development Host and
are documented as outstanding manual verification in
.superpowers/sdd/2026-08-18-wrn-html-editing/task-8-report.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:59:19 +05:30
ClintchizandClaude Opus 5 92c0920c9b test(vscode): guard the Emmet mapping and auto-close setting
Also fix an unused-var lint failure in completion-scope.test.js blocking the production gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:36:20 +05:30
Clintchiz a8d8ac386f test(vscode): add integration tests for completion provider guard 2026-08-18 21:28:38 +05:30
Clintchiz 2c8841cc5f fix(vscode): stop duplicating completions inside view blocks 2026-08-18 21:23:54 +05:30
ClintchizandClaude Opus 5 b344d2a70a fix(vscode): guard tag auto-close against replaced selections and stale round-trips
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:20:16 +05:30
ClintchizandClaude Opus 5 e83f0366ef feat(vscode): close HTML tags as they are typed in .wrn files
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:16:47 +05:30
Clintchiz d9e8f5be82 feat(language-server): add tag folding and linked editing 2026-08-18 21:10:57 +05:30
Clintchiz bd0c1317ff test(language-server): cover didClose region-cache clear end-to-end
Replaces the direct-call-only test with an over-stdio test that exercises
server.ts's didClose handler itself, so it fails if the
clearHtmlRegionCache wiring is removed or misparameterized.
2026-08-18 21:04:33 +05:30
Clintchiz 97801287f9 feat(language-server): merge HTML completions and hover into one response 2026-08-18 20:56:17 +05:30
Clintchiz d77638131b fix(language-server): don't self-close tags inside quoted attribute values 2026-08-18 20:47:19 +05:30
Clintchiz 609224591c feat(language-server): answer HTML completion, hover, folding, and tag close 2026-08-18 20:43:47 +05:30
Clintchiz 6074d19c43 fix(language-server): bypass cache for version-less documents 2026-08-18 20:38:37 +05:30
Clintchiz 7301849a7f feat(language-server): add offset-preserving virtual HTML document 2026-08-18 20:34:24 +05:30
19 changed files with 23873 additions and 20 deletions
+12 -1
View File
@@ -451,13 +451,14 @@
}, },
"packages/language-server": { "packages/language-server": {
"name": "@wrnexus/language-server", "name": "@wrnexus/language-server",
"version": "0.8.9", "version": "0.8.10",
"bin": { "bin": {
"wrnexus-language-server": "src/server.ts", "wrnexus-language-server": "src/server.ts",
}, },
"dependencies": { "dependencies": {
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"@wrnexus/typecheck": "workspace:*", "@wrnexus/typecheck": "workspace:*",
"vscode-html-languageservice": "^5.6.2",
}, },
}, },
"packages/mcp": { "packages/mcp": {
@@ -967,6 +968,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.67.0", "", { "dependencies": { "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"], "@wrnexus/ai": ["@wrnexus/ai@workspace:packages/ai"],
"@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"], "@wrnexus/auth": ["@wrnexus/auth@workspace:packages/auth"],
@@ -1387,6 +1390,14 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"web": ["web@workspace:examples/inter-app-api-showcase/apps/web"], "web": ["web@workspace:examples/inter-app-api-showcase/apps/web"],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+10 -1
View File
@@ -134,6 +134,11 @@
"maximum": 240, "maximum": 240,
"scope": "resource", "scope": "resource",
"description": "Preferred WRNexus formatter line width before long tags are expanded." "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": { "files.associations": {
"*.wrn": "wrn" "*.wrn": "wrn"
}, },
"emmet.includeLanguages": {
"wrn": "html"
},
"[wrn]": { "[wrn]": {
"editor.defaultFormatter": "wrnexus.wrnexus", "editor.defaultFormatter": "wrnexus.wrnexus",
"editor.formatOnSave": false, "editor.formatOnSave": false,
@@ -283,6 +291,7 @@
"@vscode/vsce": "^3.9.2" "@vscode/vsce": "^3.9.2"
}, },
"dependencies": { "dependencies": {
"vscode-languageclient": "^10.1.0" "vscode-languageclient": "^10.1.0",
"vscode-html-languageservice": "^5.6.2"
} }
} }
+38
View File
@@ -570,6 +570,41 @@ function isInsideWatch(document, position) {
return depth > 0; return depth > 0;
} }
/**
* Whether an offset sits inside a `view { }` block.
*
* The language server owns completion there and returns a merged list, so this
* provider stands down to avoid VS Code concatenating two independent lists.
* Quotes are only tracked inside a tag: `<p>it's</p>` would otherwise open a
* string that never closes.
*/
function isInsideViewBlock(text, offset) {
const pattern = /\bview\s*\{/g;
let match;
while ((match = pattern.exec(text))) {
const start = match.index + match[0].length;
let depth = 1;
let inTag = false;
let quote = null;
let index = start;
for (; index < text.length && depth > 0; index += 1) {
const char = text[index];
if (quote) {
if (char === quote) quote = null;
continue;
}
if (inTag && (char === '"' || char === "'")) quote = char;
else if (char === "<") inTag = true;
else if (char === ">") inTag = false;
else if (char === "{") depth += 1;
else if (char === "}") depth -= 1;
}
if (offset >= start && offset <= index) return true;
pattern.lastIndex = index;
}
return false;
}
function isAfterWatchKeyword(document, position) { function isAfterWatchKeyword(document, position) {
const linePrefix = document.lineAt(position.line).text.slice(0, position.character); const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
@@ -634,6 +669,8 @@ function addFunctionCompletions(items, document) {
} }
function provideCompletionItems(document, position) { function provideCompletionItems(document, position) {
if (isInsideViewBlock(document.getText(), document.offsetAt(position))) return [];
const items = []; const items = [];
const linePrefix = document.lineAt(position.line).text.slice(0, position.character); const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
@@ -707,6 +744,7 @@ module.exports = {
extractProps, extractProps,
extractRouteParams, extractRouteParams,
extractStates, extractStates,
isInsideViewBlock,
provideCompletionItems, provideCompletionItems,
registerCompletionProvider, registerCompletionProvider,
}; };
+39 -2
View File
@@ -1,4 +1,4 @@
// WRN editor extension source hash: c9938fa5f643ca435ead2c8dd5b545c6906c37c0024cf3ea788666236c28ddb6 // WRN editor extension source hash: 2012cf8721073e3d9df81af2d3207fdf070119fb3f85ed832ce23c3ee4307662
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728 // WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict"; "use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports); var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
@@ -22718,6 +22718,42 @@ async function recoverWrnLanguage(document) {
console.warn("[wrnexus] unable to recover .wrn language association:", error instanceof Error ? error.message : String(error)); 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;
if (change.rangeLength !== 0)
return;
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document !== event.document)
return;
const documentVersion = event.document.version;
const position = change.range.start.translate(0, change.text.length);
if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position))
return;
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;
if (vscode.window.activeTextEditor !== editor)
return;
if (editor.document !== event.document)
return;
if (editor.document.version !== documentVersion)
return;
if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position))
return;
await editor.insertSnippet(new vscode.SnippetString(snippet), position);
});
context.subscriptions.push(listener);
}
async function activate(context) { async function activate(context) {
for (const document of vscode.workspace.textDocuments) for (const document of vscode.workspace.textDocuments)
recoverWrnLanguage(document); recoverWrnLanguage(document);
@@ -22730,6 +22766,7 @@ async function activate(context) {
debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } } debug: { module: module2, transport: TransportKind.stdio, options: { execArgv: ["--nolazy"] } }
}, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] }); }, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] });
await client.start(); await client.start();
registerAutoCloseTags(context, client);
} }
async function deactivate() { async function deactivate() {
const running = client; const running = client;
@@ -22737,4 +22774,4 @@ async function deactivate() {
if (running) if (running)
await running.stop(); await running.stop();
} }
module.exports = { activate, deactivate, recoverWrnLanguage }; module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
+47 -1
View File
@@ -23,6 +23,51 @@ 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;
// A replaced selection (overtype/select-and-type) makes `range.start + text.length`
// an incorrect offset for both the query and the insertion; decline rather than guess.
if (change.rangeLength !== 0) return;
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document !== event.document) return;
const documentVersion = event.document.version;
const position = change.range.start.translate(0, change.text.length);
if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position)) return;
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;
// The user may have kept typing during the round-trip; re-validate everything the
// insertion depends on before touching the document, since a stale offset would
// silently corrupt it.
if (vscode.window.activeTextEditor !== editor) return;
if (editor.document !== event.document) return;
if (editor.document.version !== documentVersion) return;
if (!editor.selection.isEmpty || !editor.selection.active.isEqual(position)) return;
await editor.insertSnippet(new vscode.SnippetString(snippet), position);
});
context.subscriptions.push(listener);
}
/** @param {vscode.ExtensionContext} context */ /** @param {vscode.ExtensionContext} context */
async function activate(context) { async function activate(context) {
for (const document of vscode.workspace.textDocuments) void recoverWrnLanguage(document); for (const document of vscode.workspace.textDocuments) void recoverWrnLanguage(document);
@@ -43,6 +88,7 @@ async function activate(context) {
{ documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] }, { documentSelector: [{ scheme: "file", language: WRN_LANGUAGE_ID }] },
); );
await client.start(); await client.start();
registerAutoCloseTags(context, client);
} }
async function deactivate() { async function deactivate() {
@@ -51,4 +97,4 @@ async function deactivate() {
if (running) await running.stop(); if (running) await running.stop();
} }
module.exports = { activate, deactivate, recoverWrnLanguage }; module.exports = { activate, deactivate, recoverWrnLanguage, registerAutoCloseTags };
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert");
const Module = require("node:module");
// Mock the vscode module for unit tests
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === "vscode") {
return {
Position: class Position {
constructor(line, character) {
this.line = line;
this.character = character;
}
},
Range: class Range {
constructor(start, end) {
this.start = start;
this.end = end;
}
},
CompletionItem: class CompletionItem {
constructor(label, kind) {
this.label = label;
this.kind = kind;
}
},
CompletionItemKind: {
Event: 23,
Property: 10,
Function: 12,
Keyword: 14,
Variable: 13,
},
SnippetString: class SnippetString {
constructor(text) {
this.value = text;
}
},
MarkdownString: class MarkdownString {
constructor(text) {
this.value = text;
}
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
const { isInsideViewBlock, provideCompletionItems } = require("../src/completion.js");
Module._load = originalLoad;
const PAGE = `page Home {
view {
<div>hello</div>
}
functions {
function go() {}
}
}
`;
test("a markup offset is inside a view block", () => {
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("<div")), true);
});
test("a functions-block offset is not inside a view block", () => {
assert.equal(isInsideViewBlock(PAGE, PAGE.indexOf("function go")), false);
});
test("provideCompletionItems returns empty array when inside view block", () => {
const document = {
getText() {
return PAGE;
},
offsetAt() {
return PAGE.indexOf("<div");
},
lineAt() {
return { text: "<div>hello</div>" };
},
fileName: "test.wrn",
};
const position = { line: 2, character: 4 };
const result = provideCompletionItems(document, position);
assert.equal(Array.isArray(result), true);
assert.equal(result.length, 0);
});
test("provideCompletionItems returns non-empty array when inside functions block", () => {
const document = {
getText() {
return PAGE;
},
offsetAt() {
return PAGE.indexOf("function go");
},
lineAt() {
return { text: " function go() {}" };
},
fileName: "test.wrn",
};
const position = { line: 5, character: 4 };
const result = provideCompletionItems(document, position);
assert.equal(Array.isArray(result), true);
assert(result.length > 0, "should return non-empty completions outside view block");
});
+15
View File
@@ -113,6 +113,21 @@ try {
readFileSync(join(root, rel), "utf8"); readFileSync(join(root, rel), "utf8");
ok(`Marketplace document exists: ${rel}`); ok(`Marketplace document exists: ${rel}`);
} }
const emmetLanguages = manifest.contributes?.configurationDefaults?.["emmet.includeLanguages"];
emmetLanguages?.wrn === "html"
? ok("Emmet is mapped for wrn documents")
: bad("Emmet is mapped for wrn documents", `got ${JSON.stringify(emmetLanguages)}`);
const autoClose =
manifest.contributes?.configuration?.properties?.["wrnexus.html.autoClosingTags"];
autoClose?.type === "boolean" && autoClose?.default === true
? ok("auto-closing tags setting is contributed")
: bad("auto-closing tags setting is contributed", `got ${JSON.stringify(autoClose)}`);
manifest.dependencies?.["vscode-html-languageservice"]
? ok("HTML language service ships as a runtime dependency")
: bad("HTML language service ships as a runtime dependency");
} catch (e) { } catch (e) {
bad("Marketplace metadata", e.message); bad("Marketplace metadata", e.message);
} }
@@ -0,0 +1,13 @@
page HtmlEditingCheck {
seo {
title = "HTML editing check"
description = "Scratch page for verifying editor support inside view blocks."
canonical = "/html-editing-check"
}
view {
<main>
<h1>Editor check</h1>
</main>
}
}
+3
View File
@@ -9,6 +9,7 @@ export interface Routes {
"/client-only": Record<string, never>; "/client-only": Record<string, never>;
"/dashboard": Record<string, never>; "/dashboard": Record<string, never>;
"/hello": Record<string, never>; "/hello": Record<string, never>;
"/html-editing-check": Record<string, never>;
"/island-demo": Record<string, never>; "/island-demo": Record<string, never>;
"/language-tools": Record<string, never>; "/language-tools": Record<string, never>;
"/layout": Record<string, never>; "/layout": Record<string, never>;
@@ -32,6 +33,7 @@ export interface RouteNames {
"client.only": "/client-only"; "client.only": "/client-only";
"dashboard": "/dashboard"; "dashboard": "/dashboard";
"hello": "/hello"; "hello": "/hello";
"html.editing.check": "/html-editing-check";
"island.demo": "/island-demo"; "island.demo": "/island-demo";
"language.tools": "/language-tools"; "language.tools": "/language-tools";
"layout": "/layout"; "layout": "/layout";
@@ -124,6 +126,7 @@ export function route<N extends RouteName>(
"client.only": "/client-only", "client.only": "/client-only",
"dashboard": "/dashboard", "dashboard": "/dashboard",
"hello": "/hello", "hello": "/hello",
"html.editing.check": "/html-editing-check",
"island.demo": "/island-demo", "island.demo": "/island-demo",
"language.tools": "/language-tools", "language.tools": "/language-tools",
"layout": "/layout", "layout": "/layout",
+1 -1
View File
@@ -13,7 +13,7 @@ declare namespace WRNexusGenerated {
: never; : never;
type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown; type RealtimeMessage<T> = T extends import("@wrnexus/core").RoomDefinition<any, infer M> ? M : unknown;
type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown; type QueuePayload<T> = T extends import("@wrnexus/queue").JobDefinition<infer I> ? I : unknown;
type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui"; type RouteName = "about" | "async.data" | "chat" | "client.only" | "dashboard" | "hello" | "html.editing.check" | "index" | "island.demo" | "language.tools" | "layout" | "login" | "modal" | "navigation" | "partial.static" | "platform.showcase" | "reactive" | "server.actions" | "table" | "test" | "ui";
type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment"; type ApiRoute = "/api/accounts" | "/api/echo" | "/api/graphql-example" | "/api/hello" | "/api/invite" | "/api/login" | "/api/logout" | "/api/me" | "/api/typed-user" | "/api/users/csr" | "/api/users/ssr" | "/api/webhooks/payment";
type RealtimeRoute = "/realtime/chat" | "/realtime/hello"; type RealtimeRoute = "/realtime/chat" | "/realtime/hello";
type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY"; type EnvironmentKey = "APP_LABEL" | "DATABASE_URL" | "DEMO_SHARED" | "HOST" | "NODE_ENV" | "PORT" | "SESSION_SECRET" | "UAT_ONLY";
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@wrnexus/language-server", "name": "@wrnexus/language-server",
"version": "0.8.9", "version": "0.8.10",
"type": "module", "type": "module",
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.", "description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@wrnexus/syntax": "workspace:*", "@wrnexus/syntax": "workspace:*",
"@wrnexus/typecheck": "workspace:*" "@wrnexus/typecheck": "workspace:*",
"vscode-html-languageservice": "^5.6.2"
} }
} }
@@ -0,0 +1,120 @@
import type { TextDocument } from "./index.ts";
export interface HtmlRegion {
start: number;
end: number;
}
/**
* Byte ranges of the markup inside each `view { }` block.
*
* This is a tolerant scanner rather than the parser: completion fires while
* the document is being typed, which is exactly when it does not parse.
*/
export function viewRegions(text: string): HtmlRegion[] {
const regions: HtmlRegion[] = [];
const pattern = /\bview\s*\{/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text))) {
const bodyStart = match.index + match[0].length;
const end = matchingBrace(text, bodyStart);
regions.push({ start: bodyStart, end });
pattern.lastIndex = end;
}
return regions;
}
/**
* Offset of the brace closing the block that starts at `from`, or the end of
* the text when it is never closed (an unterminated block is normal mid-edit).
*
* Quotes are only tracked inside a tag, never in text content: `<p>it's</p>`
* would otherwise open a string that never closes and swallow the rest of the
* file.
*/
function matchingBrace(text: string, from: number): number {
let depth = 1;
let inTag = false;
let quote: string | null = null;
for (let index = from; index < text.length; index += 1) {
const char = text[index]!;
if (quote) {
if (char === quote) quote = null;
continue;
}
if (inTag && (char === '"' || char === "'")) {
quote = char;
continue;
}
if (char === "<") inTag = true;
else if (char === ">") inTag = false;
else if (char === "{") depth += 1;
else if (char === "}") {
depth -= 1;
if (depth === 0) return index;
}
}
return text.length;
}
/**
* A parallel document containing only the markup.
*
* Everything outside a view block becomes whitespace of the same length, and
* newlines are preserved, so an offset in the source is the same offset here.
* That removes the need for a mapping table entirely.
*/
export function virtualHtmlDocument(document: TextDocument): {
uri: string;
languageId: "html";
text: string;
} {
const source = document.text;
const keep = new Array<boolean>(source.length).fill(false);
for (const region of viewRegions(source)) {
for (let index = region.start; index < region.end; index += 1) keep[index] = true;
}
let text = "";
for (let index = 0; index < source.length; index += 1) {
const char = source[index]!;
text += keep[index] || char === "\n" ? char : char === "\r" ? "\r" : " ";
}
return { uri: `${document.uri}.html`, languageId: "html", text };
}
export function isInsideHtml(document: TextDocument, offset: number): boolean {
return regionsFor(document).some((region) => offset >= region.start && offset <= region.end);
}
/**
* Regions for a document, cached by uri and version.
*
* A single keystroke produces a burst of completion, hover, and tag-close
* requests; without this each one rescans the file.
*/
const regionCache = new Map<string, { version: number; regions: HtmlRegion[] }>();
function regionsFor(document: TextDocument): HtmlRegion[] {
// Documents without a version have no way to signal changes, so bypass cache.
if (document.version === undefined) {
return viewRegions(document.text);
}
const cached = regionCache.get(document.uri);
if (cached && cached.version === document.version) return cached.regions;
const regions = viewRegions(document.text);
regionCache.set(document.uri, { version: document.version, regions });
return regions;
}
/** Drops a document's cached regions. Call when a document closes. */
export function clearHtmlRegionCache(uri?: string): void {
if (uri) regionCache.delete(uri);
else regionCache.clear();
}
@@ -0,0 +1,176 @@
// 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 { offsetAt, type Position, type TextDocument } from "./index.ts";
export interface HtmlCompletionItem {
label: string;
kind: number;
detail?: string;
documentation?: string;
sortText?: string;
insertText?: string;
}
const service = getLanguageService();
/** The virtual document as the HTML service's own document type. */
function htmlDocument(document: TextDocument) {
const virtual = virtualHtmlDocument(document);
return HtmlTextDocument.create(virtual.uri, "html", document.version ?? 1, virtual.text);
}
function markdown(value: unknown): string {
if (typeof value === "string") return value;
if (value && typeof value === "object" && "value" in value) {
return String((value as { value: unknown }).value);
}
return "";
}
/**
* HTML completions for a position inside a view block.
*
* Every item carries the `1` sortText prefix so the server can rank WRNexus
* entries above these without filtering either list.
*/
export function htmlCompletions(document: TextDocument, position: Position): HtmlCompletionItem[] {
if (!isInsideHtml(document, offsetAt(document.text, position))) return [];
const virtual = htmlDocument(document);
const parsed = service.parseHTMLDocument(virtual);
const list = service.doComplete(virtual, position, parsed);
return list.items.map((item) => ({
label: item.label,
kind: typeof item.kind === "number" ? item.kind : 1,
detail: item.detail,
documentation: markdown(item.documentation),
sortText: `1${item.sortText ?? item.label}`,
insertText: item.textEdit && "newText" in item.textEdit ? item.textEdit.newText : undefined,
}));
}
export function htmlHover(document: TextDocument, position: Position): { contents: string } | null {
if (!isInsideHtml(document, offsetAt(document.text, position))) return null;
const virtual = htmlDocument(document);
const result = service.doHover(virtual, position, service.parseHTMLDocument(virtual));
if (!result) return null;
const contents = markdown(result.contents);
return contents ? { contents } : null;
}
export function htmlFoldingRanges(
document: TextDocument,
): Array<{ startLine: number; endLine: number }> {
return service
.getFoldingRanges(htmlDocument(document))
.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 `>`.
*
* `vscode-html-languageservice`'s own `doTagComplete` only reacts to a typed
* `/` when it opens an end tag (`</`); it has no notion of a self-closing
* start tag, since plain HTML has no such elements outside its fixed void-element
* list. WRNexus components (`<Card />`) are exactly that case, so we complete
* it ourselves rather than relying on the library.
*
* The scan tracks quote state from the tag's opening `<` up to `offset` (quotes
* are only meaningful inside a tag) so a `/` inside an attribute value — e.g. the
* first slash of `href="https://..."` — never misfires as a self-close: the
* library already returns `null` there on purpose, because the cursor sits in an
* attribute value, not a tag-close position.
*/
function selfClosingTagCompletion(text: string, offset: number): string | null {
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 = 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 ">";
}
/**
* The snippet that closes the tag being typed, or null.
*
* Void elements and already-closed tags return null, which is why this decision
* belongs here rather than in the editor client.
*/
export function htmlTagComplete(document: TextDocument, position: Position): string | null {
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);
}
/**
* 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)),
];
}
+39 -4
View File
@@ -19,6 +19,15 @@ import {
htmlToWrn, htmlToWrn,
type TextDocument, type TextDocument,
} from "./index.ts"; } from "./index.ts";
import {
htmlCompletions,
htmlFoldingRanges,
htmlHover,
htmlLinkedEditingRanges,
htmlTagComplete,
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,8 +120,12 @@ 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,
foldingRangeProvider: true,
linkedEditingRangeProvider: true,
definitionProvider: true, definitionProvider: true,
referencesProvider: true, referencesProvider: true,
renameProvider: { prepareProvider: true }, renameProvider: { prepareProvider: true },
@@ -172,6 +185,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 +209,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 +228,24 @@ 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;
}
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 "wrn/tagComplete": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? htmlTagComplete(document, params.position) : null);
break; break;
} }
case "textDocument/definition": { case "textDocument/definition": {
@@ -0,0 +1,199 @@
import { expect, test } from "bun:test";
import { fileURLToPath } from "node:url";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { positionAt } from "../src/index.ts";
// End-to-end verification (Task 8): drives the real language server, over
// real LSP stdio framing, against the real scratch page checked in at
// examples/basic-app/app/pages/html-editing-check.wrn. The page's text is
// read from disk rather than duplicated here, so this test breaks if the
// page and the test drift apart.
const pagePath = join(
fileURLToPath(new URL("../../../", import.meta.url)),
"examples/basic-app/app/pages/html-editing-check.wrn",
);
const pageText = readFileSync(pagePath, "utf8");
function packet(value: unknown): Uint8Array {
const body = JSON.stringify(value);
return new TextEncoder().encode(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
}
async function withServer<T>(
run: (
send: (msg: unknown) => Promise<void>,
readUntil: (marker: string) => Promise<string>,
) => Promise<T>,
): Promise<T> {
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
const reader = process.stdout.getReader();
let output = "";
async function readUntil(marker: string): Promise<string> {
while (!output.includes(marker)) {
const chunk = await reader.read();
if (chunk.done) break;
output += new TextDecoder().decode(chunk.value);
}
return output.slice(output.indexOf(marker));
}
async function send(message: unknown): Promise<void> {
process.stdin.write(packet(message));
await process.stdin.flush();
}
try {
return await run(send, readUntil);
} finally {
process.kill();
await process.exited;
}
}
test("HTML editor support works end-to-end against the real scratch page", async () => {
const uri = "file:///html-editing-check.wrn";
// Positions derived from the real file content, not hardcoded line/column
// literals, so the test tracks the page if it changes.
const viewOpenBrace = pageText.indexOf("view {");
const viewCloseBrace = pageText.indexOf("}", pageText.indexOf("</main>"));
const h1TagOffset = pageText.indexOf("<h1>") + 1; // inside "h1"
const mainOpenNameOffset = pageText.indexOf("<main>") + 1; // inside "main" opening tag name
const mainOpenTagEndOffset = pageText.indexOf("<main>") + "<main>".length; // just after '>'
const seoTitleOffset = pageText.indexOf('title = "HTML editing check"'); // inside the seo block
// Simulate typing "<" inside the seo {} block: insert the character into a
// copy of the real page text (the checked-in file itself is never
// mutated) and ask for completion right after it. This is the scenario
// that actually exercises the view-region guard: if the guard were
// defeated, this exact position is where the HTML language service would
// offer tag completions, because it is positioned directly after an
// unclosed "<".
const seoInjectedText = pageText.slice(0, seoTitleOffset) + "<" + pageText.slice(seoTitleOffset);
const seoInjectedPosition = positionAt(seoInjectedText, seoTitleOffset + 1);
const viewPosition = positionAt(pageText, h1TagOffset);
const linkedEditingPosition = positionAt(pageText, mainOpenNameOffset);
const tagCompletePosition = positionAt(pageText, mainOpenTagEndOffset);
const viewStartLine = positionAt(pageText, viewOpenBrace).line;
const viewEndLine = positionAt(pageText, viewCloseBrace).line;
await withServer(async (send, readUntil) => {
await send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
await readUntil('"id":1');
await send({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text: pageText } },
});
// --- Completion inside view {} : HTML entries present, WRNexus sorts first ---
await send({
jsonrpc: "2.0",
id: 2,
method: "textDocument/completion",
params: { textDocument: { uri }, position: viewPosition },
});
const completionReply = await readUntil('"id":2');
// An HTML tag entry is present, tagged with the "1" (below-wrnexus) sortText prefix.
expect(completionReply).toMatch(/"label":"div"[^}]*"sortText":"1/);
// A WRNexus keyword entry is present, tagged with the "0" (above-html) sortText prefix.
expect(completionReply).toMatch(/"label":"page"[^}]*"sortText":"0/);
// No html-prefixed sortText is lexicographically smaller than any wrnexus one:
// every "0..." sortText must precede every "1..." sortText, which is exactly
// what makes WRNexus entries render above HTML ones in an editor.
expect(completionReply).not.toMatch(/"sortText":"1[^"]*"[\s\S]*"sortText":"0/);
// --- Negative case: completion right after "<" typed inside seo {} must NOT include HTML entries ---
const seoUri = "file:///html-editing-check-seo-inject.wrn";
await send({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri: seoUri, version: 1, text: seoInjectedText } },
});
await send({
jsonrpc: "2.0",
id: 3,
method: "textDocument/completion",
params: { textDocument: { uri: seoUri }, position: seoInjectedPosition },
});
const seoReply = await readUntil('"id":3');
expect(seoReply).not.toContain('"label":"div"');
expect(seoReply).not.toContain('"label":"span"');
expect(seoReply).not.toContain('"label":"h1"');
expect(seoReply).not.toMatch(/"sortText":"1/);
// --- Hover over a tag inside the view block returns documentation ---
await send({
jsonrpc: "2.0",
id: 4,
method: "textDocument/hover",
params: { textDocument: { uri }, position: viewPosition },
});
const hoverReply = await readUntil('"id":4');
expect(hoverReply).toContain('"contents"');
expect(hoverReply).not.toMatch(/"result":\s*null/);
// --- Folding ranges are returned and all lie within the view block ---
await send({
jsonrpc: "2.0",
id: 5,
method: "textDocument/foldingRange",
params: { textDocument: { uri } },
});
const foldReply = await readUntil('"id":5');
expect(foldReply).toContain('"result":[');
const foldBody = foldReply.slice(foldReply.indexOf('"result":['));
const foldRanges = [...foldBody.matchAll(/"startLine":(\d+),"endLine":(\d+)/g)];
expect(foldRanges.length).toBeGreaterThan(0);
for (const [, start, end] of foldRanges) {
expect(Number(start)).toBeGreaterThanOrEqual(viewStartLine);
expect(Number(end)).toBeLessThanOrEqual(viewEndLine);
}
// --- Linked editing at the <main> opening tag name returns two ranges ---
await send({
jsonrpc: "2.0",
id: 6,
method: "textDocument/linkedEditingRange",
params: { textDocument: { uri }, position: linkedEditingPosition },
});
const linkedReply = await readUntil('"id":6');
expect(linkedReply).toContain('"ranges"');
const rangeCount = (linkedReply.match(/"start":\{/g) ?? []).length;
expect(rangeCount).toBe(2);
// --- wrn/tagComplete after <main> returns the closing snippet ---
await send({
jsonrpc: "2.0",
id: 7,
method: "wrn/tagComplete",
params: { textDocument: { uri }, position: tagCompletePosition },
});
const tagCompleteReply = await readUntil('"id":7');
expect(tagCompleteReply).toContain('"result":"$0</main>"');
// --- wrn/tagComplete for a void element (<br>) returns null ---
const voidText = `page A {\n view {\n <br>\n }\n}\n`;
const voidUri = "file:///html-editing-check-void.wrn";
await send({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri: voidUri, version: 1, text: voidText } },
});
const brOffset = voidText.indexOf("<br>") + "<br>".length;
const brPosition = positionAt(voidText, brOffset);
await send({
jsonrpc: "2.0",
id: 8,
method: "wrn/tagComplete",
params: { textDocument: { uri: voidUri }, position: brPosition },
});
const voidReply = await readUntil('"id":8');
expect(voidReply).toContain('"result":null');
});
});
@@ -0,0 +1,146 @@
import { expect, test } from "bun:test";
import {
clearHtmlRegionCache,
isInsideHtml,
viewRegions,
virtualHtmlDocument,
} from "../src/html-regions.ts";
function doc(text: string): { uri: string; text: string; version?: number } {
return { uri: `file:///${Math.random()}.wrn`, text };
}
const PAGE = `page Home {
view {
<div class="card">hello</div>
}
}
`;
test("the virtual document preserves length and newline offsets", () => {
// This is what makes position mapping unnecessary. If it breaks, every
// feature reports positions off by some amount instead of failing loudly.
const source = doc(PAGE);
const virtual = virtualHtmlDocument(source);
expect(virtual.text.length).toBe(source.text.length);
expect(virtual.languageId).toBe("html");
for (let i = 0; i < source.text.length; i += 1) {
if (source.text[i] === "\n") expect(virtual.text[i]).toBe("\n");
}
});
test("markup survives into the virtual document and everything else is blanked", () => {
const virtual = virtualHtmlDocument(doc(PAGE));
expect(virtual.text).toContain('<div class="card">hello</div>');
expect(virtual.text).not.toContain("page Home");
expect(virtual.text).not.toContain("view");
});
test("an apostrophe in text content does not swallow later regions", () => {
// A scanner treating ' as a string delimiter anywhere considers the rest of
// the file one open string and loses every later region.
const source = `page A {
view {
<p>it's fine</p>
}
}
component B {
view {
<span>second</span>
}
}
`;
expect(viewRegions(source)).toHaveLength(2);
expect(virtualHtmlDocument(doc(source)).text).toContain("<span>second</span>");
});
test("interpolation braces nest without ending the region early", () => {
const source = `page A {
view {
<div class={cond ? "a" : "b"} data-x={{ a: 1 }}>after</div>
}
}
`;
const regions = viewRegions(source);
expect(regions).toHaveLength(1);
expect(virtualHtmlDocument(doc(source)).text).toContain("after</div>");
});
test("unparseable mid-edit markup still yields a region", () => {
// Completion fires exactly when the document does not parse.
const source = `page A {
view {
<div class="
}
}
`;
expect(viewRegions(source).length).toBe(1);
});
test("a file with no view block yields no regions and a fully blank document", () => {
const source = `page A {
functions {
function go() {}
}
}
`;
expect(viewRegions(source)).toEqual([]);
expect(virtualHtmlDocument(doc(source)).text.trim()).toBe("");
});
test("regions are cached per document version", () => {
// One keystroke fans out into completion, hover, and tag-close requests.
const first = doc(PAGE);
first.version = 1;
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
// Same version, mutated text: the cached regions are reused, proving the
// scan did not run again.
first.text = "page A { }";
expect(isInsideHtml(first, PAGE.indexOf("<div"))).toBe(true);
first.version = 2;
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", () => {
const source = doc(PAGE);
const markupOffset = PAGE.indexOf("<div");
const keywordOffset = PAGE.indexOf("page");
expect(isInsideHtml(source, markupOffset)).toBe(true);
expect(isInsideHtml(source, keywordOffset)).toBe(false);
});
test("documents without a version field scan every time and detect mutations", () => {
// Without a version, the cache has no key to validate freshness. Mutations
// must be detected on every call, even when uri and document are reused.
const source = doc(PAGE);
// Explicitly verify version is undefined (not set by doc() helper).
expect(source.version).toBeUndefined();
const markupOffset = PAGE.indexOf("<div");
expect(isInsideHtml(source, markupOffset)).toBe(true);
// Mutate the text: remove the view block.
source.text = "page A { }";
// Same uri, same missing version, but different text: mutation must be detected.
expect(isInsideHtml(source, markupOffset)).toBe(false);
});
@@ -0,0 +1,197 @@
import { expect, test } from "bun:test";
import {
htmlCompletions,
htmlFoldingRanges,
htmlHover,
htmlLinkedEditingRanges,
htmlTagComplete,
} from "../src/html-service.ts";
function doc(text: string) {
return { uri: "file:///Page.wrn", text };
}
function positionOf(text: string, needle: string) {
const offset = text.indexOf(needle) + needle.length;
const before = text.slice(0, offset);
const lines = before.split("\n");
return { line: lines.length - 1, character: lines[lines.length - 1]!.length };
}
test("suggests HTML tags inside a view block", () => {
const text = `page A {
view {
<
}
}
`;
const items = htmlCompletions(doc(text), positionOf(text, " <"));
expect(items.some((item) => item.label === "div")).toBe(true);
expect(items.every((item) => item.sortText?.startsWith("1"))).toBe(true);
});
test("suggests attributes inside a tag", () => {
const text = `page A {
view {
<input
}
}
`;
const items = htmlCompletions(doc(text), positionOf(text, "<input "));
expect(items.some((item) => item.label === "type")).toBe(true);
});
test("returns nothing outside a view block", () => {
const text = `page A {
functions {
function go() { }
}
}
`;
expect(htmlCompletions(doc(text), positionOf(text, "function go() "))).toEqual([]);
});
test("hovers a tag inside a view block and nothing outside one", () => {
const text = `page A {
view {
<div>x</div>
}
}
`;
expect(htmlHover(doc(text), positionOf(text, "<di"))).not.toBeNull();
const code = `page A {
functions {
function go() { }
}
}
`;
expect(htmlHover(doc(code), positionOf(code, "func"))).toBeNull();
});
test("closes an open tag and leaves void elements alone", () => {
const open = `page A {
view {
<div>
}
}
`;
expect(htmlTagComplete(doc(open), positionOf(open, "<div>"))).toContain("</div>");
const void_ = `page A {
view {
<br>
}
}
`;
expect(htmlTagComplete(doc(void_), positionOf(void_, "<br>"))).toBeNull();
});
test("completes a self-closing component tag", () => {
const text = `page A {
view {
<Card /
}
}
`;
expect(htmlTagComplete(doc(text), positionOf(text, "<Card /"))).toBe(">");
});
test("does not misfire inside a quoted attribute value containing a slash", () => {
const doubleQuoted = `page A {
view {
<a href="https:/
}
}
`;
expect(htmlTagComplete(doc(doubleQuoted), positionOf(doubleQuoted, `href="https:/`))).toBeNull();
const singleQuoted = `page A {
view {
<img src='/assets/
}
}
`;
expect(htmlTagComplete(doc(singleQuoted), positionOf(singleQuoted, `src='/assets/`))).toBeNull();
});
test("still completes a self-close after a preceding attribute", () => {
const text = `page A {
view {
<Card title="x" /
}
}
`;
expect(htmlTagComplete(doc(text), positionOf(text, `title="x" /`))).toBe(">");
});
test("returns no tag completion outside a view block", () => {
const text = `page A {
functions {
function go() { }
}
}
`;
expect(htmlTagComplete(doc(text), positionOf(text, "function go() "))).toBeNull();
});
test("folding ranges stay inside view regions", () => {
const text = `page A {
view {
<ul>
<li>one</li>
</ul>
}
}
`;
const ranges = htmlFoldingRanges(doc(text));
expect(ranges.length).toBeGreaterThan(0);
const viewStartLine = text.slice(0, text.indexOf("view {")).split("\n").length - 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");
});
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();
});
@@ -217,3 +217,207 @@ test("coalesces rapid document changes into one pending diagnostic analysis", as
process.kill(); process.kill();
await process.exited; await process.exited;
}); });
test("didClose drops the region cache so a reopened document at the same version is rescanned", async () => {
// Regression coverage for the didClose wiring in server.ts: without the
// clearHtmlRegionCache(uri) call there, a document that closes and reopens
// at version 1 (a common restart point) matches the stale cache entry from
// the prior session. Open first WITHOUT a view block (caching "no HTML
// here" for this uri/version), close, then reopen the SAME uri at the SAME
// version WITH a view block covering the same offset: correct behaviour
// rescans and finds it, a stale cache still says "no HTML here" and
// suppresses the completions entirely.
const process = Bun.spawn(
["bun", "run", fileURLToPath(new URL("../src/server.ts", import.meta.url))],
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" },
);
const uri = "file:///reopen.wrn";
const withoutView = `page A {
functions {
x
}
}
`;
const withView = `page A {
view {
<
}
}
`;
const position = { line: 2, character: 5 };
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: withoutView } },
}),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 2,
method: "textDocument/completion",
params: { textDocument: { uri }, position },
}),
);
await process.stdin.flush();
await readUntil('"id":2');
const firstReply = output.slice(output.indexOf('"id":2'));
expect(firstReply).not.toContain('"label":"div"');
process.stdin.write(
packet({ jsonrpc: "2.0", method: "textDocument/didClose", params: { textDocument: { uri } } }),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
method: "textDocument/didOpen",
params: { textDocument: { uri, version: 1, text: withView } },
}),
);
process.stdin.write(
packet({
jsonrpc: "2.0",
id: 3,
method: "textDocument/completion",
params: { textDocument: { uri }, position },
}),
);
await process.stdin.flush();
await readUntil('"id":3');
const secondReply = output.slice(output.indexOf('"id":3'));
expect(secondReply).toContain('"label":"div"');
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;
});
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;
});