#!/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(); const diagnosticTimers = new Map>(); 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 { 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> = {}; 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();