diff --git a/index.ts b/index.ts index e450662..92ee557 100644 --- a/index.ts +++ b/index.ts @@ -18,8 +18,28 @@ import { UnsupportedExtensionError, } from "./src/types.ts"; +type LspUnavailable = { piLspUnavailable: true; message: string }; + +function lspUnavailable(message: string): LspUnavailable { + return { piLspUnavailable: true, message }; +} + +function isLspUnavailable(result: unknown): result is LspUnavailable { + return Boolean( + result && + typeof result === "object" && + "piLspUnavailable" in result && + (result as { piLspUnavailable?: unknown }).piLspUnavailable === true, + ); +} + +function formatUnavailable(result: LspUnavailable): string { + return `(LSP unavailable: ${result.message})`; +} + // Format Hover - Turn an LSP hover response into readable text. function formatHover(result: unknown): string { + if (isLspUnavailable(result)) return formatUnavailable(result); if (!result || typeof result !== "object") return "(no hover info)"; const hover = result as { contents?: unknown }; if (!hover.contents) return "(empty)"; @@ -49,6 +69,7 @@ function formatHover(result: unknown): string { // Format Definition - Turn definition locations into readable text. function formatDefinition(result: unknown): string { + if (isLspUnavailable(result)) return formatUnavailable(result); if (!result) return "(no definition found)"; const locations = Array.isArray(result) ? result : [result]; if (locations.length === 0) return "(no definition found)"; @@ -64,6 +85,7 @@ function formatDefinition(result: unknown): string { // Format References - Turn reference locations into readable text. function formatReferences(result: unknown): string { + if (isLspUnavailable(result)) return formatUnavailable(result); if (!result || !Array.isArray(result)) return "(no references found)"; if (result.length === 0) return "(no references found)"; @@ -85,6 +107,7 @@ function formatReferences(result: unknown): string { // Format Completions - Turn completion items into readable text. function formatCompletions(result: unknown): string { + if (isLspUnavailable(result)) return formatUnavailable(result); if (!result) return "(no completions)"; // Resolve to CompletionItem[] if it's a CompletionList @@ -150,6 +173,7 @@ function formatCompletions(result: unknown): string { // Format Document Symbols - Turn symbols into a tree outline. function formatDocumentSymbols(result: unknown): string { + if (isLspUnavailable(result)) return formatUnavailable(result); if (!result || !Array.isArray(result)) return "(no symbols)"; const symbols = result as any[]; if (symbols.length === 0) return "(no symbols)"; @@ -235,6 +259,7 @@ function formatServerDiagnostics(diags: any[], limit: number): string { // { [serverId]: { uri, diagnostics[] } }. Single-server results omit the // header to avoid noise. function formatDiagnostics(result: unknown, limit = 20): string { + if (isLspUnavailable(result)) return formatUnavailable(result); if (!result || typeof result !== "object") return "(no diagnostics)"; const grouped = result as Record; @@ -275,6 +300,34 @@ function isExpectedError(error: unknown): boolean { ); } +function unavailableForFile(filePath: string, includeDiagnosticsOnly = false): LspUnavailable { + const ext = path.extname(filePath).replace(/^\./, ""); + const label = ext ? `.${ext}` : "files without an extension"; + const matches = getServersForPath(filePath).filter((server) => + server.match.includes(ext) && (includeDiagnosticsOnly || !server.diagnosticsOnly) + ); + + if (matches.length === 0) { + return lspUnavailable(`no LSP server is registered for ${label}`); + } + + const disabled = matches.filter((server) => disabledServers.has(server.id)); + if (disabled.length === matches.length) { + return lspUnavailable( + `matching LSP server(s) are disabled: ${disabled.map((s) => s.id).join(", ")}`, + ); + } + + const unavailable = matches.filter((server) => !isServerAvailable(server)); + if (unavailable.length > 0) { + return lspUnavailable( + `matching LSP server(s) are not on PATH: ${unavailable.map((s) => s.command).join(", ")}`, + ); + } + + return lspUnavailable(`no applicable LSP server is available for ${label}`); +} + // Disabled Servers - In-memory set of disabled server IDs. Scoped to this // extension process; the shared daemon is never mutated by disable/enable. const lspToolNames = [ @@ -301,14 +354,12 @@ async function runLsp( // touching the daemon so other pi instances sharing it are unaffected. const server = pickServer(filePath); if (disabledServers.has(server.id)) { - throw new Error( - `LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`, - ); + return unavailableForFile(filePath); } return await daemonRequest(filePath, server.id, method, params); } catch (error) { if (isExpectedError(error)) { - return undefined; + return unavailableForFile(filePath); } // Daemon-wrapped errors (plain Error with expected message) are also // expected — the daemon catches pickServer() throws and returns them @@ -318,7 +369,7 @@ async function runLsp( (error.message.includes("No LSP server registered") || error.message.includes("not found on PATH")) ) { - return undefined; + return unavailableForFile(filePath); } throw error; } @@ -347,22 +398,22 @@ function withTimeout(promise: Promise, ms: number): Promise { } // Run LSP Diagnostics - Fans out to all matching servers in a single -// daemon call. Returns the grouped result map or undefined if no servers. +// daemon call. Returns an explicit unavailable result if no server applies. async function runDiagnostics(filePath: string): Promise { try { const serverIds = pickDiagnosticServers(filePath); - if (serverIds.length === 0) return undefined; + if (serverIds.length === 0) return unavailableForFile(filePath, true); return await daemonDiagnostics(filePath, serverIds, 1500); } catch (error) { if (isExpectedError(error)) { - return undefined; + return unavailableForFile(filePath, true); } if ( error instanceof Error && (error.message.includes("No LSP server registered") || error.message.includes("not found on PATH")) ) { - return undefined; + return unavailableForFile(filePath, true); } throw error; } @@ -572,6 +623,7 @@ export default function (pi: ExtensionAPI) { // Run LSP diagnostics with timeout - Prevent the auto-check hook from // blocking pi if the daemon or LSP server is slow or unresponsive. const result = await withTimeout(runDiagnostics(absolutePath), 3000); + if (isLspUnavailable(result)) return; const formatted = formatDiagnostics(result, 10); // Only send a message if there are actual diagnostics