diff --git a/core/edit/searchAndReplace/executeFindAndReplace.vitest.ts b/core/edit/searchAndReplace/executeFindAndReplace.vitest.ts index 9ae1857b6ae..de9f62cc079 100644 --- a/core/edit/searchAndReplace/executeFindAndReplace.vitest.ts +++ b/core/edit/searchAndReplace/executeFindAndReplace.vitest.ts @@ -207,6 +207,84 @@ describe("executeFindAndReplace", () => { }); }); + describe("indentation adjustment", () => { + it("should adjust indentation when trimmedMatch finds unindented old_string in indented file", () => { + const fileContent = + "def foo():\n x = 1\n y = 2\n return x + y\n"; + // LLM sends unindented old_string that matches via trimmedMatch + const oldString = "x = 1\ny = 2"; + const newString = "x = 10\ny = 20"; + const result = executeFindAndReplace( + fileContent, + oldString, + newString, + false, + ); + expect(result).toBe( + "def foo():\n x = 10\n y = 20\n return x + y\n", + ); + }); + + it("should preserve relative inner indentation in multi-line replacement", () => { + const fileContent = + "class Foo:\n def bar(self):\n if True:\n x = 1\n"; + const oldString = "if True:\n x = 1"; + const newString = "if True:\n x = 1\n y = 2"; + const result = executeFindAndReplace( + fileContent, + oldString, + newString, + false, + ); + expect(result).toBe( + "class Foo:\n def bar(self):\n if True:\n x = 1\n y = 2\n", + ); + }); + + it("should handle tab vs space mismatch", () => { + const fileContent = "function foo() {\n\tconst x = 1;\n}\n"; + // LLM sends with leading spaces (not tabs), triggering trimmedMatch + const oldString = " const x = 1;"; + const newString = " const x = 2;\n const y = 3;"; + const result = executeFindAndReplace( + fileContent, + oldString, + newString, + false, + ); + expect(result).toBe( + "function foo() {\n\tconst x = 2;\n\tconst y = 3;\n}\n", + ); + }); + + it("should not adjust indentation for exactMatch", () => { + const fileContent = " x = 1\n"; + const oldString = " x = 1"; + const newString = " x = 2"; + const result = executeFindAndReplace( + fileContent, + oldString, + newString, + false, + ); + expect(result).toBe(" x = 2\n"); + }); + + it("should handle whitespaceIgnoredMatch with different internal whitespace", () => { + const fileContent = "def outer():\n if True:\n return 42\n"; + // Different internal whitespace triggers whitespaceIgnoredMatch + const oldString = "if True:\n return 42"; + const newString = "if True:\n return 99"; + const result = executeFindAndReplace( + fileContent, + oldString, + newString, + false, + ); + expect(result).toBe("def outer():\n if True:\n return 99\n"); + }); + }); + describe("edge cases", () => { it("should handle single character replacement", () => { const content = "a b c"; diff --git a/core/edit/searchAndReplace/performReplace.ts b/core/edit/searchAndReplace/performReplace.ts index 65453e06cd6..6da5c3c5096 100644 --- a/core/edit/searchAndReplace/performReplace.ts +++ b/core/edit/searchAndReplace/performReplace.ts @@ -1,6 +1,86 @@ import { EditOperation } from "../../tools/definitions/multiEdit"; import { ContinueError, ContinueErrorReason } from "../../util/errors"; -import { findSearchMatches } from "./findSearchMatch"; +import { SearchMatchResult, findSearchMatches } from "./findSearchMatch"; + +/** + * Get the leading whitespace of the first non-empty line in a string. + */ +function getLeadingIndent(text: string): string { + const lines = text.split("\n"); + for (const line of lines) { + if (line.trim().length > 0) { + const match = line.match(/^(\s*)/); + return match ? match[1] : ""; + } + } + return ""; +} + +/** + * Get the indentation of the file line containing a given character position. + */ +function getLineIndentAtPosition( + fileContent: string, + position: number, +): string { + const lineStart = fileContent.lastIndexOf("\n", position - 1) + 1; + const lineEnd = fileContent.indexOf("\n", lineStart); + const line = fileContent.substring( + lineStart, + lineEnd === -1 ? fileContent.length : lineEnd, + ); + const match = line.match(/^(\s*)/); + return match ? match[1] : ""; +} + +/** + * When a fuzzy match strategy (trimmedMatch, whitespaceIgnoredMatch, etc.) + * finds a match, the indentation of the matched region in the file may + * differ from the indentation in the search string provided by the LLM. + * This function adjusts newString so its indentation is relative to the + * actual matched text in the file rather than the LLM-provided oldString. + */ +function adjustReplacementIndentation( + fileContent: string, + match: SearchMatchResult, + oldString: string, + newString: string, +): string { + if ( + match.strategyName === "exactMatch" || + match.strategyName === "emptySearch" + ) { + return newString; + } + + const matchedIndent = getLineIndentAtPosition(fileContent, match.startIndex); + const oldIndent = getLeadingIndent(oldString); + + if (matchedIndent === oldIndent) { + return newString; + } + + const lines = newString.split("\n"); + const adjusted = lines.map((line, index) => { + if (line.trim().length === 0) { + return line; + } + if (index === 0) { + // First line: the file content before startIndex already provides + // indentation, so strip the old indent rather than adding new + if (oldIndent && line.startsWith(oldIndent)) { + return line.slice(oldIndent.length); + } + return line; + } + // Subsequent lines: replace oldIndent prefix with matchedIndent + if (line.startsWith(oldIndent)) { + return matchedIndent + line.slice(oldIndent.length); + } + return line; + }); + return adjusted.join("\n"); +} export function executeFindAndReplace( fileContent: string, @@ -23,9 +103,15 @@ export function executeFindAndReplace( let result = fileContent; for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i]; + const adjustedNew = adjustReplacementIndentation( + result, + match, + oldString, + newString, + ); result = result.substring(0, match.startIndex) + - newString + + adjustedNew + result.substring(match.endIndex); } return result; @@ -40,9 +126,15 @@ export function executeFindAndReplace( // Apply single replacement const match = matches[0]; + const adjustedNew = adjustReplacementIndentation( + fileContent, + match, + oldString, + newString, + ); return ( fileContent.substring(0, match.startIndex) + - newString + + adjustedNew + fileContent.substring(match.endIndex) ); }