diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index a3ce7f04..23d989d0 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -108,29 +108,16 @@ const WriteFileArgsSchema = z.object({ }); const EditOperation = z.object({ - // The text to search for - oldText: z.string().describe('Text to search for - can be a substring of the target'), - // The new text to replace with - newText: z.string().describe('Text to replace the found text with'), -}); - -const EditOptions = z.object({ - preserveIndentation: z.boolean().default(true).describe('Preserve existing indentation patterns in the file'), - normalizeWhitespace: z.boolean().default(true).describe('Normalize whitespace while preserving structure'), - partialMatch: z.boolean().default(true).describe('Enable fuzzy matching with confidence scoring') + oldText: z.string().describe('Text to search for - must match exactly'), + newText: z.string().describe('Text to replace with') }); const EditFileArgsSchema = z.object({ path: z.string(), edits: z.array(EditOperation), - // Optional: preview changes without applying them - dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format'), - // Optional: configure matching and formatting behavior - options: EditOptions.default({}) + dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') }); - - const CreateDirectoryArgsSchema = z.object({ path: z.string(), }); @@ -228,227 +215,101 @@ async function searchFiles( } // file editing and diffing utilities +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, '\n'); +} + function createUnifiedDiff(originalContent: string, newContent: string, filepath: string = 'file'): string { + // Ensure consistent line endings for diff + const normalizedOriginal = normalizeLineEndings(originalContent); + const normalizedNew = normalizeLineEndings(newContent); + return createTwoFilesPatch( filepath, filepath, - originalContent, - newContent, + normalizedOriginal, + normalizedNew, 'original', 'modified' ); } -// Utility functions for text normalization and matching -function normalizeLineEndings(text: string): string { - return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - -function normalizeWhitespace(text: string, preserveIndentation: boolean = true): string { - if (!preserveIndentation) { - // Collapse all whitespace to single spaces if not preserving indentation - return text.replace(/\s+/g, ' '); - } - - // Preserve line structure but normalize inline whitespace - return text.split('\n').map(line => { - // Preserve leading whitespace - const indent = line.match(/^[\s\t]*/)?.[0] || ''; - // Normalize rest of line - const content = line.slice(indent.length).trim().replace(/\s+/g, ' '); - return indent + content; - }).join('\n'); -} - -interface EditMatch { - start: number; - end: number; - confidence: number; -} - -function findBestMatch(content: string, searchText: string, options: z.infer): EditMatch | null { - const normalizedContent = normalizeLineEndings(content); - const normalizedSearch = normalizeLineEndings(searchText); - - // Try exact match first - const exactPos = normalizedContent.indexOf(normalizedSearch); - if (exactPos !== -1) { - return { - start: exactPos, - end: exactPos + searchText.length, - confidence: 1.0 - }; - } - - // If whitespace normalization is enabled, try that next - if (options.normalizeWhitespace) { - const normContent = normalizeWhitespace(normalizedContent, options.preserveIndentation); - const normSearch = normalizeWhitespace(normalizedSearch, options.preserveIndentation); - const normPos = normContent.indexOf(normSearch); - - if (normPos !== -1) { - // Find the corresponding position in original text - const beforeMatch = normContent.slice(0, normPos); - const originalPos = findOriginalPosition(content, beforeMatch); - return { - start: originalPos, - end: originalPos + searchText.length, - confidence: 0.9 - }; - } - } - - // If partial matching is enabled, try to find the best partial match - if (options.partialMatch) { - const lines = normalizedContent.split('\n'); - const searchLines = normalizedSearch.split('\n'); - - let bestMatch: EditMatch | null = null; - let bestScore = 0; - - // Sliding window search through the content - for (let i = 0; i < lines.length - searchLines.length + 1; i++) { - let matchScore = 0; - let matchLength = 0; - - for (let j = 0; j < searchLines.length; j++) { - const contentLine = options.normalizeWhitespace - ? normalizeWhitespace(lines[i + j], options.preserveIndentation) - : lines[i + j]; - const searchLine = options.normalizeWhitespace - ? normalizeWhitespace(searchLines[j], options.preserveIndentation) - : searchLines[j]; - - const similarity = calculateSimilarity(contentLine, searchLine); - matchScore += similarity; - matchLength += lines[i + j].length + 1; // +1 for newline - } - - const averageScore = matchScore / searchLines.length; - if (averageScore > bestScore && averageScore > 0.7) { // Threshold for minimum match quality - bestScore = averageScore; - const start = lines.slice(0, i).reduce((acc, line) => acc + line.length + 1, 0); - bestMatch = { - start, - end: start + matchLength, - confidence: averageScore - }; - } - } - - return bestMatch; - } - - return null; -} - -function calculateSimilarity(str1: string, str2: string): number { - const len1 = str1.length; - const len2 = str2.length; - const matrix: number[][] = Array(len1 + 1).fill(null).map(() => Array(len2 + 1).fill(0)); - - for (let i = 0; i <= len1; i++) matrix[i][0] = i; - for (let j = 0; j <= len2; j++) matrix[0][j] = j; - - for (let i = 1; i <= len1; i++) { - for (let j = 1; j <= len2; j++) { - const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, - matrix[i][j - 1] + 1, - matrix[i - 1][j - 1] + cost - ); - } - } - - const maxLength = Math.max(len1, len2); - return maxLength === 0 ? 1 : (maxLength - matrix[len1][len2]) / maxLength; -} - -function findOriginalPosition(original: string, normalizedPrefix: string): number { - let origPos = 0; - let normPos = 0; - - while (normPos < normalizedPrefix.length && origPos < original.length) { - if (normalizeWhitespace(original[origPos], true) === normalizedPrefix[normPos]) { - normPos++; - } - origPos++; - } - - return origPos; -} - async function applyFileEdits( filePath: string, edits: Array<{oldText: string, newText: string}>, - dryRun = false, - options: z.infer = EditOptions.parse({}) + dryRun = false ): Promise { - const content = await fs.readFile(filePath, 'utf-8'); + // Read file content and normalize line endings + const content = normalizeLineEndings(await fs.readFile(filePath, 'utf-8')); + + // Apply edits sequentially let modifiedContent = content; - const failedEdits: Array<{edit: typeof edits[0], error: string}> = []; - const successfulEdits: Array<{edit: typeof edits[0], match: EditMatch}> = []; - - // Sort edits by position (if found) to apply them in order for (const edit of edits) { - const match = findBestMatch(modifiedContent, edit.oldText, options); + const normalizedOld = normalizeLineEndings(edit.oldText); + const normalizedNew = normalizeLineEndings(edit.newText); - if (!match) { - failedEdits.push({ - edit, - error: 'No suitable match found' - }); + // If exact match exists, use it + if (modifiedContent.includes(normalizedOld)) { + modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew); continue; } - // For low confidence matches in non-dry-run mode, we might want to throw - if (!dryRun && match.confidence < 0.8) { - failedEdits.push({ - edit, - error: `Match confidence too low: ${(match.confidence * 100).toFixed(1)}%` + // Otherwise, try line-by-line matching with flexibility for whitespace + const oldLines = normalizedOld.split('\n'); + const contentLines = modifiedContent.split('\n'); + let matchFound = false; + + for (let i = 0; i <= contentLines.length - oldLines.length; i++) { + const potentialMatch = contentLines.slice(i, i + oldLines.length); + + // Compare lines with normalized whitespace + const isMatch = oldLines.every((oldLine, j) => { + const contentLine = potentialMatch[j]; + return oldLine.trim() === contentLine.trim(); }); - continue; + + if (isMatch) { + // Preserve original indentation of first line + const originalIndent = contentLines[i].match(/^\s*/)?.[0] || ''; + const newLines = normalizedNew.split('\n').map((line, j) => { + if (j === 0) return originalIndent + line.trimStart(); + // For subsequent lines, try to preserve relative indentation + const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || ''; + const newIndent = line.match(/^\s*/)?.[0] || ''; + if (oldIndent && newIndent) { + const relativeIndent = newIndent.length - oldIndent.length; + return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart(); + } + return line; + }); + + contentLines.splice(i, oldLines.length, ...newLines); + modifiedContent = contentLines.join('\n'); + matchFound = true; + break; + } } - successfulEdits.push({ edit, match }); - } - - // Sort successful edits by position (reverse order to maintain positions) - successfulEdits.sort((a, b) => b.match.start - a.match.start); - - // Apply successful edits - for (const { edit, match } of successfulEdits) { - modifiedContent = - modifiedContent.slice(0, match.start) + - edit.newText + - modifiedContent.slice(match.end); - } - - if (dryRun) { - let report = createUnifiedDiff(content, modifiedContent, filePath); - - if (failedEdits.length > 0) { - report += '\nFailed edits:\n' + failedEdits.map(({ edit, error }) => - `- Error: ${error}\n Old text: ${edit.oldText.split('\n')[0]}...\n` - ).join('\n'); + if (!matchFound) { + throw new Error(`Could not find exact match for edit:\n${edit.oldText}`); } - - if (successfulEdits.length > 0) { - report += '\nSuccessful edits:\n' + successfulEdits.map(({ edit, match }) => - `- Match confidence: ${(match.confidence * 100).toFixed(1)}%\n Position: ${match.start}-${match.end}\n` - ).join('\n'); - } - - return report; } - if (failedEdits.length > 0) { - const errors = failedEdits.map(({ error }) => error).join('\n'); - throw new Error(`Some edits failed:\n${errors}`); + // Create unified diff + const diff = createUnifiedDiff(content, modifiedContent, filePath); + + // Format diff with appropriate number of backticks + let numBackticks = 3; + while (diff.includes('`'.repeat(numBackticks))) { + numBackticks++; + } + const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n`; + + if (!dryRun) { + await fs.writeFile(filePath, modifiedContent, 'utf-8'); } - return modifiedContent; + return formattedDiff; } // Tool handlers @@ -485,17 +346,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "edit_file", description: - "Make selective edits to a text file using advanced pattern matching and smart formatting preservation. Features include:\n" + - "- Line-based and multi-line content matching\n" + - "- Whitespace normalization with indentation preservation\n" + - "- Fuzzy matching with confidence scoring\n" + - "- Multiple simultaneous edits with correct positioning\n" + - "- Indentation style detection and preservation\n" + - "- Detailed diff output with context in git format\n" + - "- Dry run mode for previewing changes\n" + - "- Failed match debugging with match confidence scores\n\n" + - "Configure behavior with options.preserveIndentation, options.normalizeWhitespace, and options.partialMatch. " + - "See schema for detailed option descriptions. Only works within allowed directories.", + "Make line-based edits to a text file. Each edit replaces exact line sequences " + + "with new content. Returns a git-style diff showing the changes made. " + + "Only works within allowed directories.", inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput, }, { @@ -617,18 +470,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { throw new Error(`Invalid arguments for edit_file: ${parsed.error}`); } const validPath = await validatePath(parsed.data.path); - const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun, parsed.data.options); - - // If it's a dry run, show the unified diff - if (parsed.data.dryRun) { - return { - content: [{ type: "text", text: `Edit preview:\n${result}` }], - }; - } - - await fs.writeFile(validPath, result, "utf-8"); + const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); return { - content: [{ type: "text", text: `Successfully applied edits to ${parsed.data.path}` }], + content: [{ type: "text", text: result }], }; }