feat(lsp): add rename, code actions, and server connection pooling

- Add LSPServerManager for connection pooling with idle cleanup
- Add lsp_prepare_rename and lsp_rename tools
- Add lsp_code_actions and lsp_code_action_resolve tools
- Add WorkspaceEdit types and applyWorkspaceEdit utility
- Improve LSP client robustness with stderr buffering and process state tracking
This commit is contained in:
YeonGyu-Kim
2025-12-04 22:40:05 +09:00
parent 81869ccaac
commit dc119bc1ed
4 changed files with 802 additions and 51 deletions

View File

@@ -4,12 +4,108 @@ import { extname, resolve } from "path"
import type { ResolvedServer } from "./config" import type { ResolvedServer } from "./config"
import { getLanguageId } from "./config" import { getLanguageId } from "./config"
interface ManagedClient {
client: LSPClient
lastUsedAt: number
refCount: 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 constructor() {
this.startCleanupTimer()
}
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)
}
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) {
if (managed.client.isAlive()) {
managed.refCount++
managed.lastUsedAt = Date.now()
return managed.client
}
await managed.client.stop()
this.clients.delete(key)
}
const client = new LSPClient(root, server)
await client.start()
await client.initialize()
this.clients.set(key, {
client,
lastUsedAt: Date.now(),
refCount: 1,
})
return client
}
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()
}
}
async stopAll(): Promise<void> {
for (const [, managed] of this.clients) {
await managed.client.stop()
}
this.clients.clear()
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
}
export const lspManager = LSPServerManager.getInstance()
export class LSPClient { export class LSPClient {
private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
private buffer: Uint8Array = new Uint8Array(0) private buffer: Uint8Array = new Uint8Array(0)
private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>() private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
private requestId = 0 private requestIdCounter = 0
private openedFiles = new Set<string>() private openedFiles = new Set<string>()
private stderrBuffer: string[] = []
private processExited = false
constructor( constructor(
private root: string, private root: string,
@@ -27,7 +123,22 @@ export class LSPClient {
...this.server.env, ...this.server.env,
}, },
}) })
if (!this.proc) {
throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`)
}
this.startReading() this.startReading()
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}` : "")
)
}
} }
private startReading(): void { private startReading(): void {
@@ -38,19 +149,54 @@ export class LSPClient {
try { try {
while (true) { while (true) {
const { done, value } = await reader.read() const { done, value } = await reader.read()
if (done) break if (done) {
this.processExited = true
this.rejectAllPending("LSP server stdout closed")
break
}
const newBuf = new Uint8Array(this.buffer.length + value.length) const newBuf = new Uint8Array(this.buffer.length + value.length)
newBuf.set(this.buffer) newBuf.set(this.buffer)
newBuf.set(value, this.buffer.length) newBuf.set(value, this.buffer.length)
this.buffer = newBuf this.buffer = newBuf
this.processBuffer() this.processBuffer()
} }
} catch (err) {
this.processExited = true
this.rejectAllPending(`LSP stdout read error: ${err}`)
}
}
read()
}
private 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 { } catch {
} }
} }
read() read()
} }
private rejectAllPending(reason: string): void {
for (const [id, handler] of this.pending) {
handler.reject(new Error(reason))
this.pending.delete(id)
}
}
private findSequence(haystack: Uint8Array, needle: number[]): number { private findSequence(haystack: Uint8Array, needle: number[]): number {
outer: for (let i = 0; i <= haystack.length - needle.length; i++) { outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
for (let j = 0; j < needle.length; j++) { for (let j = 0; j < needle.length; j++) {
@@ -95,12 +241,9 @@ export class LSPClient {
try { try {
const msg = JSON.parse(content) const msg = JSON.parse(content)
// Handle server requests (has id AND method) - e.g., workspace/configuration
if ("id" in msg && "method" in msg) { if ("id" in msg && "method" in msg) {
this.handleServerRequest(msg.id, msg.method, msg.params) this.handleServerRequest(msg.id, msg.method, msg.params)
} } else if ("id" in msg && this.pending.has(msg.id)) {
// Handle server responses (has id, no method)
else if ("id" in msg && this.pending.has(msg.id)) {
const handler = this.pending.get(msg.id)! const handler = this.pending.get(msg.id)!
this.pending.delete(msg.id) this.pending.delete(msg.id)
if ("error" in msg) { if ("error" in msg) {
@@ -117,7 +260,12 @@ export class LSPClient {
private send(method: string, params?: unknown): Promise<unknown> { private send(method: string, params?: unknown): Promise<unknown> {
if (!this.proc) throw new Error("LSP client not started") if (!this.proc) throw new Error("LSP client not started")
const id = ++this.requestId if (this.processExited || 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}` : ""))
}
const id = ++this.requestIdCounter
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params })
const header = `Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n` const header = `Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n`
this.proc.stdin.write(header + msg) this.proc.stdin.write(header + msg)
@@ -127,7 +275,8 @@ export class LSPClient {
setTimeout(() => { setTimeout(() => {
if (this.pending.has(id)) { if (this.pending.has(id)) {
this.pending.delete(id) this.pending.delete(id)
reject(new Error("LSP request timeout")) const stderr = this.stderrBuffer.slice(-5).join("\n")
reject(new Error(`LSP request timeout (method: ${method})` + (stderr ? `\nrecent stderr: ${stderr}` : "")))
} }
}, 30000) }, 30000)
}) })
@@ -135,6 +284,7 @@ export class LSPClient {
private notify(method: string, params?: unknown): void { private notify(method: string, params?: unknown): void {
if (!this.proc) return if (!this.proc) return
if (this.processExited || this.proc.exitCode !== null) return
const msg = JSON.stringify({ jsonrpc: "2.0", method, params }) const msg = JSON.stringify({ jsonrpc: "2.0", method, params })
this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`) this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`)
@@ -142,6 +292,7 @@ export class LSPClient {
private respond(id: number | string, result: unknown): void { private respond(id: number | string, result: unknown): void {
if (!this.proc) return if (!this.proc) return
if (this.processExited || this.proc.exitCode !== null) return
const msg = JSON.stringify({ jsonrpc: "2.0", id, result }) const msg = JSON.stringify({ jsonrpc: "2.0", id, result })
this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`) this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`)
@@ -171,18 +322,49 @@ export class LSPClient {
references: {}, references: {},
documentSymbol: { hierarchicalDocumentSymbolSupport: true }, documentSymbol: { hierarchicalDocumentSymbolSupport: true },
publishDiagnostics: {}, 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: { workspace: {
symbol: {}, symbol: {},
workspaceFolders: true, workspaceFolders: true,
configuration: true, configuration: true,
applyEdit: true,
workspaceEdit: {
documentChanges: true,
},
}, },
}, },
...this.server.initialization, ...this.server.initialization,
}) })
this.notify("initialized") this.notify("initialized")
this.notify("workspace/didChangeConfiguration", { settings: {} }) this.notify("workspace/didChangeConfiguration", { settings: {} })
await new Promise((r) => setTimeout(r, 500)) await new Promise((r) => setTimeout(r, 300))
} }
async openFile(filePath: string): Promise<void> { async openFile(filePath: string): Promise<void> {
@@ -203,7 +385,7 @@ export class LSPClient {
}) })
this.openedFiles.add(absPath) this.openedFiles.add(absPath)
await new Promise((r) => setTimeout(r, 2000)) await new Promise((r) => setTimeout(r, 1000))
} }
async hover(filePath: string, line: number, character: number): Promise<unknown> { async hover(filePath: string, line: number, character: number): Promise<unknown> {
@@ -249,19 +431,70 @@ export class LSPClient {
async diagnostics(filePath: string): Promise<unknown> { async diagnostics(filePath: string): Promise<unknown> {
const absPath = resolve(filePath) const absPath = resolve(filePath)
await this.openFile(absPath) await this.openFile(absPath)
await new Promise((r) => setTimeout(r, 1000)) await new Promise((r) => setTimeout(r, 500))
return this.send("textDocument/diagnostic", { return this.send("textDocument/diagnostic", {
textDocument: { uri: `file://${absPath}` }, textDocument: { uri: `file://${absPath}` },
}) })
} }
async prepareRename(filePath: string, line: number, character: number): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.send("textDocument/prepareRename", {
textDocument: { uri: `file://${absPath}` },
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.send("textDocument/rename", {
textDocument: { uri: `file://${absPath}` },
position: { line: line - 1, character },
newName,
})
}
async codeAction(
filePath: string,
startLine: number,
startChar: number,
endLine: number,
endChar: number,
only?: string[]
): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
return this.send("textDocument/codeAction", {
textDocument: { uri: `file://${absPath}` },
range: {
start: { line: startLine - 1, character: startChar },
end: { line: endLine - 1, character: endChar },
},
context: {
diagnostics: [],
only,
},
})
}
async codeActionResolve(codeAction: unknown): Promise<unknown> {
return this.send("codeAction/resolve", codeAction)
}
isAlive(): boolean {
return this.proc !== null && !this.processExited && this.proc.exitCode === null
}
async stop(): Promise<void> { async stop(): Promise<void> {
try { try {
await this.send("shutdown", {}) this.notify("shutdown", {})
this.notify("exit") this.notify("exit")
} catch { } catch {
} }
this.proc?.kill() this.proc?.kill()
this.proc = null this.proc = null
this.processExited = true
} }
} }

View File

@@ -8,8 +8,27 @@ import {
formatSymbolInfo, formatSymbolInfo,
formatDiagnostic, formatDiagnostic,
filterDiagnosticsBySeverity, filterDiagnosticsBySeverity,
formatPrepareRenameResult,
formatWorkspaceEdit,
formatCodeActions,
applyWorkspaceEdit,
formatApplyResult,
} from "./utils" } from "./utils"
import type { HoverResult, Location, LocationLink, DocumentSymbol, SymbolInfo, Diagnostic } from "./types" import type {
HoverResult,
Location,
LocationLink,
DocumentSymbol,
SymbolInfo,
Diagnostic,
PrepareRenameResult,
PrepareRenameDefaultBehavior,
WorkspaceEdit,
CodeAction,
Command,
} from "./types"
export const lsp_hover = tool({ export const lsp_hover = tool({
description: description:
@@ -19,14 +38,16 @@ export const lsp_hover = tool({
line: tool.schema.number().min(1).describe("Line number (1-based)"), line: tool.schema.number().min(1).describe("Line number (1-based)"),
character: tool.schema.number().min(0).describe("Character position (0-based)"), character: tool.schema.number().min(0).describe("Character position (0-based)"),
}, },
execute: async (args) => { execute: async (args, context) => {
try { try {
const result = await withLspClient(args.filePath, async (client) => { const result = await withLspClient(args.filePath, async (client) => {
return (await client.hover(args.filePath, args.line, args.character)) as HoverResult | null return (await client.hover(args.filePath, args.line, args.character)) as HoverResult | null
}) })
return formatHoverResult(result) const output = formatHoverResult(result)
return output
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
} }
}, },
}) })
@@ -39,7 +60,7 @@ export const lsp_goto_definition = tool({
line: tool.schema.number().min(1).describe("Line number (1-based)"), line: tool.schema.number().min(1).describe("Line number (1-based)"),
character: tool.schema.number().min(0).describe("Character position (0-based)"), character: tool.schema.number().min(0).describe("Character position (0-based)"),
}, },
execute: async (args) => { execute: async (args, context) => {
try { try {
const result = await withLspClient(args.filePath, async (client) => { const result = await withLspClient(args.filePath, async (client) => {
return (await client.definition(args.filePath, args.line, args.character)) as return (await client.definition(args.filePath, args.line, args.character)) as
@@ -49,14 +70,22 @@ export const lsp_goto_definition = tool({
| null | null
}) })
if (!result) return "No definition found" if (!result) {
const output = "No definition found"
return output
}
const locations = Array.isArray(result) ? result : [result] const locations = Array.isArray(result) ? result : [result]
if (locations.length === 0) return "No definition found" if (locations.length === 0) {
const output = "No definition found"
return output
}
return locations.map(formatLocation).join("\n") const output = locations.map(formatLocation).join("\n")
return output
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
} }
}, },
}) })
@@ -70,7 +99,7 @@ export const lsp_find_references = tool({
character: tool.schema.number().min(0).describe("Character position (0-based)"), character: tool.schema.number().min(0).describe("Character position (0-based)"),
includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"), includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"),
}, },
execute: async (args) => { execute: async (args, context) => {
try { try {
const result = await withLspClient(args.filePath, async (client) => { const result = await withLspClient(args.filePath, async (client) => {
return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as
@@ -78,11 +107,16 @@ export const lsp_find_references = tool({
| null | null
}) })
if (!result || result.length === 0) return "No references found" if (!result || result.length === 0) {
const output = "No references found"
return output
}
return result.map(formatLocation).join("\n") const output = result.map(formatLocation).join("\n")
return output
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
} }
}, },
}) })
@@ -93,20 +127,27 @@ export const lsp_document_symbols = tool({
args: { args: {
filePath: tool.schema.string().describe("The absolute path to the file"), filePath: tool.schema.string().describe("The absolute path to the file"),
}, },
execute: async (args) => { execute: async (args, context) => {
try { try {
const result = await withLspClient(args.filePath, async (client) => { const result = await withLspClient(args.filePath, async (client) => {
return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null
}) })
if (!result || result.length === 0) return "No symbols found" if (!result || result.length === 0) {
const output = "No symbols found"
if ("range" in result[0]) { return output
return (result as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)).join("\n")
} }
return (result as SymbolInfo[]).map(formatSymbolInfo).join("\n")
let output: string
if ("range" in result[0]) {
output = (result as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)).join("\n")
} else {
output = (result as SymbolInfo[]).map(formatSymbolInfo).join("\n")
}
return output
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
} }
}, },
}) })
@@ -119,18 +160,23 @@ export const lsp_workspace_symbols = tool({
query: tool.schema.string().describe("The symbol name to search for (supports fuzzy matching)"), query: tool.schema.string().describe("The symbol name to search for (supports fuzzy matching)"),
limit: tool.schema.number().optional().describe("Maximum number of results to return"), limit: tool.schema.number().optional().describe("Maximum number of results to return"),
}, },
execute: async (args) => { execute: async (args, context) => {
try { try {
const result = await withLspClient(args.filePath, async (client) => { const result = await withLspClient(args.filePath, async (client) => {
return (await client.workspaceSymbols(args.query)) as SymbolInfo[] | null return (await client.workspaceSymbols(args.query)) as SymbolInfo[] | null
}) })
if (!result || result.length === 0) return "No symbols found" if (!result || result.length === 0) {
const output = "No symbols found"
return output
}
const limited = args.limit ? result.slice(0, args.limit) : result const limited = args.limit ? result.slice(0, args.limit) : result
return limited.map(formatSymbolInfo).join("\n") const output = limited.map(formatSymbolInfo).join("\n")
return output
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
} }
}, },
}) })
@@ -145,7 +191,7 @@ export const lsp_diagnostics = tool({
.optional() .optional()
.describe("Filter by severity level"), .describe("Filter by severity level"),
}, },
execute: async (args) => { execute: async (args, context) => {
try { try {
const result = await withLspClient(args.filePath, async (client) => { const result = await withLspClient(args.filePath, async (client) => {
return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
@@ -162,11 +208,16 @@ export const lsp_diagnostics = tool({
diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity) diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity)
if (diagnostics.length === 0) return "No diagnostics found" if (diagnostics.length === 0) {
const output = "No diagnostics found"
return output
}
return diagnostics.map(formatDiagnostic).join("\n") const output = diagnostics.map(formatDiagnostic).join("\n")
return output
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
} }
}, },
}) })
@@ -174,7 +225,7 @@ export const lsp_diagnostics = tool({
export const lsp_servers = tool({ export const lsp_servers = tool({
description: "List all available LSP servers and check if they are installed. Use this to see what language support is available.", description: "List all available LSP servers and check if they are installed. Use this to see what language support is available.",
args: {}, args: {},
execute: async () => { execute: async (_args, context) => {
try { try {
const servers = getAllServers() const servers = getAllServers()
const lines = servers.map((s) => { const lines = servers.map((s) => {
@@ -184,9 +235,150 @@ export const lsp_servers = tool({
const status = s.installed ? "[installed]" : "[not installed]" const status = s.installed ? "[installed]" : "[not installed]"
return `${s.id} ${status} - ${s.extensions.join(", ")}` return `${s.id} ${status} - ${s.extensions.join(", ")}`
}) })
return lines.join("\n") const output = lines.join("\n")
return output
} catch (e) { } catch (e) {
return `Error: ${e instanceof Error ? e.message : String(e)}` const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
}
},
})
export const lsp_prepare_rename = tool({
description:
"Check if a symbol at a specific position can be renamed. Use this BEFORE attempting to rename to validate the operation and get the current symbol name.",
args: {
filePath: tool.schema.string().describe("The absolute path to the file"),
line: tool.schema.number().min(1).describe("Line number (1-based)"),
character: tool.schema.number().min(0).describe("Character position (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 = tool({
description:
"Rename a symbol across the entire workspace. This APPLIES the rename to all files. Use lsp_prepare_rename first to check if rename is possible.",
args: {
filePath: tool.schema.string().describe("The absolute path to the file"),
line: tool.schema.number().min(1).describe("Line number (1-based)"),
character: tool.schema.number().min(0).describe("Character position (0-based)"),
newName: tool.schema.string().describe("The new name for the symbol"),
},
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
}
},
})
export const lsp_code_actions = tool({
description:
"Get available code actions for a range in the file. Code actions include quick fixes, refactorings (extract, inline, rewrite), and source actions (organize imports, fix all). Use this to discover what automated changes the language server can perform.",
args: {
filePath: tool.schema.string().describe("The absolute path to the file"),
startLine: tool.schema.number().min(1).describe("Start line number (1-based)"),
startCharacter: tool.schema.number().min(0).describe("Start character position (0-based)"),
endLine: tool.schema.number().min(1).describe("End line number (1-based)"),
endCharacter: tool.schema.number().min(0).describe("End character position (0-based)"),
kind: tool.schema
.enum([
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
"source.fixAll",
])
.optional()
.describe("Filter by code action kind"),
},
execute: async (args, context) => {
try {
const only = args.kind ? [args.kind] : undefined
const result = await withLspClient(args.filePath, async (client) => {
return (await client.codeAction(
args.filePath,
args.startLine,
args.startCharacter,
args.endLine,
args.endCharacter,
only
)) as (CodeAction | Command)[] | null
})
const output = formatCodeActions(result)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
}
},
})
export const lsp_code_action_resolve = tool({
description:
"Resolve and APPLY a code action. This resolves the full details and applies the changes to files. Use after getting a code action from lsp_code_actions.",
args: {
filePath: tool.schema
.string()
.describe("The absolute path to a file in the workspace (used to find the LSP server)"),
codeAction: tool.schema.string().describe("The code action JSON object as returned by lsp_code_actions (stringified)"),
},
execute: async (args, context) => {
try {
const codeAction = JSON.parse(args.codeAction) as CodeAction
const resolved = await withLspClient(args.filePath, async (client) => {
return (await client.codeActionResolve(codeAction)) as CodeAction | null
})
if (!resolved) {
const output = "Failed to resolve code action"
return output
}
const lines: string[] = []
lines.push(`Action: ${resolved.title}`)
if (resolved.kind) lines.push(`Kind: ${resolved.kind}`)
if (resolved.edit) {
const result = applyWorkspaceEdit(resolved.edit)
lines.push(formatApplyResult(result))
} else {
lines.push("No edit to apply")
}
if (resolved.command) {
lines.push(`Command: ${resolved.command.title} (${resolved.command.command}) - not executed`)
}
const output = lines.join("\n")
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
return output
} }
}, },
}) })

View File

@@ -59,3 +59,79 @@ export interface HoverResult {
| Array<{ kind?: string; value: string } | string> | Array<{ kind?: string; value: string } | string>
range?: Range range?: Range
} }
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 Command {
title: string
command: string
arguments?: unknown[]
}
export interface CodeActionContext {
diagnostics: Diagnostic[]
only?: string[]
triggerKind?: CodeActionTriggerKind
}
export type CodeActionTriggerKind = 1 | 2
export interface CodeAction {
title: string
kind?: string
diagnostics?: Diagnostic[]
isPreferred?: boolean
disabled?: { reason: string }
edit?: WorkspaceEdit
command?: Command
data?: unknown
}

View File

@@ -1,9 +1,22 @@
import { extname, resolve } from "path" import { extname, resolve } from "path"
import { existsSync } from "fs" import { existsSync, readFileSync, writeFileSync } from "fs"
import { LSPClient } from "./client" import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config" import { findServerForExtension } from "./config"
import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants" import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
import type { HoverResult, DocumentSymbol, SymbolInfo, Location, LocationLink, Diagnostic } from "./types" import type {
HoverResult,
DocumentSymbol,
SymbolInfo,
Location,
LocationLink,
Diagnostic,
PrepareRenameResult,
PrepareRenameDefaultBehavior,
WorkspaceEdit,
TextEdit,
CodeAction,
Command,
} from "./types"
export function findWorkspaceRoot(filePath: string): string { export function findWorkspaceRoot(filePath: string): string {
let dir = resolve(filePath) let dir = resolve(filePath)
@@ -36,15 +49,12 @@ export async function withLspClient<T>(filePath: string, fn: (client: LSPClient)
} }
const root = findWorkspaceRoot(absPath) const root = findWorkspaceRoot(absPath)
const client = new LSPClient(root, server) const client = await lspManager.getClient(root, server)
await client.start()
await client.initialize()
try { try {
return await fn(client) return await fn(client)
} finally { } finally {
await client.stop() lspManager.releaseClient(root, server.id)
} }
} }
@@ -142,3 +152,243 @@ export function filterDiagnosticsBySeverity(
const targetSeverity = severityMap[severityFilter] const targetSeverity = severityMap[severityFilter]
return diagnostics.filter((d) => d.severity === targetSeverity) return diagnostics.filter((d) => d.severity === targetSeverity)
} }
export function formatPrepareRenameResult(
result: PrepareRenameResult | PrepareRenameDefaultBehavior | null
): string {
if (!result) return "Cannot rename at this position"
if ("defaultBehavior" in result) {
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
}
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}`
}
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 = uri.replace("file://", "")
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 = change.textDocument.uri.replace("file://", "")
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 formatCodeAction(action: CodeAction): string {
let result = `[${action.kind || "action"}] ${action.title}`
if (action.isPreferred) {
result += " ⭐"
}
if (action.disabled) {
result += ` (disabled: ${action.disabled.reason})`
}
return result
}
export function formatCodeActions(actions: (CodeAction | Command)[] | null): string {
if (!actions || actions.length === 0) return "No code actions available"
const lines: string[] = []
for (let i = 0; i < actions.length; i++) {
const action = actions[i]
if ("command" in action && typeof action.command === "string" && !("kind" in action)) {
lines.push(`${i + 1}. [command] ${(action as Command).title}`)
} else {
lines.push(`${i + 1}. ${formatCodeAction(action as CodeAction)}`)
}
}
return lines.join("\n")
}
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 = uri.replace("file://", "")
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 = change.uri.replace("file://", "")
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 = change.oldUri.replace("file://", "")
const newPath = change.newUri.replace("file://", "")
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 = change.uri.replace("file://", "")
require("fs").unlinkSync(filePath)
result.filesModified.push(filePath)
} catch (err) {
result.success = false
result.errors.push(`Delete ${change.uri}: ${err}`)
}
}
} else {
const filePath = change.textDocument.uri.replace("file://", "")
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
}
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")
}