chore: add lsp-tools-mcp submodule at vendor/lsp-tools-mcp

This commit is contained in:
YeonGyu-Kim
2026-05-18 12:10:30 +09:00
parent 34e6af1ae6
commit d95a45c872
40 changed files with 4 additions and 3514 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/lsp-tools-mcp"]
path = vendor/lsp-tools-mcp
url = https://github.com/code-yeongyu/lsp-tools-mcp
-70
View File
@@ -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
-263
View File
@@ -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 })
}
})
})
})
-3
View File
@@ -1,3 +0,0 @@
export { validateCwd } from "./lsp-process"
export { lspManager } from "./lsp-server"
export { LSPClient } from "./lsp-client"
-129
View File
@@ -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)
})
}
})
-3
View File
@@ -1,3 +0,0 @@
export { findServerForExtension, getAllServers, getConfigPaths_ } from "./server-resolution"
export { getLanguageId } from "./language-config"
export { isServerInstalled } from "./server-installation"
-7
View File
@@ -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"
-75
View File
@@ -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)
}
},
})
-119
View File
@@ -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 })
}
})
})
})
-163
View File
@@ -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")
}
-43
View File
@@ -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
}
},
})
-42
View File
@@ -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
}
},
})
-9
View File
@@ -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"
-107
View File
@@ -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")
})
})
})
})
-65
View File
@@ -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
}
-5
View File
@@ -1,5 +0,0 @@
import { EXT_TO_LANG } from "./constants"
export function getLanguageId(ext: string): string {
return EXT_TO_LANG[ext] || "plaintext"
}
-171
View File
@@ -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",
}
-66
View File
@@ -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))
}
}
-215
View File
@@ -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()
}
}
-116
View File
@@ -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)
}
}
-129
View File
@@ -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,
})
}
}
-193
View File
@@ -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 {}
}
}
}
-37
View File
@@ -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 })
}
})
})
-182
View File
@@ -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)
}
-217
View File
@@ -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();
-53
View File
@@ -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
}
},
})
-161
View File
@@ -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 })
}
})
})
-116
View File
@@ -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
})
}
-91
View File
@@ -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"] },
}
-58
View File
@@ -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
}
-16
View File
@@ -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"),
]
}
-109
View File
@@ -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()
}
-77
View File
@@ -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)}`
}
},
})
-5
View File
@@ -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"
-124
View File
@@ -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>
}
-42
View File
@@ -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 })
}
})
})
})
-121
View File
@@ -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
}
Vendored Submodule
+1
Submodule vendor/lsp-tools-mcp added at ff2c5145e8