fix: report unavailable LSP servers explicitly

This commit is contained in:
2026-05-31 20:07:17 -04:00
parent 8cfe604de7
commit 2da4103cd7

View File

@@ -18,8 +18,28 @@ import {
UnsupportedExtensionError, UnsupportedExtensionError,
} from "./src/types.ts"; } 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. // Format Hover - Turn an LSP hover response into readable text.
function formatHover(result: unknown): string { function formatHover(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || typeof result !== "object") return "(no hover info)"; if (!result || typeof result !== "object") return "(no hover info)";
const hover = result as { contents?: unknown }; const hover = result as { contents?: unknown };
if (!hover.contents) return "(empty)"; if (!hover.contents) return "(empty)";
@@ -49,6 +69,7 @@ function formatHover(result: unknown): string {
// Format Definition - Turn definition locations into readable text. // Format Definition - Turn definition locations into readable text.
function formatDefinition(result: unknown): string { function formatDefinition(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result) return "(no definition found)"; if (!result) return "(no definition found)";
const locations = Array.isArray(result) ? result : [result]; const locations = Array.isArray(result) ? result : [result];
if (locations.length === 0) return "(no definition found)"; 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. // Format References - Turn reference locations into readable text.
function formatReferences(result: unknown): string { function formatReferences(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || !Array.isArray(result)) return "(no references found)"; if (!result || !Array.isArray(result)) return "(no references found)";
if (result.length === 0) 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. // Format Completions - Turn completion items into readable text.
function formatCompletions(result: unknown): string { function formatCompletions(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result) return "(no completions)"; if (!result) return "(no completions)";
// Resolve to CompletionItem[] if it's a CompletionList // 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. // Format Document Symbols - Turn symbols into a tree outline.
function formatDocumentSymbols(result: unknown): string { function formatDocumentSymbols(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || !Array.isArray(result)) return "(no symbols)"; if (!result || !Array.isArray(result)) return "(no symbols)";
const symbols = result as any[]; const symbols = result as any[];
if (symbols.length === 0) return "(no symbols)"; 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 // { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
// header to avoid noise. // header to avoid noise.
function formatDiagnostics(result: unknown, limit = 20): string { function formatDiagnostics(result: unknown, limit = 20): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || typeof result !== "object") return "(no diagnostics)"; if (!result || typeof result !== "object") return "(no diagnostics)";
const grouped = result as Record<string, any>; const grouped = result as Record<string, any>;
@@ -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 // Disabled Servers - In-memory set of disabled server IDs. Scoped to this
// extension process; the shared daemon is never mutated by disable/enable. // extension process; the shared daemon is never mutated by disable/enable.
const lspToolNames = [ const lspToolNames = [
@@ -301,14 +354,12 @@ async function runLsp(
// touching the daemon so other pi instances sharing it are unaffected. // touching the daemon so other pi instances sharing it are unaffected.
const server = pickServer(filePath); const server = pickServer(filePath);
if (disabledServers.has(server.id)) { if (disabledServers.has(server.id)) {
throw new Error( return unavailableForFile(filePath);
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
);
} }
return await daemonRequest(filePath, server.id, method, params); return await daemonRequest(filePath, server.id, method, params);
} catch (error) { } catch (error) {
if (isExpectedError(error)) { if (isExpectedError(error)) {
return undefined; return unavailableForFile(filePath);
} }
// Daemon-wrapped errors (plain Error with expected message) are also // Daemon-wrapped errors (plain Error with expected message) are also
// expected — the daemon catches pickServer() throws and returns them // 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("No LSP server registered") ||
error.message.includes("not found on PATH")) error.message.includes("not found on PATH"))
) { ) {
return undefined; return unavailableForFile(filePath);
} }
throw error; throw error;
} }
@@ -347,22 +398,22 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
} }
// Run LSP Diagnostics - Fans out to all matching servers in a single // 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<unknown> { async function runDiagnostics(filePath: string): Promise<unknown> {
try { try {
const serverIds = pickDiagnosticServers(filePath); const serverIds = pickDiagnosticServers(filePath);
if (serverIds.length === 0) return undefined; if (serverIds.length === 0) return unavailableForFile(filePath, true);
return await daemonDiagnostics(filePath, serverIds, 1500); return await daemonDiagnostics(filePath, serverIds, 1500);
} catch (error) { } catch (error) {
if (isExpectedError(error)) { if (isExpectedError(error)) {
return undefined; return unavailableForFile(filePath, true);
} }
if ( if (
error instanceof Error && error instanceof Error &&
(error.message.includes("No LSP server registered") || (error.message.includes("No LSP server registered") ||
error.message.includes("not found on PATH")) error.message.includes("not found on PATH"))
) { ) {
return undefined; return unavailableForFile(filePath, true);
} }
throw error; throw error;
} }
@@ -572,6 +623,7 @@ export default function (pi: ExtensionAPI) {
// Run LSP diagnostics with timeout - Prevent the auto-check hook from // Run LSP diagnostics with timeout - Prevent the auto-check hook from
// blocking pi if the daemon or LSP server is slow or unresponsive. // blocking pi if the daemon or LSP server is slow or unresponsive.
const result = await withTimeout(runDiagnostics(absolutePath), 3000); const result = await withTimeout(runDiagnostics(absolutePath), 3000);
if (isLspUnavailable(result)) return;
const formatted = formatDiagnostics(result, 10); const formatted = formatDiagnostics(result, 10);
// Only send a message if there are actual diagnostics // Only send a message if there are actual diagnostics