chore: add lsp-tools-mcp submodule at vendor/lsp-tools-mcp
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/lsp-tools-mcp"]
|
||||
path = vendor/lsp-tools-mcp
|
||||
url = https://github.com/code-yeongyu/lsp-tools-mcp
|
||||
@@ -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
|
||||
@@ -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<ReturnType<typeof setTimeout>>(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<void>(() => {})
|
||||
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<never>((_, 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
export { validateCwd } from "./lsp-process"
|
||||
export { lspManager } from "./lsp-server"
|
||||
export { LSPClient } from "./lsp-client"
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
export { findServerForExtension, getAllServers, getConfigPaths_ } from "./server-resolution"
|
||||
export { getLanguageId } from "./language-config"
|
||||
export { isServerInstalled } from "./server-installation"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string> {
|
||||
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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, number>()
|
||||
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<typeof lstatSync> | 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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { EXT_TO_LANG } from "./constants"
|
||||
|
||||
export function getLanguageId(ext: string): string {
|
||||
return EXT_TO_LANG[ext] || "plaintext"
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
export const SYMBOL_KIND_MAP: Record<number, string> = {
|
||||
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<number, string> = {
|
||||
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<string, string> = {
|
||||
".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",
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { pathToFileURL } from "node:url"
|
||||
|
||||
import { LSPClientTransport } from "./lsp-client-transport"
|
||||
|
||||
export class LSPClientConnection extends LSPClientTransport {
|
||||
async initialize(): Promise<void> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<string, Diagnostic[]>()
|
||||
protected readonly REQUEST_TIMEOUT = 15000
|
||||
|
||||
constructor(protected root: string, protected server: ResolvedServer) {}
|
||||
async start(): Promise<void> {
|
||||
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<T>(method: string): Promise<T>
|
||||
protected sendRequest<T>(method: string, params: unknown): Promise<T>
|
||||
protected async sendRequest<T>(method: string, ...args: [] | [unknown]): Promise<T> {
|
||||
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<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<never>((_, 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<T>
|
||||
|
||||
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<void> {
|
||||
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<typeof setTimeout> | undefined
|
||||
const timeoutPromise = new Promise<void>((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<void>((resolve) => setTimeout(resolve, 1000))])
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
this.processExited = true
|
||||
this.diagnosticsStore.clear()
|
||||
}
|
||||
}
|
||||
@@ -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<ServerLookupResult, { status: "found" }>): 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<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<string>()
|
||||
private documentVersions = new Map<string, number>()
|
||||
private lastSyncedText = new Map<string, string>()
|
||||
|
||||
async openFile(filePath: string): Promise<void> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.sendRequest("textDocument/documentSymbol", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
})
|
||||
}
|
||||
|
||||
async workspaceSymbols(query: string): Promise<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.sendRequest("textDocument/rename", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
newName,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
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")
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
type ManagedClientForCleanup = {
|
||||
client: {
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
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<void>[] = [];
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
type ManagedClientForTempDirectoryCleanup = {
|
||||
refCount: number
|
||||
client: {
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupTempDirectoryLspClients(
|
||||
clients: Map<string, ManagedClientForTempDirectoryCleanup>
|
||||
): Promise<void> {
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof spawnProcess> | 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<number>
|
||||
kill(signal?: string): void
|
||||
}
|
||||
function wrapNodeProcess(proc: ChildProcess): UnifiedProcess {
|
||||
let resolveExited: (code: number) => void
|
||||
let exitCode: number | null = null
|
||||
const exitedPromise = new Promise<number>((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<ReadResult> {
|
||||
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<string, string | undefined> }
|
||||
): 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)
|
||||
}
|
||||
@@ -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<void>;
|
||||
isInitializing: boolean;
|
||||
initializingSince?: number;
|
||||
}
|
||||
class LSPServerManager {
|
||||
private static instance: LSPServerManager;
|
||||
private clients = new Map<string, ManagedClient>();
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | 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<LSPClient> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await cleanupTempDirectoryLspClients(this.clients);
|
||||
}
|
||||
}
|
||||
|
||||
export const lspManager = LSPServerManager.getInstance();
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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<typeof testData>(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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>
|
||||
initialization?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ConfigJson {
|
||||
lsp?: Record<string, LspEntry>
|
||||
}
|
||||
|
||||
type ConfigSource = "project" | "user" | "opencode"
|
||||
|
||||
interface ServerWithSource extends ResolvedServer {
|
||||
source: ConfigSource
|
||||
}
|
||||
|
||||
export function loadJsonFile<T>(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<ConfigSource, ConfigJson> {
|
||||
const paths = getConfigPaths()
|
||||
const configs = new Map<ConfigSource, ConfigJson>()
|
||||
|
||||
const project = loadJsonFile<ConfigJson>(paths.project)
|
||||
if (project) configs.set("project", project)
|
||||
|
||||
const user = loadJsonFile<ConfigJson>(paths.user)
|
||||
if (user) configs.set("user", user)
|
||||
|
||||
const opencode = loadJsonFile<ConfigJson>(paths.opencode)
|
||||
if (opencode) configs.set("opencode", opencode)
|
||||
|
||||
return configs
|
||||
}
|
||||
|
||||
export function getMergedServers(): ServerWithSource[] {
|
||||
const configs = loadAllConfigs()
|
||||
const servers: ServerWithSource[] = []
|
||||
const disabled = new Set<string>()
|
||||
const seen = new Set<string>()
|
||||
|
||||
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<ConfigSource, number> = { project: 0, user: 1, opencode: 2 }
|
||||
return order[a.source] - order[b.source]
|
||||
}
|
||||
return b.priority - a.priority
|
||||
})
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { LSPServerConfig } from "./types"
|
||||
|
||||
export const LSP_INSTALL_HINTS: Record<string, string> = {
|
||||
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<string, Omit<LSPServerConfig, "id">> = {
|
||||
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"] },
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
]
|
||||
}
|
||||
@@ -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<string>()
|
||||
|
||||
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<string>()
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
@@ -1,124 +0,0 @@
|
||||
export interface LSPServerConfig {
|
||||
id: string
|
||||
command: string[]
|
||||
extensions: string[]
|
||||
disabled?: boolean
|
||||
env?: Record<string, string>
|
||||
initialization?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<string, string>
|
||||
initialization?: Record<string, unknown>
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
+1
Submodule vendor/lsp-tools-mcp added at ff2c5145e8
Reference in New Issue
Block a user