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