simplify edit_file to use git-style diffs and substring matching
This commit is contained in:
parent
40ec305556
commit
e5e9e0a0d2
1 changed files with 38 additions and 149 deletions
|
|
@ -107,30 +107,12 @@ const WriteFileArgsSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const EditOperation = z.object({
|
const EditOperation = z.object({
|
||||||
// Primary edit specification
|
// The text to search for
|
||||||
oldText: z.string().describe('Exact text to match, including whitespace/formatting'),
|
oldText: z.string().describe('Text to search for - can be a substring of the target'),
|
||||||
newText: z.string().describe('Replacement text with desired formatting'),
|
// The new text to replace with
|
||||||
|
newText: z.string().describe('Text to replace the found text with'),
|
||||||
// Location finding (one of these should be provided)
|
// Optional: preview changes without applying them
|
||||||
startLine: z.number().int().min(1).optional().describe('Exact line number to start edit'),
|
dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format')
|
||||||
findAnchor: z.string().optional().describe('Text to search for to locate edit position'),
|
|
||||||
|
|
||||||
// Edit behavior
|
|
||||||
insertMode: z.enum(['replace', 'before', 'after']).default('replace')
|
|
||||||
.describe('Whether to replace matched text or insert before/after it'),
|
|
||||||
verifyState: z.boolean().default(true)
|
|
||||||
.describe('Whether to verify exact text matches before editing'),
|
|
||||||
readBeforeEdit: z.boolean().default(false)
|
|
||||||
.describe('Whether to re-read file between multiple edits'),
|
|
||||||
|
|
||||||
// Optional context verification
|
|
||||||
beforeContext: z.string().optional().describe('Text that should appear before edit point'),
|
|
||||||
afterContext: z.string().optional().describe('Text that should appear after edit point'),
|
|
||||||
contextRadius: z.number().int().min(0).default(3)
|
|
||||||
.describe('Number of lines to check for context matches'),
|
|
||||||
|
|
||||||
// Preview mode
|
|
||||||
dryRun: z.boolean().default(false).describe('Preview changes without applying them'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const EditFileArgsSchema = z.object({
|
const EditFileArgsSchema = z.object({
|
||||||
|
|
@ -234,131 +216,59 @@ async function searchFiles(
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content normalization utilities
|
|
||||||
// Used only for fuzzy matching of anchor text and context verification
|
|
||||||
// Does not affect the actual content replacement
|
|
||||||
function normalizeForComparison(content: string): string {
|
|
||||||
// Normalize line endings and whitespace for comparison only
|
|
||||||
return content.replace(/\r\n/g, '\n')
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit preview type
|
// Edit preview type
|
||||||
interface EditPreview {
|
interface EditPreview {
|
||||||
originalContent: string;
|
original: string;
|
||||||
newContent: string;
|
modified: string;
|
||||||
lineNumber: number;
|
lineNumber: number;
|
||||||
matchedAnchor?: string;
|
preview: string; // Git-style diff format
|
||||||
contextVerified: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// File editing utilities
|
// File editing utilities
|
||||||
async function applyFileEdits(filePath: string, edits: z.infer<typeof EditOperation>[]): Promise<string | EditPreview[]> {
|
async function applyFileEdits(filePath: string, edits: z.infer<typeof EditOperation>[]): Promise<string | EditPreview[]> {
|
||||||
// Read the file content
|
let content = await fs.readFile(filePath, 'utf-8');
|
||||||
let currentContent = await fs.readFile(filePath, 'utf-8');
|
|
||||||
const previews: EditPreview[] = [];
|
const previews: EditPreview[] = [];
|
||||||
|
|
||||||
for (const edit of edits) {
|
for (const edit of edits) {
|
||||||
let editContent = currentContent;
|
const pos = content.indexOf(edit.oldText);
|
||||||
let editPosition = -1;
|
if (pos === -1) {
|
||||||
|
|
||||||
// Find the edit position using anchor if provided
|
|
||||||
if (edit.findAnchor) {
|
|
||||||
const normalizedContent = normalizeForComparison(currentContent);
|
|
||||||
const normalizedAnchor = normalizeForComparison(edit.findAnchor);
|
|
||||||
const anchorPos = normalizedContent.indexOf(normalizedAnchor);
|
|
||||||
|
|
||||||
if (anchorPos === -1) {
|
|
||||||
throw new Error(`Edit failed - anchor text not found: ${edit.findAnchor} in ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map normalized position back to original content
|
|
||||||
editPosition = currentContent.slice(0, anchorPos).split('\n').length - 1;
|
|
||||||
} else if (edit.startLine) {
|
|
||||||
editPosition = edit.startLine - 1;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Edit failed - no valid position found in ${filePath}. Operation requires either startLine or findAnchor`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify context if provided
|
|
||||||
if (edit.beforeContext || edit.afterContext) {
|
|
||||||
const lines = currentContent.split('\n');
|
|
||||||
const radius = edit.contextRadius || 3;
|
|
||||||
const beforeText = lines.slice(Math.max(0, editPosition - radius), editPosition).join('\n');
|
|
||||||
const afterText = lines.slice(editPosition + 1, editPosition + radius + 1).join('\n');
|
|
||||||
|
|
||||||
let contextVerified = true;
|
|
||||||
if (edit.beforeContext && !normalizeForComparison(beforeText).includes(normalizeForComparison(edit.beforeContext))) {
|
|
||||||
contextVerified = false;
|
|
||||||
}
|
|
||||||
if (edit.afterContext && !normalizeForComparison(afterText).includes(normalizeForComparison(edit.afterContext))) {
|
|
||||||
contextVerified = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contextVerified && edit.verifyState) {
|
|
||||||
throw new Error(
|
|
||||||
`Edit failed - context verification failed in ${filePath} at line ${editPosition + 1}\n` +
|
|
||||||
`Expected before context: ${edit.beforeContext}\n` +
|
|
||||||
`Expected after context: ${edit.afterContext}\n` +
|
|
||||||
`Found before context: ${beforeText}\n` +
|
|
||||||
`Found after context: ${afterText}\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look for exact match of oldText
|
|
||||||
const searchStr = edit.oldText;
|
|
||||||
const searchPos = currentContent.indexOf(searchStr);
|
|
||||||
|
|
||||||
if (searchPos === -1 && edit.verifyState) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Edit failed - content not found in ${filePath}\n` +
|
`Search text not found in ${filePath}:\n${edit.oldText}`
|
||||||
`Expected to find:\n${searchStr}\n`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate line number for reporting
|
||||||
|
const lineNumber = content.slice(0, pos).split(/\r?\n/).length;
|
||||||
|
|
||||||
if (edit.dryRun) {
|
if (edit.dryRun) {
|
||||||
|
// Create git-style diff preview
|
||||||
|
const preview = [
|
||||||
|
`@@ line ${lineNumber} @@`,
|
||||||
|
'<<<<<<< ORIGINAL',
|
||||||
|
edit.oldText,
|
||||||
|
'=======',
|
||||||
|
edit.newText,
|
||||||
|
'>>>>>>> MODIFIED'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
previews.push({
|
previews.push({
|
||||||
originalContent: searchStr,
|
original: edit.oldText,
|
||||||
newContent: edit.newText,
|
modified: edit.newText,
|
||||||
lineNumber: editPosition + 1,
|
lineNumber,
|
||||||
matchedAnchor: edit.findAnchor,
|
preview
|
||||||
contextVerified: true
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the edit based on insertMode
|
// Apply the edit
|
||||||
switch (edit.insertMode) {
|
content = content.slice(0, pos) + edit.newText + content.slice(pos + edit.oldText.length);
|
||||||
case 'before':
|
|
||||||
editContent = currentContent.slice(0, searchPos) +
|
|
||||||
edit.newText + currentContent.slice(searchPos);
|
|
||||||
break;
|
|
||||||
case 'after':
|
|
||||||
editContent = currentContent.slice(0, searchPos + searchStr.length) +
|
|
||||||
edit.newText + currentContent.slice(searchPos + searchStr.length);
|
|
||||||
break;
|
|
||||||
default: // 'replace'
|
|
||||||
editContent = currentContent.slice(0, searchPos) +
|
|
||||||
edit.newText + currentContent.slice(searchPos + searchStr.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update content for next edit
|
|
||||||
if (edit.readBeforeEdit) {
|
|
||||||
await fs.writeFile(filePath, editContent, 'utf-8');
|
|
||||||
currentContent = await fs.readFile(filePath, 'utf-8');
|
|
||||||
} else {
|
|
||||||
currentContent = editContent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (edits.some(e => e.dryRun)) {
|
if (edits.some(e => e.dryRun)) {
|
||||||
return previews;
|
return previews;
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentContent;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool handlers
|
// Tool handlers
|
||||||
|
|
@ -395,24 +305,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||||
{
|
{
|
||||||
name: "edit_file",
|
name: "edit_file",
|
||||||
description:
|
description:
|
||||||
"Make selective edits to a text file with advanced pattern matching and validation. " +
|
"Make selective edits to a text file using simple search and replace with git-style preview format. " +
|
||||||
"Supports multiple edit modes:\n" +
|
"Finds text to replace using substring matching and shows changes in a familiar git-diff format. " +
|
||||||
"1. Line-based: Use startLine to specify exact positions\n" +
|
"Use dry run mode to preview changes before applying them. " +
|
||||||
"2. Pattern-based: Use findAnchor to locate edit points by matching text\n" +
|
"Only works within allowed directories.",
|
||||||
"3. Context-aware: Verify surrounding text with beforeContext/afterContext\n\n" +
|
|
||||||
"Features:\n" +
|
|
||||||
"- Dry run mode for previewing changes (dryRun: true)\n" +
|
|
||||||
"- Multiple insertion modes: 'replace', 'before', 'after'\n" +
|
|
||||||
"- Anchor-based positioning with offset support\n" +
|
|
||||||
"- Automatic state refresh between edits (readBeforeEdit)\n" +
|
|
||||||
"- Context verification to ensure edit safety\n\n" +
|
|
||||||
"Recommended workflow:\n" +
|
|
||||||
"1. Use dryRun to preview changes\n" +
|
|
||||||
"2. Use findAnchor for resilient positioning\n" +
|
|
||||||
"3. Enable readBeforeEdit for multi-step changes\n" +
|
|
||||||
"4. Verify context when position is critical\n\n" +
|
|
||||||
"This is safer than complete file overwrites as it verifies existing content " +
|
|
||||||
"and supports granular changes. Only works within allowed directories.",
|
|
||||||
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
|
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -538,14 +434,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
|
||||||
// If it's a dry run, format the previews
|
// If it's a dry run, format the previews
|
||||||
if (Array.isArray(result)) {
|
if (Array.isArray(result)) {
|
||||||
const previewText = result.map(preview =>
|
const previewText = result.map(preview => preview.preview).join('\n\n');
|
||||||
`Line ${preview.lineNumber}:\n` +
|
|
||||||
`${preview.matchedAnchor ? `Matched anchor: ${preview.matchedAnchor}\n` : ''}` +
|
|
||||||
`Context verified: ${preview.contextVerified}\n` +
|
|
||||||
`Original:\n${preview.originalContent}\n` +
|
|
||||||
`New:\n${preview.newContent}\n`
|
|
||||||
).join('\n---\n');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: `Edit preview:\n${previewText}` }],
|
content: [{ type: "text", text: `Edit preview:\n${previewText}` }],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue