From d95a45c87206ef5c6adce3546de483ad03fd7240 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 12:10:30 +0900 Subject: [PATCH] chore: add lsp-tools-mcp submodule at vendor/lsp-tools-mcp --- .gitmodules | 3 + src/tools/lsp/AGENTS.md | 70 ----- src/tools/lsp/client.test.ts | 263 ------------------ src/tools/lsp/client.ts | 3 - src/tools/lsp/config.test.ts | 129 --------- src/tools/lsp/config.ts | 3 - src/tools/lsp/constants.ts | 7 - src/tools/lsp/diagnostics-tool.ts | 75 ----- src/tools/lsp/directory-diagnostics.test.ts | 119 -------- src/tools/lsp/directory-diagnostics.ts | 163 ----------- src/tools/lsp/find-references-tool.ts | 43 --- src/tools/lsp/goto-definition-tool.ts | 42 --- src/tools/lsp/index.ts | 9 - src/tools/lsp/infer-extension.test.ts | 107 ------- src/tools/lsp/infer-extension.ts | 65 ----- src/tools/lsp/language-config.ts | 5 - src/tools/lsp/language-mappings.ts | 171 ------------ src/tools/lsp/lsp-client-connection.ts | 66 ----- src/tools/lsp/lsp-client-transport.ts | 215 -------------- src/tools/lsp/lsp-client-wrapper.ts | 116 -------- src/tools/lsp/lsp-client.ts | 129 --------- src/tools/lsp/lsp-formatters.ts | 193 ------------- src/tools/lsp/lsp-manager-process-cleanup.ts | 83 ------ .../lsp/lsp-manager-temp-directory-cleanup.ts | 29 -- src/tools/lsp/lsp-process.test.ts | 37 --- src/tools/lsp/lsp-process.ts | 182 ------------ src/tools/lsp/lsp-server.ts | 217 --------------- src/tools/lsp/rename-tools.ts | 53 ---- src/tools/lsp/server-config-loader.test.ts | 161 ----------- src/tools/lsp/server-config-loader.ts | 116 -------- src/tools/lsp/server-definitions.ts | 91 ------ src/tools/lsp/server-installation.ts | 58 ---- src/tools/lsp/server-path-bases.ts | 16 -- src/tools/lsp/server-resolution.ts | 109 -------- src/tools/lsp/symbols-tool.ts | 77 ----- src/tools/lsp/tools.ts | 5 - src/tools/lsp/types.ts | 124 --------- src/tools/lsp/utils.test.ts | 42 --- src/tools/lsp/workspace-edit.ts | 121 -------- vendor/lsp-tools-mcp | 1 + 40 files changed, 4 insertions(+), 3514 deletions(-) create mode 100644 .gitmodules delete mode 100644 src/tools/lsp/AGENTS.md delete mode 100644 src/tools/lsp/client.test.ts delete mode 100644 src/tools/lsp/client.ts delete mode 100644 src/tools/lsp/config.test.ts delete mode 100644 src/tools/lsp/config.ts delete mode 100644 src/tools/lsp/constants.ts delete mode 100644 src/tools/lsp/diagnostics-tool.ts delete mode 100644 src/tools/lsp/directory-diagnostics.test.ts delete mode 100644 src/tools/lsp/directory-diagnostics.ts delete mode 100644 src/tools/lsp/find-references-tool.ts delete mode 100644 src/tools/lsp/goto-definition-tool.ts delete mode 100644 src/tools/lsp/index.ts delete mode 100644 src/tools/lsp/infer-extension.test.ts delete mode 100644 src/tools/lsp/infer-extension.ts delete mode 100644 src/tools/lsp/language-config.ts delete mode 100644 src/tools/lsp/language-mappings.ts delete mode 100644 src/tools/lsp/lsp-client-connection.ts delete mode 100644 src/tools/lsp/lsp-client-transport.ts delete mode 100644 src/tools/lsp/lsp-client-wrapper.ts delete mode 100644 src/tools/lsp/lsp-client.ts delete mode 100644 src/tools/lsp/lsp-formatters.ts delete mode 100644 src/tools/lsp/lsp-manager-process-cleanup.ts delete mode 100644 src/tools/lsp/lsp-manager-temp-directory-cleanup.ts delete mode 100644 src/tools/lsp/lsp-process.test.ts delete mode 100644 src/tools/lsp/lsp-process.ts delete mode 100644 src/tools/lsp/lsp-server.ts delete mode 100644 src/tools/lsp/rename-tools.ts delete mode 100644 src/tools/lsp/server-config-loader.test.ts delete mode 100644 src/tools/lsp/server-config-loader.ts delete mode 100644 src/tools/lsp/server-definitions.ts delete mode 100644 src/tools/lsp/server-installation.ts delete mode 100644 src/tools/lsp/server-path-bases.ts delete mode 100644 src/tools/lsp/server-resolution.ts delete mode 100644 src/tools/lsp/symbols-tool.ts delete mode 100644 src/tools/lsp/tools.ts delete mode 100644 src/tools/lsp/types.ts delete mode 100644 src/tools/lsp/utils.test.ts delete mode 100644 src/tools/lsp/workspace-edit.ts create mode 160000 vendor/lsp-tools-mcp diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..55d94e4c2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/lsp-tools-mcp"] + path = vendor/lsp-tools-mcp + url = https://github.com/code-yeongyu/lsp-tools-mcp diff --git a/src/tools/lsp/AGENTS.md b/src/tools/lsp/AGENTS.md deleted file mode 100644 index c023affb9..000000000 --- a/src/tools/lsp/AGENTS.md +++ /dev/null @@ -1,70 +0,0 @@ -# src/tools/lsp/ — LSP Tool Implementations - -**Generated:** 2026-05-15 - -## OVERVIEW - -33 files. Full LSP (Language Server Protocol) client stack exposed as 6 tools. Custom implementation that manages server processes, opens files, and forwards requests — does NOT delegate to OpenCode's built-in LSP. - -## TOOL EXPOSURE - -| Tool | File | What It Does | -|------|------|--------------| -| `lsp_goto_definition` | `goto-definition-tool.ts` | Jump to symbol definition | -| `lsp_find_references` | `find-references-tool.ts` | All usages of a symbol | -| `lsp_symbols` | `symbols-tool.ts` | Document outline or workspace symbol search | -| `lsp_diagnostics` | `diagnostics-tool.ts` | Errors/warnings from language server | -| `lsp_prepare_rename` | `rename-tools.ts` | Validate rename before applying | -| `lsp_rename` | `rename-tools.ts` | Apply safe rename across workspace | - -All 6 are direct `ToolDefinition` objects (not factory functions) — registered directly in `tool-registry.ts`. - -## ARCHITECTURE - -``` -tools.ts (6 ToolDefinition exports) - ↓ uses -LspClientWrapper (lsp-client-wrapper.ts) - ↓ wraps -LSPClient (lsp-client.ts) extends LSPClientConnection (lsp-client-connection.ts) - ↓ communicates via -LSPClientTransport (lsp-client-transport.ts) - ↓ talks to -LSPProcess (lsp-process.ts) — spawns server binary -``` - -## KEY FILES - -| File | Purpose | -|------|---------| -| `lsp-client-wrapper.ts` | High-level entry: resolves server, opens file, runs request | -| `lsp-client.ts` | `LSPClient` — file tracking, document sync (`didOpen`/`didChange`) | -| `lsp-client-connection.ts` | JSON-RPC request/response/notification layer | -| `lsp-client-transport.ts` | stdin/stdout byte-stream framing | -| `lsp-process.ts` | Spawn + cleanup of LSP server process | -| `lsp-manager-process-cleanup.ts` | Reap orphan LSP processes on exit | -| `lsp-manager-temp-directory-cleanup.ts` | Clean temp dirs used by some servers | -| `server-definitions.ts` | 40+ builtin servers synced from OpenCode's `server.ts` | -| `server-config-loader.ts` | Load custom server config from `.opencode/lsp.json` | -| `server-resolution.ts` | Resolve which server handles a file extension | -| `server-installation.ts` | Detect missing binaries, surface install hints | -| `language-mappings.ts` | Extension → language ID mapping | -| `lsp-formatters.ts` | Format LSP responses into human-readable strings | -| `workspace-edit.ts` | Apply `WorkspaceEdit` results to disk (for rename) | -| `types.ts` | `LSPServerConfig`, `Position`, `Range`, `Location`, `Diagnostic` etc. | - -## SERVER RESOLUTION - -``` -file.ts → extension (.ts) → language-mappings → server ID (typescript) - → server-resolution: check user config (.opencode/lsp.json) → fall back to server-definitions.ts - → server-installation: verify binary exists (warn with install hint if not) - → LSPProcess.spawn(command[]) -``` - -## NOTES - -- File must be opened via `didOpen` before any LSP request — `LSPClient.openFile()` handles this -- 1s delay after `didOpen` for server initialization before sending requests -- `lsp_servers` tool was removed — duplicates OpenCode's built-in `LspServers` tool -- Synced with OpenCode's `server.ts` — when adding servers, check upstream first diff --git a/src/tools/lsp/client.test.ts b/src/tools/lsp/client.test.ts deleted file mode 100644 index d7d4f7c73..000000000 --- a/src/tools/lsp/client.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { tmpdir } from "node:os" - -import { describe, it, expect, spyOn, mock, beforeEach, afterEach, afterAll } from "bun:test" - -mock.module("vscode-jsonrpc/node", () => ({ - createMessageConnection: () => { - throw new Error("not used in unit test") - }, - StreamMessageReader: function StreamMessageReader() {}, - StreamMessageWriter: function StreamMessageWriter() {}, -})) - -afterAll(() => { mock.restore() }) - -import { LSPClient, lspManager, validateCwd } from "./client" -import type { ResolvedServer } from "./types" -import { unsafeTestValue } from "../../../test-support/unsafe-test-value" - -describe("LSPClient", () => { - beforeEach(async () => { - await lspManager.stopAll() - }) - - afterEach(async () => { - await lspManager.stopAll() - }) - - describe("openFile", () => { - it("sends didChange when a previously opened file changes on disk", async () => { - // #given - const dir = mkdtempSync(join(tmpdir(), "lsp-client-test-")) - const filePath = join(dir, "test.ts") - writeFileSync(filePath, "const a = 1\n") - - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = ((fn: (...args: unknown[]) => void, _ms?: number) => { - fn() - return unsafeTestValue>(0) - }) as typeof setTimeout - - const server: ResolvedServer = { - id: "typescript", - command: ["typescript-language-server", "--stdio"], - extensions: [".ts"], - priority: 0, - } - - const client = new LSPClient(dir, server) - - // Stub protocol output: we only want to assert notifications. - const sendNotificationSpy = spyOn( - unsafeTestValue<{ sendNotification: (m: string, p?: unknown) => void }>(client), - "sendNotification" - ) - - try { - // #when - await client.openFile(filePath) - writeFileSync(filePath, "const a = 2\n") - await client.openFile(filePath) - - // #then - const methods = sendNotificationSpy.mock.calls.map((c) => c[0]) - expect(methods).toContain("textDocument/didOpen") - expect(methods).toContain("textDocument/didChange") - } finally { - globalThis.setTimeout = originalSetTimeout - rmSync(dir, { recursive: true, force: true }) - } - }) - }) - - describe("LSPServerManager", () => { - it("recreates client after init failure instead of staying permanently blocked", async () => { - //#given - const dir = mkdtempSync(join(tmpdir(), "lsp-manager-test-")) - - const server: ResolvedServer = { - id: "typescript", - command: ["typescript-language-server", "--stdio"], - extensions: [".ts"], - priority: 0, - } - - const startSpy = spyOn(LSPClient.prototype, "start") - const initializeSpy = spyOn(LSPClient.prototype, "initialize") - const isAliveSpy = spyOn(LSPClient.prototype, "isAlive") - const stopSpy = spyOn(LSPClient.prototype, "stop") - - startSpy.mockImplementationOnce(async () => { - throw new Error("boom") - }) - startSpy.mockImplementation(async () => {}) - initializeSpy.mockImplementation(async () => {}) - isAliveSpy.mockImplementation(() => true) - stopSpy.mockImplementation(async () => {}) - - try { - //#when - await expect(lspManager.getClient(dir, server)).rejects.toThrow("boom") - - const client = await lspManager.getClient(dir, server) - - //#then - expect(client).toBeInstanceOf(LSPClient) - expect(startSpy).toHaveBeenCalledTimes(2) - expect(stopSpy).toHaveBeenCalled() - } finally { - startSpy.mockRestore() - initializeSpy.mockRestore() - isAliveSpy.mockRestore() - stopSpy.mockRestore() - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("resets stale initializing entry so a hung init does not permanently block future clients", async () => { - //#given - const dir = mkdtempSync(join(tmpdir(), "lsp-manager-stale-test-")) - - const server: ResolvedServer = { - id: "typescript", - command: ["typescript-language-server", "--stdio"], - extensions: [".ts"], - priority: 0, - } - - const dateNowSpy = spyOn(Date, "now") - - const startSpy = spyOn(LSPClient.prototype, "start") - const initializeSpy = spyOn(LSPClient.prototype, "initialize") - const isAliveSpy = spyOn(LSPClient.prototype, "isAlive") - const stopSpy = spyOn(LSPClient.prototype, "stop") - - // First client init hangs forever. - const never = new Promise(() => {}) - startSpy.mockImplementationOnce(async () => { - await never - }) - - // Second attempt should be allowed after stale reset. - startSpy.mockImplementationOnce(async () => {}) - startSpy.mockImplementation(async () => {}) - initializeSpy.mockImplementation(async () => {}) - isAliveSpy.mockImplementation(() => true) - stopSpy.mockImplementation(async () => {}) - - try { - //#when - dateNowSpy.mockReturnValueOnce(0) - lspManager.warmupClient(dir, server) - - dateNowSpy.mockReturnValueOnce(60_000) - - const client = await Promise.race([ - lspManager.getClient(dir, server), - new Promise((_, reject) => setTimeout(() => reject(new Error("test-timeout")), 50)), - ]) - - //#then - expect(client).toBeInstanceOf(LSPClient) - expect(startSpy).toHaveBeenCalledTimes(2) - expect(stopSpy).toHaveBeenCalled() - } finally { - dateNowSpy.mockRestore() - startSpy.mockRestore() - initializeSpy.mockRestore() - isAliveSpy.mockRestore() - stopSpy.mockRestore() - rmSync(dir, { recursive: true, force: true }) - } - }) - }) - - describe("validateCwd", () => { - it("returns valid for existing directory", () => { - // #given - const dir = mkdtempSync(join(tmpdir(), "lsp-cwd-test-")) - - try { - // #when - const result = validateCwd(dir) - - // #then - expect(result.valid).toBe(true) - expect(result.error).toBeUndefined() - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - - it("returns invalid for non-existent directory", () => { - // #given - const nonExistentDir = join(tmpdir(), "lsp-cwd-nonexistent-" + Date.now()) - - // #when - const result = validateCwd(nonExistentDir) - - // #then - expect(result.valid).toBe(false) - expect(result.error).toContain("Working directory does not exist") - }) - - it("returns invalid when path is a file", () => { - // #given - const dir = mkdtempSync(join(tmpdir(), "lsp-cwd-file-test-")) - const filePath = join(dir, "not-a-dir.txt") - writeFileSync(filePath, "test content") - - try { - // #when - const result = validateCwd(filePath) - - // #then - expect(result.valid).toBe(false) - expect(result.error).toContain("Path is not a directory") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - }) - - describe("start", () => { - it("throws error when working directory does not exist", async () => { - // #given - const nonExistentDir = join(tmpdir(), "lsp-test-nonexistent-" + Date.now()) - const server: ResolvedServer = { - id: "typescript", - command: ["typescript-language-server", "--stdio"], - extensions: [".ts"], - priority: 0, - } - const client = new LSPClient(nonExistentDir, server) - - // #when / #then - await expect(client.start()).rejects.toThrow("Working directory does not exist") - }) - - it("throws error when path is a file instead of directory", async () => { - // #given - const dir = mkdtempSync(join(tmpdir(), "lsp-client-test-")) - const filePath = join(dir, "not-a-dir.txt") - writeFileSync(filePath, "test content") - - const server: ResolvedServer = { - id: "typescript", - command: ["typescript-language-server", "--stdio"], - extensions: [".ts"], - priority: 0, - } - const client = new LSPClient(filePath, server) - - try { - // #when / #then - await expect(client.start()).rejects.toThrow("Path is not a directory") - } finally { - rmSync(dir, { recursive: true, force: true }) - } - }) - }) -}) diff --git a/src/tools/lsp/client.ts b/src/tools/lsp/client.ts deleted file mode 100644 index 5e4651f1b..000000000 --- a/src/tools/lsp/client.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { validateCwd } from "./lsp-process" -export { lspManager } from "./lsp-server" -export { LSPClient } from "./lsp-client" diff --git a/src/tools/lsp/config.test.ts b/src/tools/lsp/config.test.ts deleted file mode 100644 index 59459cde8..000000000 --- a/src/tools/lsp/config.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test" -import { isServerInstalled } from "./config" -import { mkdtempSync, rmSync, writeFileSync } from "fs" -import { join } from "path" -import { tmpdir } from "os" - -describe("isServerInstalled", () => { - let tempDir: string - let savedEnv: { [key: string]: string | undefined } - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "lsp-config-test-")) - savedEnv = { - PATH: process.env.PATH, - Path: process.env.Path, - PATHEXT: process.env.PATHEXT, - } - }) - - afterEach(() => { - try { - rmSync(tempDir, { recursive: true, force: true }) - } catch { - } - - if (process.platform === "win32") { - const pathVal = savedEnv.PATH ?? savedEnv.Path - if (pathVal === undefined) { - delete process.env.PATH - delete process.env.Path - } else { - process.env.PATH = pathVal - process.env.Path = pathVal - } - } else { - if (savedEnv.PATH === undefined) { - delete process.env.PATH - } else { - process.env.PATH = savedEnv.PATH - } - - if (savedEnv.Path === undefined) { - delete process.env.Path - } else { - process.env.Path = savedEnv.Path - } - } - - const pathextVal = savedEnv.PATHEXT - if (pathextVal === undefined) { - delete process.env.PATHEXT - } else { - process.env.PATHEXT = pathextVal - } - }) - - test("detects executable in PATH", () => { - const binName = "test-lsp-server" - const ext = process.platform === "win32" ? ".cmd" : "" - const binPath = join(tempDir, binName + ext) - - writeFileSync(binPath, "echo hello") - - const pathSep = process.platform === "win32" ? ";" : ":" - process.env.PATH = `${tempDir}${pathSep}${process.env.PATH || ""}` - - expect(isServerInstalled([binName])).toBe(true) - }) - - test("returns false for missing executable", () => { - expect(isServerInstalled(["non-existent-server"])).toBe(false) - }) - - if (process.platform === "win32") { - test("Windows: detects executable with Path env var", () => { - const binName = "test-lsp-server-case" - const binPath = join(tempDir, binName + ".cmd") - writeFileSync(binPath, "echo hello") - - delete process.env.PATH - process.env.Path = tempDir - - expect(isServerInstalled([binName])).toBe(true) - }) - - test("Windows: respects PATHEXT", () => { - const binName = "test-lsp-server-custom" - const binPath = join(tempDir, binName + ".COM") - writeFileSync(binPath, "echo hello") - - process.env.PATH = tempDir - process.env.PATHEXT = ".COM;.EXE" - - expect(isServerInstalled([binName])).toBe(true) - }) - - test("Windows: ensures default extensions are checked even if PATHEXT is missing", () => { - const binName = "test-lsp-server-default" - const binPath = join(tempDir, binName + ".bat") - writeFileSync(binPath, "echo hello") - - process.env.PATH = tempDir - delete process.env.PATHEXT - - expect(isServerInstalled([binName])).toBe(true) - }) - - test("Windows: ensures default extensions are checked even if PATHEXT does not include them", () => { - const binName = "test-lsp-server-ps1" - const binPath = join(tempDir, binName + ".ps1") - writeFileSync(binPath, "echo hello") - - process.env.PATH = tempDir - process.env.PATHEXT = ".COM" - - expect(isServerInstalled([binName])).toBe(true) - }) - } else { - test("Non-Windows: does not use windows extensions", () => { - const binName = "test-lsp-server-win" - const binPath = join(tempDir, binName + ".cmd") - writeFileSync(binPath, "echo hello") - - process.env.PATH = tempDir - - expect(isServerInstalled([binName])).toBe(false) - }) - } -}) diff --git a/src/tools/lsp/config.ts b/src/tools/lsp/config.ts deleted file mode 100644 index 2d36aa0f5..000000000 --- a/src/tools/lsp/config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { findServerForExtension, getAllServers, getConfigPaths_ } from "./server-resolution" -export { getLanguageId } from "./language-config" -export { isServerInstalled } from "./server-installation" diff --git a/src/tools/lsp/constants.ts b/src/tools/lsp/constants.ts deleted file mode 100644 index 758ff269c..000000000 --- a/src/tools/lsp/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const DEFAULT_MAX_REFERENCES = 200 -export const DEFAULT_MAX_SYMBOLS = 200 -export const DEFAULT_MAX_DIAGNOSTICS = 200 -export const DEFAULT_MAX_DIRECTORY_FILES = 50 - -export { SYMBOL_KIND_MAP, SEVERITY_MAP, EXT_TO_LANG } from "./language-mappings" -export { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./server-definitions" diff --git a/src/tools/lsp/diagnostics-tool.ts b/src/tools/lsp/diagnostics-tool.ts deleted file mode 100644 index 0e0317beb..000000000 --- a/src/tools/lsp/diagnostics-tool.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { resolve } from "path" - -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" - -import { DEFAULT_MAX_DIAGNOSTICS } from "./constants" -import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics" -import { inferExtensionFromDirectory } from "./infer-extension" -import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters" -import { isDirectoryPath, withLspClient } from "./lsp-client-wrapper" -import type { Diagnostic } from "./types" - -export const lsp_diagnostics: ToolDefinition = tool({ - description: - 'Get errors, warnings, hints from language server BEFORE running build. Works for both single files and directories - file extension is auto-detected for directories.', - args: { - filePath: tool.schema - .string() - .describe("File or directory path to check diagnostics for"), - severity: tool.schema - .enum(["error", "warning", "information", "hint", "all"]) - .optional() - .describe("Filter by severity level"), - }, - execute: async (args, _context) => { - try { - if (!args.filePath) { - throw new Error("'filePath' parameter is required.") - } - const absPath = resolve(args.filePath) - - if (isDirectoryPath(absPath)) { - const extension = inferExtensionFromDirectory(absPath) - if (!extension) { - throw new Error( - `No supported source files found in directory: ${absPath}` - ) - } - return await aggregateDiagnosticsForDirectory(absPath, extension, args.severity) - } - - const result = await withLspClient(args.filePath, async (client) => { - return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null - }) - - let diagnostics: Diagnostic[] = [] - if (result) { - if (Array.isArray(result)) { - diagnostics = result - } else if (result.items) { - diagnostics = result.items - } - } - - diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity) - - if (diagnostics.length === 0) { - const output = "No diagnostics found" - return output - } - - const total = diagnostics.length - const truncated = total > DEFAULT_MAX_DIAGNOSTICS - const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics - const lines = limited.map(formatDiagnostic) - if (truncated) { - lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`) - } - const output = lines.join("\n") - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - throw new Error(output) - } - }, -}) diff --git a/src/tools/lsp/directory-diagnostics.test.ts b/src/tools/lsp/directory-diagnostics.test.ts deleted file mode 100644 index 1c8f89133..000000000 --- a/src/tools/lsp/directory-diagnostics.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" - -import * as configModule from "./config" -import { lspManager } from "./lsp-server" -import { isDirectoryPath } from "./lsp-client-wrapper" -import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics" -import type { Diagnostic } from "./types" - -const diagnosticsMock = mock(async (_filePath: string) => ({ items: [] as Diagnostic[] })) -const getClientMock = mock(async () => ({ diagnostics: diagnosticsMock })) -const releaseClientMock = mock(() => {}) - -function createDiagnostic(message: string): Diagnostic { - return { - message, - severity: 1, - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 1 }, - }, - } -} - -describe("directory diagnostics", () => { - beforeEach(() => { - diagnosticsMock.mockReset() - diagnosticsMock.mockImplementation(async (_filePath: string) => ({ items: [] })) - getClientMock.mockClear() - releaseClientMock.mockClear() - - spyOn(configModule, "findServerForExtension").mockReturnValue({ - status: "found", - server: { - id: "test-server", - command: ["test-server"], - extensions: [".ts"], - priority: 1, - }, - }) - spyOn(lspManager, "getClient").mockImplementation(getClientMock as never) - spyOn(lspManager, "releaseClient").mockImplementation(releaseClientMock) - }) - - afterEach(() => { - mock.restore() - }) - - describe("isDirectoryPath", () => { - it("returns true for existing directory", () => { - const tmp = mkdtempSync(join(tmpdir(), "omo-isdir-")) - try { - expect(isDirectoryPath(tmp)).toBe(true) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } - }) - - it("returns false for existing file", () => { - const tmp = mkdtempSync(join(tmpdir(), "omo-isdir-file-")) - try { - const file = join(tmp, "test.txt") - writeFileSync(file, "content") - expect(isDirectoryPath(file)).toBe(false) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } - }) - - it("returns false for non-existent path", () => { - const nonExistent = join(tmpdir(), "omo-nonexistent-" + Date.now()) - expect(isDirectoryPath(nonExistent)).toBe(false) - }) - }) - - describe("aggregateDiagnosticsForDirectory", () => { - it("throws error when extension does not start with dot", async () => { - const tmp = mkdtempSync(join(tmpdir(), "omo-aggr-ext-")) - try { - await expect(aggregateDiagnosticsForDirectory(tmp, "ts")).rejects.toThrow( - 'Extension must start with a dot (e.g., ".ts", not "ts")' - ) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } - }) - - it("throws error when directory does not exist", async () => { - const nonExistent = join(tmpdir(), "omo-nonexistent-dir-" + Date.now()) - await expect(aggregateDiagnosticsForDirectory(nonExistent, ".ts")).rejects.toThrow( - "Directory does not exist" - ) - }) - - it("#given diagnostics from multiple files #when aggregating directory diagnostics #then each entry includes the source file path", async () => { - const tmp = mkdtempSync(join(tmpdir(), "omo-aggr-files-")) - try { - const firstFile = join(tmp, "first.ts") - const secondFile = join(tmp, "second.ts") - - writeFileSync(firstFile, "export const first = true\n") - writeFileSync(secondFile, "export const second = true\n") - - diagnosticsMock.mockImplementation(async (filePath: string) => ({ - items: [createDiagnostic(`problem in ${filePath}`)], - })) - - const result = await aggregateDiagnosticsForDirectory(tmp, ".ts") - - expect(result).toContain(`${firstFile}: error at 1:0: problem in ${firstFile}`) - expect(result).toContain(`${secondFile}: error at 1:0: problem in ${secondFile}`) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } - }) - }) -}) diff --git a/src/tools/lsp/directory-diagnostics.ts b/src/tools/lsp/directory-diagnostics.ts deleted file mode 100644 index b3dd96106..000000000 --- a/src/tools/lsp/directory-diagnostics.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { existsSync, lstatSync, readdirSync, type Stats } from "fs" -import { extname, join, resolve } from "path" - -import { findServerForExtension } from "./config" -import { findWorkspaceRoot, formatServerLookupError } from "./lsp-client-wrapper" -import { filterDiagnosticsBySeverity, formatDiagnostic } from "./lsp-formatters" -import { LSPClient } from "./lsp-client" -import { lspManager } from "./lsp-server" -import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_DIRECTORY_FILES } from "./constants" -import type { Diagnostic } from "./types" - -const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]) - -type FileDiagnostic = { - filePath: string - diagnostic: Diagnostic -} - -function collectFilesWithExtension(dir: string, extension: string, maxFiles: number): string[] { - const files: string[] = [] - - function walk(currentDir: string): void { - if (files.length >= maxFiles) return - - let entries: string[] = [] - try { - entries = readdirSync(currentDir) - } catch { - return - } - - for (const entry of entries) { - if (files.length >= maxFiles) return - - const fullPath = join(currentDir, entry) - - let stat: Stats | undefined - try { - stat = lstatSync(fullPath) - } catch { - continue - } - - if (!stat || stat.isSymbolicLink()) { - continue - } - - if (stat.isDirectory()) { - if (!SKIP_DIRECTORIES.has(entry)) { - walk(fullPath) - } - } else if (stat.isFile()) { - if (extname(fullPath) === extension) { - files.push(fullPath) - } - } - } - } - - walk(dir) - return files -} - -export async function aggregateDiagnosticsForDirectory( - directory: string, - extension: string, - severity?: "error" | "warning" | "information" | "hint" | "all", - maxFiles: number = DEFAULT_MAX_DIRECTORY_FILES -): Promise { - if (!extension.startsWith(".")) { - throw new Error( - `Extension must start with a dot (e.g., ".ts", not "${extension}"). ` + - `Use ".${extension}" instead.` - ) - } - - const absDir = resolve(directory) - if (!existsSync(absDir)) { - throw new Error(`Directory does not exist: ${absDir}`) - } - - const serverResult = findServerForExtension(extension) - if (serverResult.status !== "found") { - throw new Error(formatServerLookupError(serverResult)) - } - - const server = serverResult.server - const allFiles = collectFilesWithExtension(absDir, extension, maxFiles + 1) - const wasCapped = allFiles.length > maxFiles - const filesToProcess = allFiles.slice(0, maxFiles) - - if (filesToProcess.length === 0) { - return [ - `Directory: ${absDir}`, - `Extension: ${extension}`, - `Files scanned: 0`, - `No files found with extension "${extension}".`, - ].join("\n") - } - - const root = findWorkspaceRoot(absDir) - - const allDiagnostics: FileDiagnostic[] = [] - const fileErrors: { file: string; error: string }[] = [] - - let client: LSPClient - try { - client = await lspManager.getClient(root, server) - - for (const file of filesToProcess) { - try { - const result = await client.diagnostics(file) - const filtered = filterDiagnosticsBySeverity(result.items, severity) - allDiagnostics.push( - ...filtered.map((diagnostic) => ({ - filePath: file, - diagnostic, - })) - ) - } catch (e) { - fileErrors.push({ - file, - error: e instanceof Error ? e.message : String(e), - }) - } - } - } finally { - lspManager.releaseClient(root, server.id) - } - - const displayDiagnostics = allDiagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) - const wasDiagCapped = allDiagnostics.length > DEFAULT_MAX_DIAGNOSTICS - - const lines: string[] = [ - `Directory: ${absDir}`, - `Extension: ${extension}`, - `Files scanned: ${filesToProcess.length}${wasCapped ? ` (capped at ${maxFiles})` : ""}`, - `Files with errors: ${fileErrors.length}`, - `Total diagnostics: ${allDiagnostics.length}`, - ] - - if (fileErrors.length > 0) { - lines.push("", "File processing errors:") - for (const { file, error } of fileErrors) { - lines.push(` ${file}: ${error}`) - } - } - - if (displayDiagnostics.length > 0) { - lines.push("") - for (const { filePath, diagnostic } of displayDiagnostics) { - lines.push(`${filePath}: ${formatDiagnostic(diagnostic)}`) - } - if (wasDiagCapped) { - lines.push( - "", - `... (${allDiagnostics.length - DEFAULT_MAX_DIAGNOSTICS} more diagnostics not shown)` - ) - } - } - - return lines.join("\n") -} diff --git a/src/tools/lsp/find-references-tool.ts b/src/tools/lsp/find-references-tool.ts deleted file mode 100644 index 744e52e5d..000000000 --- a/src/tools/lsp/find-references-tool.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" - -import { DEFAULT_MAX_REFERENCES } from "./constants" -import { formatLocation } from "./lsp-formatters" -import { withLspClient } from "./lsp-client-wrapper" -import type { Location } from "./types" - -export const lsp_find_references: ToolDefinition = tool({ - description: "Find ALL usages/references of a symbol across the entire workspace.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"), - }, - execute: async (args, _context) => { - try { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as - | Location[] - | null - }) - - if (!result || result.length === 0) { - const output = "No references found" - return output - } - - const total = result.length - const truncated = total > DEFAULT_MAX_REFERENCES - const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result - const lines = limited.map(formatLocation) - if (truncated) { - lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`) - } - const output = lines.join("\n") - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) diff --git a/src/tools/lsp/goto-definition-tool.ts b/src/tools/lsp/goto-definition-tool.ts deleted file mode 100644 index c72ebd4c4..000000000 --- a/src/tools/lsp/goto-definition-tool.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" - -import { formatLocation } from "./lsp-formatters" -import { withLspClient } from "./lsp-client-wrapper" -import type { Location, LocationLink } from "./types" - -export const lsp_goto_definition: ToolDefinition = tool({ - description: "Jump to symbol definition. Find WHERE something is defined.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - }, - execute: async (args, _context) => { - try { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.definition(args.filePath, args.line, args.character)) as - | Location - | Location[] - | LocationLink[] - | null - }) - - if (!result) { - const output = "No definition found" - return output - } - - const locations = Array.isArray(result) ? result : [result] - if (locations.length === 0) { - const output = "No definition found" - return output - } - - const output = locations.map(formatLocation).join("\n") - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) diff --git a/src/tools/lsp/index.ts b/src/tools/lsp/index.ts deleted file mode 100644 index 924217654..000000000 --- a/src/tools/lsp/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./types" -export * from "./constants" -export * from "./config" -export * from "./client" -export * from "./lsp-client-wrapper" -export * from "./lsp-formatters" -export * from "./workspace-edit" -// NOTE: lsp_servers removed - duplicates OpenCode's built-in LspServers -export { lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename } from "./tools" diff --git a/src/tools/lsp/infer-extension.test.ts b/src/tools/lsp/infer-extension.test.ts deleted file mode 100644 index 6aea85838..000000000 --- a/src/tools/lsp/infer-extension.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" - -import { inferExtensionFromDirectory } from "./infer-extension" - -describe("inferExtensionFromDirectory", () => { - let tmpDir: string - - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "omo-infer-ext-")) - }) - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }) - }) - - describe("#given a directory with TypeScript files", () => { - beforeEach(() => { - writeFileSync(join(tmpDir, "index.ts"), "export const a = 1") - writeFileSync(join(tmpDir, "utils.ts"), "export const b = 2") - writeFileSync(join(tmpDir, "app.tsx"), "export const c = 3") - }) - - describe("#when inferring extension", () => { - it("#then returns .ts as the most common extension", () => { - const result = inferExtensionFromDirectory(tmpDir) - expect(result).toBe(".ts") - }) - }) - }) - - describe("#given a directory with mixed file types where Python dominates", () => { - beforeEach(() => { - writeFileSync(join(tmpDir, "main.py"), "x = 1") - writeFileSync(join(tmpDir, "utils.py"), "y = 2") - writeFileSync(join(tmpDir, "helper.py"), "z = 3") - writeFileSync(join(tmpDir, "config.ts"), "export default {}") - }) - - describe("#when inferring extension", () => { - it("#then returns .py as the most common extension", () => { - const result = inferExtensionFromDirectory(tmpDir) - expect(result).toBe(".py") - }) - }) - }) - - describe("#given an empty directory", () => { - describe("#when inferring extension", () => { - it("#then returns null", () => { - const result = inferExtensionFromDirectory(tmpDir) - expect(result).toBeNull() - }) - }) - }) - - describe("#given a directory with only unsupported files", () => { - beforeEach(() => { - writeFileSync(join(tmpDir, "data.csv"), "a,b,c") - writeFileSync(join(tmpDir, "image.png"), "fake") - }) - - describe("#when inferring extension", () => { - it("#then returns null", () => { - const result = inferExtensionFromDirectory(tmpDir) - expect(result).toBeNull() - }) - }) - }) - - describe("#given a directory with nested subdirectories", () => { - beforeEach(() => { - writeFileSync(join(tmpDir, "root.go"), "package main") - const sub = join(tmpDir, "pkg") - mkdirSync(sub) - writeFileSync(join(sub, "handler.go"), "package pkg") - writeFileSync(join(sub, "model.go"), "package pkg") - }) - - describe("#when inferring extension", () => { - it("#then counts files recursively", () => { - const result = inferExtensionFromDirectory(tmpDir) - expect(result).toBe(".go") - }) - }) - }) - - describe("#given a directory with node_modules", () => { - beforeEach(() => { - writeFileSync(join(tmpDir, "index.ts"), "export {}") - const nm = join(tmpDir, "node_modules", "pkg") - mkdirSync(nm, { recursive: true }) - writeFileSync(join(nm, "a.js"), "module.exports = {}") - writeFileSync(join(nm, "b.js"), "module.exports = {}") - writeFileSync(join(nm, "c.js"), "module.exports = {}") - }) - - describe("#when inferring extension", () => { - it("#then skips node_modules and returns .ts", () => { - const result = inferExtensionFromDirectory(tmpDir) - expect(result).toBe(".ts") - }) - }) - }) -}) diff --git a/src/tools/lsp/infer-extension.ts b/src/tools/lsp/infer-extension.ts deleted file mode 100644 index 79259a782..000000000 --- a/src/tools/lsp/infer-extension.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { readdirSync, lstatSync } from "fs" -import { extname, join } from "path" - -import { EXT_TO_LANG } from "./language-mappings" - -const SKIP_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", "out"]) -const MAX_SCAN_ENTRIES = 500 - -export function inferExtensionFromDirectory(directory: string): string | null { - const extensionCounts = new Map() - let scanned = 0 - - function walk(dir: string): void { - if (scanned >= MAX_SCAN_ENTRIES) return - - let entries: string[] - try { - entries = readdirSync(dir) - } catch { - return - } - - for (const entry of entries) { - if (scanned >= MAX_SCAN_ENTRIES) return - - const fullPath = join(dir, entry) - - let stat: ReturnType | undefined - try { - stat = lstatSync(fullPath) - } catch { - continue - } - - if (stat.isSymbolicLink()) continue - scanned++ - - if (stat.isDirectory()) { - if (!SKIP_DIRECTORIES.has(entry)) { - walk(fullPath) - } - } else if (stat.isFile()) { - const ext = extname(fullPath) - if (ext && ext in EXT_TO_LANG) { - extensionCounts.set(ext, (extensionCounts.get(ext) ?? 0) + 1) - } - } - } - } - - walk(directory) - - if (extensionCounts.size === 0) return null - - let maxExt = "" - let maxCount = 0 - for (const [ext, count] of extensionCounts) { - if (count > maxCount) { - maxCount = count - maxExt = ext - } - } - - return maxExt || null -} diff --git a/src/tools/lsp/language-config.ts b/src/tools/lsp/language-config.ts deleted file mode 100644 index 75b84f8fb..000000000 --- a/src/tools/lsp/language-config.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EXT_TO_LANG } from "./constants" - -export function getLanguageId(ext: string): string { - return EXT_TO_LANG[ext] || "plaintext" -} diff --git a/src/tools/lsp/language-mappings.ts b/src/tools/lsp/language-mappings.ts deleted file mode 100644 index 136c68214..000000000 --- a/src/tools/lsp/language-mappings.ts +++ /dev/null @@ -1,171 +0,0 @@ -export const SYMBOL_KIND_MAP: Record = { - 1: "File", - 2: "Module", - 3: "Namespace", - 4: "Package", - 5: "Class", - 6: "Method", - 7: "Property", - 8: "Field", - 9: "Constructor", - 10: "Enum", - 11: "Interface", - 12: "Function", - 13: "Variable", - 14: "Constant", - 15: "String", - 16: "Number", - 17: "Boolean", - 18: "Array", - 19: "Object", - 20: "Key", - 21: "Null", - 22: "EnumMember", - 23: "Struct", - 24: "Event", - 25: "Operator", - 26: "TypeParameter", -} - -export const SEVERITY_MAP: Record = { - 1: "error", - 2: "warning", - 3: "information", - 4: "hint", -} - -// Synced with OpenCode's language.ts -// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts -export const EXT_TO_LANG: Record = { - ".abap": "abap", - ".bat": "bat", - ".bib": "bibtex", - ".bibtex": "bibtex", - ".clj": "clojure", - ".cljs": "clojure", - ".cljc": "clojure", - ".edn": "clojure", - ".coffee": "coffeescript", - ".c": "c", - ".cpp": "cpp", - ".cxx": "cpp", - ".cc": "cpp", - ".c++": "cpp", - ".cs": "csharp", - ".css": "css", - ".d": "d", - ".pas": "pascal", - ".pascal": "pascal", - ".diff": "diff", - ".patch": "diff", - ".dart": "dart", - ".dockerfile": "dockerfile", - ".ex": "elixir", - ".exs": "elixir", - ".erl": "erlang", - ".hrl": "erlang", - ".fs": "fsharp", - ".fsi": "fsharp", - ".fsx": "fsharp", - ".fsscript": "fsharp", - ".gitcommit": "git-commit", - ".gitrebase": "git-rebase", - ".go": "go", - ".groovy": "groovy", - ".gleam": "gleam", - ".hbs": "handlebars", - ".handlebars": "handlebars", - ".hs": "haskell", - ".html": "html", - ".htm": "html", - ".ini": "ini", - ".java": "java", - ".js": "javascript", - ".jsx": "javascriptreact", - ".json": "json", - ".jsonc": "jsonc", - ".tex": "latex", - ".latex": "latex", - ".less": "less", - ".lua": "lua", - ".makefile": "makefile", - makefile: "makefile", - ".md": "markdown", - ".markdown": "markdown", - ".m": "objective-c", - ".mm": "objective-cpp", - ".pl": "perl", - ".pm": "perl", - ".pm6": "perl6", - ".php": "php", - ".ps1": "powershell", - ".psm1": "powershell", - ".pug": "jade", - ".jade": "jade", - ".py": "python", - ".pyi": "python", - ".r": "r", - ".cshtml": "razor", - ".razor": "razor", - ".rb": "ruby", - ".rake": "ruby", - ".gemspec": "ruby", - ".ru": "ruby", - ".erb": "erb", - ".html.erb": "erb", - ".js.erb": "erb", - ".css.erb": "erb", - ".json.erb": "erb", - ".rs": "rust", - ".scss": "scss", - ".sass": "sass", - ".scala": "scala", - ".shader": "shaderlab", - ".sh": "shellscript", - ".bash": "shellscript", - ".zsh": "shellscript", - ".ksh": "shellscript", - ".sql": "sql", - ".svelte": "svelte", - ".swift": "swift", - ".ts": "typescript", - ".tsx": "typescriptreact", - ".mts": "typescript", - ".cts": "typescript", - ".mtsx": "typescriptreact", - ".ctsx": "typescriptreact", - ".xml": "xml", - ".xsl": "xsl", - ".yaml": "yaml", - ".yml": "yaml", - ".mjs": "javascript", - ".cjs": "javascript", - ".vue": "vue", - ".zig": "zig", - ".zon": "zig", - ".astro": "astro", - ".ml": "ocaml", - ".mli": "ocaml", - ".tf": "terraform", - ".tfvars": "terraform-vars", - ".hcl": "hcl", - ".nix": "nix", - ".typ": "typst", - ".typc": "typst", - ".ets": "typescript", - ".lhs": "haskell", - ".kt": "kotlin", - ".kts": "kotlin", - ".prisma": "prisma", - // Additional extensions not in OpenCode - ".h": "c", - ".hpp": "cpp", - ".hh": "cpp", - ".hxx": "cpp", - ".h++": "cpp", - ".objc": "objective-c", - ".objcpp": "objective-cpp", - ".fish": "fish", - ".graphql": "graphql", - ".gql": "graphql", -} diff --git a/src/tools/lsp/lsp-client-connection.ts b/src/tools/lsp/lsp-client-connection.ts deleted file mode 100644 index e75a681c7..000000000 --- a/src/tools/lsp/lsp-client-connection.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { pathToFileURL } from "node:url" - -import { LSPClientTransport } from "./lsp-client-transport" - -export class LSPClientConnection extends LSPClientTransport { - async initialize(): Promise { - const rootUri = pathToFileURL(this.root).href - await this.sendRequest("initialize", { - processId: process.pid, - rootUri, - rootPath: this.root, - workspaceFolders: [{ uri: rootUri, name: "workspace" }], - capabilities: { - textDocument: { - hover: { contentFormat: ["markdown", "plaintext"] }, - definition: { linkSupport: true }, - references: {}, - documentSymbol: { hierarchicalDocumentSymbolSupport: true }, - publishDiagnostics: {}, - rename: { - prepareSupport: true, - prepareSupportDefaultBehavior: 1, - honorsChangeAnnotations: true, - }, - codeAction: { - codeActionLiteralSupport: { - codeActionKind: { - valueSet: [ - "quickfix", - "refactor", - "refactor.extract", - "refactor.inline", - "refactor.rewrite", - "source", - "source.organizeImports", - "source.fixAll", - ], - }, - }, - isPreferredSupport: true, - disabledSupport: true, - dataSupport: true, - resolveSupport: { - properties: ["edit", "command"], - }, - }, - }, - workspace: { - symbol: {}, - workspaceFolders: true, - configuration: true, - applyEdit: true, - workspaceEdit: { - documentChanges: true, - }, - }, - }, - initializationOptions: this.server.initialization, - }) - this.sendNotification("initialized") - this.sendNotification("workspace/didChangeConfiguration", { - settings: { json: { validate: { enable: true } } }, - }) - await new Promise((r) => setTimeout(r, 300)) - } -} diff --git a/src/tools/lsp/lsp-client-transport.ts b/src/tools/lsp/lsp-client-transport.ts deleted file mode 100644 index 05d1d0f18..000000000 --- a/src/tools/lsp/lsp-client-transport.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { Readable, Writable } from "node:stream" -import { delimiter } from "path" -import { - createMessageConnection, - StreamMessageReader, - StreamMessageWriter, - type MessageConnection, -} from "vscode-jsonrpc/node" -import type { Diagnostic, ResolvedServer } from "./types" -import { spawnProcess, type UnifiedProcess } from "./lsp-process" -import { getLspServerAdditionalPathBases } from "./server-path-bases" -import { log } from "../../shared/logger" -export class LSPClientTransport { - protected proc: UnifiedProcess | null = null - protected connection: MessageConnection | null = null - protected readonly stderrBuffer: string[] = [] - protected processExited = false - protected readonly diagnosticsStore = new Map() - protected readonly REQUEST_TIMEOUT = 15000 - - constructor(protected root: string, protected server: ResolvedServer) {} - async start(): Promise { - const env = { - ...process.env, - ...this.server.env, - } - const pathValue = process.platform === "win32" ? env.PATH ?? env.Path ?? "" : env.PATH ?? "" - const spawnPath = [pathValue, ...getLspServerAdditionalPathBases(this.root)] - .filter(Boolean) - .join(delimiter) - if (process.platform === "win32" && env.Path !== undefined) { - env.Path = spawnPath - } - env.PATH = spawnPath - - this.proc = spawnProcess(this.server.command, { - cwd: this.root, - env, - }) - if (!this.proc) { - throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`) - } - this.startStderrReading() - await new Promise((resolve) => setTimeout(resolve, 100)) - - if (this.proc.exitCode !== null) { - const stderr = this.stderrBuffer.join("\n") - throw new Error(`LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : "")) - } - - const stdoutReader = this.proc.stdout.getReader() - const nodeReadable = new Readable({ - async read() { - try { - const { done, value } = await stdoutReader.read() - if (done || !value) { - this.push(null) - } else { - this.push(Buffer.from(value)) - } - } catch { - this.push(null) - } - }, - }) - - const stdin = this.proc.stdin - const nodeWritable = new Writable({ - write(chunk, _encoding, callback) { - try { - stdin.write(chunk) - callback() - } catch (err) { - callback(err as Error) - } - }, - }) - - this.connection = createMessageConnection(new StreamMessageReader(nodeReadable), new StreamMessageWriter(nodeWritable)) - - this.connection.onNotification("textDocument/publishDiagnostics", (params: { uri?: string; diagnostics?: Diagnostic[] }) => { - if (params.uri) { - this.diagnosticsStore.set(params.uri, params.diagnostics ?? []) - } - }) - - this.connection.onRequest("workspace/configuration", (params: { items?: Array<{ section?: string }> }) => { - const items = params?.items ?? [] - return items.map((item) => { - if (item.section === "json") return { validate: { enable: true } } - return {} - }) - }) - - this.connection.onRequest("client/registerCapability", () => null) - this.connection.onRequest("window/workDoneProgress/create", () => null) - - this.connection.onClose(() => { - this.processExited = true - }) - - this.connection.onError((error) => { - log("LSP connection error:", error) - }) - - this.connection.listen() - } - - protected startStderrReading(): void { - if (!this.proc) return - const reader = this.proc.stderr.getReader() - const read = async () => { - const decoder = new TextDecoder() - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - const text = decoder.decode(value) - this.stderrBuffer.push(text) - if (this.stderrBuffer.length > 100) { - this.stderrBuffer.shift() - } - } - } catch {} - } - read() - } - - protected sendRequest(method: string): Promise - protected sendRequest(method: string, params: unknown): Promise - protected async sendRequest(method: string, ...args: [] | [unknown]): Promise { - if (!this.connection) throw new Error("LSP client not started") - - if (this.processExited || (this.proc && this.proc.exitCode !== null)) { - const stderr = this.stderrBuffer.slice(-10).join("\n") - throw new Error(`LSP server already exited (code: ${this.proc?.exitCode})` + (stderr ? `\nstderr: ${stderr}` : "")) - } - - let timeoutId: ReturnType | undefined - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - const stderr = this.stderrBuffer.slice(-5).join("\n") - reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : ""))) - }, this.REQUEST_TIMEOUT) - }) - const clearRequestTimeout = (): void => { - if (timeoutId !== undefined) { - clearTimeout(timeoutId) - } - } - - const requestPromise = this.connection.sendRequest(method, ...args) as Promise - - try { - const result = await Promise.race([requestPromise, timeoutPromise]) - clearRequestTimeout() - return result - } catch (error) { - clearRequestTimeout() - throw error - } - } - - protected sendNotification(method: string): void - protected sendNotification(method: string, params: unknown): void - protected sendNotification(method: string, ...args: [] | [unknown]): void { - if (!this.connection) return - if (this.processExited || (this.proc && this.proc.exitCode !== null)) return - this.connection.sendNotification(method, ...args) - } - - isAlive(): boolean { - return this.proc !== null && !this.processExited && this.proc.exitCode === null - } - - async stop(): Promise { - if (this.connection) { - try { - this.sendNotification("shutdown", {}) - this.sendNotification("exit") - } catch {} - this.connection.dispose() - this.connection = null - } - const proc = this.proc - if (proc) { - this.proc = null - let exitedBeforeTimeout = false - try { - proc.kill() - // Wait for exit with timeout to prevent indefinite hang - let timeoutId: ReturnType | undefined - const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(resolve, 5000) - }) - await Promise.race([ - proc.exited.then(() => { - exitedBeforeTimeout = true - }).finally(() => timeoutId && clearTimeout(timeoutId)), - timeoutPromise, - ]) - if (!exitedBeforeTimeout) { - log("[LSPClient] Process did not exit within timeout, escalating to SIGKILL") - try { - proc.kill("SIGKILL") - // Wait briefly for SIGKILL to take effect - await Promise.race([proc.exited, new Promise((resolve) => setTimeout(resolve, 1000))]) - } catch {} - } - } catch {} - } - this.processExited = true - this.diagnosticsStore.clear() - } -} diff --git a/src/tools/lsp/lsp-client-wrapper.ts b/src/tools/lsp/lsp-client-wrapper.ts deleted file mode 100644 index 6c3f82265..000000000 --- a/src/tools/lsp/lsp-client-wrapper.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { extname, resolve } from "path" -import { fileURLToPath } from "node:url" -import { existsSync, statSync } from "fs" - -import { LSPClient, lspManager } from "./client" -import { findServerForExtension } from "./config" -import type { ServerLookupResult } from "./types" -import { CONFIG_BASENAME } from "../../shared/plugin-identity" - -export function isDirectoryPath(filePath: string): boolean { - if (!existsSync(filePath)) { - return false - } - return statSync(filePath).isDirectory() -} - -export function uriToPath(uri: string): string { - return fileURLToPath(uri) -} - -export function findWorkspaceRoot(filePath: string): string { - let dir = resolve(filePath) - - if (!existsSync(dir) || !isDirectoryPath(dir)) { - dir = require("path").dirname(dir) - } - - const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod", "pom.xml", "build.gradle"] - - let prevDir = "" - while (dir !== prevDir) { - for (const marker of markers) { - if (existsSync(require("path").join(dir, marker))) { - return dir - } - } - prevDir = dir - dir = require("path").dirname(dir) - } - - 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 'LspServers' 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 ${CONFIG_BASENAME}.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) - - if (isDirectoryPath(absPath)) { - throw new Error( - `Directory paths are not supported by this LSP tool. ` + - `Use lsp_diagnostics with the 'extension' parameter for directory diagnostics.` - ) - } - - const ext = extname(absPath) - const result = findServerForExtension(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) - - try { - return await fn(client) - } catch (e) { - if (e instanceof Error && e.message.includes("timeout")) { - const isInitializing = lspManager.isServerInitializing(root, server.id) - if (isInitializing) { - throw new Error( - `LSP server is still initializing. Please retry in a few seconds. ` + - `Original error: ${e.message}` - ) - } - } - throw e - } finally { - lspManager.releaseClient(root, server.id) - } -} diff --git a/src/tools/lsp/lsp-client.ts b/src/tools/lsp/lsp-client.ts deleted file mode 100644 index 4785909cc..000000000 --- a/src/tools/lsp/lsp-client.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { readFileSync } from "fs" -import { extname, resolve } from "path" -import { pathToFileURL } from "node:url" - -import { getLanguageId } from "./config" -import { LSPClientConnection } from "./lsp-client-connection" -import type { Diagnostic } from "./types" - -export class LSPClient extends LSPClientConnection { - private openedFiles = new Set() - private documentVersions = new Map() - private lastSyncedText = new Map() - - async openFile(filePath: string): Promise { - const absPath = resolve(filePath) - - const uri = pathToFileURL(absPath).href - const text = readFileSync(absPath, "utf-8") - - if (!this.openedFiles.has(absPath)) { - const ext = extname(absPath) - const languageId = getLanguageId(ext) - const version = 1 - - this.sendNotification("textDocument/didOpen", { - textDocument: { - uri, - languageId, - version, - text, - }, - }) - - this.openedFiles.add(absPath) - this.documentVersions.set(uri, version) - this.lastSyncedText.set(uri, text) - await new Promise((r) => setTimeout(r, 1000)) - return - } - - const prevText = this.lastSyncedText.get(uri) - if (prevText === text) { - return - } - - const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1 - this.documentVersions.set(uri, nextVersion) - this.lastSyncedText.set(uri, text) - - this.sendNotification("textDocument/didChange", { - textDocument: { uri, version: nextVersion }, - contentChanges: [{ text }], - }) - - // Some servers update diagnostics only after save - this.sendNotification("textDocument/didSave", { - textDocument: { uri }, - text, - }) - } - - async definition(filePath: string, line: number, character: number): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/definition", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - }) - } - - async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/references", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - context: { includeDeclaration }, - }) - } - - async documentSymbols(filePath: string): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/documentSymbol", { - textDocument: { uri: pathToFileURL(absPath).href }, - }) - } - - async workspaceSymbols(query: string): Promise { - return this.sendRequest("workspace/symbol", { query }) - } - - async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> { - const absPath = resolve(filePath) - const uri = pathToFileURL(absPath).href - await this.openFile(absPath) - await new Promise((r) => setTimeout(r, 500)) - - try { - const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", { - textDocument: { uri }, - }) - if (result && typeof result === "object" && "items" in result) { - return result as { items: Diagnostic[] } - } - } catch {} - - return { items: this.diagnosticsStore.get(uri) ?? [] } - } - - async prepareRename(filePath: string, line: number, character: number): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/prepareRename", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - }) - } - - async rename(filePath: string, line: number, character: number, newName: string): Promise { - const absPath = resolve(filePath) - await this.openFile(absPath) - return this.sendRequest("textDocument/rename", { - textDocument: { uri: pathToFileURL(absPath).href }, - position: { line: line - 1, character }, - newName, - }) - } -} diff --git a/src/tools/lsp/lsp-formatters.ts b/src/tools/lsp/lsp-formatters.ts deleted file mode 100644 index 0633d55f0..000000000 --- a/src/tools/lsp/lsp-formatters.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants" -import { uriToPath } from "./lsp-client-wrapper" -import type { - Diagnostic, - DocumentSymbol, - Location, - LocationLink, - PrepareRenameDefaultBehavior, - PrepareRenameResult, - Range, - SymbolInfo, - TextEdit, - WorkspaceEdit, -} from "./types" -import type { ApplyResult } from "./workspace-edit" - -export function formatLocation(loc: Location | LocationLink): string { - if ("targetUri" in loc) { - const uri = uriToPath(loc.targetUri) - const line = loc.targetRange.start.line + 1 - const char = loc.targetRange.start.character - return `${uri}:${line}:${char}` - } - - const uri = uriToPath(loc.uri) - const line = loc.range.start.line + 1 - const char = loc.range.start.character - return `${uri}:${line}:${char}` -} - -export function formatSymbolKind(kind: number): string { - return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})` -} - -export function formatSeverity(severity: number | undefined): string { - if (!severity) return "unknown" - return SEVERITY_MAP[severity] || `unknown(${severity})` -} - -export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string { - const prefix = " ".repeat(indent) - const kind = formatSymbolKind(symbol.kind) - const line = symbol.range.start.line + 1 - let result = `${prefix}${symbol.name} (${kind}) - line ${line}` - - if (symbol.children && symbol.children.length > 0) { - for (const child of symbol.children) { - result += "\n" + formatDocumentSymbol(child, indent + 1) - } - } - - return result -} - -export function formatSymbolInfo(symbol: SymbolInfo): string { - const kind = formatSymbolKind(symbol.kind) - const loc = formatLocation(symbol.location) - const container = symbol.containerName ? ` (in ${symbol.containerName})` : "" - return `${symbol.name} (${kind})${container} - ${loc}` -} - -export function formatDiagnostic(diag: Diagnostic): string { - const severity = formatSeverity(diag.severity) - const line = diag.range.start.line + 1 - const char = diag.range.start.character - const source = diag.source ? `[${diag.source}]` : "" - const code = diag.code ? ` (${diag.code})` : "" - return `${severity}${source}${code} at ${line}:${char}: ${diag.message}` -} - -export function filterDiagnosticsBySeverity( - diagnostics: Diagnostic[], - severityFilter?: "error" | "warning" | "information" | "hint" | "all" -): Diagnostic[] { - if (!severityFilter || severityFilter === "all") { - return diagnostics - } - - const severityMap: Record = { - error: 1, - warning: 2, - information: 3, - hint: 4, - } - - const targetSeverity = severityMap[severityFilter] - return diagnostics.filter((d) => d.severity === targetSeverity) -} - -export function formatPrepareRenameResult( - result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null -): string { - if (!result) return "Cannot rename at this position" - - // Case 1: { defaultBehavior: boolean } - if ("defaultBehavior" in result) { - return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position" - } - - // Case 2: { range: Range, placeholder?: string } - if ("range" in result && result.range) { - const startLine = result.range.start.line + 1 - const startChar = result.range.start.character - const endLine = result.range.end.line + 1 - const endChar = result.range.end.character - const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : "" - return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}` - } - - // Case 3: Range directly (has start/end but no range property) - if ("start" in result && "end" in result) { - const startLine = result.start.line + 1 - const startChar = result.start.character - const endLine = result.end.line + 1 - const endChar = result.end.character - return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}` - } - - return "Cannot rename at this position" -} - -export function formatTextEdit(edit: TextEdit): string { - const startLine = edit.range.start.line + 1 - const startChar = edit.range.start.character - const endLine = edit.range.end.line + 1 - const endChar = edit.range.end.character - - const rangeStr = `${startLine}:${startChar}-${endLine}:${endChar}` - const preview = edit.newText.length > 50 ? edit.newText.substring(0, 50) + "..." : edit.newText - - return ` ${rangeStr}: "${preview}"` -} - -export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string { - if (!edit) return "No changes" - - const lines: string[] = [] - - if (edit.changes) { - for (const [uri, edits] of Object.entries(edit.changes)) { - const filePath = uriToPath(uri) - lines.push(`File: ${filePath}`) - for (const textEdit of edits) { - lines.push(formatTextEdit(textEdit)) - } - } - } - - if (edit.documentChanges) { - for (const change of edit.documentChanges) { - if ("kind" in change) { - if (change.kind === "create") { - lines.push(`Create: ${change.uri}`) - } else if (change.kind === "rename") { - lines.push(`Rename: ${change.oldUri} -> ${change.newUri}`) - } else if (change.kind === "delete") { - lines.push(`Delete: ${change.uri}`) - } - } else { - const filePath = uriToPath(change.textDocument.uri) - lines.push(`File: ${filePath}`) - for (const textEdit of change.edits) { - lines.push(formatTextEdit(textEdit)) - } - } - } - } - - if (lines.length === 0) return "No changes" - - return lines.join("\n") -} - -export function formatApplyResult(result: ApplyResult): string { - const lines: string[] = [] - - if (result.success) { - lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`) - for (const file of result.filesModified) { - lines.push(` - ${file}`) - } - } else { - lines.push("Failed to apply some changes:") - for (const err of result.errors) { - lines.push(` Error: ${err}`) - } - if (result.filesModified.length > 0) { - lines.push(`Successfully modified: ${result.filesModified.join(", ")}`) - } - } - - return lines.join("\n") -} diff --git a/src/tools/lsp/lsp-manager-process-cleanup.ts b/src/tools/lsp/lsp-manager-process-cleanup.ts deleted file mode 100644 index 4bf6b14f7..000000000 --- a/src/tools/lsp/lsp-manager-process-cleanup.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { log } from "../../shared/logger" - -type ManagedClientForCleanup = { - client: { - stop: () => Promise; - }; -}; - -type ProcessCleanupOptions = { - getClients: () => IterableIterator<[string, ManagedClientForCleanup]>; - clearClients: () => void; - clearCleanupInterval: () => void; -}; - -type RegisteredHandler = { - event: string; - listener: (...args: unknown[]) => void; -}; - -export type LspProcessCleanupHandle = { - unregister: () => void; -}; - -export function registerLspManagerProcessCleanup(options: ProcessCleanupOptions): LspProcessCleanupHandle { - const handlers: RegisteredHandler[] = []; - - const logCleanupError = (phase: string, error: unknown): void => { - log(`[lsp-manager-process-cleanup] ${phase}`, { - error: error instanceof Error ? error.message : String(error), - }); - }; - - const syncCleanup = () => { - for (const [, managed] of options.getClients()) { - try { - void managed.client.stop().catch((error) => { - logCleanupError("stop failed during exit cleanup", error); - }); - } catch (error) { - logCleanupError("failed to schedule exit cleanup", error); - } - } - options.clearClients(); - options.clearCleanupInterval(); - }; - - const asyncCleanup = async () => { - const stopPromises: Promise[] = []; - for (const [, managed] of options.getClients()) { - stopPromises.push(managed.client.stop().catch((error) => { - logCleanupError("stop failed during signal cleanup", error); - })); - } - await Promise.allSettled(stopPromises); - options.clearClients(); - options.clearCleanupInterval(); - }; - - const registerHandler = (event: string, listener: (...args: unknown[]) => void) => { - handlers.push({ event, listener }); - process.on(event, listener); - }; - - registerHandler("exit", syncCleanup); - - const signalCleanup = () => void asyncCleanup().catch((error) => { - logCleanupError("signal cleanup failed", error); - }); - registerHandler("SIGINT", signalCleanup); - registerHandler("SIGTERM", signalCleanup); - if (process.platform === "win32") { - registerHandler("SIGBREAK", signalCleanup); - } - - return { - unregister: () => { - for (const { event, listener } of handlers) { - process.off(event, listener); - } - handlers.length = 0; - }, - }; -} diff --git a/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts b/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts deleted file mode 100644 index 5ce5aa979..000000000 --- a/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts +++ /dev/null @@ -1,29 +0,0 @@ -type ManagedClientForTempDirectoryCleanup = { - refCount: number - client: { - stop: () => Promise - } -} - -export async function cleanupTempDirectoryLspClients( - clients: Map -): Promise { - const keysToRemove: string[] = [] - for (const [key, managed] of clients.entries()) { - const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/") - const isIdle = managed.refCount === 0 - if (isTempDir && isIdle) { - keysToRemove.push(key) - } - } - - for (const key of keysToRemove) { - const managed = clients.get(key) - if (managed) { - clients.delete(key) - try { - await managed.client.stop() - } catch {} - } - } -} diff --git a/src/tools/lsp/lsp-process.test.ts b/src/tools/lsp/lsp-process.test.ts deleted file mode 100644 index a4ecbc0d9..000000000 --- a/src/tools/lsp/lsp-process.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" - -import { describe, expect, it, spyOn } from "bun:test" - -describe("spawnProcess", () => { - it("proceeds to node spawn on Windows when command is available", async () => { - //#given - const originalPlatform = process.platform - const rootDir = mkdtempSync(join(tmpdir(), "lsp-process-test-")) - const childProcess = await import("node:child_process") - const nodeSpawnSpy = spyOn(childProcess, "spawn") - - try { - Object.defineProperty(process, "platform", { value: "win32" }) - const { spawnProcess } = await import("./lsp-process") - - //#when - let result: ReturnType | null = null - expect(() => { - result = spawnProcess(["node", "--version"], { - cwd: rootDir, - env: process.env, - }) - }).not.toThrow(/Binary 'node' not found/) - - //#then - expect(nodeSpawnSpy).toHaveBeenCalled() - expect(result).not.toBeNull() - } finally { - Object.defineProperty(process, "platform", { value: originalPlatform }) - nodeSpawnSpy.mockRestore() - rmSync(rootDir, { recursive: true, force: true }) - } - }) -}) diff --git a/src/tools/lsp/lsp-process.ts b/src/tools/lsp/lsp-process.ts deleted file mode 100644 index 634e66b2b..000000000 --- a/src/tools/lsp/lsp-process.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { spawn as bunSpawn, type SpawnedProcess } from "../../shared/bun-spawn-shim" -import { spawn as nodeSpawn, type ChildProcess } from "node:child_process" -import { existsSync, statSync } from "fs" -import { log } from "../../shared/logger" -function shouldUseNodeSpawn(): boolean { - return process.platform === "win32" -} -export function validateCwd(cwd: string): { valid: boolean; error?: string } { - try { - if (!existsSync(cwd)) { - return { valid: false, error: `Working directory does not exist: ${cwd}` } - } - const stats = statSync(cwd) - if (!stats.isDirectory()) { - return { valid: false, error: `Path is not a directory: ${cwd}` } - } - return { valid: true } - } catch (err) { - return { valid: false, error: `Cannot access working directory: ${cwd} (${err instanceof Error ? err.message : String(err)})` } - } -} -interface StreamReader { - read(): Promise<{ done: boolean; value: Uint8Array | undefined }> -} -export interface UnifiedProcess { - stdin: { write(chunk: Uint8Array | string): void } - stdout: { getReader(): StreamReader } - stderr: { getReader(): StreamReader } - exitCode: number | null - exited: Promise - kill(signal?: string): void -} -function wrapNodeProcess(proc: ChildProcess): UnifiedProcess { - let resolveExited: (code: number) => void - let exitCode: number | null = null - const exitedPromise = new Promise((resolve) => { - resolveExited = resolve - }) - proc.on("exit", (code) => { - exitCode = code ?? 1 - resolveExited(exitCode) - }) - proc.on("error", () => { - if (exitCode === null) { - exitCode = 1 - resolveExited(1) - } - }) - const createStreamReader = (nodeStream: NodeJS.ReadableStream | null): StreamReader => { - const chunks: Uint8Array[] = [] - let streamEnded = false - type ReadResult = { done: boolean; value: Uint8Array | undefined } - let waitingResolve: ((result: ReadResult) => void) | null = null - - if (nodeStream) { - nodeStream.on("data", (chunk: Buffer) => { - const uint8 = new Uint8Array(chunk) - if (waitingResolve) { - const resolve = waitingResolve - waitingResolve = null - resolve({ done: false, value: uint8 }) - } else { - chunks.push(uint8) - } - }) - - nodeStream.on("end", () => { - streamEnded = true - if (waitingResolve) { - const resolve = waitingResolve - waitingResolve = null - resolve({ done: true, value: undefined }) - } - }) - - nodeStream.on("error", () => { - streamEnded = true - if (waitingResolve) { - const resolve = waitingResolve - waitingResolve = null - resolve({ done: true, value: undefined }) - } - }) - } else { - streamEnded = true - } - return { - read(): Promise { - return new Promise((resolve) => { - if (chunks.length > 0) { - resolve({ done: false, value: chunks.shift()! }) - } else if (streamEnded) { - resolve({ done: true, value: undefined }) - } else { - waitingResolve = resolve - } - }) - }, - } - } - return { - stdin: { - write(chunk: Uint8Array | string) { - if (proc.stdin) { - proc.stdin.write(chunk) - } - }, - }, - stdout: { - getReader: () => createStreamReader(proc.stdout), - }, - stderr: { - getReader: () => createStreamReader(proc.stderr), - }, - get exitCode() { - return exitCode - }, - exited: exitedPromise, - kill(signal?: string) { - try { - if (signal === "SIGKILL") { - proc.kill("SIGKILL") - } else { - proc.kill() - } - } catch {} - }, - } -} - -function wrapBunProcess(proc: SpawnedProcess): UnifiedProcess { - return { - stdin: { - write(chunk: Uint8Array | string) { - proc.stdin.write(chunk) - }, - }, - stdout: { - getReader: () => proc.stdout.getReader(), - }, - stderr: { - getReader: () => proc.stderr.getReader(), - }, - get exitCode() { - return proc.exitCode - }, - exited: proc.exited, - kill(signal?: string) { - proc.kill(signal === "SIGKILL" ? "SIGKILL" : undefined) - }, - } -} - -export function spawnProcess( - command: string[], - options: { cwd: string; env: Record } -): UnifiedProcess { - const cwdValidation = validateCwd(options.cwd) - if (!cwdValidation.valid) { - throw new Error(`[LSP] ${cwdValidation.error}`) - } - if (shouldUseNodeSpawn()) { - const [cmd, ...args] = command - log("[LSP] Using Node.js child_process on Windows to avoid Bun spawn segfault") - const proc = nodeSpawn(cmd, args, { - cwd: options.cwd, - env: options.env as NodeJS.ProcessEnv, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - shell: true, - }) - return wrapNodeProcess(proc) - } - const proc = bunSpawn(command, { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - cwd: options.cwd, - env: options.env, - }) - return wrapBunProcess(proc) -} diff --git a/src/tools/lsp/lsp-server.ts b/src/tools/lsp/lsp-server.ts deleted file mode 100644 index 4a70a8855..000000000 --- a/src/tools/lsp/lsp-server.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { LSPClient } from "./lsp-client"; -import { registerLspManagerProcessCleanup, type LspProcessCleanupHandle } from "./lsp-manager-process-cleanup"; -import { cleanupTempDirectoryLspClients } from "./lsp-manager-temp-directory-cleanup"; -import type { ResolvedServer } from "./types"; -interface ManagedClient { - client: LSPClient; - lastUsedAt: number; - refCount: number; - initPromise?: Promise; - isInitializing: boolean; - initializingSince?: number; -} -class LSPServerManager { - private static instance: LSPServerManager; - private clients = new Map(); - private cleanupInterval: ReturnType | null = null; - private readonly IDLE_TIMEOUT = 5 * 60 * 1000; - private readonly INIT_TIMEOUT = 60 * 1000; - private cleanupHandle: LspProcessCleanupHandle | null = null; - private constructor() { - this.startCleanupTimer(); - this.registerProcessCleanup(); - } - private registerProcessCleanup(): void { - this.cleanupHandle = registerLspManagerProcessCleanup({ - getClients: () => this.clients.entries(), - clearClients: () => { - this.clients.clear(); - }, - clearCleanupInterval: () => { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - }, - }); - } - - static getInstance(): LSPServerManager { - if (!LSPServerManager.instance) { - LSPServerManager.instance = new LSPServerManager(); - } - return LSPServerManager.instance; - } - - private getKey(root: string, serverId: string): string { - return `${root}::${serverId}`; - } - - private startCleanupTimer(): void { - if (this.cleanupInterval) return; - this.cleanupInterval = setInterval(() => { - this.cleanupIdleClients(); - }, 60000); - if (typeof this.cleanupInterval === "object" && "unref" in this.cleanupInterval) { - this.cleanupInterval.unref(); - } - } - - private cleanupIdleClients(): void { - const now = Date.now(); - for (const [key, managed] of this.clients) { - if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) { - managed.client.stop(); - this.clients.delete(key); - } - } - } - - async getClient(root: string, server: ResolvedServer): Promise { - const key = this.getKey(root, server.id); - let managed = this.clients.get(key); - if (managed) { - const now = Date.now(); - if ( - managed.isInitializing && - managed.initializingSince !== undefined && - now - managed.initializingSince >= this.INIT_TIMEOUT - ) { - // Stale init can permanently block subsequent calls (e.g., LSP process hang) - try { - await managed.client.stop(); - } catch {} - this.clients.delete(key); - managed = undefined; - } - } - if (managed) { - if (managed.initPromise) { - try { - await managed.initPromise; - } catch { - // Failed init should not keep the key blocked forever. - try { - await managed.client.stop(); - } catch {} - this.clients.delete(key); - managed = undefined; - } - } - - if (managed) { - if (managed.client.isAlive()) { - managed.refCount++; - managed.lastUsedAt = Date.now(); - return managed.client; - } - try { - await managed.client.stop(); - } catch {} - this.clients.delete(key); - } - } - - const client = new LSPClient(root, server); - const initPromise = (async () => { - await client.start(); - await client.initialize(); - })(); - const initStartedAt = Date.now(); - this.clients.set(key, { - client, - lastUsedAt: initStartedAt, - refCount: 1, - initPromise, - isInitializing: true, - initializingSince: initStartedAt, - }); - - try { - await initPromise; - } catch (error) { - this.clients.delete(key); - try { - await client.stop(); - } catch {} - throw error; - } - const m = this.clients.get(key); - if (m) { - m.initPromise = undefined; - m.isInitializing = false; - m.initializingSince = undefined; - } - - return client; - } - - warmupClient(root: string, server: ResolvedServer): void { - const key = this.getKey(root, server.id); - if (this.clients.has(key)) return; - const client = new LSPClient(root, server); - const initPromise = (async () => { - await client.start(); - await client.initialize(); - })(); - - const initStartedAt = Date.now(); - this.clients.set(key, { - client, - lastUsedAt: initStartedAt, - refCount: 0, - initPromise, - isInitializing: true, - initializingSince: initStartedAt, - }); - - initPromise - .then(() => { - const m = this.clients.get(key); - if (m) { - m.initPromise = undefined; - m.isInitializing = false; - m.initializingSince = undefined; - } - }) - .catch(() => { - // Warmup failures must not permanently block future initialization. - this.clients.delete(key); - void client.stop().catch(() => {}); - }); - } - - releaseClient(root: string, serverId: string): void { - const key = this.getKey(root, serverId); - const managed = this.clients.get(key); - if (managed && managed.refCount > 0) { - managed.refCount--; - managed.lastUsedAt = Date.now(); - } - } - - isServerInitializing(root: string, serverId: string): boolean { - const key = this.getKey(root, serverId); - const managed = this.clients.get(key); - return managed?.isInitializing ?? false; - } - - async stopAll(): Promise { - this.cleanupHandle?.unregister(); - this.cleanupHandle = null; - for (const [, managed] of this.clients) { - await managed.client.stop(); - } - this.clients.clear(); - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - } - - async cleanupTempDirectoryClients(): Promise { - await cleanupTempDirectoryLspClients(this.clients); - } -} - -export const lspManager = LSPServerManager.getInstance(); diff --git a/src/tools/lsp/rename-tools.ts b/src/tools/lsp/rename-tools.ts deleted file mode 100644 index d49cfebb8..000000000 --- a/src/tools/lsp/rename-tools.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" - -import { formatApplyResult, formatPrepareRenameResult } from "./lsp-formatters" -import { withLspClient } from "./lsp-client-wrapper" -import { applyWorkspaceEdit } from "./workspace-edit" -import type { PrepareRenameDefaultBehavior, PrepareRenameResult, WorkspaceEdit } from "./types" - -export const lsp_prepare_rename: ToolDefinition = tool({ - description: "Check if rename is valid. Use BEFORE lsp_rename.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - }, - execute: async (args, _context) => { - try { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.prepareRename(args.filePath, args.line, args.character)) as - | PrepareRenameResult - | PrepareRenameDefaultBehavior - | null - }) - const output = formatPrepareRenameResult(result) - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) - -export const lsp_rename: ToolDefinition = tool({ - description: "Rename symbol across entire workspace. APPLIES changes to all files.", - args: { - filePath: tool.schema.string(), - line: tool.schema.number().min(1).describe("1-based"), - character: tool.schema.number().min(0).describe("0-based"), - newName: tool.schema.string().describe("New symbol name"), - }, - execute: async (args, _context) => { - try { - const edit = await withLspClient(args.filePath, async (client) => { - return (await client.rename(args.filePath, args.line, args.character, args.newName)) as WorkspaceEdit | null - }) - const result = applyWorkspaceEdit(edit) - const output = formatApplyResult(result) - return output - } catch (e) { - const output = `Error: ${e instanceof Error ? e.message : String(e)}` - return output - } - }, -}) diff --git a/src/tools/lsp/server-config-loader.test.ts b/src/tools/lsp/server-config-loader.test.ts deleted file mode 100644 index 1d8937ab4..000000000 --- a/src/tools/lsp/server-config-loader.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, expect } from "bun:test" -import { writeFileSync, unlinkSync, mkdirSync, rmSync } from "fs" -import { join } from "path" -import { tmpdir } from "os" -import { loadJsonFile, getConfigPaths, getMergedServers } from "./server-config-loader" - -describe("loadJsonFile", () => { - it("parses JSONC config files with comments correctly", () => { - // given - const testData = { - lsp: { - typescript: { - command: ["tsserver"], - extensions: [".ts", ".tsx"] - } - } - } - const jsoncContent = `{ - // LSP configuration for TypeScript - "lsp": { - "typescript": { - "command": ["tsserver"], - "extensions": [".ts", ".tsx"] // TypeScript extensions - } - } -}` - const tempPath = join(tmpdir(), "test-config.jsonc") - writeFileSync(tempPath, jsoncContent, "utf-8") - - // when - const result = loadJsonFile(tempPath) - - // then - expect(result).toEqual(testData) - - // cleanup - unlinkSync(tempPath) - }) - - it("discovers JSONC-only user config (oh-my-opencode.jsonc)", () => { - const originalEnv = process.env.OPENCODE_CONFIG_DIR - const tempBase = join(tmpdir(), `omo-test-user-jsonc-${Date.now()}-${Math.random().toString(36).slice(2)}`) - try { - mkdirSync(tempBase, { recursive: true }) - process.env.OPENCODE_CONFIG_DIR = tempBase - - const userJsonc = `{ - // user jsonc config - "lsp": { - "user-jsonc": { - "command": ["user-jsonc-cmd"], - "extensions": [".ujs"] - } - } -}` - const userPath = join(tempBase, "oh-my-opencode.jsonc") - writeFileSync(userPath, userJsonc, "utf-8") - - const servers = getMergedServers() - const found = servers.find(s => s.id === "user-jsonc" && s.source === "user") - expect(found !== undefined).toBe(true) - } finally { - if (originalEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = originalEnv - rmSync(tempBase, { recursive: true, force: true }) - } - }) - - it("discovers JSONC-only opencode config (opencode.jsonc)", () => { - const originalEnv = process.env.OPENCODE_CONFIG_DIR - const tempBase = join(tmpdir(), `omo-test-oc-jsonc-${Date.now()}-${Math.random().toString(36).slice(2)}`) - try { - mkdirSync(tempBase, { recursive: true }) - process.env.OPENCODE_CONFIG_DIR = tempBase - - const opencodeJsonc = `{ - // opencode jsonc config - "lsp": { - "opencode-jsonc": { - "command": ["opencode-jsonc-cmd"], - "extensions": [".ocjs"] - } - } -}` - const opencodePath = join(tempBase, "opencode.jsonc") - writeFileSync(opencodePath, opencodeJsonc, "utf-8") - - const servers = getMergedServers() - const found = servers.find(s => s.id === "opencode-jsonc" && s.source === "opencode") - expect(found !== undefined).toBe(true) - } finally { - if (originalEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = originalEnv - rmSync(tempBase, { recursive: true, force: true }) - } - }) - - it("discovers JSONC-only project config (.opencode/oh-my-opencode.jsonc)", () => { - const originalCwd = process.cwd() - const tempProject = join(tmpdir(), `omo-test-project-jsonc-${Date.now()}-${Math.random().toString(36).slice(2)}`) - try { - mkdirSync(join(tempProject, ".opencode"), { recursive: true }) - const projectJsonc = `{ - // project jsonc config - "lsp": { - "project-jsonc": { - "command": ["project-jsonc-cmd"], - "extensions": [".pjs"] - } - } -}` - const projectPath = join(tempProject, ".opencode", "oh-my-opencode.jsonc") - writeFileSync(projectPath, projectJsonc, "utf-8") - - process.chdir(tempProject) - const servers = getMergedServers() - const found = servers.find(s => s.id === "project-jsonc" && s.source === "project") - expect(found !== undefined).toBe(true) - } finally { - process.chdir(originalCwd) - rmSync(tempProject, { recursive: true, force: true }) - } - }) - - it("prefers .jsonc over .json when both exist for same config id", () => { - const originalEnv = process.env.OPENCODE_CONFIG_DIR - const tempBase = join(tmpdir(), `omo-test-precedence-${Date.now()}-${Math.random().toString(36).slice(2)}`) - try { - mkdirSync(tempBase, { recursive: true }) - process.env.OPENCODE_CONFIG_DIR = tempBase - - const jsonContent = `{ - "lsp": { - "conflict": { - "command": ["from-json"], - "extensions": [".j"] - } - } -}` - const jsoncContent = `{ - // jsonc should take precedence - "lsp": { - "conflict": { - "command": ["from-jsonc"], - "extensions": [".jc"] - } - } -}` - writeFileSync(join(tempBase, "oh-my-opencode.json"), jsonContent, "utf-8") - writeFileSync(join(tempBase, "oh-my-opencode.jsonc"), jsoncContent, "utf-8") - - const servers = getMergedServers() - const found = servers.find(s => s.id === "conflict" && s.source === "user") - expect(found?.command && Array.isArray(found.command) && found.command[0] === "from-jsonc").toBe(true) - } finally { - if (originalEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = originalEnv - rmSync(tempBase, { recursive: true, force: true }) - } - }) -}) diff --git a/src/tools/lsp/server-config-loader.ts b/src/tools/lsp/server-config-loader.ts deleted file mode 100644 index 9cc38d9b8..000000000 --- a/src/tools/lsp/server-config-loader.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { existsSync, readFileSync } from "fs" -import { join } from "path" - -import { BUILTIN_SERVERS } from "./constants" -import type { ResolvedServer } from "./types" -import { getOpenCodeConfigDir } from "../../shared" -import { parseJsonc, detectConfigFile, detectPluginConfigFile } from "../../shared/jsonc-parser" - -interface LspEntry { - disabled?: boolean - command?: string[] - extensions?: string[] - priority?: number - env?: Record - initialization?: Record -} - -interface ConfigJson { - lsp?: Record -} - -type ConfigSource = "project" | "user" | "opencode" - -interface ServerWithSource extends ResolvedServer { - source: ConfigSource -} - -export function loadJsonFile(path: string): T | null { - if (!existsSync(path)) return null - try { - return parseJsonc(readFileSync(path, "utf-8")) as T - } catch { - return null - } -} - -export function getConfigPaths(): { project: string; user: string; opencode: string } { - const cwd = process.cwd() - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - return { - project: detectPluginConfigFile(join(cwd, ".opencode")).path, - user: detectPluginConfigFile(configDir).path, - opencode: detectConfigFile(join(configDir, "opencode")).path, - } -} - -export function loadAllConfigs(): Map { - const paths = getConfigPaths() - const configs = new Map() - - const project = loadJsonFile(paths.project) - if (project) configs.set("project", project) - - const user = loadJsonFile(paths.user) - if (user) configs.set("user", user) - - const opencode = loadJsonFile(paths.opencode) - if (opencode) configs.set("opencode", opencode) - - return configs -} - -export function getMergedServers(): ServerWithSource[] { - const configs = loadAllConfigs() - const servers: ServerWithSource[] = [] - const disabled = new Set() - const seen = new Set() - - const sources: ConfigSource[] = ["project", "user", "opencode"] - - for (const source of sources) { - const config = configs.get(source) - if (!config?.lsp) continue - - for (const [id, entry] of Object.entries(config.lsp)) { - if (entry.disabled) { - disabled.add(id) - continue - } - - if (seen.has(id)) continue - if (!entry.command || !entry.extensions) continue - - servers.push({ - id, - command: entry.command, - extensions: entry.extensions, - priority: entry.priority ?? 0, - env: entry.env, - initialization: entry.initialization, - source, - }) - seen.add(id) - } - } - - for (const [id, config] of Object.entries(BUILTIN_SERVERS)) { - if (disabled.has(id) || seen.has(id)) continue - - servers.push({ - id, - command: config.command, - extensions: config.extensions, - priority: -100, - source: "opencode", - }) - } - - return servers.sort((a, b) => { - if (a.source !== b.source) { - const order: Record = { project: 0, user: 1, opencode: 2 } - return order[a.source] - order[b.source] - } - return b.priority - a.priority - }) -} diff --git a/src/tools/lsp/server-definitions.ts b/src/tools/lsp/server-definitions.ts deleted file mode 100644 index 0e00f1395..000000000 --- a/src/tools/lsp/server-definitions.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { LSPServerConfig } from "./types" - -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", - terraform: "See https://github.com/hashicorp/terraform-ls", - prisma: "npm install -g prisma", - "ocaml-lsp": "opam install ocaml-lsp-server", - texlab: "See https://github.com/latex-lsp/texlab", - dockerfile: "npm install -g dockerfile-language-server-nodejs", - gleam: "See https://gleam.run/getting-started/installing/", - "clojure-lsp": "See https://clojure-lsp.io/installation/", - nixd: "nix profile install nixpkgs#nixd", - tinymist: "See https://github.com/Myriad-Dreamin/tinymist", - "haskell-language-server": "ghcup install hls", - bash: "npm install -g bash-language-server", - "kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp", -} - -// Synced with OpenCode's server.ts -// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts -export const BUILTIN_SERVERS: Record> = { - typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"] }, - deno: { command: ["deno", "lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"] }, - vue: { command: ["vue-language-server", "--stdio"], extensions: [".vue"] }, - eslint: { command: ["vscode-eslint-language-server", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"] }, - oxlint: { command: ["oxlint", "--lsp"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"] }, - biome: { command: ["biome", "lsp-proxy", "--stdio"], extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".json", ".jsonc", ".vue", ".astro", ".svelte", ".css", ".graphql", ".gql", ".html"] }, - gopls: { command: ["gopls"], extensions: [".go"] }, - "ruby-lsp": { command: ["rubocop", "--lsp"], extensions: [".rb", ".rake", ".gemspec", ".ru"] }, - basedpyright: { command: ["basedpyright-langserver", "--stdio"], extensions: [".py", ".pyi"] }, - pyright: { command: ["pyright-langserver", "--stdio"], extensions: [".py", ".pyi"] }, - ty: { command: ["ty", "server"], extensions: [".py", ".pyi"] }, - ruff: { command: ["ruff", "server"], extensions: [".py", ".pyi"] }, - "elixir-ls": { command: ["elixir-ls"], extensions: [".ex", ".exs"] }, - zls: { command: ["zls"], extensions: [".zig", ".zon"] }, - csharp: { command: ["csharp-ls"], extensions: [".cs"] }, - fsharp: { command: ["fsautocomplete"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"] }, - "sourcekit-lsp": { command: ["sourcekit-lsp"], extensions: [".swift", ".objc", ".objcpp"] }, - rust: { command: ["rust-analyzer"], extensions: [".rs"] }, - clangd: { command: ["clangd", "--background-index", "--clang-tidy"], extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"] }, - svelte: { command: ["svelteserver", "--stdio"], extensions: [".svelte"] }, - astro: { command: ["astro-ls", "--stdio"], extensions: [".astro"] }, - bash: { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] }, - // Keep legacy alias for backward compatibility - "bash-ls": { command: ["bash-language-server", "start"], extensions: [".sh", ".bash", ".zsh", ".ksh"] }, - jdtls: { command: ["jdtls"], extensions: [".java"] }, - "yaml-ls": { command: ["yaml-language-server", "--stdio"], extensions: [".yaml", ".yml"] }, - "lua-ls": { command: ["lua-language-server"], extensions: [".lua"] }, - php: { command: ["intelephense", "--stdio"], extensions: [".php"] }, - dart: { command: ["dart", "language-server", "--lsp"], extensions: [".dart"] }, - terraform: { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, - // Legacy alias for backward compatibility - "terraform-ls": { command: ["terraform-ls", "serve"], extensions: [".tf", ".tfvars"] }, - prisma: { command: ["prisma", "language-server"], extensions: [".prisma"] }, - "ocaml-lsp": { command: ["ocamllsp"], extensions: [".ml", ".mli"] }, - texlab: { command: ["texlab"], extensions: [".tex", ".bib"] }, - dockerfile: { command: ["docker-langserver", "--stdio"], extensions: [".dockerfile"] }, - gleam: { command: ["gleam", "lsp"], extensions: [".gleam"] }, - "clojure-lsp": { command: ["clojure-lsp", "listen"], extensions: [".clj", ".cljs", ".cljc", ".edn"] }, - nixd: { command: ["nixd"], extensions: [".nix"] }, - tinymist: { command: ["tinymist"], extensions: [".typ", ".typc"] }, - "haskell-language-server": { command: ["haskell-language-server-wrapper", "--lsp"], extensions: [".hs", ".lhs"] }, - "kotlin-ls": { command: ["kotlin-lsp"], extensions: [".kt", ".kts"] }, -} diff --git a/src/tools/lsp/server-installation.ts b/src/tools/lsp/server-installation.ts deleted file mode 100644 index 9e26eee7a..000000000 --- a/src/tools/lsp/server-installation.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { existsSync } from "fs" -import { delimiter, join } from "path" - -import { getLspServerAdditionalPathBases } from "./server-path-bases" - -export function isServerInstalled(command: string[]): boolean { - if (command.length === 0) return false - - const cmd = command[0] - - // Support absolute paths (e.g., C:\Users\...\server.exe or /usr/local/bin/server) - if (cmd.includes("/") || cmd.includes("\\")) { - if (existsSync(cmd)) return true - } - - const isWindows = process.platform === "win32" - - let exts = [""] - if (isWindows) { - const pathExt = process.env.PATHEXT || "" - if (pathExt) { - const systemExts = pathExt.split(";").filter(Boolean) - exts = [...new Set([...exts, ...systemExts, ".exe", ".cmd", ".bat", ".ps1"])] - } else { - exts = ["", ".exe", ".cmd", ".bat", ".ps1"] - } - } - - let pathEnv = process.env.PATH || "" - if (isWindows && !pathEnv) { - pathEnv = process.env.Path || "" - } - - const paths = pathEnv.split(delimiter) - - for (const p of paths) { - for (const suffix of exts) { - if (existsSync(join(p, cmd + suffix))) { - return true - } - } - } - - for (const base of getLspServerAdditionalPathBases(process.cwd())) { - for (const suffix of exts) { - if (existsSync(join(base, cmd + suffix))) { - return true - } - } - } - - // Runtime wrappers (bun/node) are always available in oh-my-opencode context - if (cmd === "bun" || cmd === "node") { - return true - } - - return false -} diff --git a/src/tools/lsp/server-path-bases.ts b/src/tools/lsp/server-path-bases.ts deleted file mode 100644 index 7f6b00490..000000000 --- a/src/tools/lsp/server-path-bases.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { join } from "path" - -import { getDataDir, getOpenCodeConfigDir } from "../../shared" - -export function getLspServerAdditionalPathBases(workingDirectory: string): string[] { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const dataDir = join(getDataDir(), "opencode") - - return [ - join(workingDirectory, "node_modules", ".bin"), - join(configDir, "bin"), - join(configDir, "node_modules", ".bin"), - join(dataDir, "bin"), - join(dataDir, "bin", "node_modules", ".bin"), - ] -} diff --git a/src/tools/lsp/server-resolution.ts b/src/tools/lsp/server-resolution.ts deleted file mode 100644 index 4279110e6..000000000 --- a/src/tools/lsp/server-resolution.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { BUILTIN_SERVERS, LSP_INSTALL_HINTS } from "./constants" -import { getConfigPaths, getMergedServers, loadAllConfigs } from "./server-config-loader" -import { isServerInstalled } from "./server-installation" -import type { ServerLookupResult } from "./types" - -export function findServerForExtension(ext: string): ServerLookupResult { - const servers = getMergedServers() - - for (const server of servers) { - if (server.extensions.includes(ext) && isServerInstalled(server.command)) { - return { - status: "found", - server: { - id: server.id, - command: server.command, - extensions: server.extensions, - priority: server.priority, - env: server.env, - initialization: server.initialization, - }, - } - } - } - - 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 getAllServers(): Array<{ - id: string - installed: boolean - extensions: string[] - disabled: boolean - source: string - priority: number -}> { - const configs = loadAllConfigs() - const servers = getMergedServers() - const disabled = new Set() - - for (const config of configs.values()) { - if (!config.lsp) continue - for (const [id, entry] of Object.entries(config.lsp)) { - if (entry.disabled) disabled.add(id) - } - } - - const result: Array<{ - id: string - installed: boolean - extensions: string[] - disabled: boolean - source: string - priority: number - }> = [] - - const seen = new Set() - - for (const server of servers) { - if (seen.has(server.id)) continue - result.push({ - id: server.id, - installed: isServerInstalled(server.command), - extensions: server.extensions, - disabled: false, - source: server.source, - priority: server.priority, - }) - seen.add(server.id) - } - - for (const id of disabled) { - if (seen.has(id)) continue - const builtin = BUILTIN_SERVERS[id] - result.push({ - id, - installed: builtin ? isServerInstalled(builtin.command) : false, - extensions: builtin?.extensions || [], - disabled: true, - source: "disabled", - priority: 0, - }) - } - - return result -} - -export function getConfigPaths_(): { project: string; user: string; opencode: string } { - return getConfigPaths() -} diff --git a/src/tools/lsp/symbols-tool.ts b/src/tools/lsp/symbols-tool.ts deleted file mode 100644 index 3af960731..000000000 --- a/src/tools/lsp/symbols-tool.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" - -import { DEFAULT_MAX_SYMBOLS } from "./constants" -import { formatDocumentSymbol, formatSymbolInfo } from "./lsp-formatters" -import { withLspClient } from "./lsp-client-wrapper" -import type { DocumentSymbol, SymbolInfo } from "./types" - -export const lsp_symbols: ToolDefinition = tool({ - description: - "Get symbols from file (document) or search across workspace. Use scope='document' for file outline, scope='workspace' for project-wide symbol search.", - args: { - filePath: tool.schema.string().describe("File path for LSP context"), - scope: tool.schema - .enum(["document", "workspace"]) - .default("document") - .describe("'document' for file symbols, 'workspace' for project-wide search"), - query: tool.schema.string().optional().describe("Symbol name to search (required for workspace scope)"), - limit: tool.schema.number().optional().describe("Max results (default 50)"), - }, - execute: async (args, _context) => { - try { - const scope = args.scope ?? "document" - - if (scope === "workspace") { - const query = args.query - if (!query) { - return "Error: 'query' is required for workspace scope" - } - - const result = await withLspClient(args.filePath, async (client) => { - return (await client.workspaceSymbols(query)) as SymbolInfo[] | null - }) - - if (!result || result.length === 0) { - return "No symbols found" - } - - const total = result.length - const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) - const truncated = total > limit - const limited = result.slice(0, limit) - const lines = limited.map(formatSymbolInfo) - if (truncated) { - lines.unshift(`Found ${total} symbols (showing first ${limit}):`) - } - return lines.join("\n") - } else { - const result = await withLspClient(args.filePath, async (client) => { - return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null - }) - - if (!result || result.length === 0) { - return "No symbols found" - } - - const total = result.length - const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS) - const truncated = total > limit - const limited = truncated ? result.slice(0, limit) : result - - const lines: string[] = [] - if (truncated) { - lines.push(`Found ${total} symbols (showing first ${limit}):`) - } - - if ("range" in limited[0]) { - lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s))) - } else { - lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo)) - } - return lines.join("\n") - } - } catch (e) { - return `Error: ${e instanceof Error ? e.message : String(e)}` - } - }, -}) diff --git a/src/tools/lsp/tools.ts b/src/tools/lsp/tools.ts deleted file mode 100644 index 9ed6ff7b3..000000000 --- a/src/tools/lsp/tools.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { lsp_goto_definition } from "./goto-definition-tool" -export { lsp_find_references } from "./find-references-tool" -export { lsp_symbols } from "./symbols-tool" -export { lsp_diagnostics } from "./diagnostics-tool" -export { lsp_prepare_rename, lsp_rename } from "./rename-tools" diff --git a/src/tools/lsp/types.ts b/src/tools/lsp/types.ts deleted file mode 100644 index 6a7c1ddfc..000000000 --- a/src/tools/lsp/types.ts +++ /dev/null @@ -1,124 +0,0 @@ -export interface LSPServerConfig { - id: string - command: string[] - extensions: string[] - disabled?: boolean - env?: Record - initialization?: Record -} - -export interface Position { - line: number - character: number -} - -export interface Range { - start: Position - end: Position -} - -export interface Location { - uri: string - range: Range -} - -export interface LocationLink { - targetUri: string - targetRange: Range - targetSelectionRange: Range - originSelectionRange?: Range -} - -export interface SymbolInfo { - name: string - kind: number - location: Location - containerName?: string -} - -export interface DocumentSymbol { - name: string - kind: number - range: Range - selectionRange: Range - children?: DocumentSymbol[] -} - -export interface Diagnostic { - range: Range - severity?: number - code?: string | number - source?: string - message: string -} - -export interface TextDocumentIdentifier { - uri: string -} - -export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier { - version: number | null -} - -export interface TextEdit { - range: Range - newText: string -} - -export interface TextDocumentEdit { - textDocument: VersionedTextDocumentIdentifier - edits: TextEdit[] -} - -export interface CreateFile { - kind: "create" - uri: string - options?: { overwrite?: boolean; ignoreIfExists?: boolean } -} - -export interface RenameFile { - kind: "rename" - oldUri: string - newUri: string - options?: { overwrite?: boolean; ignoreIfExists?: boolean } -} - -export interface DeleteFile { - kind: "delete" - uri: string - options?: { recursive?: boolean; ignoreIfNotExists?: boolean } -} - -export interface WorkspaceEdit { - changes?: { [uri: string]: TextEdit[] } - documentChanges?: (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[] -} - -export interface PrepareRenameResult { - range: Range - placeholder?: string -} - -export interface PrepareRenameDefaultBehavior { - defaultBehavior: boolean -} - -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.test.ts b/src/tools/lsp/utils.test.ts deleted file mode 100644 index a323bd872..000000000 --- a/src/tools/lsp/utils.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" - -import { findWorkspaceRoot } from "./lsp-client-wrapper" - -describe("lsp utils", () => { - describe("findWorkspaceRoot", () => { - it("returns an existing directory even when the file path points to a non-existent nested path", () => { - const tmp = mkdtempSync(join(tmpdir(), "omo-lsp-root-")) - try { - // Add a marker so the function can discover the workspace root. - writeFileSync(join(tmp, "package.json"), "{}") - - const nonExistentFile = join(tmp, "does-not-exist", "deep", "file.ts") - const root = findWorkspaceRoot(nonExistentFile) - - expect(root).toBe(tmp) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } - }) - - it("prefers the nearest marker directory when markers exist above the file", () => { - const tmp = mkdtempSync(join(tmpdir(), "omo-lsp-marker-")) - try { - const repo = join(tmp, "repo") - const src = join(repo, "src") - mkdirSync(src, { recursive: true }) - - writeFileSync(join(repo, "package.json"), "{}") - const file = join(src, "index.ts") - writeFileSync(file, "export {}") - - expect(findWorkspaceRoot(file)).toBe(repo) - } finally { - rmSync(tmp, { recursive: true, force: true }) - } - }) - }) -}) diff --git a/src/tools/lsp/workspace-edit.ts b/src/tools/lsp/workspace-edit.ts deleted file mode 100644 index e0a836dc2..000000000 --- a/src/tools/lsp/workspace-edit.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { readFileSync, writeFileSync } from "fs" - -import { uriToPath } from "./lsp-client-wrapper" -import type { TextEdit, WorkspaceEdit } from "./types" - -export interface ApplyResult { - success: boolean - filesModified: string[] - totalEdits: number - errors: string[] -} - -function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } { - try { - let content = readFileSync(filePath, "utf-8") - const lines = content.split("\n") - - const sortedEdits = [...edits].sort((a, b) => { - if (b.range.start.line !== a.range.start.line) { - return b.range.start.line - a.range.start.line - } - return b.range.start.character - a.range.start.character - }) - - for (const edit of sortedEdits) { - const startLine = edit.range.start.line - const startChar = edit.range.start.character - const endLine = edit.range.end.line - const endChar = edit.range.end.character - - if (startLine === endLine) { - const line = lines[startLine] || "" - lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar) - } else { - const firstLine = lines[startLine] || "" - const lastLine = lines[endLine] || "" - const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar) - lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n")) - } - } - - writeFileSync(filePath, lines.join("\n"), "utf-8") - return { success: true, editCount: edits.length } - } catch (err) { - return { success: false, editCount: 0, error: err instanceof Error ? err.message : String(err) } - } -} - -export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult { - if (!edit) { - return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] } - } - - const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] } - - if (edit.changes) { - for (const [uri, edits] of Object.entries(edit.changes)) { - const filePath = uriToPath(uri) - const applyResult = applyTextEditsToFile(filePath, edits) - - if (applyResult.success) { - result.filesModified.push(filePath) - result.totalEdits += applyResult.editCount - } else { - result.success = false - result.errors.push(`${filePath}: ${applyResult.error}`) - } - } - } - - if (edit.documentChanges) { - for (const change of edit.documentChanges) { - if ("kind" in change) { - if (change.kind === "create") { - try { - const filePath = uriToPath(change.uri) - writeFileSync(filePath, "", "utf-8") - result.filesModified.push(filePath) - } catch (err) { - result.success = false - result.errors.push(`Create ${change.uri}: ${err}`) - } - } else if (change.kind === "rename") { - try { - const oldPath = uriToPath(change.oldUri) - const newPath = uriToPath(change.newUri) - const content = readFileSync(oldPath, "utf-8") - writeFileSync(newPath, content, "utf-8") - require("fs").unlinkSync(oldPath) - result.filesModified.push(newPath) - } catch (err) { - result.success = false - result.errors.push(`Rename ${change.oldUri}: ${err}`) - } - } else if (change.kind === "delete") { - try { - const filePath = uriToPath(change.uri) - require("fs").unlinkSync(filePath) - result.filesModified.push(filePath) - } catch (err) { - result.success = false - result.errors.push(`Delete ${change.uri}: ${err}`) - } - } - } else { - const filePath = uriToPath(change.textDocument.uri) - const applyResult = applyTextEditsToFile(filePath, change.edits) - - if (applyResult.success) { - result.filesModified.push(filePath) - result.totalEdits += applyResult.editCount - } else { - result.success = false - result.errors.push(`${filePath}: ${applyResult.error}`) - } - } - } - } - - return result -} diff --git a/vendor/lsp-tools-mcp b/vendor/lsp-tools-mcp new file mode 160000 index 000000000..ff2c5145e --- /dev/null +++ b/vendor/lsp-tools-mcp @@ -0,0 +1 @@ +Subproject commit ff2c5145e8333ac3e0eeae4b3dcfdcffe2325134