Files
pi-config/extensions/pi-lsp-extension/src/tools/rename.ts

161 lines
7.4 KiB
TypeScript

/**
* lsp_rename — Preview a rename refactoring across the workspace.
* Returns planned edits but does NOT apply them.
*/
import { Type } from "@sinclair/typebox";
import type { WorkspaceEdit, TextEdit } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
function formatWorkspaceEdit(edit: WorkspaceEdit, rootDir: string): { summary: string; fileCount: number; editCount: number } {
const lines: string[] = [];
let totalEdits = 0;
let fileCount = 0;
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("textDocument" in change && "edits" in change) {
fileCount++;
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(change.textDocument.uri)); } catch { relPath = change.textDocument.uri; }
lines.push(`${relPath}:`);
for (const textEdit of change.edits as TextEdit[]) {
const line = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
lines.push(` ${line}:${col} → "${textEdit.newText}"`);
totalEdits++;
}
}
}
}
const changes = edit.changes ?? {};
for (const [uri, edits] of Object.entries(changes)) {
fileCount++;
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(uri)); } catch { relPath = uri; }
lines.push(`${relPath}:`);
for (const textEdit of edits) {
const line = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
lines.push(` ${line}:${col} → "${textEdit.newText}"`);
totalEdits++;
}
}
return { summary: lines.join("\n"), fileCount, editCount: totalEdits };
}
const RenameParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
newName: Type.String({ description: "New name for the symbol" }),
});
interface RenameDetails { fileCount: number; editCount: number }
export function createRenameTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof RenameParams, RenameDetails> {
return {
name: "lsp_rename",
label: "LSP Rename",
description: "Preview a rename refactoring for a symbol at a position. Returns the list of changes that would be made across all files. Does NOT apply the changes — use edit/write tools to apply them. Line and character are 1-indexed.",
promptSnippet: "Preview rename refactoring for a symbol (returns planned edits, does not apply them)",
parameters: RenameParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { fileCount: 0, editCount: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { fileCount: 0, editCount: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { fileCount: 0, editCount: 0 } };
}
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const result = await client.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
textDocument: { uri }, position, newName: params.newName,
});
if (!result) {
const text = resolvedFrom ? `${resolvedFrom}\n\nRename not possible at this position.` : "Rename not possible at this position.";
return { content: [{ type: "text", text }], details: { fileCount: 0, editCount: 0 } };
}
const rootDir = manager.resolvePath(".");
const { summary, fileCount, editCount } = formatWorkspaceEdit(result, rootDir);
if (editCount === 0) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo edits needed for this rename.` : "No edits needed for this rename.";
return { content: [{ type: "text", text }], details: { fileCount: 0, editCount: 0 } };
}
const truncation = truncateHead(summary, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = "";
if (resolvedFrom) text += `${resolvedFrom}\n\n`;
text += `Rename "${params.newName}": ${editCount} edit(s) across ${fileCount} file(s)\n\n`;
text += "NOTE: These changes are NOT applied. Use edit/write tools to make the changes.\n\n";
text += truncation.content;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { fileCount, editCount } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP rename request failed: ${err.message}` }], details: { fileCount: 0, editCount: 0 } };
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_rename "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}" → ${args.newName}`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
text += theme.fg("muted", `${args.newName}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Computing..."), 0, 0);
const details = result.details;
if (!details || details.editCount === 0) return new Text(theme.fg("dim", "No edits"), 0, 0);
return new Text(theme.fg("success", `${details.editCount} edit(s) in ${details.fileCount} file(s) (preview only)`), 0, 0);
},
};
}