452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
#!/usr/bin/env bun
|
|
import {
|
|
completionItems,
|
|
definitionLocation,
|
|
documentDiagnostics,
|
|
documentSymbols,
|
|
formatDocument,
|
|
hover,
|
|
offsetAt,
|
|
positionAt,
|
|
semanticTokens,
|
|
semanticTokensLegend,
|
|
wordAt,
|
|
virtualTypeScriptDocument,
|
|
workspaceCompletionItems,
|
|
workspaceSymbolLocations,
|
|
clearWorkspaceIndexCache,
|
|
extractComponentRefactor,
|
|
htmlToWrn,
|
|
type TextDocument,
|
|
} from "./index.ts";
|
|
import {
|
|
htmlCompletions,
|
|
htmlFoldingRanges,
|
|
htmlHover,
|
|
htmlLinkedEditingRanges,
|
|
mergeCompletions,
|
|
} from "./html-service.ts";
|
|
import { clearHtmlRegionCache } from "./html-regions.ts";
|
|
|
|
type JsonRpc = { jsonrpc?: string; id?: number | string; method?: string; params?: any };
|
|
const documents = new Map<string, TextDocument>();
|
|
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
const MAX_OPEN_DOCUMENTS = 256;
|
|
const DIAGNOSTIC_DEBOUNCE_MS = 300;
|
|
let buffer = Buffer.alloc(0);
|
|
let workspaceRoot = process.cwd();
|
|
|
|
function rootFromUri(uri?: string): string {
|
|
if (!uri?.startsWith("file://")) return workspaceRoot;
|
|
return decodeURIComponent(uri.slice(7)).replace(/^\/([A-Za-z]:)/, "$1");
|
|
}
|
|
|
|
function send(value: unknown): void {
|
|
const body = Buffer.from(JSON.stringify(value));
|
|
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
|
|
process.stdout.write(body);
|
|
}
|
|
function result(id: JsonRpc["id"], value: unknown): void {
|
|
send({ jsonrpc: "2.0", id, result: value });
|
|
}
|
|
function internalDiagnostic(document: TextDocument, error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
|
severity: 1,
|
|
code: "WRN-LSP-INTERNAL",
|
|
source: "wrnexus",
|
|
message: `WRNexus language analysis failed safely: ${message}`,
|
|
};
|
|
}
|
|
|
|
function safeDocumentDiagnostics(document: TextDocument, includeTypes = true) {
|
|
try {
|
|
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`,
|
|
);
|
|
return [internalDiagnostic(document, error)];
|
|
}
|
|
}
|
|
|
|
function publish(document: TextDocument, includeTypes = true): void {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
method: "textDocument/publishDiagnostics",
|
|
params: { uri: document.uri, diagnostics: safeDocumentDiagnostics(document, includeTypes) },
|
|
});
|
|
}
|
|
|
|
function clearDiagnosticTimer(uri: string): void {
|
|
const timer = diagnosticTimers.get(uri);
|
|
if (timer) clearTimeout(timer);
|
|
diagnosticTimers.delete(uri);
|
|
}
|
|
|
|
function schedulePublish(document: TextDocument): void {
|
|
clearDiagnosticTimer(document.uri);
|
|
const expectedVersion = document.version;
|
|
diagnosticTimers.set(
|
|
document.uri,
|
|
setTimeout(() => {
|
|
diagnosticTimers.delete(document.uri);
|
|
const current = documents.get(document.uri);
|
|
if (current && current.version === expectedVersion) publish(current, false);
|
|
}, DIAGNOSTIC_DEBOUNCE_MS),
|
|
);
|
|
}
|
|
|
|
function rememberDocument(document: TextDocument): void {
|
|
documents.delete(document.uri);
|
|
documents.set(document.uri, document);
|
|
while (documents.size > MAX_OPEN_DOCUMENTS) {
|
|
const oldest = documents.keys().next().value;
|
|
if (typeof oldest !== "string") break;
|
|
documents.delete(oldest);
|
|
clearDiagnosticTimer(oldest);
|
|
}
|
|
}
|
|
|
|
async function handle(message: JsonRpc): Promise<void> {
|
|
const params = message.params ?? {};
|
|
switch (message.method) {
|
|
case "initialize":
|
|
workspaceRoot = params.rootPath ?? rootFromUri(params.rootUri);
|
|
result(message.id, {
|
|
serverInfo: { name: "WRNexus Language Server", version: "0.8.3" },
|
|
capabilities: {
|
|
textDocumentSync: { openClose: true, change: 1, save: true },
|
|
documentFormattingProvider: true,
|
|
completionProvider: {
|
|
triggerCharacters: ["<", "@", ":", ".", " ", "=", '"', "/"],
|
|
},
|
|
hoverProvider: true,
|
|
foldingRangeProvider: true,
|
|
linkedEditingRangeProvider: true,
|
|
definitionProvider: true,
|
|
referencesProvider: true,
|
|
renameProvider: { prepareProvider: true },
|
|
documentSymbolProvider: true,
|
|
semanticTokensProvider: { legend: semanticTokensLegend, full: true },
|
|
codeActionProvider: {
|
|
codeActionKinds: [
|
|
"quickfix",
|
|
"refactor.extract",
|
|
"refactor.rewrite",
|
|
"source.organizeImports",
|
|
],
|
|
},
|
|
experimental: { wrnexusVirtualTypeScript: true },
|
|
},
|
|
});
|
|
break;
|
|
case "initialized":
|
|
break;
|
|
case "shutdown":
|
|
for (const timer of diagnosticTimers.values()) clearTimeout(timer);
|
|
diagnosticTimers.clear();
|
|
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": {
|
|
const item = params.textDocument;
|
|
const document = { uri: item.uri, text: item.text, version: item.version };
|
|
rememberDocument(document);
|
|
publish(document);
|
|
break;
|
|
}
|
|
case "textDocument/didChange": {
|
|
const existing = documents.get(params.textDocument.uri);
|
|
const text = params.contentChanges?.at(-1)?.text;
|
|
if (existing && typeof text === "string") {
|
|
existing.text = text;
|
|
existing.version = params.textDocument.version;
|
|
schedulePublish(existing);
|
|
}
|
|
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);
|
|
clearHtmlRegionCache(params.textDocument.uri);
|
|
send({
|
|
jsonrpc: "2.0",
|
|
method: "textDocument/publishDiagnostics",
|
|
params: { uri: params.textDocument.uri, diagnostics: [] },
|
|
});
|
|
break;
|
|
case "wrnexus/serverStatus":
|
|
result(message.id, {
|
|
openDocuments: documents.size,
|
|
pendingDiagnostics: diagnosticTimers.size,
|
|
memory: process.memoryUsage(),
|
|
});
|
|
break;
|
|
case "textDocument/formatting": {
|
|
const document = documents.get(params.textDocument.uri);
|
|
result(
|
|
message.id,
|
|
document
|
|
? formatDocument(document, params.options?.tabSize, params.options?.insertSpaces)
|
|
: [],
|
|
);
|
|
break;
|
|
}
|
|
case "textDocument/completion": {
|
|
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;
|
|
}
|
|
case "textDocument/documentSymbol": {
|
|
const document = documents.get(params.textDocument.uri);
|
|
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);
|
|
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 "textDocument/definition": {
|
|
const document = documents.get(params.textDocument.uri);
|
|
result(message.id, document ? definitionLocation(document, params.position) : null);
|
|
break;
|
|
}
|
|
case "textDocument/references": {
|
|
const document = documents.get(params.textDocument.uri);
|
|
const selected = document ? wordAt(document.text, params.position) : null;
|
|
result(message.id, selected ? workspaceSymbolLocations(workspaceRoot, selected.word) : []);
|
|
break;
|
|
}
|
|
case "textDocument/prepareRename": {
|
|
const document = documents.get(params.textDocument.uri);
|
|
result(message.id, document ? (wordAt(document.text, params.position)?.range ?? null) : null);
|
|
break;
|
|
}
|
|
case "textDocument/rename": {
|
|
const document = documents.get(params.textDocument.uri);
|
|
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": {
|
|
const document = documents.get(params.textDocument.uri);
|
|
if (!document) {
|
|
result(message.id, []);
|
|
break;
|
|
}
|
|
const actions: any[] = safeDocumentDiagnostics(document)
|
|
.filter((item) => item.code === "WRNA11Y001")
|
|
.map((item) => ({
|
|
title: "Add empty alt attribute",
|
|
kind: "quickfix",
|
|
diagnostics: [item],
|
|
edit: {
|
|
changes: {
|
|
[document.uri]: [
|
|
{ range: { start: item.range.end, end: item.range.end }, newText: ' alt=""' },
|
|
],
|
|
},
|
|
},
|
|
}));
|
|
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/)
|
|
.slice(0, params.range.start.line)
|
|
.reduce((n, line) => n + line.length + 1, 0) + params.range.start.character,
|
|
document.text
|
|
.split(/\r?\n/)
|
|
.slice(0, params.range.end.line)
|
|
.reduce((n, line) => n + line.length + 1, 0) + params.range.end.character,
|
|
);
|
|
if (selected.trim().startsWith("<")) {
|
|
try {
|
|
actions.push({
|
|
title: "Extract selection to WRN component",
|
|
kind: "refactor.extract",
|
|
edit: extractComponentRefactor(document, params.range, "ExtractedComponent"),
|
|
});
|
|
actions.push({
|
|
title: "Convert selected HTML to WRN page",
|
|
kind: "refactor.rewrite",
|
|
edit: {
|
|
changes: {
|
|
[document.uri]: [
|
|
{ range: params.range, newText: htmlToWrn(selected, "ImportedPage") },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
} catch {
|
|
/* selection is not safely convertible */
|
|
}
|
|
}
|
|
result(message.id, actions);
|
|
break;
|
|
}
|
|
case "wrnexus/virtualDocument": {
|
|
const document = documents.get(params.textDocument?.uri ?? params.uri);
|
|
result(message.id, document ? virtualTypeScriptDocument(document) : null);
|
|
break;
|
|
}
|
|
default:
|
|
if (message.id !== undefined) result(message.id, null);
|
|
}
|
|
}
|
|
|
|
function consume(): void {
|
|
while (true) {
|
|
const end = buffer.indexOf("\r\n\r\n");
|
|
if (end < 0) return;
|
|
const header = buffer.subarray(0, end).toString();
|
|
const length = Number(/Content-Length:\s*(\d+)/i.exec(header)?.[1]);
|
|
if (!Number.isFinite(length)) {
|
|
buffer = Buffer.alloc(0);
|
|
return;
|
|
}
|
|
const bodyStart = end + 4;
|
|
if (buffer.length < bodyStart + length) return;
|
|
const body = buffer.subarray(bodyStart, bodyStart + length).toString();
|
|
buffer = buffer.subarray(bodyStart + length);
|
|
let message: JsonRpc;
|
|
try {
|
|
message = JSON.parse(body) as JsonRpc;
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`[wrnexus-lsp] invalid JSON-RPC payload: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
);
|
|
continue;
|
|
}
|
|
void handle(message).catch((error) => {
|
|
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
|
process.stderr.write(`[wrnexus-lsp] request failed: ${detail}\n`);
|
|
if (message.id !== undefined) {
|
|
send({
|
|
jsonrpc: "2.0",
|
|
id: message.id,
|
|
error: { code: -32603, message: "WRNexus language server request failed", data: detail },
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
process.stdin.on("data", (chunk) => {
|
|
try {
|
|
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
consume();
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`[wrnexus-lsp] input processing failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
|
);
|
|
}
|
|
});
|
|
process.stdin.resume();
|