import * as fs from "node:fs";
import path from "node:path";
import type {
AgentTool,
AgentToolContext,
AgentToolResult,
AgentToolUpdateCallback,
ToolApprovalDecision,
} from "@oh-my-pi/pi-agent-core";
import { logger, once, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import type { BunFile } from "bun";
import { type Theme, theme } from "../modes/theme/theme";
import lspDescription from "../prompts/tools/lsp.md" with { type: "text" };
import type { ToolSession } from "../tools";
import { truncateForPrompt } from "../tools/approval";
import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
import { clampTimeout } from "../tools/tool-timeouts";
import {
ensureFileOpen,
getActiveClients,
getOrCreateClient,
type LspServerStatus,
notifySaved,
refreshFile,
sendNotification,
sendRequest,
setIdleTimeout,
syncContent,
WARMUP_TIMEOUT_MS,
waitForProjectLoaded,
} from "./client";
import { getLinterClient } from "./clients";
import { getServersForFile, type LspConfig, loadConfig } from "./config";
import {
applyTextEdits,
applyTextEditsToString,
applyWorkspaceEdit,
flattenWorkspaceTextEdits,
rangesOverlap,
} from "./edits";
import { detectLspmux } from "./lspmux";
import { renderCall, renderResult } from "./render";
import {
type CodeAction,
type CodeActionContext,
type Command,
type Diagnostic,
type DocumentSymbol,
type Hover,
type Location,
type LocationLink,
type LspClient,
type LspParams,
type LspToolDetails,
lspSchema,
type Position,
type PublishedDiagnostics,
type ServerConfig,
type SymbolInformation,
type TextEdit,
type WorkspaceEdit,
} from "./types";
import {
applyCodeAction,
dedupeWorkspaceSymbols,
extractHoverText,
fileToUri,
filterWorkspaceSymbols,
formatCodeAction,
formatDiagnostic,
formatDiagnosticsSummary,
formatDocumentSymbol,
formatGroupedDiagnosticMessages,
formatLocation,
formatSymbolInformation,
formatWorkspaceEdit,
readLocationContext,
resolveDiagnosticTargets,
resolveSymbolColumn,
sortDiagnostics,
symbolKindToIcon,
uriToFile,
} from "./utils";
export type { LspServerStatus } from "./client";
export type { LspToolDetails } from "./types";
* LSP actions that do not mutate the workspace or language-server state.
* Anything not in this set (rename, code_actions with apply, rename_file,
* reload, raw request, etc.) is classified as write-tier.
*/
export const LSP_READONLY_ACTIONS: ReadonlySet<string> = new Set([
"diagnostics",
"definition",
"type_definition",
"implementation",
"references",
"hover",
"symbols",
"status",
"capabilities",
]);
export interface LspStartupServerInfo {
name: string;
status: "connecting" | "ready" | "error";
fileTypes: string[];
error?: string;
}
export interface LspWarmupResult {
servers: Array<LspStartupServerInfo & { status: "ready" | "error" }>;
}
export interface LspWarmupOptions {
onConnecting?: (serverNames: string[]) => void;
}
export function discoverStartupLspServers(cwd: string): LspStartupServerInfo[] {
const config = loadConfig(cwd);
return getLspServers(config).map(([name, serverConfig]) => ({
name,
status: "connecting",
fileTypes: serverConfig.fileTypes,
}));
}
* Warm up LSP servers for a directory by connecting to all detected servers.
* This should be called at startup to avoid cold-start delays.
*
* @param cwd - Working directory to detect and start servers for
* @param options - Optional callbacks for progress reporting
* @returns Status of each server that was started
*/
export async function warmupLspServers(cwd: string, options?: LspWarmupOptions): Promise<LspWarmupResult> {
const config = loadConfig(cwd);
setIdleTimeout(config.idleTimeoutMs);
const servers: LspWarmupResult["servers"] = [];
const lspServers = getLspServers(config);
if (lspServers.length > 0 && options?.onConnecting) {
options.onConnecting(lspServers.map(([name]) => name));
}
const results = await Promise.allSettled(
lspServers.map(async ([name, serverConfig]) => {
const client = await getOrCreateClient(serverConfig, cwd, serverConfig.warmupTimeoutMs ?? WARMUP_TIMEOUT_MS);
return { name, client, fileTypes: serverConfig.fileTypes };
}),
);
for (let i = 0; i < results.length; i++) {
const result = results[i];
const [name, serverConfig] = lspServers[i];
if (result.status === "fulfilled") {
servers.push({
name: result.value.name,
status: "ready",
fileTypes: result.value.fileTypes,
});
} else {
const errorMsg = result.reason?.message ?? String(result.reason);
logger.warn("LSP server failed to start", { server: name, error: errorMsg });
servers.push({
name,
status: "error",
fileTypes: serverConfig.fileTypes,
error: errorMsg,
});
}
}
return { servers };
}
* Get status of currently active LSP servers.
*/
export function getLspStatus(): LspServerStatus[] {
return getActiveClients();
}
* Sync in-memory file content to all applicable LSP servers.
* Sends didOpen (if new) or didChange (if already open).
*
* @param absolutePath - Absolute path to the file
* @param content - The new file content
* @param cwd - Working directory for LSP config resolution
* @param servers - Servers to sync to
*/
async function syncFileContent(
absolutePath: string,
content: string,
cwd: string,
servers: Array<[string, ServerConfig]>,
signal?: AbortSignal,
): Promise<void> {
throwIfAborted(signal);
await Promise.allSettled(
servers.map(async ([_serverName, serverConfig]) => {
throwIfAborted(signal);
if (serverConfig.createClient) {
return;
}
const client = await getOrCreateClient(serverConfig, cwd);
throwIfAborted(signal);
await syncContent(client, absolutePath, content, signal);
}),
);
}
* Notify all LSP servers that a file was saved.
* Assumes content was already synced via syncFileContent.
*
* @param absolutePath - Absolute path to the file
* @param cwd - Working directory for LSP config resolution
* @param servers - Servers to notify
*/
async function notifyFileSaved(
absolutePath: string,
cwd: string,
servers: Array<[string, ServerConfig]>,
signal?: AbortSignal,
): Promise<void> {
throwIfAborted(signal);
await Promise.allSettled(
servers.map(async ([_serverName, serverConfig]) => {
throwIfAborted(signal);
if (serverConfig.createClient) {
return;
}
const client = await getOrCreateClient(serverConfig, cwd);
await notifySaved(client, absolutePath, signal);
}),
);
}
const configCache = new Map<string, LspConfig>();
function getConfig(cwd: string): LspConfig {
let config = configCache.get(cwd);
if (!config) {
config = loadConfig(cwd);
setIdleTimeout(config.idleTimeoutMs);
configCache.set(cwd, config);
}
return config;
}
function isCustomLinter(serverConfig: ServerConfig): boolean {
return Boolean(serverConfig.createClient);
}
function splitServers(servers: Array<[string, ServerConfig]>): {
lspServers: Array<[string, ServerConfig]>;
customLinterServers: Array<[string, ServerConfig]>;
} {
const lspServers: Array<[string, ServerConfig]> = [];
const customLinterServers: Array<[string, ServerConfig]> = [];
for (const entry of servers) {
if (isCustomLinter(entry[1])) {
customLinterServers.push(entry);
} else {
lspServers.push(entry);
}
}
return { lspServers, customLinterServers };
}
function getLspServers(config: LspConfig): Array<[string, ServerConfig]> {
return (Object.entries(config.servers) as Array<[string, ServerConfig]>).filter(
([, serverConfig]) => !isCustomLinter(serverConfig),
);
}
function getLspServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
return getServersForFile(config, filePath).filter(([, serverConfig]) => !isCustomLinter(serverConfig));
}
function getLspServerForFile(config: LspConfig, filePath: string): [string, ServerConfig] | null {
const servers = getLspServersForFile(config, filePath);
return servers.length > 0 ? servers[0] : null;
}
function isProjectAwareLspServer(serverConfig: ServerConfig): boolean {
return !serverConfig.createClient && !serverConfig.isLinter;
}
const DIAGNOSTIC_MESSAGE_LIMIT = 50;
const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
const WORKSPACE_SYMBOL_LIMIT = 200;
function limitDiagnosticMessages(messages: string[]): string[] {
if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
return messages;
}
return messages.slice(0, DIAGNOSTIC_MESSAGE_LIMIT);
}
const LOCATION_CONTEXT_LINES = 1;
const REFERENCE_CONTEXT_LIMIT = 50;
const REFERENCES_RETRY_COUNT = 2;
const REFERENCES_RETRY_DELAY_MS = 250;
function comparePosition(a: Position, b: Position): number {
return a.line === b.line ? a.character - b.character : a.line - b.line;
}
function rangeContainsPosition(range: Location["range"], position: Position): boolean {
return comparePosition(range.start, position) <= 0 && comparePosition(position, range.end) <= 0;
}
function isOnlyQueriedDeclaration(locations: Location[], uri: string, position: Position): boolean {
return locations.length === 1 && locations[0]?.uri === uri && rangeContainsPosition(locations[0].range, position);
}
function normalizeLocationResult(result: Location | Location[] | LocationLink | LocationLink[] | null): Location[] {
if (!result) return [];
const raw = Array.isArray(result) ? result : [result];
return raw.flatMap(loc => {
if ("uri" in loc) {
return [loc as Location];
}
if ("targetUri" in loc) {
const link = loc as LocationLink;
return [{ uri: link.targetUri, range: link.targetSelectionRange ?? link.targetRange }];
}
return [];
});
}
async function formatLocationWithContext(location: Location, cwd: string): Promise<string> {
const header = ` ${formatLocation(location, cwd)}`;
const context = await readLocationContext(
uriToFile(location.uri),
location.range.start.line + 1,
LOCATION_CONTEXT_LINES,
);
if (context.length === 0) {
return header;
}
return `${header}\n${context.map(lineText => ` ${lineText}`).join("\n")}`;
}
const MAX_RENAME_PAIRS = 1000;
interface FileRenamePair {
oldUri: string;
newUri: string;
}
* Enumerate the {oldUri, newUri} pairs needed for an LSP willRenameFiles/didRenameFiles request.
* For files this is a single pair. For directories this walks every regular file underneath
* and produces a parallel pair anchored at the new directory root.
*/
async function enumerateRenamePairs(
source: string,
dest: string,
): Promise<{ pairs: FileRenamePair[]; directory: boolean; exceeded: boolean }> {
const stat = await fs.promises.stat(source);
if (!stat.isDirectory()) {
return {
pairs: [{ oldUri: fileToUri(source), newUri: fileToUri(dest) }],
directory: false,
exceeded: false,
};
}
const entries = await fs.promises.readdir(source, { recursive: true, withFileTypes: true });
const pairs: FileRenamePair[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
if (pairs.length >= MAX_RENAME_PAIRS) {
return { pairs, directory: true, exceeded: true };
}
const parent = entry.parentPath ?? source;
const absOld = path.join(parent, entry.name);
const rel = path.relative(source, absOld);
pairs.push({
oldUri: fileToUri(absOld),
newUri: fileToUri(path.join(dest, rel)),
});
}
return { pairs, directory: true, exceeded: false };
}
function isMethodNotFoundError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("method not found") ||
msg.includes("unhandled method") ||
msg.includes("not supported") ||
msg.includes("-32601")
);
}
async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
let output = `Restarted ${serverName}`;
const reloadMethods = ["rust-analyzer/reloadWorkspace", "workspace/didChangeConfiguration"];
for (const method of reloadMethods) {
try {
await sendRequest(client, method, method.includes("Configuration") ? { settings: {} } : null, signal);
output = `Reloaded ${serverName}`;
break;
} catch {
}
}
if (output.startsWith("Restarted")) {
client.proc.kill();
}
return output;
}
interface WaitForDiagnosticsOptions {
timeoutMs?: number;
signal?: AbortSignal;
minVersion?: number;
expectedDocumentVersion?: number;
allowUnversioned?: boolean;
}
function getAcceptedDiagnostics(
publishedDiagnostics: PublishedDiagnostics | undefined,
expectedDocumentVersion?: number,
allowUnversioned = true,
): Diagnostic[] | undefined {
if (!publishedDiagnostics) {
return undefined;
}
if (expectedDocumentVersion === undefined) {
return publishedDiagnostics.diagnostics;
}
if (publishedDiagnostics.version === expectedDocumentVersion) {
return publishedDiagnostics.diagnostics;
}
if (allowUnversioned && publishedDiagnostics.version == null) {
return publishedDiagnostics.diagnostics;
}
return undefined;
}
async function waitForDiagnostics(
client: LspClient,
uri: string,
options: WaitForDiagnosticsOptions = {},
): Promise<Diagnostic[]> {
const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, allowUnversioned = true } = options;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
throwIfAborted(signal);
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
const diagnostics = getAcceptedDiagnostics(
client.diagnostics.get(uri),
expectedDocumentVersion,
allowUnversioned,
);
if (diagnostics !== undefined && versionOk) {
return diagnostics;
}
await Bun.sleep(100);
}
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
if (!versionOk) {
return [];
}
return getAcceptedDiagnostics(client.diagnostics.get(uri), expectedDocumentVersion, allowUnversioned) ?? [];
}
interface ProjectType {
type: "rust" | "typescript" | "go" | "python" | "unknown";
command?: string[];
description: string;
}
function detectProjectType(cwd: string): ProjectType {
if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" };
}
if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" };
}
if (fs.existsSync(path.join(cwd, "go.mod"))) {
return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" };
}
if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "pyrightconfig.json"))) {
return { type: "python", command: ["pyright"], description: "Python (pyright)" };
}
return { type: "unknown", description: "Unknown project type" };
}
async function runWorkspaceDiagnostics(
cwd: string,
signal?: AbortSignal,
): Promise<{ output: string; projectType: ProjectType }> {
throwIfAborted(signal);
const projectType = detectProjectType(cwd);
if (!projectType.command) {
return {
output: `Cannot detect project type. Supported: Rust (Cargo.toml), TypeScript (tsconfig.json), Go (go.mod), Python (pyproject.toml)`,
projectType,
};
}
const proc = Bun.spawn(projectType.command, {
cwd,
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
});
const abortHandler = () => {
proc.kill();
};
if (signal) {
signal.addEventListener("abort", abortHandler, { once: true });
}
try {
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
await proc.exited;
throwIfAborted(signal);
const combined = (stdout + stderr).trim();
if (!combined) {
return { output: "No issues found", projectType };
}
const lines = combined.split("\n");
if (lines.length > 50) {
return { output: `${lines.slice(0, 50).join("\n")}\n... and ${lines.length - 50} more lines`, projectType };
}
return { output: combined, projectType };
} catch (e) {
if (signal?.aborted) {
throw new ToolAbortError();
}
return { output: `Failed to run ${projectType.command.join(" ")}: ${e}`, projectType };
} finally {
signal?.removeEventListener("abort", abortHandler);
}
}
export interface FileDiagnosticsResult {
server?: string;
messages: string[];
summary: string;
errored: boolean;
formatter?: FileFormatResult;
}
type ServerVersionMap = Map<string, number>;
interface GetDiagnosticsForFileOptions {
signal?: AbortSignal;
minVersions?: ServerVersionMap;
expectedDocumentVersions?: ServerVersionMap;
allowUnversionedLspDiagnostics?: boolean;
}
* Capture current diagnostic versions for all LSP servers.
* Call this BEFORE syncing content to detect stale diagnostics later.
*/
async function captureDiagnosticVersions(
cwd: string,
servers: Array<[string, ServerConfig]>,
): Promise<ServerVersionMap> {
const versions = new Map<string, number>();
await Promise.allSettled(
servers.map(async ([serverName, serverConfig]) => {
if (serverConfig.createClient) return;
const client = await getOrCreateClient(serverConfig, cwd);
versions.set(serverName, client.diagnosticsVersion);
}),
);
return versions;
}
async function captureOpenFileVersions(
absolutePath: string,
cwd: string,
servers: Array<[string, ServerConfig]>,
): Promise<ServerVersionMap> {
const uri = fileToUri(absolutePath);
const versions = new Map<string, number>();
await Promise.allSettled(
servers.map(async ([serverName, serverConfig]) => {
const client = await getOrCreateClient(serverConfig, cwd);
const version = client.openFiles.get(uri)?.version;
if (version !== undefined) {
versions.set(serverName, version);
}
}),
);
return versions;
}
* Get diagnostics for a file using LSP or custom linter client.
*
* @param absolutePath - Absolute path to the file
* @param cwd - Working directory for LSP config resolution
* @param servers - Servers to query diagnostics for
* @param minVersions - Minimum diagnostic versions per server (to detect stale results)
* @returns Diagnostic results or undefined if no servers
*/
async function getDiagnosticsForFile(
absolutePath: string,
cwd: string,
servers: Array<[string, ServerConfig]>,
options: GetDiagnosticsForFileOptions = {},
): Promise<FileDiagnosticsResult | undefined> {
const { signal, minVersions, expectedDocumentVersions, allowUnversionedLspDiagnostics = true } = options;
if (servers.length === 0) {
return undefined;
}
const uri = fileToUri(absolutePath);
const relPath = formatPathRelativeToCwd(absolutePath, cwd);
const allDiagnostics: Diagnostic[] = [];
const serverNames: string[] = [];
const results = await Promise.allSettled(
servers.map(async ([serverName, serverConfig]) => {
throwIfAborted(signal);
if (serverConfig.createClient) {
const linterClient = getLinterClient(serverName, serverConfig, cwd);
const diagnostics = await linterClient.lint(absolutePath);
return { serverName, diagnostics };
}
const client = await getOrCreateClient(serverConfig, cwd);
throwIfAborted(signal);
if (isProjectAwareLspServer(serverConfig)) {
await waitForProjectLoaded(client, signal);
throwIfAborted(signal);
}
const minVersion = minVersions?.get(serverName);
const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
const diagnostics = await waitForDiagnostics(client, uri, {
timeoutMs: 3000,
signal,
minVersion,
expectedDocumentVersion,
allowUnversioned: allowUnversionedLspDiagnostics,
});
return { serverName, diagnostics };
}),
);
for (const result of results) {
if (result.status === "fulfilled") {
serverNames.push(result.value.serverName);
allDiagnostics.push(...result.value.diagnostics);
}
}
if (serverNames.length === 0) {
return undefined;
}
if (allDiagnostics.length === 0) {
return {
server: serverNames.join(", "),
messages: [],
summary: "OK",
errored: false,
};
}
const seen = new Set<string>();
const uniqueDiagnostics: Diagnostic[] = [];
for (const d of allDiagnostics) {
const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
if (!seen.has(key)) {
seen.add(key);
uniqueDiagnostics.push(d);
}
}
sortDiagnostics(uniqueDiagnostics);
const formatted = uniqueDiagnostics.map(d => formatDiagnostic(d, relPath));
const limited = limitDiagnosticMessages(formatted);
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
const hasErrors = uniqueDiagnostics.some(d => d.severity === 1);
return {
server: serverNames.join(", "),
messages: limited,
summary,
errored: hasErrors,
};
}
export enum FileFormatResult {
UNCHANGED = "unchanged",
FORMATTED = "formatted",
}
const DEFAULT_FORMAT_OPTIONS = {
tabSize: 3,
insertSpaces: true,
trimTrailingWhitespace: true,
insertFinalNewline: true,
trimFinalNewlines: true,
};
* Format content using LSP or custom linter client.
*
* @param absolutePath - Absolute path (for URI)
* @param content - Content to format
* @param cwd - Working directory for LSP config resolution
* @param servers - Servers to try formatting with
* @returns Formatted content, or original if no formatter available
*/
async function formatContent(
absolutePath: string,
content: string,
cwd: string,
servers: Array<[string, ServerConfig]>,
signal?: AbortSignal,
): Promise<string> {
if (servers.length === 0) {
return content;
}
const uri = fileToUri(absolutePath);
for (const [serverName, serverConfig] of servers) {
try {
throwIfAborted(signal);
if (serverConfig.createClient) {
const linterClient = getLinterClient(serverName, serverConfig, cwd);
return await linterClient.format(absolutePath, content);
}
const client = await getOrCreateClient(serverConfig, cwd);
throwIfAborted(signal);
const caps = client.serverCapabilities;
if (!caps?.documentFormattingProvider) {
continue;
}
const edits = (await sendRequest(
client,
"textDocument/formatting",
{
textDocument: { uri },
options: DEFAULT_FORMAT_OPTIONS,
},
signal,
)) as TextEdit[] | null;
if (!edits || edits.length === 0) {
return content;
}
return applyTextEditsToString(content, edits);
} catch {}
}
return content;
}
export interface WritethroughOptions {
enableFormat?: boolean;
enableDiagnostics?: boolean;
onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void;
deferredSignal?: AbortSignal;
}
type ResolvedWritethroughOptions = {
enableFormat: boolean;
enableDiagnostics: boolean;
};
export type WritethroughDeferredHandle = {
onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
signal: AbortSignal;
finalize: (diagnostics: FileDiagnosticsResult | undefined) => void;
};
export type WritethroughCallback = (
dst: string,
content: string,
signal?: AbortSignal,
file?: BunFile,
batch?: LspWritethroughBatchRequest,
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
) => Promise<FileDiagnosticsResult | undefined>;
export async function writethroughNoop(
dst: string,
content: string,
_signal?: AbortSignal,
file?: BunFile,
_batch?: LspWritethroughBatchRequest,
_getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
): Promise<FileDiagnosticsResult | undefined> {
if (file) {
await file.write(content);
} else {
await Bun.write(dst, content);
}
return undefined;
}
interface PendingWritethrough {
dst: string;
content: string;
file?: BunFile;
}
interface LspWritethroughBatchRequest {
id: string;
flush: boolean;
}
interface LspWritethroughBatchState {
entries: Map<string, PendingWritethrough>;
options: ResolvedWritethroughOptions;
}
const writethroughBatches = new Map<string, LspWritethroughBatchState>();
function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughOptions): LspWritethroughBatchState {
const existing = writethroughBatches.get(id);
if (existing) {
existing.options.enableFormat ||= options.enableFormat;
existing.options.enableDiagnostics ||= options.enableDiagnostics;
return existing;
}
const batch: LspWritethroughBatchState = {
entries: new Map<string, PendingWritethrough>(),
options: { ...options },
};
writethroughBatches.set(id, batch);
return batch;
}
export async function flushLspWritethroughBatch(
id: string,
cwd: string,
signal?: AbortSignal,
): Promise<FileDiagnosticsResult | undefined> {
const state = writethroughBatches.get(id);
if (!state) {
return undefined;
}
writethroughBatches.delete(id);
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
}
function summarizeDiagnosticMessages(messages: string[]): { summary: string; errored: boolean } {
const counts = { error: 0, warning: 0, info: 0, hint: 0 };
for (const message of messages) {
const match = message.match(/\[(error|warning|info|hint)\]/i);
if (!match) continue;
const key = match[1].toLowerCase() as keyof typeof counts;
counts[key] += 1;
}
const parts: string[] = [];
if (counts.error > 0) parts.push(`${counts.error} error(s)`);
if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`);
if (counts.info > 0) parts.push(`${counts.info} info(s)`);
if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`);
return {
summary: parts.length > 0 ? parts.join(", ") : "no issues",
errored: counts.error > 0,
};
}
function mergeDiagnostics(
results: Array<FileDiagnosticsResult | undefined>,
options: ResolvedWritethroughOptions,
): FileDiagnosticsResult | undefined {
const messages: string[] = [];
const servers = new Set<string>();
let hasResults = false;
let hasFormatter = false;
let formatted = false;
for (const result of results) {
if (!result) continue;
hasResults = true;
if (result.server) {
for (const server of result.server.split(",")) {
const trimmed = server.trim();
if (trimmed) {
servers.add(trimmed);
}
}
}
if (result.messages.length > 0) {
messages.push(...result.messages);
}
if (result.formatter !== undefined) {
hasFormatter = true;
if (result.formatter === FileFormatResult.FORMATTED) {
formatted = true;
}
}
}
if (!hasResults && !hasFormatter) {
return undefined;
}
let summary = options.enableDiagnostics ? "no issues" : "OK";
let errored = false;
let limitedMessages = messages;
if (messages.length > 0) {
const summaryInfo = summarizeDiagnosticMessages(messages);
summary = summaryInfo.summary;
errored = summaryInfo.errored;
limitedMessages = limitDiagnosticMessages(messages);
}
const formatter = hasFormatter ? (formatted ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED) : undefined;
return {
server: servers.size > 0 ? Array.from(servers).join(", ") : undefined,
messages: limitedMessages,
summary,
errored,
formatter,
};
}
async function scheduleDeferredDiagnosticsFetch(args: {
dst: string;
cwd: string;
servers: Array<[string, ServerConfig]>;
minVersions: ServerVersionMap | undefined;
expectedDocumentVersions: ServerVersionMap | undefined;
signal: AbortSignal;
callback: (diagnostics: FileDiagnosticsResult) => void;
}): Promise<void> {
try {
const deferredTimeout = AbortSignal.timeout(25_000);
const combined = AbortSignal.any([args.signal, deferredTimeout]);
const diagnostics = await getDiagnosticsForFile(args.dst, args.cwd, args.servers, {
signal: combined,
minVersions: args.minVersions,
expectedDocumentVersions: args.expectedDocumentVersions,
});
if (args.signal.aborted || diagnostics === undefined) return;
args.callback(diagnostics);
} catch {
}
}
async function runLspWritethrough(
dst: string,
content: string,
cwd: string,
options: ResolvedWritethroughOptions,
signal?: AbortSignal,
file?: BunFile,
deferred?: {
onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
signal: AbortSignal;
},
): Promise<FileDiagnosticsResult | undefined> {
const { enableFormat, enableDiagnostics } = options;
const config = getConfig(cwd);
const servers = getServersForFile(config, dst);
if (servers.length === 0) {
return writethroughNoop(dst, content, signal, file);
}
const { lspServers, customLinterServers } = splitServers(servers);
let finalContent = content;
const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
const getWritePromise = once(() => writeContent(finalContent));
const useCustomFormatter = enableFormat && customLinterServers.length > 0;
const minVersions = enableDiagnostics ? await captureDiagnosticVersions(cwd, servers) : undefined;
let expectedDocumentVersions: ServerVersionMap | undefined;
let formatter: FileFormatResult | undefined;
let diagnostics: FileDiagnosticsResult | undefined;
let timedOut = false;
try {
const timeoutSignal = AbortSignal.timeout(5_000);
timeoutSignal.addEventListener(
"abort",
() => {
timedOut = true;
},
{ once: true },
);
const operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
await untilAborted(operationSignal, async () => {
if (useCustomFormatter) {
await writeContent(content);
finalContent = await formatContent(dst, content, cwd, customLinterServers, operationSignal);
formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
await writeContent(finalContent);
await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
} else {
await syncFileContent(dst, content, cwd, lspServers, operationSignal);
if (enableFormat) {
finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
}
if (finalContent !== content) {
await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
}
await getWritePromise();
}
if (enableDiagnostics) {
expectedDocumentVersions = await captureOpenFileVersions(dst, cwd, lspServers);
}
await notifyFileSaved(dst, cwd, lspServers, operationSignal);
if (enableDiagnostics) {
diagnostics = await getDiagnosticsForFile(dst, cwd, servers, {
signal: operationSignal,
minVersions,
expectedDocumentVersions,
allowUnversionedLspDiagnostics: false,
});
}
});
} catch {
if (timedOut) {
formatter = undefined;
diagnostics = undefined;
if (deferred && !deferred.signal.aborted && enableDiagnostics) {
void scheduleDeferredDiagnosticsFetch({
dst,
cwd,
servers,
minVersions,
expectedDocumentVersions,
signal: deferred.signal,
callback: deferred.onDeferredDiagnostics,
});
}
}
await getWritePromise();
}
if (formatter !== undefined) {
diagnostics ??= {
server: servers.map(([name]) => name).join(", "),
messages: [],
summary: "OK",
errored: false,
};
diagnostics.formatter = formatter;
}
return diagnostics;
}
async function flushWritethroughBatch(
batch: PendingWritethrough[],
cwd: string,
options: ResolvedWritethroughOptions,
signal?: AbortSignal,
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
): Promise<FileDiagnosticsResult | undefined> {
if (batch.length === 0) {
return undefined;
}
const results: Array<FileDiagnosticsResult | undefined> = [];
for (const entry of batch) {
const bundle = getDeferred?.(entry.dst);
const deferredInner =
bundle &&
({
onDeferredDiagnostics: bundle.onDeferredDiagnostics,
signal: bundle.signal,
} as const);
const diag = await runLspWritethrough(entry.dst, entry.content, cwd, options, signal, entry.file, deferredInner);
bundle?.finalize(diag);
results.push(diag);
}
return mergeDiagnostics(results, options);
}
export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
const resolvedOptions: ResolvedWritethroughOptions = {
enableFormat: options?.enableFormat ?? false,
enableDiagnostics: options?.enableDiagnostics ?? false,
};
if (!resolvedOptions.enableFormat && !resolvedOptions.enableDiagnostics) {
return writethroughNoop;
}
return async (
dst: string,
content: string,
signal?: AbortSignal,
file?: BunFile,
batch?: LspWritethroughBatchRequest,
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
) => {
if (!batch) {
const bundle = getDeferred?.(dst);
const deferredInner =
bundle &&
({
onDeferredDiagnostics: bundle.onDeferredDiagnostics,
signal: bundle.signal,
} as const);
const diagnostics = await runLspWritethrough(dst, content, cwd, resolvedOptions, signal, file, deferredInner);
bundle?.finalize(diagnostics);
return diagnostics;
}
const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
state.entries.set(dst, { dst, content, file });
if (!batch.flush) {
await writethroughNoop(dst, content, signal, file);
return undefined;
}
writethroughBatches.delete(batch.id);
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal, getDeferred);
};
}
* LSP tool for language server protocol operations.
*/
export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Theme> {
readonly name = "lsp";
readonly approval = (args: unknown): ToolApprovalDecision => {
const rawAction = (args as Partial<LspParams>).action;
const action = typeof rawAction === "string" ? rawAction.toLowerCase() : "";
return LSP_READONLY_ACTIONS.has(action) ? "read" : "write";
};
readonly formatApprovalDetails = (args: unknown): string[] => {
const params = args as Partial<LspParams>;
const lines = [`Action: ${typeof params.action === "string" ? params.action : "(missing)"}`];
if (typeof params.file === "string" && params.file.length > 0) {
lines.push(`File: ${truncateForPrompt(params.file)}`);
}
return lines;
};
readonly label = "LSP";
readonly loadMode = "discoverable";
readonly summary = "Query LSP (language server) for diagnostics, hover info, and references";
readonly description: string;
readonly parameters = lspSchema;
readonly renderCall = renderCall;
readonly renderResult = renderResult;
readonly mergeCallAndResult = true;
readonly inline = true;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = prompt.render(lspDescription);
}
static createIf(session: ToolSession): LspTool | null {
return session.enableLsp === false ? null : new LspTool(session);
}
async execute(
_toolCallId: string,
params: LspParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<LspToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<LspToolDetails>> {
const { action, file, line, symbol, query, new_name, apply, timeout } = params;
const timeoutSec = clampTimeout("lsp", timeout);
const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
const callerSignal = signal;
signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
throwIfAborted(signal);
const config = getConfig(this.session.cwd);
if (action === "status") {
const servers = Object.keys(config.servers);
const lspmuxState = await detectLspmux();
const lspmuxStatus = lspmuxState.available
? lspmuxState.running
? "lspmux: active (multiplexing enabled)"
: "lspmux: installed but server not running"
: "";
const serverStatus =
servers.length > 0
? `Active language servers: ${servers.join(", ")}`
: "No language servers configured for this project";
const output = lspmuxStatus ? `${serverStatus}\n${lspmuxStatus}` : serverStatus;
return {
content: [{ type: "text", text: output }],
details: { action, success: true, request: params },
};
}
if (action === "diagnostics") {
if (file === "*") {
const result = await runWorkspaceDiagnostics(this.session.cwd, signal);
return {
content: [
{
type: "text",
text: `Workspace diagnostics (${result.projectType.description}):\n${result.output}`,
},
],
details: { action, success: true, request: params },
};
}
if (!file) {
return {
content: [
{
type: "text",
text: "Error: file parameter required. Use `*` for workspace-wide diagnostics or a path/glob for specific files.",
},
],
details: { action, success: false, request: params },
};
}
let targets: string[];
let truncatedGlobTargets = false;
const resolvedTargets = await resolveDiagnosticTargets(file, this.session.cwd, MAX_GLOB_DIAGNOSTIC_TARGETS);
targets = resolvedTargets.matches;
truncatedGlobTargets = resolvedTargets.truncated;
if (targets.length === 0) {
return {
content: [{ type: "text", text: `No files matched pattern: ${file}` }],
details: { action, success: true, request: params },
};
}
const detailed = targets.length > 1 || truncatedGlobTargets;
const diagnosticsWaitTimeoutMs = detailed
? Math.min(BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS, timeoutSec * 1000)
: Math.min(SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS, timeoutSec * 1000);
const results: string[] = [];
const allServerNames = new Set<string>();
if (truncatedGlobTargets) {
results.push(
`${theme.status.warning} Pattern matched more than ${MAX_GLOB_DIAGNOSTIC_TARGETS} files; showing first ${MAX_GLOB_DIAGNOSTIC_TARGETS}. Narrow the glob or use workspace diagnostics.`,
);
}
for (const target of targets) {
throwIfAborted(signal);
const resolved = resolveToCwd(target, this.session.cwd);
const servers = getServersForFile(config, resolved);
if (servers.length === 0) {
results.push(`${theme.status.error} ${target}: No language server found`);
continue;
}
const uri = fileToUri(resolved);
const relPath = formatPathRelativeToCwd(resolved, this.session.cwd);
const allDiagnostics: Diagnostic[] = [];
for (const [serverName, serverConfig] of servers) {
allServerNames.add(serverName);
try {
throwIfAborted(signal);
if (serverConfig.createClient) {
const linterClient = getLinterClient(serverName, serverConfig, this.session.cwd);
const diagnostics = await linterClient.lint(resolved);
allDiagnostics.push(...diagnostics);
continue;
}
const client = await getOrCreateClient(serverConfig, this.session.cwd);
if (isProjectAwareLspServer(serverConfig)) {
await waitForProjectLoaded(client, signal);
throwIfAborted(signal);
}
const minVersion = client.diagnosticsVersion;
await refreshFile(client, resolved, signal);
const expectedDocumentVersion = client.openFiles.get(uri)?.version;
const diagnostics = await waitForDiagnostics(client, uri, {
timeoutMs: diagnosticsWaitTimeoutMs,
signal,
minVersion,
expectedDocumentVersion,
});
allDiagnostics.push(...diagnostics);
} catch (err) {
if (err instanceof ToolAbortError || signal?.aborted) {
throw err;
}
}
}
const seen = new Set<string>();
const uniqueDiagnostics: Diagnostic[] = [];
for (const d of allDiagnostics) {
const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
if (!seen.has(key)) {
seen.add(key);
uniqueDiagnostics.push(d);
}
}
sortDiagnostics(uniqueDiagnostics);
if (!detailed && targets.length === 1) {
if (uniqueDiagnostics.length === 0) {
return {
content: [{ type: "text", text: "OK" }],
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
};
}
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
const formatted = uniqueDiagnostics.map(d => formatDiagnostic(d, relPath));
const output = `${summary}:\n${formatGroupedDiagnosticMessages(formatted)}`;
return {
content: [{ type: "text", text: output }],
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
};
}
if (uniqueDiagnostics.length === 0) {
results.push(`${theme.status.success} ${relPath}: no issues`);
} else {
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
results.push(`${theme.status.error} ${relPath}: ${summary}`);
const formatted = uniqueDiagnostics.map(d => formatDiagnostic(d, relPath));
results.push(formatGroupedDiagnosticMessages(formatted));
}
}
return {
content: [{ type: "text", text: results.join("\n") }],
details: { action, serverName: Array.from(allServerNames).join(", "), success: true },
};
}
if (action === "rename_file") {
if (!file || !new_name) {
return {
content: [
{
type: "text",
text: "Error: rename_file requires both `file` (source path) and `new_name` (destination path)",
},
],
details: { action, success: false, request: params },
};
}
const source = resolveToCwd(file, this.session.cwd);
const dest = resolveToCwd(new_name, this.session.cwd);
if (source === dest) {
return {
content: [{ type: "text", text: "Error: source and destination paths are identical" }],
details: { action, success: false, request: params },
};
}
let sourceStat: fs.Stats;
try {
sourceStat = await fs.promises.stat(source);
} catch {
return {
content: [
{
type: "text",
text: `Error: source path does not exist: ${formatPathRelativeToCwd(source, this.session.cwd)}`,
},
],
details: { action, success: false, request: params },
};
}
let destExists = false;
try {
await fs.promises.stat(dest);
destExists = true;
} catch {
}
if (destExists) {
return {
content: [
{
type: "text",
text: `Error: destination already exists: ${formatPathRelativeToCwd(dest, this.session.cwd)}`,
},
],
details: { action, success: false, request: params },
};
}
const enumerated = await enumerateRenamePairs(source, dest);
if (enumerated.exceeded) {
return {
content: [
{
type: "text",
text: `Error: directory contains more than ${MAX_RENAME_PAIRS} files; rename in smaller batches to keep LSP edits accurate`,
},
],
details: { action, success: false, request: params },
};
}
const { pairs } = enumerated;
if (pairs.length === 0) {
return {
content: [{ type: "text", text: "Error: no files to rename" }],
details: { action, success: false, request: params },
};
}
const lspParams = { files: pairs };
const servers = getLspServers(config);
const respondingServers = new Set<string>();
const perServerEdits: Array<{ serverName: string; edit: WorkspaceEdit }> = [];
const serverNotes: string[] = [];
for (const [serverName, serverConfig] of servers) {
throwIfAborted(signal);
try {
const client = await getOrCreateClient(serverConfig, this.session.cwd);
if (isProjectAwareLspServer(serverConfig)) {
await waitForProjectLoaded(client, signal);
}
const result = (await sendRequest(
client,
"workspace/willRenameFiles",
lspParams,
signal,
)) as WorkspaceEdit | null;
respondingServers.add(serverName);
if (result && (result.changes || result.documentChanges)) {
perServerEdits.push({ serverName, edit: result });
}
} catch (err) {
if (err instanceof ToolAbortError || signal?.aborted) {
throw err;
}
if (!isMethodNotFoundError(err)) {
const msg = err instanceof Error ? err.message : String(err);
serverNotes.push(` ${serverName}: ${msg}`);
}
}
}
const sourceLabel = formatPathRelativeToCwd(source, this.session.cwd);
const destLabel = formatPathRelativeToCwd(dest, this.session.cwd);
const fileCountLabel = sourceStat.isDirectory()
? `${pairs.length} file${pairs.length !== 1 ? "s" : ""} under ${sourceLabel}`
: sourceLabel;
const shouldApply = apply !== false;
if (!shouldApply) {
const lines: string[] = [];
lines.push(`Rename preview: ${fileCountLabel} → ${destLabel}`);
if (perServerEdits.length === 0) {
lines.push(" No LSP edits would be applied");
} else {
for (const { serverName, edit } of perServerEdits) {
const edits = formatWorkspaceEdit(edit, this.session.cwd);
if (edits.length === 0) continue;
lines.push(` ${serverName}:`);
for (const e of edits) {
lines.push(` ${e}`);
}
}
}
if (serverNotes.length > 0) {
lines.push(" Server notes:");
lines.push(...serverNotes);
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
action,
serverName: Array.from(respondingServers).join(", "),
success: true,
request: params,
},
};
}
const summary: string[] = [];
const serverConfigByName = new Map(servers);
interface AcceptedBucket {
primaryServer: string;
edits: TextEdit[];
discarded: number;
conflictServers: Set<string>;
}
const acceptedByUri = new Map<string, AcceptedBucket>();
for (const { serverName, edit } of perServerEdits) {
const cfg = serverConfigByName.get(serverName);
const incomingPrimary = cfg ? isProjectAwareLspServer(cfg) : false;
const flat = flattenWorkspaceTextEdits(edit);
for (const [uri, edits] of flat) {
const existing = acceptedByUri.get(uri);
if (!existing) {
acceptedByUri.set(uri, {
primaryServer: serverName,
edits: [...edits],
discarded: 0,
conflictServers: new Set(),
});
continue;
}
const existingCfg = serverConfigByName.get(existing.primaryServer);
const existingIsPrimary = existingCfg ? isProjectAwareLspServer(existingCfg) : false;
if (incomingPrimary && !existingIsPrimary) {
const keptOld: TextEdit[] = [];
let discardedOld = 0;
for (const oe of existing.edits) {
if (edits.some(ne => rangesOverlap(ne.range, oe.range))) discardedOld++;
else keptOld.push(oe);
}
if (discardedOld > 0) existing.conflictServers.add(existing.primaryServer);
existing.discarded += discardedOld;
existing.primaryServer = serverName;
existing.edits = [...edits, ...keptOld];
} else {
let discardedNew = 0;
for (const ne of edits) {
if (existing.edits.some(ae => rangesOverlap(ae.range, ne.range))) {
discardedNew++;
} else {
existing.edits.push(ne);
}
}
if (discardedNew > 0) {
existing.conflictServers.add(serverName);
existing.discarded += discardedNew;
}
}
}
}
for (const [uri, bucket] of acceptedByUri) {
const filePath = uriToFile(uri);
await applyTextEdits(filePath, bucket.edits);
const rel = formatPathRelativeToCwd(filePath, this.session.cwd);
summary.push(` ${bucket.primaryServer}: applied ${bucket.edits.length} edit(s) to ${rel}`);
if (bucket.discarded > 0) {
const others = Array.from(bucket.conflictServers).join(", ");
summary.push(
` note: discarded ${bucket.discarded} overlapping edit(s) from ${others} (kept ${bucket.primaryServer})`,
);
logger.warn(
`lsp rename_file: discarded ${bucket.discarded} overlapping edit(s) from ${others} on ${rel}; kept ${bucket.primaryServer}`,
);
}
}
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.rename(source, dest);
summary.push(` Renamed ${sourceLabel} → ${destLabel}`);
for (const [serverName, serverConfig] of servers) {
try {
const client = await getOrCreateClient(serverConfig, this.session.cwd);
for (const { oldUri } of pairs) {
if (client.openFiles.has(oldUri)) {
await sendNotification(client, "textDocument/didClose", {
textDocument: { uri: oldUri },
});
client.openFiles.delete(oldUri);
}
}
await sendNotification(client, "workspace/didRenameFiles", lspParams);
} catch (err) {
if (err instanceof ToolAbortError || signal?.aborted) {
throw err;
}
const msg = err instanceof Error ? err.message : String(err);
serverNotes.push(` ${serverName}: ${msg}`);
}
}
if (serverNotes.length > 0) {
summary.push(" Server notes:");
summary.push(...serverNotes);
}
const header = `Renamed ${fileCountLabel} → ${destLabel}`;
return {
content: [{ type: "text", text: `${header}\n${summary.join("\n")}` }],
details: {
action,
serverName: Array.from(respondingServers).join(", "),
success: true,
request: params,
},
};
}
if (action === "capabilities") {
let serverList: Array<[string, ServerConfig]>;
if (file && file !== "*") {
const resolved = resolveToCwd(file, this.session.cwd);
serverList = getLspServersForFile(config, resolved);
if (serverList.length === 0) {
return {
content: [{ type: "text", text: "No language server found for this file" }],
details: { action, success: false, request: params },
};
}
} else {
serverList = getLspServers(config);
}
if (serverList.length === 0) {
return {
content: [{ type: "text", text: "No language servers configured" }],
details: { action, success: false, request: params },
};
}
const sections: string[] = [];
const respondingServers = new Set<string>();
for (const [serverName, serverConfig] of serverList) {
throwIfAborted(signal);
try {
const client = await getOrCreateClient(serverConfig, this.session.cwd);
respondingServers.add(serverName);
const caps = client.serverCapabilities ?? {};
sections.push(`${serverName}:`);
sections.push(` capabilities: ${JSON.stringify(caps, null, 2).split("\n").join("\n ")}`);
} catch (err) {
if (err instanceof ToolAbortError || signal?.aborted) {
throw err;
}
const msg = err instanceof Error ? err.message : String(err);
sections.push(`${serverName}: failed to start (${msg})`);
}
}
return {
content: [{ type: "text", text: sections.join("\n") }],
details: {
action,
serverName: Array.from(respondingServers).join(", "),
success: true,
request: params,
},
};
}
if (action === "request") {
const method = query?.trim();
if (!method) {
return {
content: [
{
type: "text",
text: "Error: action=request requires `query` to specify the LSP method name (e.g., 'rust-analyzer/expandMacro')",
},
],
details: { action, success: false, request: params },
};
}
let chosenServer: [string, ServerConfig] | null = null;
let resolvedTarget: string | null = null;
if (file && file !== "*") {
resolvedTarget = resolveToCwd(file, this.session.cwd);
chosenServer = getLspServerForFile(config, resolvedTarget);
if (!chosenServer) {
return {
content: [{ type: "text", text: "No language server found for this file" }],
details: { action, success: false, request: params },
};
}
} else {
const all = getLspServers(config);
if (all.length === 0) {
return {
content: [{ type: "text", text: "No language servers configured" }],
details: { action, success: false, request: params },
};
}
chosenServer = all[0];
}
const [chosenName, chosenConfig] = chosenServer;
let requestParams: unknown;
if (params.payload !== undefined) {
try {
requestParams = JSON.parse(params.payload);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `Error: invalid JSON in payload: ${msg}` }],
details: { action, serverName: chosenName, success: false, request: params },
};
}
} else if (resolvedTarget) {
const uri = fileToUri(resolvedTarget);
if (line !== undefined) {
const character = await resolveSymbolColumn(resolvedTarget, line, symbol);
requestParams = { textDocument: { uri }, position: { line: line - 1, character } };
} else {
requestParams = { textDocument: { uri } };
}
} else {
requestParams = {};
}
try {
const client = await getOrCreateClient(chosenConfig, this.session.cwd);
if (resolvedTarget) {
await ensureFileOpen(client, resolvedTarget, signal);
}
const result = await sendRequest(client, method, requestParams, signal);
const formatted =
result === null || result === undefined
? "null"
: typeof result === "string"
? result
: JSON.stringify(result, null, 2);
return {
content: [{ type: "text", text: `${chosenName} ← ${method}:\n${formatted}` }],
details: { action, serverName: chosenName, success: true, request: params },
};
} catch (err) {
if (err instanceof ToolAbortError || signal?.aborted) {
throw new ToolAbortError();
}
const msg = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `LSP error from ${chosenName} on ${method}: ${msg}` }],
details: { action, serverName: chosenName, success: false, request: params },
};
}
}
const isWorkspace = file === "*";
const requiresFile = !file && action !== "reload";
if (requiresFile) {
return {
content: [
{
type: "text",
text: "Error: file parameter required. Use `*` for workspace scope where supported.",
},
],
details: { action, success: false },
};
}
const resolvedFile = file && !isWorkspace ? resolveToCwd(file, this.session.cwd) : null;
if (action === "symbols" && (isWorkspace || !resolvedFile)) {
const normalizedQuery = query?.trim();
if (!normalizedQuery) {
return {
content: [{ type: "text", text: "Error: query parameter required for workspace symbol search" }],
details: { action, success: false, request: params },
};
}
const servers = getLspServers(config);
if (servers.length === 0) {
return {
content: [{ type: "text", text: "No language server found for this action" }],
details: { action, success: false, request: params },
};
}
const aggregatedSymbols: SymbolInformation[] = [];
const respondingServers = new Set<string>();
for (const [workspaceServerName, workspaceServerConfig] of servers) {
throwIfAborted(signal);
try {
const workspaceClient = await getOrCreateClient(workspaceServerConfig, this.session.cwd);
const workspaceResult = (await sendRequest(
workspaceClient,
"workspace/symbol",
{ query: normalizedQuery },
signal,
)) as SymbolInformation[] | null;
if (!workspaceResult || workspaceResult.length === 0) {
continue;
}
respondingServers.add(workspaceServerName);
aggregatedSymbols.push(...filterWorkspaceSymbols(workspaceResult, normalizedQuery));
} catch (err) {
if (err instanceof ToolAbortError || signal?.aborted) {
throw err;
}
}
}
const dedupedSymbols = dedupeWorkspaceSymbols(aggregatedSymbols);
if (dedupedSymbols.length === 0) {
return {
content: [{ type: "text", text: `No symbols matching "${normalizedQuery}"` }],
details: {
action,
serverName: Array.from(respondingServers).join(", "),
success: true,
request: params,
},
};
}
const limitedSymbols = dedupedSymbols.slice(0, WORKSPACE_SYMBOL_LIMIT);
const lines = limitedSymbols.map(s => formatSymbolInformation(s, this.session.cwd));
const truncationLine =
dedupedSymbols.length > WORKSPACE_SYMBOL_LIMIT
? `\n... ${dedupedSymbols.length - WORKSPACE_SYMBOL_LIMIT} additional symbol(s) omitted`
: "";
return {
content: [
{
type: "text",
text: `Found ${dedupedSymbols.length} symbol(s) matching "${normalizedQuery}":\n${lines.map(l => ` ${l}`).join("\n")}${truncationLine}`,
},
],
details: {
action,
serverName: Array.from(respondingServers).join(", "),
success: true,
request: params,
},
};
}
if (action === "reload" && (isWorkspace || !resolvedFile)) {
const servers = getLspServers(config);
if (servers.length === 0) {
return {
content: [{ type: "text", text: "No language server found for this action" }],
details: { action, success: false, request: params },
};
}
const outputs: string[] = [];
for (const [workspaceServerName, workspaceServerConfig] of servers) {
throwIfAborted(signal);
try {
const workspaceClient = await getOrCreateClient(workspaceServerConfig, this.session.cwd);
outputs.push(await reloadServer(workspaceClient, workspaceServerName, signal));
} catch (err) {
if (err instanceof ToolAbortError || signal?.aborted) {
throw err;
}
const errorMessage = err instanceof Error ? err.message : String(err);
outputs.push(`Failed to reload ${workspaceServerName}: ${errorMessage}`);
}
}
return {
content: [{ type: "text", text: outputs.join("\n") }],
details: { action, serverName: servers.map(([name]) => name).join(", "), success: true, request: params },
};
}
const serverInfo = resolvedFile ? getLspServerForFile(config, resolvedFile) : null;
if (!serverInfo) {
return {
content: [{ type: "text", text: "No language server found for this action" }],
details: { action, success: false },
};
}
const [serverName, serverConfig] = serverInfo;
try {
const client = await getOrCreateClient(serverConfig, this.session.cwd);
const targetFile = resolvedFile;
if (targetFile) {
await ensureFileOpen(client, targetFile, signal);
}
if (
targetFile &&
line !== undefined &&
!symbol &&
(action === "references" || action === "rename" || action === "definition") &&
isProjectAwareLspServer(serverConfig)
) {
throw new ToolError(
`symbol is required for project-aware ${action}; pass symbol=<name>, optionally symbol#N for repeated occurrences`,
);
}
const uri = targetFile ? fileToUri(targetFile) : "";
const resolvedLine = line ?? 1;
const resolvedCharacter = targetFile ? await resolveSymbolColumn(targetFile, resolvedLine, symbol) : 0;
const position = { line: resolvedLine - 1, character: resolvedCharacter };
let output: string;
const crossFileActions = new Set(["definition", "type_definition", "implementation", "references", "rename"]);
if (crossFileActions.has(action)) {
await waitForProjectLoaded(client, signal);
}
switch (action) {
case "definition": {
const result = (await sendRequest(
client,
"textDocument/definition",
{
textDocument: { uri },
position,
},
signal,
)) as Location | Location[] | LocationLink | LocationLink[] | null;
const locations = normalizeLocationResult(result);
if (locations.length === 0) {
output = "No definition found";
} else {
const lines = await Promise.all(
locations.map(location => formatLocationWithContext(location, this.session.cwd)),
);
output = `Found ${locations.length} definition(s):\n${lines.join("\n")}`;
}
break;
}
case "type_definition": {
const result = (await sendRequest(
client,
"textDocument/typeDefinition",
{
textDocument: { uri },
position,
},
signal,
)) as Location | Location[] | LocationLink | LocationLink[] | null;
const locations = normalizeLocationResult(result);
if (locations.length === 0) {
output = "No type definition found";
} else {
const lines = await Promise.all(
locations.map(location => formatLocationWithContext(location, this.session.cwd)),
);
output = `Found ${locations.length} type definition(s):\n${lines.join("\n")}`;
}
break;
}
case "implementation": {
const result = (await sendRequest(
client,
"textDocument/implementation",
{
textDocument: { uri },
position,
},
signal,
)) as Location | Location[] | LocationLink | LocationLink[] | null;
const locations = normalizeLocationResult(result);
if (locations.length === 0) {
output = "No implementation found";
} else {
const lines = await Promise.all(
locations.map(location => formatLocationWithContext(location, this.session.cwd)),
);
output = `Found ${locations.length} implementation(s):\n${lines.join("\n")}`;
}
break;
}
case "references": {
let result: Location[] | null = null;
for (let attempt = 0; attempt <= REFERENCES_RETRY_COUNT; attempt++) {
result = (await sendRequest(
client,
"textDocument/references",
{
textDocument: { uri },
position,
context: { includeDeclaration: true },
},
signal,
)) as Location[] | null;
const locations = result ?? [];
if (!isProjectAwareLspServer(serverConfig) || attempt === REFERENCES_RETRY_COUNT) {
break;
}
if (locations.length > 0 && !isOnlyQueriedDeclaration(locations, uri, position)) {
break;
}
await waitForProjectLoaded(client, signal);
throwIfAborted(signal);
await untilAborted(signal, () => Bun.sleep(REFERENCES_RETRY_DELAY_MS));
}
if (!result || result.length === 0) {
output = "No references found";
} else {
const contextualReferences = result.slice(0, REFERENCE_CONTEXT_LIMIT);
const plainReferences = result.slice(REFERENCE_CONTEXT_LIMIT);
const contextualLines = await Promise.all(
contextualReferences.map(location => formatLocationWithContext(location, this.session.cwd)),
);
const plainLines = plainReferences.map(location => ` ${formatLocation(location, this.session.cwd)}`);
const lines = plainLines.length
? [
...contextualLines,
` ... ${plainLines.length} additional reference(s) shown without context`,
...plainLines,
]
: contextualLines;
output = `Found ${result.length} reference(s):\n${lines.join("\n")}`;
}
break;
}
case "hover": {
const result = (await sendRequest(
client,
"textDocument/hover",
{
textDocument: { uri },
position,
},
signal,
)) as Hover | null;
if (!result?.contents) {
output = "No hover information";
} else {
output = extractHoverText(result.contents);
}
break;
}
case "code_actions": {
const diagnostics = client.diagnostics.get(uri)?.diagnostics ?? [];
const context: CodeActionContext = {
diagnostics,
only: !apply && query ? [query] : undefined,
triggerKind: 1,
};
const result = (await sendRequest(
client,
"textDocument/codeAction",
{
textDocument: { uri },
range: { start: position, end: position },
context,
},
signal,
)) as (CodeAction | Command)[] | null;
if (!result || result.length === 0) {
output = "No code actions available";
break;
}
if (apply === true && query) {
const normalizedQuery = query.trim();
if (normalizedQuery.length === 0) {
output = "Error: query parameter required when apply=true for code_actions";
break;
}
const parsedIndex = /^\d+$/.test(normalizedQuery) ? Number.parseInt(normalizedQuery, 10) : null;
const selectedAction = result.find(
(actionItem, index) =>
(parsedIndex !== null && index === parsedIndex) ||
actionItem.title.toLowerCase().includes(normalizedQuery.toLowerCase()),
);
if (!selectedAction) {
const actionLines = result.map((actionItem, index) => ` ${formatCodeAction(actionItem, index)}`);
output = `No code action matches "${normalizedQuery}". Available actions:\n${actionLines.join("\n")}`;
break;
}
const appliedAction = await applyCodeAction(selectedAction, {
resolveCodeAction: async actionItem =>
(await sendRequest(client, "codeAction/resolve", actionItem, signal)) as CodeAction,
applyWorkspaceEdit: async edit => applyWorkspaceEdit(edit, this.session.cwd),
executeCommand: async commandItem => {
await sendRequest(
client,
"workspace/executeCommand",
{
command: commandItem.command,
arguments: commandItem.arguments ?? [],
},
signal,
);
},
});
if (!appliedAction) {
output = `Action "${selectedAction.title}" has no workspace edit or command to apply`;
break;
}
const summaryLines: string[] = [];
if (appliedAction.edits.length > 0) {
summaryLines.push(" Workspace edit:");
summaryLines.push(...appliedAction.edits.map(item => ` ${item}`));
}
if (appliedAction.executedCommands.length > 0) {
summaryLines.push(" Executed command(s):");
summaryLines.push(...appliedAction.executedCommands.map(commandName => ` ${commandName}`));
}
output = `Applied "${appliedAction.title}":\n${summaryLines.join("\n")}`;
break;
}
const actionLines = result.map((actionItem, index) => ` ${formatCodeAction(actionItem, index)}`);
output = `${result.length} code action(s):\n${actionLines.join("\n")}`;
break;
}
case "symbols": {
if (!targetFile) {
output = "Error: file parameter required for document symbols";
break;
}
const result = (await sendRequest(
client,
"textDocument/documentSymbol",
{
textDocument: { uri },
},
signal,
)) as (DocumentSymbol | SymbolInformation)[] | null;
if (!result || result.length === 0) {
output = "No symbols found";
} else {
const relPath = formatPathRelativeToCwd(targetFile, this.session.cwd);
if ("selectionRange" in result[0]) {
const lines = (result as DocumentSymbol[]).flatMap(s => formatDocumentSymbol(s));
output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
} else {
const lines = (result as SymbolInformation[]).map(s => {
const line = s.location.range.start.line + 1;
const icon = symbolKindToIcon(s.kind);
return `${icon} ${s.name} @ line ${line}`;
});
output = `Symbols in ${relPath}:\n${lines.join("\n")}`;
}
}
break;
}
case "rename": {
if (!new_name) {
return {
content: [{ type: "text", text: "Error: new_name parameter required for rename" }],
details: { action, serverName, success: false },
};
}
const result = (await sendRequest(
client,
"textDocument/rename",
{
textDocument: { uri },
position,
newName: new_name,
},
signal,
)) as WorkspaceEdit | null;
if (!result) {
output = "Rename returned no edits";
} else {
const shouldApply = apply !== false;
if (shouldApply) {
const applied = await applyWorkspaceEdit(result, this.session.cwd);
output = `Applied rename:\n${applied.map(a => ` ${a}`).join("\n")}`;
} else {
const preview = formatWorkspaceEdit(result, this.session.cwd);
output = `Rename preview:\n${preview.map(p => ` ${p}`).join("\n")}`;
}
}
break;
}
case "reload": {
output = await reloadServer(client, serverName, signal);
break;
}
default:
output = `Unknown action: ${action}`;
}
return {
content: [{ type: "text", text: output }],
details: { serverName, action, success: true, request: params },
};
} catch (err) {
if (err instanceof ToolError) throw err;
if (err instanceof ToolAbortError || signal?.aborted) {
if (timeoutSignal.aborted && !callerSignal?.aborted) {
throw new ToolError(
`LSP ${action} timed out after ${timeoutSec}s on ${serverName}. The server may still be indexing; try again or pass timeout=<larger>.`,
);
}
throw new ToolAbortError();
}
const errorMessage = err instanceof Error ? err.message : String(err);
return {
content: [{ type: "text", text: `LSP error: ${errorMessage}` }],
details: { serverName, action, success: false, request: params },
};
}
}
}