user aider inspired diff approach

This commit is contained in:
Marc Goodner
2024-12-04 19:21:04 -08:00
parent bb7925fe11
commit 02ff589f58

View File

@@ -216,79 +216,146 @@ async function searchFiles(
return results; return results;
} }
interface Position { interface DiffLine {
start: number; type: 'context' | 'addition' | 'deletion';
end: number; content: string;
lineNumber: number; lineNumber: number;
} }
function findTextPosition(content: string, searchText: string): Position { function createUnifiedDiff(originalLines: string[], newLines: string[], contextSize: number = 3): string {
// Handle different line endings const differ = new Array<DiffLine>();
const normalized = content.replace(/\r\n/g, '\n'); let lineNumber = 1;
const searchNormalized = searchText.replace(/\r\n/g, '\n');
const pos = normalized.indexOf(searchNormalized); // Helper to add context lines
if (pos === -1) { function addContext(lines: string[], start: number, count: number) {
throw new Error(`Text not found:\n${searchText}`); for (let i = 0; i < count && start + i < lines.length; i++) {
differ.push({
type: 'context',
content: lines[start + i],
lineNumber: start + i + 1
});
}
} }
// Map back to original content position // Find the differences using longest common subsequence
const originalPos = content.slice(0, pos).replace(/[ \t]+/g, ' ').length; const changes: Array<{type: 'context' | 'addition' | 'deletion', line: string, index: number}> = [];
const originalEnd = originalPos + searchText.length; let i = 0, j = 0;
return { while (i < originalLines.length || j < newLines.length) {
start: pos, if (i < originalLines.length && j < newLines.length && originalLines[i] === newLines[j]) {
end: originalEnd, changes.push({type: 'context', line: originalLines[i], index: i});
lineNumber: normalized.slice(0, pos).split('\n').length i++;
}; j++;
} } else {
if (i < originalLines.length) {
changes.push({type: 'deletion', line: originalLines[i], index: i});
i++;
}
if (j < newLines.length) {
changes.push({type: 'addition', line: newLines[j], index: j});
j++;
}
}
}
// Edit preview type // Group changes into hunks with context
interface EditPreview { let currentHunk: DiffLine[] = [];
original: string; let hunks: DiffLine[][] = [];
modified: string; let lastChangeIndex = -1;
lineNumber: number;
preview: string; // Git-style diff format for (let i = 0; i < changes.length; i++) {
const change = changes[i];
if (change.type !== 'context' ||
(lastChangeIndex >= 0 && i - lastChangeIndex <= contextSize * 2)) {
if (change.type !== 'context') {
lastChangeIndex = i;
}
currentHunk.push({
type: change.type,
content: change.line,
lineNumber: change.index + 1
});
} else {
if (currentHunk.length > 0) {
hunks.push(currentHunk);
currentHunk = [];
}
}
}
if (currentHunk.length > 0) {
hunks.push(currentHunk);
}
// Format the diff output
let diffOutput = '';
for (const hunk of hunks) {
const startLine = hunk[0].lineNumber;
const endLine = hunk[hunk.length - 1].lineNumber;
diffOutput += `@@ -${startLine},${endLine} @@\n`;
for (const line of hunk) {
const prefix = line.type === 'addition' ? '+' :
line.type === 'deletion' ? '-' : ' ';
diffOutput += `${prefix}${line.content}\n`;
}
diffOutput += '\n';
}
return diffOutput;
} }
// File editing utilities // File editing utilities
async function applyFileEdits(filePath: string, edits: Array<{oldText: string, newText: string}>, dryRun = false): Promise<string | EditPreview[]> { async function applyFileEdits(
filePath: string,
edits: Array<{oldText: string, newText: string}>,
dryRun = false
): Promise<string | string> {
let content = await fs.readFile(filePath, 'utf-8'); let content = await fs.readFile(filePath, 'utf-8');
const previews: EditPreview[] = []; const originalLines = content.split('\n');
let modifiedContent = content;
// Find all positions first // First, validate all edits can be applied
const positions = edits.map(edit => ({ const positions = edits.map(edit => {
const pos = modifiedContent.indexOf(edit.oldText);
if (pos === -1) {
throw new Error(`Text not found:\n${edit.oldText}`);
}
return {
edit, edit,
position: findTextPosition(content, edit.oldText) position: pos,
})); length: edit.oldText.length
};
// Sort by position in reverse order
positions.sort((a, b) => b.position.start - a.position.start);
// Apply edits from end to start
for (const {edit, position} of positions) {
const preview = [
`@@ line ${position.lineNumber} @@`,
'<<<<<<< ORIGINAL',
edit.oldText,
'=======',
edit.newText,
'>>>>>>> MODIFIED'
].join('\n');
previews.push({
original: edit.oldText,
modified: edit.newText,
lineNumber: position.lineNumber,
preview
}); });
if (!dryRun) { // Sort positions in reverse order to apply from end to start
content = content.slice(0, position.start) + edit.newText + content.slice(position.end); positions.sort((a, b) => b.position - a.position);
}
if (dryRun) {
// For dry run, create a unified diff preview
for (const {edit, position} of positions) {
modifiedContent =
modifiedContent.slice(0, position) +
edit.newText +
modifiedContent.slice(position + edit.oldText.length);
} }
return dryRun ? previews : content; const modifiedLines = modifiedContent.split('\n');
return createUnifiedDiff(originalLines, modifiedLines);
} else {
// Apply the edits
for (const {edit, position} of positions) {
modifiedContent =
modifiedContent.slice(0, position) +
edit.newText +
modifiedContent.slice(position + edit.oldText.length);
}
return modifiedContent;
}
} }
// Tool handlers // Tool handlers
@@ -325,9 +392,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
{ {
name: "edit_file", name: "edit_file",
description: description:
"Make selective edits to a text file using simple search and replace with git-style preview format. " + "Make selective edits to a text file using search and replace with unified diff previews. " +
"Finds text to replace using substring matching and shows changes in a familiar git-diff format. " + "Shows changes in standard unified diff format with context lines, similar to git diff. " +
"Use dry run mode to preview changes before applying them. " + "Use dry run mode to preview changes in patch format before applying them. " +
"Only works within allowed directories.", "Only works within allowed directories.",
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
}, },
@@ -452,15 +519,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const validPath = await validatePath(parsed.data.path); const validPath = await validatePath(parsed.data.path);
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun);
// If it's a dry run, format the previews // If it's a dry run, show the unified diff
if (parsed.data.dryRun) { if (parsed.data.dryRun) {
const previewText = (result as EditPreview[]).map(preview => preview.preview).join('\n\n');
return { return {
content: [{ type: "text", text: `Edit preview:\n${previewText}` }], content: [{ type: "text", text: `Edit preview:\n${result}` }],
}; };
} }
await fs.writeFile(validPath, result as string, "utf-8"); await fs.writeFile(validPath, result, "utf-8");
return { return {
content: [{ type: "text", text: `Successfully applied edits to ${parsed.data.path}` }], content: [{ type: "text", text: `Successfully applied edits to ${parsed.data.path}` }],
}; };