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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user