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
}
}

View File

@@ -8,8 +8,27 @@ import {
formatSymbolInfo,
formatDiagnostic,
filterDiagnosticsBySeverity,
formatPrepareRenameResult,
formatWorkspaceEdit,
formatCodeActions,
applyWorkspaceEdit,
formatApplyResult,
} 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({
description:
@@ -19,14 +38,16 @@ export const lsp_hover = tool({
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) => {
execute: async (args, context) => {
try {
const result = await withLspClient(args.filePath, async (client) => {
return (await client.hover(args.filePath, args.line, args.character)) as HoverResult | null
})
return formatHoverResult(result)
const output = formatHoverResult(result)
return output
} 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)"),
character: tool.schema.number().min(0).describe("Character position (0-based)"),
},
execute: async (args) => {
execute: async (args, context) => {
try {
const result = await withLspClient(args.filePath, async (client) => {
return (await client.definition(args.filePath, args.line, args.character)) as
@@ -49,14 +70,22 @@ export const lsp_goto_definition = tool({
| null
})
if (!result) return "No definition found"
if (!result) {
const output = "No definition found"
return output
}
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) {
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)"),
includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"),
},
execute: async (args) => {
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
@@ -78,11 +107,16 @@ export const lsp_find_references = tool({
| 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) {
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: {
filePath: tool.schema.string().describe("The absolute path to the file"),
},
execute: async (args) => {
execute: async (args, context) => {
try {
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"
if ("range" in result[0]) {
return (result as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)).join("\n")
if (!result || result.length === 0) {
const output = "No symbols found"
return output
}
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) {
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)"),
limit: tool.schema.number().optional().describe("Maximum number of results to return"),
},
execute: async (args) => {
execute: async (args, context) => {
try {
const result = await withLspClient(args.filePath, async (client) => {
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
return limited.map(formatSymbolInfo).join("\n")
const output = limited.map(formatSymbolInfo).join("\n")
return output
} 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()
.describe("Filter by severity level"),
},
execute: async (args) => {
execute: async (args, context) => {
try {
const result = await withLspClient(args.filePath, async (client) => {
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)
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) {
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({
description: "List all available LSP servers and check if they are installed. Use this to see what language support is available.",
args: {},
execute: async () => {
execute: async (_args, context) => {
try {
const servers = getAllServers()
const lines = servers.map((s) => {
@@ -184,9 +235,150 @@ export const lsp_servers = tool({
const status = s.installed ? "[installed]" : "[not installed]"
return `${s.id} ${status} - ${s.extensions.join(", ")}`
})
return lines.join("\n")
const output = lines.join("\n")
return output
} 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>
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 { existsSync } from "fs"
import { LSPClient } from "./client"
import { existsSync, readFileSync, writeFileSync } from "fs"
import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config"
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 {
let dir = resolve(filePath)
@@ -36,15 +49,12 @@ export async function withLspClient<T>(filePath: string, fn: (client: LSPClient)
}
const root = findWorkspaceRoot(absPath)
const client = new LSPClient(root, server)
await client.start()
await client.initialize()
const client = await lspManager.getClient(root, server)
try {
return await fn(client)
} finally {
await client.stop()
lspManager.releaseClient(root, server.id)
}
}
@@ -142,3 +152,243 @@ export function filterDiagnosticsBySeverity(
const targetSeverity = severityMap[severityFilter]
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")
}