From 8a51a6c0b0aba90f666701c583c8f83b0d613c77 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 | 62 +- src/core/__tests__/Cline.test.ts | 4 +- src/core/diff/DiffStrategy.ts | 9 +- .../__tests__/multi-search-replace.test.ts | 1566 +++++++++++++++++ .../diff/strategies/multi-search-replace.ts | 365 ++++ src/core/diff/types.ts | 9 +- src/core/webview/ClineProvider.ts | 1 + src/shared/__tests__/experiments.test.ts | 3 + src/shared/experiments.ts | 7 + 9 files changed, 2007 insertions(+), 19 deletions(-) create mode 100644 src/core/diff/strategies/__tests__/multi-search-replace.test.ts create mode 100644 src/core/diff/strategies/multi-search-replace.ts diff --git a/src/core/Cline.ts b/src/core/Cline.ts index b9b243dbc13..9a660fab05e 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -163,7 +163,10 @@ export class Cline { this.enableCheckpoints = enableCheckpoints ?? false // Initialize diffStrategy based on current state - this.updateDiffStrategy(Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.DIFF_STRATEGY)) + this.updateDiffStrategy( + Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.DIFF_STRATEGY), + Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.MULTI_SEARCH_AND_REPLACE), + ) if (startTask) { if (task || images) { @@ -193,13 +196,23 @@ export class Cline { } // Add method to update diffStrategy - async updateDiffStrategy(experimentalDiffStrategy?: boolean) { + async updateDiffStrategy(experimentalDiffStrategy?: boolean, multiSearchReplaceDiffStrategy?: boolean) { // If not provided, get from current state - if (experimentalDiffStrategy === undefined) { + if (experimentalDiffStrategy === undefined || multiSearchReplaceDiffStrategy === undefined) { const { experiments: stateExperimental } = (await this.providerRef.deref()?.getState()) ?? {} - experimentalDiffStrategy = stateExperimental?.[EXPERIMENT_IDS.DIFF_STRATEGY] ?? false + if (experimentalDiffStrategy === undefined) { + experimentalDiffStrategy = stateExperimental?.[EXPERIMENT_IDS.DIFF_STRATEGY] ?? false + } + if (multiSearchReplaceDiffStrategy === undefined) { + multiSearchReplaceDiffStrategy = stateExperimental?.[EXPERIMENT_IDS.MULTI_SEARCH_AND_REPLACE] ?? false + } } - this.diffStrategy = getDiffStrategy(this.api.getModel().id, this.fuzzyMatchThreshold, experimentalDiffStrategy) + this.diffStrategy = getDiffStrategy( + this.api.getModel().id, + this.fuzzyMatchThreshold, + experimentalDiffStrategy, + multiSearchReplaceDiffStrategy, + ) } // Storing task to disk for history @@ -1578,17 +1591,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) } @@ -1618,6 +1650,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 = `But unable to apply all diff parts to file: ${absolutePath}, silently use tool to check newest file version and re-apply diffs\n` + } if (userEdits) { await this.say( "user_feedback_diff", @@ -1629,6 +1665,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 || "", @@ -1641,7 +1678,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/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 9910896ebb9..a53f31bf09b 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -374,7 +374,7 @@ describe("Cline", () => { expect(cline.diffEnabled).toBe(true) expect(cline.diffStrategy).toBeDefined() - expect(getDiffStrategySpy).toHaveBeenCalledWith("claude-3-5-sonnet-20241022", 0.9, false) + expect(getDiffStrategySpy).toHaveBeenCalledWith("claude-3-5-sonnet-20241022", 0.9, false, false) getDiffStrategySpy.mockRestore() @@ -395,7 +395,7 @@ describe("Cline", () => { expect(cline.diffEnabled).toBe(true) expect(cline.diffStrategy).toBeDefined() - expect(getDiffStrategySpy).toHaveBeenCalledWith("claude-3-5-sonnet-20241022", 1.0, false) + expect(getDiffStrategySpy).toHaveBeenCalledWith("claude-3-5-sonnet-20241022", 1.0, false, false) getDiffStrategySpy.mockRestore() diff --git a/src/core/diff/DiffStrategy.ts b/src/core/diff/DiffStrategy.ts index de52498557e..e532aec4b06 100644 --- a/src/core/diff/DiffStrategy.ts +++ b/src/core/diff/DiffStrategy.ts @@ -2,6 +2,7 @@ import type { DiffStrategy } from "./types" import { UnifiedDiffStrategy } from "./strategies/unified" import { SearchReplaceDiffStrategy } from "./strategies/search-replace" import { NewUnifiedDiffStrategy } from "./strategies/new-unified" +import { MultiSearchReplaceDiffStrategy } from "./strategies/multi-search-replace" /** * Get the appropriate diff strategy for the given model * @param model The name of the model being used (e.g., 'gpt-4', 'claude-3-opus') @@ -11,11 +12,17 @@ export function getDiffStrategy( model: string, fuzzyMatchThreshold?: number, experimentalDiffStrategy: boolean = false, + multiSearchReplaceDiffStrategy: boolean = false, ): DiffStrategy { if (experimentalDiffStrategy) { return new NewUnifiedDiffStrategy(fuzzyMatchThreshold) } - return new SearchReplaceDiffStrategy(fuzzyMatchThreshold) + + if (multiSearchReplaceDiffStrategy) { + return new MultiSearchReplaceDiffStrategy(fuzzyMatchThreshold) + } else { + return new SearchReplaceDiffStrategy(fuzzyMatchThreshold) + } } export type { DiffStrategy } diff --git a/src/core/diff/strategies/__tests__/multi-search-replace.test.ts b/src/core/diff/strategies/__tests__/multi-search-replace.test.ts new file mode 100644 index 00000000000..8fc16d2303e --- /dev/null +++ b/src/core/diff/strategies/__tests__/multi-search-replace.test.ts @@ -0,0 +1,1566 @@ +import { MultiSearchReplaceDiffStrategy } from "../multi-search-replace" + +describe("MultiSearchReplaceDiffStrategy", () => { + describe("exact matching", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(1.0, 5) // Default 1.0 threshold for exact matching, 5 line buffer for tests + }) + + it("should replace matching content", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function hello() { + console.log("hello") +} +======= +function hello() { + console.log("hello world") +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('function hello() {\n console.log("hello world")\n}\n') + } + }) + + it("should match content with different surrounding whitespace", async () => { + const originalContent = "\nfunction example() {\n return 42;\n}\n\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function example() { + return 42; +} +======= +function example() { + return 43; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\nfunction example() {\n return 43;\n}\n\n") + } + }) + + it("should match content with different indentation in search block", async () => { + const originalContent = " function test() {\n return true;\n }\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { + return true; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" function test() {\n return false;\n }\n") + } + }) + + it("should handle tab-based indentation", async () => { + const originalContent = "function test() {\n\treturn true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { +\treturn true; +} +======= +function test() { +\treturn false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n\treturn false;\n}\n") + } + }) + + it("should preserve mixed tabs and spaces", async () => { + const originalContent = "\tclass Example {\n\t constructor() {\n\t\tthis.value = 0;\n\t }\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +\tclass Example { +\t constructor() { +\t\tthis.value = 0; +\t } +\t} +======= +\tclass Example { +\t constructor() { +\t\tthis.value = 1; +\t } +\t} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "\tclass Example {\n\t constructor() {\n\t\tthis.value = 1;\n\t }\n\t}", + ) + } + }) + + it("should handle additional indentation with tabs", async () => { + const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { +\treturn true; +} +======= +function test() { +\t// Add comment +\treturn false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("\tfunction test() {\n\t\t// Add comment\n\t\treturn false;\n\t}") + } + }) + + it("should preserve exact indentation characters when adding lines", async () => { + const originalContent = "\tfunction test() {\n\t\treturn true;\n\t}" + const diffContent = `test.ts +<<<<<<< SEARCH +\tfunction test() { +\t\treturn true; +\t} +======= +\tfunction test() { +\t\t// First comment +\t\t// Second comment +\t\treturn true; +\t} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "\tfunction test() {\n\t\t// First comment\n\t\t// Second comment\n\t\treturn true;\n\t}", + ) + } + }) + + it("should handle Windows-style CRLF line endings", async () => { + const originalContent = "function test() {\r\n return true;\r\n}\r\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function test() { + return true; +} +======= +function test() { + return false; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\r\n return false;\r\n}\r\n") + } + }) + + it("should return false if search content does not match", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts +<<<<<<< SEARCH +function hello() { + console.log("wrong") +} +======= +function hello() { + console.log("hello world") +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should return false if diff format is invalid", async () => { + const originalContent = 'function hello() {\n console.log("hello")\n}\n' + const diffContent = `test.ts\nInvalid diff format` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should handle multiple lines with proper indentation", async () => { + const originalContent = + "class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n return this.value\n }\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH + getValue() { + return this.value + } +======= + getValue() { + // Add logging + console.log("Getting value") + return this.value + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + 'class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n', + ) + } + }) + + it("should preserve whitespace exactly in the output", async () => { + const originalContent = " indented\n more indented\n back\n" + const diffContent = `test.ts +<<<<<<< SEARCH + indented + more indented + back +======= + modified + still indented + end +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" modified\n still indented\n end\n") + } + }) + + it("should preserve indentation when adding new lines after existing content", async () => { + const originalContent = " onScroll={() => updateHighlights()}" + const diffContent = `test.ts +<<<<<<< SEARCH + onScroll={() => updateHighlights()} +======= + onScroll={() => updateHighlights()} + onDragOver={(e) => { + e.preventDefault() + e.stopPropagation() + }} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + " onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}", + ) + } + }) + + it("should handle varying indentation levels correctly", async () => { + const originalContent = ` +class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim() + + const diffContent = `test.ts +<<<<<<< SEARCH + class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } + } +======= + class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } + } +>>>>>>> REPLACE`.trim() + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + ` +class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim(), + ) + } + }) + + it("should handle mixed indentation styles in the same file", async () => { + const originalContent = `class Example { + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + constructor() { + this.value = 0; + if (true) { + this.init(); + } + } +======= + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + this.value = 1; + if (true) { + this.init(); + this.validate(); + } + } +}`) + } + }) + + it("should handle Python-style significant whitespace", async () => { + const originalContent = `def example(): + if condition: + do_something() + for item in items: + process(item) + return True`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + if condition: + do_something() + for item in items: + process(item) +======= + if condition: + do_something() + while items: + item = items.pop() + process(item) +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`def example(): + if condition: + do_something() + while items: + item = items.pop() + process(item) + return True`) + } + }) + + it("should preserve empty lines with indentation", async () => { + const originalContent = `function test() { + const x = 1; + + if (x) { + return true; + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + const x = 1; + + if (x) { +======= + const x = 1; + + // Check x + if (x) { +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + const x = 1; + + // Check x + if (x) { + return true; + } +}`) + } + }) + + it("should handle indentation when replacing entire blocks", async () => { + const originalContent = `class Test { + method() { + if (true) { + console.log("test"); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + method() { + if (true) { + console.log("test"); + } + } +======= + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Test { + method() { + try { + if (true) { + console.log("test"); + } + } catch (e) { + console.error(e); + } + } +}`) + } + }) + + it("should handle negative indentation relative to search content", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); +======= + this.init(); + this.setup(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + } + } +}`) + } + }) + + it("should handle extreme negative indentation (no indent)", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); +======= +this.init(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { +this.init(); + } + } +}`) + } + }) + + it("should handle mixed indentation changes in replace block", async () => { + const originalContent = `class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH + this.init(); + this.setup(); + this.validate(); +======= + this.init(); + this.setup(); + this.validate(); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + if (true) { + this.init(); + this.setup(); + this.validate(); + } + } +}`) + } + }) + + it("should find matches from middle out", async () => { + const originalContent = ` +function one() { + return "target"; +} + +function two() { + return "target"; +} + +function three() { + return "target"; +} + +function four() { + return "target"; +} + +function five() { + return "target"; +}`.trim() + + const diffContent = `test.ts +<<<<<<< SEARCH + return "target"; +======= + return "updated"; +>>>>>>> REPLACE` + + // Search around the middle (function three) + // Even though all functions contain the target text, + // it should match the one closest to line 9 first + const result = await strategy.applyDiff(originalContent, diffContent, 9, 9) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "target"; +} + +function two() { + return "target"; +} + +function three() { + return "updated"; +} + +function four() { + return "target"; +} + +function five() { + return "target"; +}`) + } + }) + }) + + describe("line number stripping", () => { + describe("line number stripping", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should strip line numbers from both search and replace sections", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { +2 | return false; +3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n return false;\n}\n") + } + }) + + it("should strip line numbers with leading spaces", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH + 1 | function test() { + 2 | return true; + 3 | } +======= + 1 | function test() { + 2 | return false; + 3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n return false;\n}\n") + } + }) + + it("should not strip when not all lines have numbers in either section", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { + return false; +3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should preserve content that naturally starts with pipe", async () => { + const originalContent = "|header|another|\n|---|---|\n|data|more|\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | |header|another| +2 | |---|---| +3 | |data|more| +======= +1 | |header|another| +2 | |---|---| +3 | |data|updated| +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("|header|another|\n|---|---|\n|data|updated|\n") + } + }) + + it("should preserve indentation when stripping line numbers", async () => { + const originalContent = " function test() {\n return true;\n }\n" + const diffContent = `test.ts +<<<<<<< SEARCH +1 | function test() { +2 | return true; +3 | } +======= +1 | function test() { +2 | return false; +3 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(" function test() {\n return false;\n }\n") + } + }) + + it("should handle different line numbers between sections", async () => { + const originalContent = "function test() {\n return true;\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +10 | function test() { +11 | return true; +12 | } +======= +20 | function test() { +21 | return false; +22 | } +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function test() {\n return false;\n}\n") + } + }) + + it("should not strip content that starts with pipe but no line number", async () => { + const originalContent = "| Pipe\n|---|\n| Data\n" + const diffContent = `test.ts +<<<<<<< SEARCH +| Pipe +|---| +| Data +======= +| Pipe +|---| +| Updated +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("| Pipe\n|---|\n| Updated\n") + } + }) + + it("should handle mix of line-numbered and pipe-only content", async () => { + const originalContent = "| Pipe\n|---|\n| Data\n" + const diffContent = `test.ts +<<<<<<< SEARCH +| Pipe +|---| +| Data +======= +1 | | Pipe +2 | |---| +3 | | NewData +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("1 | | Pipe\n2 | |---|\n3 | | NewData\n") + } + }) + }) + }) + + describe("insertion/deletion", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + describe("deletion", () => { + it("should delete code when replace block is empty", async () => { + const originalContent = `function test() { + console.log("hello"); + // Comment to remove + console.log("world"); +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Comment to remove +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + console.log("hello"); + console.log("world"); +}`) + } + }) + + it("should delete multiple lines when replace block is empty", async () => { + const originalContent = `class Example { + constructor() { + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init + } +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Initialize + this.value = 0; + // Set defaults + this.name = ""; + // End init +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`class Example { + constructor() { + } +}`) + } + }) + + it("should preserve indentation when deleting nested code", async () => { + const originalContent = `function outer() { + if (true) { + // Remove this + console.log("test"); + // And this + } + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH + // Remove this + console.log("test"); + // And this +======= +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function outer() { + if (true) { + } + return true; +}`) + } + }) + }) + + describe("insertion", () => { + it("should insert code at specified line when search block is empty", async () => { + const originalContent = `function test() { + const x = 1; + return x; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:2 +:end_line:2 +------- +======= + console.log("Adding log"); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 2, 2) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + console.log("Adding log"); + const x = 1; + return x; +}`) + } + }) + + it("should preserve indentation when inserting at nested location", async () => { + const originalContent = `function test() { + if (true) { + const x = 1; + } +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:3 +:end_line:3 +------- +======= + console.log("Before"); + console.log("After"); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 3, 3) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + if (true) { + console.log("Before"); + console.log("After"); + const x = 1; + } +}`) + } + }) + + it("should handle insertion at start of file", async () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:1 +:end_line:1 +------- +======= +// Copyright 2024 +// License: MIT + +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 1, 1) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`// Copyright 2024 +// License: MIT + +function test() { + return true; +}`) + } + }) + + it("should handle insertion at end of file", async () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:4 +:end_line:4 +------- +======= + +// End of file +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 4, 4) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function test() { + return true; +} + +// End of file`) + } + }) + + it("should error if no start_line is provided for insertion", async () => { + const originalContent = `function test() { + return true; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +======= +console.log("test"); +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + }) + }) + + describe("fuzzy matching", () => { + let strategy: MultiSearchReplaceDiffStrategy + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // 90% similarity threshold, 5 line buffer for tests + }) + + it("should match content with small differences (>90% similar)", async () => { + const originalContent = + "function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function getData() { + const result = fetchData(); + return results.filter(Boolean); +} +======= +function getData() { + const data = fetchData(); + return data.filter(Boolean); +} +>>>>>>> REPLACE` + + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) // Use 5 line buffer for tests + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe( + "function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n", + ) + } + }) + + it("should not match when content is too different (<90% similar)", async () => { + const originalContent = "function processUsers(data) {\n return data.map(user => user.name);\n}\n" + const diffContent = `test.ts +<<<<<<< SEARCH +function handleItems(items) { + return items.map(item => item.username); +} +======= +function processData(data) { + return data.map(d => d.value); +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should match content with extra whitespace", async () => { + const originalContent = "function sum(a, b) {\n return a + b;\n}" + const diffContent = `test.ts +<<<<<<< SEARCH +function sum(a, b) { + return a + b; +} +======= +function sum(a, b) { + return a + b + 1; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe("function sum(a, b) {\n return a + b + 1;\n}") + } + }) + + it("should not exact match empty lines", async () => { + const originalContent = "function sum(a, b) {\n\n return a + b;\n}" + const diffContent = `test.ts +<<<<<<< SEARCH +function sum(a, b) { +======= +import { a } from "a"; +function sum(a, b) { +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe('import { a } from "a";\nfunction sum(a, b) {\n\n return a + b;\n}') + } + }) + }) + + describe("line-constrained search", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy(0.9, 5) + }) + + it("should find and replace within specified line range", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function two() { + return 2; +} +======= +function two() { + return "two"; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return "two"; +} + +function three() { + return 3; +}`) + } + }) + + it("should find and replace within buffer zone (5 lines before/after)", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function three() { + return 3; +} +======= +function three() { + return "three"; +} +>>>>>>> REPLACE` + + // Even though we specify lines 5-7, it should still find the match at lines 9-11 + // because it's within the 5-line buffer zone + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return "three"; +}`) + } + }) + + it("should not find matches outside search range and buffer zone", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} + +function four() { + return 4; +} + +function five() { + return 5; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +:start_line:5 +:end_line:7 +------- +function five() { + return 5; +} +======= +function five() { + return "five"; +} +>>>>>>> REPLACE` + + // Searching around function two() (lines 5-7) + // function five() is more than 5 lines away, so it shouldn't match + const result = await strategy.applyDiff(originalContent, diffContent) + expect(result.success).toBe(false) + }) + + it("should handle search range at start of file", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function one() { + return 1; +} +======= +function one() { + return "one"; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 1, 3) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "one"; +} + +function two() { + return 2; +}`) + } + }) + + it("should handle search range at end of file", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function two() { + return 2; +} +======= +function two() { + return "two"; +} +>>>>>>> REPLACE` + + const result = await strategy.applyDiff(originalContent, diffContent, 5, 7) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return "two"; +}`) + } + }) + + it("should match specific instance of duplicate code using line numbers", async () => { + const originalContent = ` +function processData(data) { + return data.map(x => x * 2); +} + +function unrelatedStuff() { + console.log("hello"); +} + +// Another data processor +function processData(data) { + return data.map(x => x * 2); +} + +function moreStuff() { + console.log("world"); +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function processData(data) { + return data.map(x => x * 2); +} +======= +function processData(data) { + // Add logging + console.log("Processing data..."); + return data.map(x => x * 2); +} +>>>>>>> REPLACE` + + // Target the second instance of processData + const result = await strategy.applyDiff(originalContent, diffContent, 10, 12) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function processData(data) { + return data.map(x => x * 2); +} + +function unrelatedStuff() { + console.log("hello"); +} + +// Another data processor +function processData(data) { + // Add logging + console.log("Processing data..."); + return data.map(x => x * 2); +} + +function moreStuff() { + console.log("world"); +}`) + } + }) + + it("should search from start line to end of file when only start_line is provided", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function three() { + return 3; +} +======= +function three() { + return "three"; +} +>>>>>>> REPLACE` + + // Only provide start_line, should search from there to end of file + const result = await strategy.applyDiff(originalContent, diffContent, 8) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return "three"; +}`) + } + }) + + it("should search from start of file to end line when only end_line is provided", async () => { + const originalContent = ` +function one() { + return 1; +} + +function two() { + return 2; +} + +function three() { + return 3; +} +`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function one() { + return 1; +} +======= +function one() { + return "one"; +} +>>>>>>> REPLACE` + + // Only provide end_line, should search from start of file to there + const result = await strategy.applyDiff(originalContent, diffContent, undefined, 4) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return "one"; +} + +function two() { + return 2; +} + +function three() { + return 3; +}`) + } + }) + + it("should prioritize exact line match over expanded search", async () => { + const originalContent = ` +function one() { + return 1; +} + +function process() { + return "old"; +} + +function process() { + return "old"; +} + +function two() { + return 2; +}` + const diffContent = `test.ts +<<<<<<< SEARCH +function process() { + return "old"; +} +======= +function process() { + return "new"; +} +>>>>>>> REPLACE` + + // Should match the second instance exactly at lines 10-12 + // even though the first instance at 6-8 is within the expanded search range + const result = await strategy.applyDiff(originalContent, diffContent, 10, 12) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(` +function one() { + return 1; +} + +function process() { + return "old"; +} + +function process() { + return "new"; +} + +function two() { + return 2; +}`) + } + }) + + it("should fall back to expanded search only if exact match fails", async () => { + const originalContent = ` +function one() { + return 1; +} + +function process() { + return "target"; +} + +function two() { + return 2; +}`.trim() + const diffContent = `test.ts +<<<<<<< SEARCH +function process() { + return "target"; +} +======= +function process() { + return "updated"; +} +>>>>>>> REPLACE` + + // Specify wrong line numbers (3-5), but content exists at 6-8 + // Should still find and replace it since it's within the expanded range + const result = await strategy.applyDiff(originalContent, diffContent, 3, 5) + expect(result.success).toBe(true) + if (result.success) { + expect(result.content).toBe(`function one() { + return 1; +} + +function process() { + return "updated"; +} + +function two() { + return 2; +}`) + } + }) + }) + + describe("getToolDescription", () => { + let strategy: MultiSearchReplaceDiffStrategy + + beforeEach(() => { + strategy = new MultiSearchReplaceDiffStrategy() + }) + + it("should include the current working directory", async () => { + const cwd = "/test/dir" + const description = await strategy.getToolDescription({ cwd }) + expect(description).toContain(`relative to the current working directory ${cwd}`) + }) + + it("should include required format elements", async () => { + const description = await strategy.getToolDescription({ cwd: "/test" }) + expect(description).toContain("<<<<<<< SEARCH") + expect(description).toContain("=======") + expect(description).toContain(">>>>>>> REPLACE") + expect(description).toContain("") + expect(description).toContain("") + }) + }) +}) diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts new file mode 100644 index 00000000000..99c22a31df9 --- /dev/null +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -0,0 +1,365 @@ +import { DiffStrategy, DiffResult } from "../types" +import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text" +import { distance } from "fastest-levenshtein" + +const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches + +function getSimilarity(original: string, search: string): number { + if (search === "") { + return 1 + } + + // Normalize strings by removing extra whitespace but preserve case + const normalizeStr = (str: string) => str.replace(/\s+/g, " ").trim() + + const normalizedOriginal = normalizeStr(original) + const normalizedSearch = normalizeStr(search) + + if (normalizedOriginal === normalizedSearch) { + return 1 + } + + // Calculate Levenshtein distance using fastest-levenshtein's distance function + const dist = distance(normalizedOriginal, normalizedSearch) + + // Calculate similarity ratio (0 to 1, where 1 is an exact match) + const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length) + return 1 - dist / maxLength +} + +export class MultiSearchReplaceDiffStrategy implements DiffStrategy { + private fuzzyThreshold: number + private bufferLines: number + + constructor(fuzzyThreshold?: number, bufferLines?: number) { + // Use provided threshold or default to exact matching (1.0) + // Note: fuzzyThreshold is inverted in UI (0% = 1.0, 10% = 0.9) + // so we use it directly here + this.fuzzyThreshold = fuzzyThreshold ?? 1.0 + this.bufferLines = bufferLines ?? BUFFER_LINES + } + + getToolDescription(args: { cwd: string; toolOptions?: { [key: string]: string } }): string { + return `## apply_diff +Description: Request to replace existing code using a search and replace block. +This tool allows for precise, surgical replaces to files by specifying exactly what content to search for and what to replace it with. +The tool will maintain proper indentation and formatting while making changes. +Only a single operation is allowed per tool use. +The SEARCH section must exactly match existing content including whitespace and indentation. +If you're not confident in the exact content to search for, use the read_file tool first to get the exact content. +When applying the diffs, be extra careful to remember to change any closing brackets or other syntax that may be affected by the diff farther down in the file. +ALWAYS make as many changes in a single 'apply_diff' request as possible using multiple SEARCH/REPLACE blocks + +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. + +Diff format: +\`\`\` +<<<<<<< SEARCH +:start_line: (required) The line number of original content where the search block starts. +:end_line: (required) The line number of original content where the search block ends. +------- +[exact content to find including whitespace] +======= +[new content to replace with] +>>>>>>> REPLACE + +\`\`\` + +Example: + +Original file: +\`\`\` +1 | def calculate_total(items): +2 | total = 0 +3 | for item in items: +4 | total += item +5 | return total +\`\`\` + +Search/Replace content: +\`\`\` +<<<<<<< SEARCH +:start_line:1 +:end_line:5 +------- +def calculate_total(items): + total = 0 + for item in items: + total += item + return total +======= +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: + +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. +Only use a single line of '=======' between search and replacement content, because multiple '=======' will corrupt the file. + +` + } + + async applyDiff( + originalContent: string, + diffContent: string, + _paramStartLine?: number, + _paramEndLine?: number, + ): Promise { + let matches = [ + ...diffContent.matchAll( + /<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}(-------\n){0,1}([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g, + ), + ] + + if (matches.length === 0) { + return { + success: false, + error: `Invalid diff format - missing required sections\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n:start_line: start line\\n:end_line: end line\\n-------\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include start_line/end_line/SEARCH/REPLACE sections with correct markers`, + } + } + // 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 + const replacements = matches + .map((match) => ({ + startLine: Number(match[2] ?? 0), + endLine: Number(match[4] ?? resultLines.length), + searchContent: match[6], + replaceContent: match[7], + })) + .sort((a, b) => a.startLine - b.startLine) + + for (let { searchContent, replaceContent, startLine, endLine } of replacements) { + startLine += startLine === 0 ? 0 : 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) + } + + // 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) { + 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) { + 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 = 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 = 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 = resultLines.slice(leftIndex, leftIndex + searchLines.length).join("\n") + const similarity = getSimilarity(originalChunk, searchChunk) + if (similarity > bestMatchScore) { + bestMatchScore = similarity + matchIndex = leftIndex + bestMatchContent = originalChunk + } + leftIndex-- + } + + // 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++ + } + } + } + + // 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, + failParts: diffResults, + } + } + return { + success: true, + content: finalContent, + failParts: diffResults, + } + } +} diff --git a/src/core/diff/types.ts b/src/core/diff/types.ts index 61275deb6be..be6d8cd3110 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 { /** diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0a2e385b6ab..f8b6b0ce1d9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1482,6 +1482,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (message.values[EXPERIMENT_IDS.DIFF_STRATEGY] !== undefined && this.cline) { await this.cline.updateDiffStrategy( Experiments.isEnabled(updatedExperiments, EXPERIMENT_IDS.DIFF_STRATEGY), + Experiments.isEnabled(updatedExperiments, EXPERIMENT_IDS.MULTI_SEARCH_AND_REPLACE), ) } diff --git a/src/shared/__tests__/experiments.test.ts b/src/shared/__tests__/experiments.test.ts index c5b999a1a33..07566c8e9cc 100644 --- a/src/shared/__tests__/experiments.test.ts +++ b/src/shared/__tests__/experiments.test.ts @@ -20,6 +20,7 @@ describe("experiments", () => { experimentalDiffStrategy: false, search_and_replace: false, insert_content: false, + multi_search_and_replace: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) @@ -30,6 +31,7 @@ describe("experiments", () => { experimentalDiffStrategy: false, search_and_replace: false, insert_content: false, + multi_search_and_replace: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(true) }) @@ -40,6 +42,7 @@ describe("experiments", () => { search_and_replace: false, insert_content: false, powerSteering: false, + multi_search_and_replace: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.POWER_STEERING)).toBe(false) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index 2f946283c0d..dd8ff84a8ce 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -3,6 +3,7 @@ export const EXPERIMENT_IDS = { SEARCH_AND_REPLACE: "search_and_replace", INSERT_BLOCK: "insert_content", POWER_STEERING: "powerSteering", + MULTI_SEARCH_AND_REPLACE: "multi_search_and_replace", } as const export type ExperimentKey = keyof typeof EXPERIMENT_IDS @@ -42,6 +43,12 @@ export const experimentConfigsMap: Record = { "When enabled, Roo will remind the model about the details of its current mode definition more frequently. This will lead to stronger adherence to role definitions and custom instructions, but will use more tokens per message.", enabled: false, }, + MULTI_SEARCH_AND_REPLACE: { + name: "Use experimental multi block diff tool", + description: + "When enabled, Roo will use multi block diff tool. This will try to update multiple code blocks in the file in one request.", + enabled: false, + }, } export const experimentDefault = Object.fromEntries(