Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions core/edit/searchAndReplace/executeFindAndReplace.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
98 changes: 95 additions & 3 deletions core/edit/searchAndReplace/performReplace.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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)
);
}
Expand Down
Loading