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 { 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 {
private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
private buffer: Uint8Array = new Uint8Array(0)
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 stderrBuffer: string[] = []
private processExited = false
constructor(
private root: string,
@@ -27,7 +123,22 @@ export class LSPClient {
...this.server.env,
},
})
if (!this.proc) {
throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`)
}
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 {
@@ -38,19 +149,54 @@ export class LSPClient {
try {
while (true) {
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)
newBuf.set(this.buffer)
newBuf.set(value, this.buffer.length)
this.buffer = newBuf
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 {
}
}
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 {
outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
for (let j = 0; j < needle.length; j++) {
@@ -95,12 +241,9 @@ export class LSPClient {
try {
const msg = JSON.parse(content)
// Handle server requests (has id AND method) - e.g., workspace/configuration
if ("id" in msg && "method" in msg) {
this.handleServerRequest(msg.id, msg.method, msg.params)
}
// Handle server responses (has id, no method)
else if ("id" in msg && this.pending.has(msg.id)) {
} else if ("id" in msg && this.pending.has(msg.id)) {
const handler = this.pending.get(msg.id)!
this.pending.delete(msg.id)
if ("error" in msg) {
@@ -117,7 +260,12 @@ export class LSPClient {
private send(method: string, params?: unknown): Promise<unknown> {
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 header = `Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n`
this.proc.stdin.write(header + msg)
@@ -127,7 +275,8 @@ export class LSPClient {
setTimeout(() => {
if (this.pending.has(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)
})
@@ -135,6 +284,7 @@ export class LSPClient {
private notify(method: string, params?: unknown): void {
if (!this.proc) return
if (this.processExited || this.proc.exitCode !== null) return
const msg = JSON.stringify({ jsonrpc: "2.0", method, params })
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 {
if (!this.proc) return
if (this.processExited || this.proc.exitCode !== null) return
const msg = JSON.stringify({ jsonrpc: "2.0", id, result })
this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`)
@@ -171,18 +322,49 @@ export class LSPClient {
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,
},
},
},
...this.server.initialization,
})
this.notify("initialized")
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> {
@@ -203,7 +385,7 @@ export class LSPClient {
})
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> {
@@ -249,19 +431,70 @@ export class LSPClient {
async diagnostics(filePath: string): Promise<unknown> {
const absPath = resolve(filePath)
await this.openFile(absPath)
await new Promise((r) => setTimeout(r, 1000))
await new Promise((r) => setTimeout(r, 500))
return this.send("textDocument/diagnostic", {
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> {
try {
await this.send("shutdown", {})
this.notify("shutdown", {})
this.notify("exit")
} catch {
}
this.proc?.kill()
this.proc = null
this.processExited = true
}
}