From 3d257cd4d45b5451244fefec12f46fce2204fd07 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:16:23 -0700 Subject: [PATCH 1/2] fix: preserve indentation when applying code edits via fuzzy matching When fallback matching strategies (trimmedMatch, whitespaceIgnoredMatch) find a match with different indentation than the LLM-provided old_string, the replacement new_string now has its indentation adjusted to match the actual indentation level in the file. This fixes incorrect indentation in Python code edits where the LLM provides unindented search/replace strings that match indented code blocks. Fixes #11282 Co-Authored-By: Claude Opus 4.6 --- core/edit/searchAndReplace/performReplace.ts | 79 +++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/core/edit/searchAndReplace/performReplace.ts b/core/edit/searchAndReplace/performReplace.ts index 65453e06cd6..845e8fc00f1 100644 --- a/core/edit/searchAndReplace/performReplace.ts +++ b/core/edit/searchAndReplace/performReplace.ts @@ -1,6 +1,67 @@ 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 ""; +} + +/** + * 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 matchedText = fileContent.substring(match.startIndex, match.endIndex); + const matchedIndent = getLeadingIndent(matchedText); + 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 (line.startsWith(oldIndent)) { + return matchedIndent + line.slice(oldIndent.length); + } + // For lines that don't start with the old indent (e.g. first line + // might have no indent if old_string was trimmed), apply the + // matched indent directly + if (index === 0 && oldIndent === "" && matchedIndent !== "") { + return matchedIndent + line; + } + return line; + }); + return adjusted.join("\n"); +} export function executeFindAndReplace( fileContent: string, @@ -23,9 +84,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 +107,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) ); } From 1cb68a58922ac7030eb9c5d208d2cd7f8fd8dafe Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Thu, 12 Mar 2026 20:54:48 -0700 Subject: [PATCH 2/2] fix(core): adjust indentation when fallback match strategies splice replacements Co-Authored-By: Claude Opus 4.6 --- .../executeFindAndReplace.vitest.ts | 78 +++++++++++++++++++ core/edit/searchAndReplace/performReplace.ts | 35 +++++++-- 2 files changed, 105 insertions(+), 8 deletions(-) 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 845e8fc00f1..6da5c3c5096 100644 --- a/core/edit/searchAndReplace/performReplace.ts +++ b/core/edit/searchAndReplace/performReplace.ts @@ -16,6 +16,23 @@ function getLeadingIndent(text: string): string { 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 @@ -36,8 +53,7 @@ function adjustReplacementIndentation( return newString; } - const matchedText = fileContent.substring(match.startIndex, match.endIndex); - const matchedIndent = getLeadingIndent(matchedText); + const matchedIndent = getLineIndentAtPosition(fileContent, match.startIndex); const oldIndent = getLeadingIndent(oldString); if (matchedIndent === oldIndent) { @@ -49,15 +65,18 @@ function adjustReplacementIndentation( 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); } - // For lines that don't start with the old indent (e.g. first line - // might have no indent if old_string was trimmed), apply the - // matched indent directly - if (index === 0 && oldIndent === "" && matchedIndent !== "") { - return matchedIndent + line; - } return line; }); return adjusted.join("\n");