From b2d4caa8acf579d7ada6b5252e5938096f863199 Mon Sep 17 00:00:00 2001 From: axb Date: Thu, 27 Feb 2025 15:07:32 +0800 Subject: [PATCH] Supports updating multiple locations of a file in one call of the apply_diff tool --- src/core/Cline.ts | 39 +- src/core/diff/strategies/search-replace.ts | 392 ++++++++++++--------- src/core/diff/types.ts | 9 +- 3 files changed, 264 insertions(+), 176 deletions(-) diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 532b9cbe99..64291d325f 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -1555,17 +1555,36 @@ export class Cline { success: false, error: "No diff strategy available", } + let partResults = "" + if (!diffResult.success) { this.consecutiveMistakeCount++ const currentCount = (this.consecutiveMistakeCountForApplyDiff.get(relPath) || 0) + 1 this.consecutiveMistakeCountForApplyDiff.set(relPath, currentCount) - const errorDetails = diffResult.details - ? JSON.stringify(diffResult.details, null, 2) - : "" - const formattedError = `Unable to apply diff to file: ${absolutePath}\n\n\n${ - diffResult.error - }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` + let formattedError = "" + if (diffResult.failParts && diffResult.failParts.length > 0) { + for (const failPart of diffResult.failParts) { + if (failPart.success) { + continue + } + const errorDetails = failPart.details + ? JSON.stringify(failPart.details, null, 2) + : "" + formattedError = `\n${ + failPart.error + }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` + partResults += formattedError + } + } else { + const errorDetails = diffResult.details + ? JSON.stringify(diffResult.details, null, 2) + : "" + formattedError = `Unable to apply diff to file: ${absolutePath}\n\n\n${ + diffResult.error + }${errorDetails ? `\n\nDetails:\n${errorDetails}` : ""}\n` + } + if (currentCount >= 2) { await this.say("error", formattedError) } @@ -1595,6 +1614,10 @@ export class Cline { const { newProblemsMessage, userEdits, finalContent } = await this.diffViewProvider.saveChanges() this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request + let partFailHint = "" + if (diffResult.failParts && diffResult.failParts.length > 0) { + partFailHint = `Unable to apply all diff parts to file: ${absolutePath}, use tool to check newest file version and re-apply diffs\n` + } if (userEdits) { await this.say( "user_feedback_diff", @@ -1606,6 +1629,7 @@ export class Cline { ) pushToolResult( `The user made the following updates to your content:\n\n${userEdits}\n\n` + + partFailHint + `The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` + `\n${addLineNumbers( finalContent || "", @@ -1618,7 +1642,8 @@ export class Cline { ) } else { pushToolResult( - `Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`, + `Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}\n` + + partFailHint, ) } await this.diffViewProvider.reset() diff --git a/src/core/diff/strategies/search-replace.ts b/src/core/diff/strategies/search-replace.ts index a9bf46758d..231a4aa90d 100644 --- a/src/core/diff/strategies/search-replace.ts +++ b/src/core/diff/strategies/search-replace.ts @@ -52,16 +52,18 @@ When applying the diffs, be extra careful to remember to change any closing brac Parameters: - path: (required) The path of the file to modify (relative to the current working directory ${args.cwd}) - diff: (required) The search/replace block defining the changes. -- start_line: (required) The line number where the search block starts. -- end_line: (required) The line number where the search block ends. Diff format: \`\`\` <<<<<<< SEARCH +> start_line: The line number of original content where the search block starts. +> end_line: The line number of original content where the search block ends. +------------ [exact content to find including whitespace] ======= [new content to replace with] >>>>>>> REPLACE + \`\`\` Example: @@ -78,6 +80,9 @@ Original file: Search/Replace content: \`\`\` <<<<<<< SEARCH +> start_line:1 +> end_line:5 +------------ def calculate_total(items): total = 0 for item in items: @@ -88,6 +93,32 @@ def calculate_total(items): """Calculate total with 10% markup""" return sum(item * 1.1 for item in items) >>>>>>> REPLACE + +\`\`\` + +Search/Replace content with multi edits: +\`\`\` +<<<<<<< SEARCH +> start_line:1 +> end_line:2 +------------ +def calculate_sum(items): + sum = 0 +======= +def calculate_sum(items): + sum = 0 +>>>>>>> REPLACE + +<<<<<<< SEARCH +> start_line:4 +> end_line:5 +------------ + total += item + return total +======= + sum += item + return sum +>>>>>>> REPLACE \`\`\` Usage: @@ -95,208 +126,239 @@ Usage: File path here Your search/replace content here +You can use multi search/replace block in one diff block, but make sure to include the line numbers for each block. +You may not want to use too many search blocks in one diff block, as it may cause to generate incorrect results. -1 -5 ` } async applyDiff( originalContent: string, diffContent: string, - startLine?: number, - endLine?: number, + _paramStartLine?: number, + _paramEndLIne?: number, ): Promise { - // Extract the search and replace blocks - const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/) - if (!match) { + let matches = [ + ...diffContent.matchAll( + /<<<<<<< SEARCH\n> start_line:\s*(\d+)\n> end_line:\s*(\d+)\n------------\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g, + ), + ] + + if (matches.length === 0) { return { success: false, error: `Invalid diff format - missing required SEARCH/REPLACE sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include both SEARCH and REPLACE sections with correct markers`, } } - - let [_, searchContent, replaceContent] = match - + const replacements = matches + .map((match) => ({ + startLine: Number(match[1]), + endLine: Number(match[2]), + searchContent: match[3], + replaceContent: match[4], + })) + .sort((a, b) => a.startLine - b.startLine) // Detect line ending from original content const lineEnding = originalContent.includes("\r\n") ? "\r\n" : "\n" + let resultLines = originalContent.split(/\r?\n/) + let delta = 0 + let diffResults: DiffResult[] = [] + let appliedCount = 0 + + for (let { searchContent, replaceContent, startLine, endLine } of replacements) { + startLine += delta + endLine += delta + + // Strip line numbers from search and replace content if every line starts with a line number + if (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) { + searchContent = stripLineNumbers(searchContent) + replaceContent = stripLineNumbers(replaceContent) + } - // Strip line numbers from search and replace content if every line starts with a line number - if (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) { - searchContent = stripLineNumbers(searchContent) - replaceContent = stripLineNumbers(replaceContent) - } - - // Split content into lines, handling both \n and \r\n - const searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/) - const replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/) - const originalLines = originalContent.split(/\r?\n/) + // Split content into lines, handling both \n and \r\n + const searchLines = searchContent === "" ? [] : searchContent.split(/\r?\n/) + const replaceLines = replaceContent === "" ? [] : replaceContent.split(/\r?\n/) - // Validate that empty search requires start line - if (searchLines.length === 0 && !startLine) { - return { - success: false, - error: `Empty search content requires start_line to be specified\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, specify the line number where content should be inserted`, + // Validate that empty search requires start line + if (searchLines.length === 0 && !startLine) { + diffResults.push({ + success: false, + error: `Empty search content requires start_line to be specified\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, specify the line number where content should be inserted`, + }) + continue } - } - // Validate that empty search requires same start and end line - if (searchLines.length === 0 && startLine && endLine && startLine !== endLine) { - return { - success: false, - error: `Empty search content requires start_line and end_line to be the same (got ${startLine}-${endLine})\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, use the same line number for both start_line and end_line`, + // Validate that empty search requires same start and end line + if (searchLines.length === 0 && startLine && endLine && startLine !== endLine) { + diffResults.push({ + success: false, + error: `Empty search content requires start_line and end_line to be the same (got ${startLine}-${endLine})\n\nDebug Info:\n- Empty search content is only valid for insertions at a specific line\n- For insertions, use the same line number for both start_line and end_line`, + }) + continue } - } - // Initialize search variables - let matchIndex = -1 - let bestMatchScore = 0 - let bestMatchContent = "" - const searchChunk = searchLines.join("\n") - - // Determine search bounds - let searchStartIndex = 0 - let searchEndIndex = originalLines.length - - // Validate and handle line range if provided - if (startLine && endLine) { - // Convert to 0-based index - const exactStartIndex = startLine - 1 - const exactEndIndex = endLine - 1 + // Initialize search variables + let matchIndex = -1 + let bestMatchScore = 0 + let bestMatchContent = "" + const searchChunk = searchLines.join("\n") - if (exactStartIndex < 0 || exactEndIndex > originalLines.length || exactStartIndex > exactEndIndex) { - return { - success: false, - error: `Line range ${startLine}-${endLine} is invalid (file has ${originalLines.length} lines)\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${originalLines.length}`, + // Determine search bounds + let searchStartIndex = 0 + let searchEndIndex = resultLines.length + + // Validate and handle line range if provided + if (startLine && endLine) { + // Convert to 0-based index + const exactStartIndex = startLine - 1 + const exactEndIndex = endLine - 1 + + if (exactStartIndex < 0 || exactEndIndex > resultLines.length || exactStartIndex > exactEndIndex) { + diffResults.push({ + success: false, + error: `Line range ${startLine}-${endLine} is invalid (file has ${resultLines.length} lines)\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${resultLines.length}`, + }) + continue } - } - // Try exact match first - const originalChunk = originalLines.slice(exactStartIndex, exactEndIndex + 1).join("\n") - const similarity = getSimilarity(originalChunk, searchChunk) - if (similarity >= this.fuzzyThreshold) { - matchIndex = exactStartIndex - bestMatchScore = similarity - bestMatchContent = originalChunk - } else { - // Set bounds for buffered search - searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1)) - searchEndIndex = Math.min(originalLines.length, endLine + this.bufferLines) + // Try exact match first + const originalChunk = resultLines.slice(exactStartIndex, exactEndIndex + 1).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity >= this.fuzzyThreshold) { + matchIndex = exactStartIndex + bestMatchScore = similarity + bestMatchContent = originalChunk + } else { + // Set bounds for buffered search + searchStartIndex = Math.max(0, startLine - (this.bufferLines + 1)) + searchEndIndex = Math.min(resultLines.length, endLine + this.bufferLines) + } } - } - // If no match found yet, try middle-out search within bounds - if (matchIndex === -1) { - const midPoint = Math.floor((searchStartIndex + searchEndIndex) / 2) - let leftIndex = midPoint - let rightIndex = midPoint + 1 - - // Search outward from the middle within bounds - while (leftIndex >= searchStartIndex || rightIndex <= searchEndIndex - searchLines.length) { - // Check left side if still in range - if (leftIndex >= searchStartIndex) { - const originalChunk = originalLines.slice(leftIndex, leftIndex + searchLines.length).join("\n") - const similarity = getSimilarity(originalChunk, searchChunk) - if (similarity > bestMatchScore) { - bestMatchScore = similarity - matchIndex = leftIndex - bestMatchContent = originalChunk + // If no match found yet, try middle-out search within bounds + if (matchIndex === -1) { + const midPoint = Math.floor((searchStartIndex + searchEndIndex) / 2) + let leftIndex = midPoint + let rightIndex = midPoint + 1 + + // Search outward from the middle within bounds + while (leftIndex >= searchStartIndex || rightIndex <= searchEndIndex - searchLines.length) { + // Check left side if still in range + if (leftIndex >= searchStartIndex) { + const originalChunk = resultLines.slice(leftIndex, leftIndex + searchLines.length).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity > bestMatchScore) { + bestMatchScore = similarity + matchIndex = leftIndex + bestMatchContent = originalChunk + } + leftIndex-- } - leftIndex-- - } - // Check right side if still in range - if (rightIndex <= searchEndIndex - searchLines.length) { - const originalChunk = originalLines.slice(rightIndex, rightIndex + searchLines.length).join("\n") - const similarity = getSimilarity(originalChunk, searchChunk) - if (similarity > bestMatchScore) { - bestMatchScore = similarity - matchIndex = rightIndex - bestMatchContent = originalChunk + // Check right side if still in range + if (rightIndex <= searchEndIndex - searchLines.length) { + const originalChunk = resultLines.slice(rightIndex, rightIndex + searchLines.length).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity > bestMatchScore) { + bestMatchScore = similarity + matchIndex = rightIndex + bestMatchContent = originalChunk + } + rightIndex++ } - rightIndex++ } } - } - // Require similarity to meet threshold - if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) { - const searchChunk = searchLines.join("\n") - const originalContentSection = - startLine !== undefined && endLine !== undefined - ? `\n\nOriginal Content:\n${addLineNumbers( - originalLines - .slice( - Math.max(0, startLine - 1 - this.bufferLines), - Math.min(originalLines.length, endLine + this.bufferLines), - ) - .join("\n"), - Math.max(1, startLine - this.bufferLines), - )}` - : `\n\nOriginal Content:\n${addLineNumbers(originalLines.join("\n"))}` - - const bestMatchSection = bestMatchContent - ? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}` - : `\n\nBest Match Found:\n(no match)` - - const lineRange = - startLine || endLine - ? ` at ${startLine ? `start: ${startLine}` : "start"} to ${endLine ? `end: ${endLine}` : "end"}` - : "" + // Require similarity to meet threshold + if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) { + const searchChunk = searchLines.join("\n") + const originalContentSection = + startLine !== undefined && endLine !== undefined + ? `\n\nOriginal Content:\n${addLineNumbers( + resultLines + .slice( + Math.max(0, startLine - 1 - this.bufferLines), + Math.min(resultLines.length, endLine + this.bufferLines), + ) + .join("\n"), + Math.max(1, startLine - this.bufferLines), + )}` + : `\n\nOriginal Content:\n${addLineNumbers(resultLines.join("\n"))}` + + const bestMatchSection = bestMatchContent + ? `\n\nBest Match Found:\n${addLineNumbers(bestMatchContent, matchIndex + 1)}` + : `\n\nBest Match Found:\n(no match)` + + const lineRange = + startLine || endLine + ? ` at ${startLine ? `start: ${startLine}` : "start"} to ${endLine ? `end: ${endLine}` : "end"}` + : "" + + diffResults.push({ + success: false, + error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine && endLine ? `lines ${startLine}-${endLine}` : "start to end"}\n- Tip: Use read_file to get the latest content of the file before attempting the diff again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, + }) + continue + } + + // Get the matched lines from the original content + const matchedLines = resultLines.slice(matchIndex, matchIndex + searchLines.length) + + // Get the exact indentation (preserving tabs/spaces) of each line + const originalIndents = matchedLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Get the exact indentation of each line in the search block + const searchIndents = searchLines.map((line) => { + const match = line.match(/^[\t ]*/) + return match ? match[0] : "" + }) + + // Apply the replacement while preserving exact indentation + const indentedReplaceLines = replaceLines.map((line, i) => { + // Get the matched line's exact indentation + const matchedIndent = originalIndents[0] || "" + + // Get the current line's indentation relative to the search content + const currentIndentMatch = line.match(/^[\t ]*/) + const currentIndent = currentIndentMatch ? currentIndentMatch[0] : "" + const searchBaseIndent = searchIndents[0] || "" + + // Calculate the relative indentation level + const searchBaseLevel = searchBaseIndent.length + const currentLevel = currentIndent.length + const relativeLevel = currentLevel - searchBaseLevel + + // If relative level is negative, remove indentation from matched indent + // If positive, add to matched indent + const finalIndent = + relativeLevel < 0 + ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel)) + : matchedIndent + currentIndent.slice(searchBaseLevel) + + return finalIndent + line.trim() + }) + + // Construct the final content + const beforeMatch = resultLines.slice(0, matchIndex) + const afterMatch = resultLines.slice(matchIndex + searchLines.length) + resultLines = [...beforeMatch, ...indentedReplaceLines, ...afterMatch] + delta = delta - matchedLines.length + replaceLines.length + appliedCount++ + } + const finalContent = resultLines.join(lineEnding) + if (appliedCount === 0) { return { success: false, - error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine && endLine ? `lines ${startLine}-${endLine}` : "start to end"}\n- Tip: Use read_file to get the latest content of the file before attempting the diff again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`, + failParts: diffResults, } } - - // Get the matched lines from the original content - const matchedLines = originalLines.slice(matchIndex, matchIndex + searchLines.length) - - // Get the exact indentation (preserving tabs/spaces) of each line - const originalIndents = matchedLines.map((line) => { - const match = line.match(/^[\t ]*/) - return match ? match[0] : "" - }) - - // Get the exact indentation of each line in the search block - const searchIndents = searchLines.map((line) => { - const match = line.match(/^[\t ]*/) - return match ? match[0] : "" - }) - - // Apply the replacement while preserving exact indentation - const indentedReplaceLines = replaceLines.map((line, i) => { - // Get the matched line's exact indentation - const matchedIndent = originalIndents[0] || "" - - // Get the current line's indentation relative to the search content - const currentIndentMatch = line.match(/^[\t ]*/) - const currentIndent = currentIndentMatch ? currentIndentMatch[0] : "" - const searchBaseIndent = searchIndents[0] || "" - - // Calculate the relative indentation level - const searchBaseLevel = searchBaseIndent.length - const currentLevel = currentIndent.length - const relativeLevel = currentLevel - searchBaseLevel - - // If relative level is negative, remove indentation from matched indent - // If positive, add to matched indent - const finalIndent = - relativeLevel < 0 - ? matchedIndent.slice(0, Math.max(0, matchedIndent.length + relativeLevel)) - : matchedIndent + currentIndent.slice(searchBaseLevel) - - return finalIndent + line.trim() - }) - - // Construct the final content - const beforeMatch = originalLines.slice(0, matchIndex) - const afterMatch = originalLines.slice(matchIndex + searchLines.length) - - const finalContent = [...beforeMatch, ...indentedReplaceLines, ...afterMatch].join(lineEnding) return { success: true, content: finalContent, + failParts: diffResults, } } } diff --git a/src/core/diff/types.ts b/src/core/diff/types.ts index 61275deb6b..be6d8cd311 100644 --- a/src/core/diff/types.ts +++ b/src/core/diff/types.ts @@ -3,10 +3,10 @@ */ export type DiffResult = - | { success: true; content: string } - | { + | { success: true; content: string; failParts?: DiffResult[] } + | ({ success: false - error: string + error?: string details?: { similarity?: number threshold?: number @@ -14,7 +14,8 @@ export type DiffResult = searchContent?: string bestMatch?: string } - } + failParts?: DiffResult[] + } & ({ error: string } | { failParts: DiffResult[] })) export interface DiffStrategy { /**