release: prepare WRNexusJS 0.8.8
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-11 13:40:03 +05:30
parent 816594a58b
commit feff30a2b5
97 changed files with 769 additions and 10487 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/language-server",
"version": "0.8.7",
"version": "0.8.8",
"type": "module",
"description": "Editor-neutral Language Server Protocol implementation for WRNexus .wrn files.",
"main": "src/index.ts",
+63 -2
View File
@@ -75,7 +75,10 @@ function diagnosticRange(diagnostic: WrnDiagnostic): Range {
};
}
export function documentDiagnostics(document: TextDocument) {
export function documentDiagnostics(
document: TextDocument,
options: { includeTypes?: boolean } = {},
) {
if (Buffer.byteLength(document.text, "utf8") > 1_048_576) {
return [
{
@@ -96,7 +99,8 @@ export function documentDiagnostics(document: TextDocument) {
message: diagnostic.message,
}),
);
if (syntax.some((diagnostic) => diagnostic.severity === 1)) return syntax;
if (syntax.some((diagnostic) => diagnostic.severity === 1) || options.includeTypes === false)
return syntax;
const types = checkWrnSource(document.text, { filePath: documentPath(document.uri) }).map(
(diagnostic) => ({
range: {
@@ -235,4 +239,61 @@ export function hover(document: TextDocument, position: Position) {
export function completionItems() {
return WRN_COMPLETIONS.map((label) => ({ label, kind: 14, detail: "WRNexus language keyword" }));
}
const semanticTokenTypes = ["variable"] as const;
const semanticTokenModifiers = ["declaration", "modification"] as const;
export const semanticTokensLegend = {
tokenTypes: [...semanticTokenTypes],
tokenModifiers: [...semanticTokenModifiers],
};
/** Encode state declarations, reads, and writes using the LSP relative token format. */
export function semanticTokens(document: TextDocument): { data: number[] } {
const declarations = new Map<string, Set<number>>();
for (const match of document.text.matchAll(
/\bstate\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?==|;|\r?$)/gm,
)) {
const name = match[1]!;
const offset = match.index! + match[0].lastIndexOf(name);
const offsets = declarations.get(name) ?? new Set<number>();
offsets.add(offset);
declarations.set(name, offsets);
}
const comments = [...document.text.matchAll(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g)].map((match) => ({
start: match.index!,
end: match.index! + match[0].length,
}));
const tokens: Array<{ line: number; character: number; length: number; modifiers: number }> = [];
for (const [name, offsets] of declarations) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
for (const match of document.text.matchAll(
new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`, "g"),
)) {
const offset = match.index!;
if (comments.some((range) => offset >= range.start && offset < range.end)) continue;
const position = positionAt(document.text, offset);
const after = document.text
.slice(offset + name.length)
.match(/^\s*(?:=|\+=|-=|\*=|\/=|%=|\+\+|--)/);
const before = document.text.slice(Math.max(0, offset - 8), offset).match(/(?:\+\+|--)\s*$/);
tokens.push({
...position,
length: name.length,
modifiers: offsets.has(offset) ? 1 : after || before ? 2 : 0,
});
}
}
tokens.sort((a, b) => a.line - b.line || a.character - b.character);
const data: number[] = [];
let previousLine = 0;
let previousCharacter = 0;
for (const token of tokens) {
const deltaLine = token.line - previousLine;
const deltaCharacter = deltaLine === 0 ? token.character - previousCharacter : token.character;
data.push(deltaLine, deltaCharacter, token.length, 0, token.modifiers);
previousLine = token.line;
previousCharacter = token.character;
}
return { data };
}
export * from "./workspace.ts";
+113 -16
View File
@@ -6,10 +6,15 @@ import {
documentSymbols,
formatDocument,
hover,
symbolLocations,
offsetAt,
positionAt,
semanticTokens,
semanticTokensLegend,
wordAt,
virtualTypeScriptDocument,
workspaceCompletionItems,
workspaceSymbolLocations,
clearWorkspaceIndexCache,
extractComponentRefactor,
htmlToWrn,
type TextDocument,
@@ -47,9 +52,9 @@ function internalDiagnostic(document: TextDocument, error: unknown) {
};
}
function safeDocumentDiagnostics(document: TextDocument) {
function safeDocumentDiagnostics(document: TextDocument, includeTypes = true) {
try {
return documentDiagnostics(document);
return documentDiagnostics(document, { includeTypes });
} catch (error) {
process.stderr.write(
`[wrnexus-lsp] diagnostics failed for ${document.uri}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
@@ -58,11 +63,11 @@ function safeDocumentDiagnostics(document: TextDocument) {
}
}
function publish(document: TextDocument): void {
function publish(document: TextDocument, includeTypes = true): void {
send({
jsonrpc: "2.0",
method: "textDocument/publishDiagnostics",
params: { uri: document.uri, diagnostics: safeDocumentDiagnostics(document) },
params: { uri: document.uri, diagnostics: safeDocumentDiagnostics(document, includeTypes) },
});
}
@@ -80,7 +85,7 @@ function schedulePublish(document: TextDocument): void {
setTimeout(() => {
diagnosticTimers.delete(document.uri);
const current = documents.get(document.uri);
if (current && current.version === expectedVersion) publish(current);
if (current && current.version === expectedVersion) publish(current, false);
}, DIAGNOSTIC_DEBOUNCE_MS),
);
}
@@ -104,7 +109,7 @@ async function handle(message: JsonRpc): Promise<void> {
result(message.id, {
serverInfo: { name: "WRNexus Language Server", version: "0.8.3" },
capabilities: {
textDocumentSync: 1,
textDocumentSync: { openClose: true, change: 1, save: true },
documentFormattingProvider: true,
completionProvider: { triggerCharacters: ["<", "@", ":", "."] },
hoverProvider: true,
@@ -112,8 +117,14 @@ async function handle(message: JsonRpc): Promise<void> {
referencesProvider: true,
renameProvider: { prepareProvider: true },
documentSymbolProvider: true,
semanticTokensProvider: { legend: semanticTokensLegend, full: true },
codeActionProvider: {
codeActionKinds: ["quickfix", "refactor.extract", "refactor.rewrite"],
codeActionKinds: [
"quickfix",
"refactor.extract",
"refactor.rewrite",
"source.organizeImports",
],
},
experimental: { wrnexusVirtualTypeScript: true },
},
@@ -127,6 +138,9 @@ async function handle(message: JsonRpc): Promise<void> {
result(message.id, null);
break;
case "exit":
for (const timer of diagnosticTimers.values()) clearTimeout(timer);
diagnosticTimers.clear();
documents.clear();
process.exit(0);
break;
case "textDocument/didOpen": {
@@ -146,6 +160,15 @@ async function handle(message: JsonRpc): Promise<void> {
}
break;
}
case "textDocument/didSave": {
const document = documents.get(params.textDocument.uri);
if (document) {
clearDiagnosticTimer(document.uri);
clearWorkspaceIndexCache(workspaceRoot);
publish(document, true);
}
break;
}
case "textDocument/didClose":
clearDiagnosticTimer(params.textDocument.uri);
documents.delete(params.textDocument.uri);
@@ -180,6 +203,11 @@ async function handle(message: JsonRpc): Promise<void> {
result(message.id, document ? documentSymbols(document) : []);
break;
}
case "textDocument/semanticTokens/full": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? semanticTokens(document) : { data: [] });
break;
}
case "textDocument/hover": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? hover(document, params.position) : null);
@@ -192,7 +220,8 @@ async function handle(message: JsonRpc): Promise<void> {
}
case "textDocument/references": {
const document = documents.get(params.textDocument.uri);
result(message.id, document ? symbolLocations(document, params.position) : []);
const selected = document ? wordAt(document.text, params.position) : null;
result(message.id, selected ? workspaceSymbolLocations(workspaceRoot, selected.word) : []);
break;
}
case "textDocument/prepareRename": {
@@ -202,13 +231,16 @@ async function handle(message: JsonRpc): Promise<void> {
}
case "textDocument/rename": {
const document = documents.get(params.textDocument.uri);
const edits = document
? symbolLocations(document, params.position).map(({ range }) => ({
range,
newText: params.newName,
}))
: [];
result(message.id, document ? { changes: { [document.uri]: edits } } : null);
const selected = document ? wordAt(document.text, params.position) : null;
if (!selected || !/^[A-Za-z_$][\w$]*$/.test(params.newName)) {
result(message.id, null);
break;
}
const changes: Record<string, Array<{ range: any; newText: string }>> = {};
for (const location of workspaceSymbolLocations(workspaceRoot, selected.word)) {
(changes[location.uri] ??= []).push({ range: location.range, newText: params.newName });
}
result(message.id, { changes });
break;
}
case "textDocument/codeAction": {
@@ -231,6 +263,71 @@ async function handle(message: JsonRpc): Promise<void> {
},
},
}));
const imports = [
...document.text.matchAll(
/^import(?:\s+type)?[\s\S]*?(?:;\s*|\n(?=import|\s*(?:page|component|layout|global\s+store|page\s+store)\b))/gm,
),
].map((match) => ({
start: match.index!,
end: match.index! + match[0].length,
text: match[0].trim(),
}));
if (imports.length > 1) {
const ordered = [...imports].sort((a, b) => {
const aType = /^import\s+type\b/.test(a.text) ? 0 : 1;
const bType = /^import\s+type\b/.test(b.text) ? 0 : 1;
return aType - bType || a.text.localeCompare(b.text);
});
const replacement = ordered.map((entry) => entry.text.replace(/;$/, "")).join("\n") + "\n";
const start = imports[0]!.start;
const end = imports.at(-1)!.end;
if (document.text.slice(start, end) !== replacement) {
actions.push({
title: "Organize WRN imports",
kind: "source.organizeImports",
edit: {
changes: {
[document.uri]: [
{
range: {
start: positionAt(document.text, start),
end: positionAt(document.text, end),
},
newText: replacement,
},
],
},
},
});
}
}
for (const diagnostic of params.context?.diagnostics ?? []) {
if (diagnostic.code !== "WRN-OUTPUT-LEGACY-EMIT") continue;
const start = offsetAt(document.text, diagnostic.range.start);
const end = offsetAt(document.text, diagnostic.range.end);
const source = document.text.slice(start, end);
const match = /\$emit\(\s*["']([A-Za-z_$][\w$]*)["']\s*,?/.exec(source);
if (!match) continue;
actions.push({
title: `Convert $emit to output.${match[1]}`,
kind: "quickfix",
diagnostics: [diagnostic],
isPreferred: true,
edit: {
changes: {
[document.uri]: [
{
range: diagnostic.range,
newText: source.replace(
/\$emit\(\s*["'][A-Za-z_$][\w$]*["']\s*,?\s*/,
`output.${match[1]}(`,
),
},
],
},
},
});
}
const selected = document.text.slice(
document.text
.split(/\r?\n/)
+27 -1
View File
@@ -15,7 +15,9 @@ const SKIPPED_DIRECTORIES = new Set([
]);
const MAX_INDEX_FILES = 5_000;
const MAX_SOURCE_BYTES = 1_048_576;
const INDEX_TTL_MS = 5_000;
// Completion can be requested repeatedly while the list is visible. Avoid
// rescanning thousands of files on that hot path; document saves invalidate it.
const INDEX_TTL_MS = 5 * 60_000;
const MAX_CACHED_ROOTS = 8;
const workspaceIndexCache = new Map<
string,
@@ -168,6 +170,30 @@ export function clearWorkspaceIndexCache(root?: string): void {
else workspaceIndexCache.clear();
}
export function workspaceSymbolLocations(root: string, name: string, limit = 1_000) {
if (!/^[A-Za-z_$][\w$]*$/.test(name)) return [];
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`, "g");
const locations: Array<{ uri: string; range: Range }> = [];
for (const file of walk(resolve(root), (path) => extname(path) === ".wrn").slice(0, limit)) {
let source: string;
try {
source = readFileSync(file, "utf8");
} catch {
continue;
}
for (const match of source.matchAll(pattern)) {
const before = source.slice(0, match.index!).split(/\r?\n/);
const start = { line: before.length - 1, character: before.at(-1)?.length ?? 0 };
locations.push({
uri: `file:///${file.replace(/\\/g, "/")}`,
range: { start, end: { line: start.line, character: start.character + name.length } },
});
}
}
return locations;
}
export function extractComponentRefactor(document: TextDocument, range: Range, name: string) {
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(name)) throw new Error("Component name must use PascalCase");
const lines = document.text.split(/\r?\n/);