This commit is contained in:
Christian Gunderman
2026-02-25 11:20:03 -08:00
parent 2406cdd984
commit 16543ed5ed
2 changed files with 123 additions and 27 deletions
+61
View File
@@ -373,6 +373,67 @@ describe('EditTool', () => {
expect(result.occurrences).toBe(1);
});
it('should correctly calculate matchRanges for exact multi-line replacements', async () => {
const content = 'line1\nmatch\nline\nline3\nmatch\nline\nline5';
const oldString = 'match\nline';
const result = await calculateReplacement(mockConfig, {
params: {
file_path: 'test.ts',
old_string: oldString,
new_string: 'replacement',
allow_multiple: true,
},
currentContent: content,
abortSignal,
});
expect(result.occurrences).toBe(2);
expect(result.matchRanges).toEqual([
{ start: 2, end: 3 },
{ start: 5, end: 6 },
]);
});
it('should correctly calculate matchRanges for flexible multi-line replacements', async () => {
const content =
' line1\n match\n line\n line3\n match\n line\n line5';
const oldString = 'match\nline';
const result = await calculateReplacement(mockConfig, {
params: {
file_path: 'test.ts',
old_string: oldString,
new_string: 'replacement',
allow_multiple: true,
},
currentContent: content,
abortSignal,
});
expect(result.occurrences).toBe(2);
expect(result.strategy).toBe('flexible');
expect(result.matchRanges).toEqual([
{ start: 2, end: 3 },
{ start: 5, end: 6 },
]);
});
it('should correctly calculate matchRanges for regex replacements', async () => {
const content = ' function foo() {\n return 1;\n }';
const oldString = 'function foo() {';
const result = await calculateReplacement(mockConfig, {
params: {
file_path: 'test.js',
old_string: oldString,
new_string: 'function bar() {',
},
currentContent: content,
abortSignal,
});
expect(result.strategy).toBe('regex');
expect(result.matchRanges).toEqual([{ start: 1, end: 1 }]);
});
it('should perform a fuzzy replacement when exact match fails but similarity is high', async () => {
const content =
'const myConfig = {\n enableFeature: true,\n retries: 3\n};';
+62 -27
View File
@@ -138,18 +138,28 @@ async function calculateExactReplacement(
const normalizedReplace = new_string.replace(/\r\n/g, '\n');
const matchRanges: Array<{ start: number; end: number }> = [];
const searchNewlines = (normalizedSearch.match(/\n/g) || []).length;
let currentLine = 1;
let lastIndex = 0;
let index = normalizedCode.indexOf(normalizedSearch);
while (index !== -1) {
const startLine = normalizedCode
.substring(0, index)
.split(String.fromCharCode(10)).length;
const endLine =
startLine + normalizedSearch.split(String.fromCharCode(10)).length - 1;
// Count newlines from the last match to the current match.
const prefix = normalizedCode.substring(lastIndex, index);
for (let i = 0; i < prefix.length; i++) {
if (prefix[i] === '\n') {
currentLine++;
}
}
const startLine = currentLine;
const endLine = startLine + searchNewlines;
matchRanges.push({ start: startLine, end: endLine });
index = normalizedCode.indexOf(
normalizedSearch,
index + normalizedSearch.length,
);
// Move currentLine past the newlines in the current match.
currentLine += searchNewlines;
lastIndex = index + normalizedSearch.length;
index = normalizedCode.indexOf(normalizedSearch, lastIndex);
}
const exactOccurrences = matchRanges.length;
@@ -197,11 +207,13 @@ async function calculateFlexibleReplacement(
const sourceLines = normalizedCode.match(/.*(?:\n|$)/g)?.slice(0, -1) ?? [];
const searchLinesStripped = normalizedSearch
.split(String.fromCharCode(10))
.split('\n')
.map((line: string) => line.trim());
const replaceLines = normalizedReplace.split(String.fromCharCode(10));
const replaceLines = normalizedReplace.split('\n');
let flexibleOccurrences = 0;
const matchRanges: Array<{ start: number; end: number }> = [];
let lineOffset = 0;
let i = 0;
while (i <= sourceLines.length - searchLinesStripped.length) {
const window = sourceLines.slice(i, i + searchLinesStripped.length);
@@ -212,6 +224,10 @@ async function calculateFlexibleReplacement(
if (isMatch) {
flexibleOccurrences++;
matchRanges.push({
start: i + 1 - lineOffset,
end: i + searchLinesStripped.length - lineOffset,
});
const firstLineInMatch = window[0];
const indentationMatch = firstLineInMatch.match(/^([ \t]*)/);
const indentation = indentationMatch ? indentationMatch[1] : '';
@@ -221,6 +237,7 @@ async function calculateFlexibleReplacement(
searchLinesStripped.length,
newBlockWithIndent.join('\n'),
);
lineOffset += replaceLines.length - searchLinesStripped.length;
i += replaceLines.length;
} else {
i++;
@@ -235,6 +252,8 @@ async function calculateFlexibleReplacement(
occurrences: flexibleOccurrences,
finalOldString: normalizedSearch,
finalNewString: normalizedReplace,
strategy: 'flexible',
matchRanges,
};
}
@@ -257,13 +276,11 @@ async function calculateRegexReplacement(
let processedString = normalizedSearch;
for (const delim of delimiters) {
processedString = processedString
.split(String.fromCharCode(10))
.join(` ${delim} `);
processedString = processedString.split(delim).join(` ${delim} `);
}
// Split by any whitespace and remove empty strings.
const tokens = processedString.split(String.fromCharCode(10)).filter(Boolean);
const tokens = processedString.split(/\s+/).filter(Boolean);
if (tokens.length === 0) {
return null;
@@ -279,14 +296,32 @@ async function calculateRegexReplacement(
// Always use a global regex to count all potential occurrences for accurate validation.
const globalRegex = new RegExp(finalPattern, 'gm');
const matches = currentContent.match(globalRegex);
const matchRanges: Array<{ start: number; end: number }> = [];
let match;
let currentLine = 1;
let lastIndex = 0;
while ((match = globalRegex.exec(currentContent)) !== null) {
const prefix = currentContent.substring(lastIndex, match.index);
for (let i = 0; i < prefix.length; i++) {
if (prefix[i] === '\n') {
currentLine++;
}
}
const startLine = currentLine;
const matchContent = match[0];
const matchNewlines = (matchContent.match(/\n/g) || []).length;
matchRanges.push({ start: startLine, end: startLine + matchNewlines });
if (!matches) {
currentLine += matchNewlines;
lastIndex = match.index + match[0].length;
}
if (matchRanges.length === 0) {
return null;
}
const occurrences = matches.length;
const newLines = normalizedReplace.split(String.fromCharCode(10));
const occurrences = matchRanges.length;
const newLines = normalizedReplace.split('\n');
// Use the appropriate regex for replacement based on allow_multiple.
const replaceRegex = new RegExp(
@@ -305,6 +340,8 @@ async function calculateRegexReplacement(
occurrences,
finalOldString: normalizedSearch,
finalNewString: normalizedReplace,
strategy: 'regex',
matchRanges,
};
}
@@ -786,13 +823,11 @@ class EditToolInvocation
}
const oldStringSnippet =
this.params.old_string
.split(String.fromCharCode(10))[0]
.substring(0, 30) + (this.params.old_string.length > 30 ? '...' : '');
this.params.old_string.split('\n')[0].substring(0, 30) +
(this.params.old_string.length > 30 ? '...' : '');
const newStringSnippet =
this.params.new_string
.split(String.fromCharCode(10))[0]
.substring(0, 30) + (this.params.new_string.length > 30 ? '...' : '');
this.params.new_string.split('\n')[0].substring(0, 30) +
(this.params.new_string.length > 30 ? '...' : '');
if (this.params.old_string === this.params.new_string) {
return `No file changes to ${shortenPath(relativePath)}`;
@@ -900,7 +935,7 @@ class EditToolInvocation
};
}
const totalLength = finalContent.split(String.fromCharCode(10)).length;
const totalLength = finalContent.split('\n').length;
const metadataParts = [];
if (editData.matchRanges && editData.matchRanges.length > 0) {
if (editData.matchRanges.length === 1) {
@@ -1250,7 +1285,7 @@ async function calculateFuzzyReplacement(
// so that indices remain valid
selectedMatches.sort((a, b) => b.index - a.index);
const newLines = normalizedReplace.split(String.fromCharCode(10));
const newLines = normalizedReplace.split('\n');
for (const match of selectedMatches) {
// If we want to preserve the indentation of the first line of the match: