diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts index 1906dd6..d724589 100644 --- a/src/tools/lsp/client.ts +++ b/src/tools/lsp/client.ts @@ -1,9 +1,8 @@ import { spawn, type Subprocess } from "bun" import { readFileSync } from "fs" import { extname, resolve } from "path" -import type { ResolvedServer } from "./config" import { getLanguageId } from "./config" -import type { Diagnostic } from "./types" +import type { Diagnostic, ResolvedServer } from "./types" interface ManagedClient { client: LSPClient diff --git a/src/tools/lsp/config.ts b/src/tools/lsp/config.ts index 7bea891..b965a43 100644 --- a/src/tools/lsp/config.ts +++ b/src/tools/lsp/config.ts @@ -1,16 +1,8 @@ import { existsSync, readFileSync } from "fs" import { join } from "path" import { homedir } from "os" -import { BUILTIN_SERVERS, EXT_TO_LANG } from "./constants" - -export interface ResolvedServer { - id: string - command: string[] - extensions: string[] - priority: number - env?: Record - initialization?: Record -} +import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants" +import type { ResolvedServer, ServerLookupResult } from "./types" interface LspEntry { disabled?: boolean @@ -120,23 +112,47 @@ function getMergedServers(): ServerWithSource[] { }) } -export function findServerForExtension(ext: string): ResolvedServer | null { +export function findServerForExtension(ext: string): ServerLookupResult { const servers = getMergedServers() for (const server of servers) { if (server.extensions.includes(ext) && isServerInstalled(server.command)) { return { - id: server.id, - command: server.command, - extensions: server.extensions, - priority: server.priority, - env: server.env, - initialization: server.initialization, + status: "found", + server: { + id: server.id, + command: server.command, + extensions: server.extensions, + priority: server.priority, + env: server.env, + initialization: server.initialization, + }, } } } - return null + for (const server of servers) { + if (server.extensions.includes(ext)) { + const installHint = + LSP_INSTALL_HINTS[server.id] || `Install '${server.command[0]}' and ensure it's in your PATH` + return { + status: "not_installed", + server: { + id: server.id, + command: server.command, + extensions: server.extensions, + }, + installHint, + } + } + } + + const availableServers = [...new Set(servers.map((s) => s.id))] + return { + status: "not_configured", + extension: ext, + availableServers, + } } export function getLanguageId(ext: string): string { diff --git a/src/tools/lsp/constants.ts b/src/tools/lsp/constants.ts index 0e4ca1a..267268f 100644 --- a/src/tools/lsp/constants.ts +++ b/src/tools/lsp/constants.ts @@ -40,6 +40,37 @@ export const DEFAULT_MAX_REFERENCES = 200 export const DEFAULT_MAX_SYMBOLS = 200 export const DEFAULT_MAX_DIAGNOSTICS = 200 +export const LSP_INSTALL_HINTS: Record = { + typescript: "npm install -g typescript-language-server typescript", + deno: "Install Deno from https://deno.land", + vue: "npm install -g @vue/language-server", + eslint: "npm install -g vscode-langservers-extracted", + oxlint: "npm install -g oxlint", + biome: "npm install -g @biomejs/biome", + gopls: "go install golang.org/x/tools/gopls@latest", + "ruby-lsp": "gem install ruby-lsp", + basedpyright: "pip install basedpyright", + pyright: "pip install pyright", + ty: "pip install ty", + ruff: "pip install ruff", + "elixir-ls": "See https://github.com/elixir-lsp/elixir-ls", + zls: "See https://github.com/zigtools/zls", + csharp: "dotnet tool install -g csharp-ls", + fsharp: "dotnet tool install -g fsautocomplete", + "sourcekit-lsp": "Included with Xcode or Swift toolchain", + rust: "rustup component add rust-analyzer", + clangd: "See https://clangd.llvm.org/installation", + svelte: "npm install -g svelte-language-server", + astro: "npm install -g @astrojs/language-server", + "bash-ls": "npm install -g bash-language-server", + jdtls: "See https://github.com/eclipse-jdtls/eclipse.jdt.ls", + "yaml-ls": "npm install -g yaml-language-server", + "lua-ls": "See https://github.com/LuaLS/lua-language-server", + php: "npm install -g intelephense", + dart: "Included with Dart SDK", + "terraform-ls": "See https://github.com/hashicorp/terraform-ls", +} + // Synced with OpenCode's server.ts // https://github.com/sst/opencode/blob/main/packages/opencode/src/lsp/server.ts export const BUILTIN_SERVERS: Record> = { diff --git a/src/tools/lsp/types.ts b/src/tools/lsp/types.ts index 42b54eb..895375d 100644 --- a/src/tools/lsp/types.ts +++ b/src/tools/lsp/types.ts @@ -135,3 +135,23 @@ export interface CodeAction { command?: Command data?: unknown } + +export interface ServerLookupInfo { + id: string + command: string[] + extensions: string[] +} + +export type ServerLookupResult = + | { status: "found"; server: ResolvedServer } + | { status: "not_configured"; extension: string; availableServers: string[] } + | { status: "not_installed"; server: ServerLookupInfo; installHint: string } + +export interface ResolvedServer { + id: string + command: string[] + extensions: string[] + priority: number + env?: Record + initialization?: Record +} diff --git a/src/tools/lsp/utils.ts b/src/tools/lsp/utils.ts index e227d64..52edbc1 100644 --- a/src/tools/lsp/utils.ts +++ b/src/tools/lsp/utils.ts @@ -17,6 +17,7 @@ import type { TextEdit, CodeAction, Command, + ServerLookupResult, } from "./types" export function findWorkspaceRoot(filePath: string): string { @@ -40,15 +41,51 @@ export function findWorkspaceRoot(filePath: string): string { return require("path").dirname(resolve(filePath)) } +export function formatServerLookupError(result: Exclude): string { + if (result.status === "not_installed") { + const { server, installHint } = result + return [ + `LSP server '${server.id}' is configured but NOT INSTALLED.`, + ``, + `Command not found: ${server.command[0]}`, + ``, + `To install:`, + ` ${installHint}`, + ``, + `Supported extensions: ${server.extensions.join(", ")}`, + ``, + `After installation, the server will be available automatically.`, + `Run 'lsp_servers' tool to verify installation status.`, + ].join("\n") + } + + return [ + `No LSP server configured for extension: ${result.extension}`, + ``, + `Available servers: ${result.availableServers.slice(0, 10).join(", ")}${result.availableServers.length > 10 ? "..." : ""}`, + ``, + `To add a custom server, configure 'lsp' in oh-my-opencode.json:`, + ` {`, + ` "lsp": {`, + ` "my-server": {`, + ` "command": ["my-lsp", "--stdio"],`, + ` "extensions": ["${result.extension}"]`, + ` }`, + ` }`, + ` }`, + ].join("\n") +} + export async function withLspClient(filePath: string, fn: (client: LSPClient) => Promise): Promise { const absPath = resolve(filePath) const ext = extname(absPath) - const server = findServerForExtension(ext) + const result = findServerForExtension(ext) - if (!server) { - throw new Error(`No LSP server configured for extension: ${ext}`) + if (result.status !== "found") { + throw new Error(formatServerLookupError(result)) } + const server = result.server const root = findWorkspaceRoot(absPath) const client = await lspManager.getClient(root, server)