fix(coding-agent): preserve untouched lines in fuzzy edit

closes #5899
This commit is contained in:
Armin Ronacher
2026-06-19 20:16:05 +02:00
parent 1287b69fe0
commit 128330e36f
3 changed files with 184 additions and 25 deletions

View File

@@ -53,6 +53,124 @@ export function normalizeForFuzzyMatch(text: string): string {
);
}
function splitLinesWithEndings(content: string): string[] {
return content.match(/[^\n]*\n|[^\n]+/g) ?? [];
}
interface LineSpan {
start: number;
end: number;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
type TextReplacement = Pick<MatchedEdit, "matchIndex" | "matchLength" | "newText">;
function getLineSpans(content: string): LineSpan[] {
let offset = 0;
return splitLinesWithEndings(content).map((line) => {
const span = { start: offset, end: offset + line.length };
offset = span.end;
return span;
});
}
function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) {
const replacementStart = replacement.matchIndex;
const replacementEnd = replacement.matchIndex + replacement.matchLength;
let startLine = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (replacementStart >= line.start && replacementStart < line.end) {
startLine = i;
break;
}
}
if (startLine === -1) {
throw new Error("Replacement range is outside the base content.");
}
let endLine = startLine;
while (endLine < lines.length && lines[endLine].end < replacementEnd) {
endLine++;
}
if (endLine >= lines.length) {
throw new Error("Replacement range is outside the base content.");
}
return { startLine, endLine: endLine + 1 };
}
function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string {
let result = content;
for (let i = replacements.length - 1; i >= 0; i--) {
const replacement = replacements[i];
const matchIndex = replacement.matchIndex - offset;
result =
result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength);
}
return result;
}
/**
* Apply replacements matched against `baseContent` to `originalContent` while
* preserving unchanged line blocks from the original.
*
* This is useful when `baseContent` is a normalized view of the original. Each
* replacement is widened to the lines it actually touches, those touched lines
* are rewritten from the normalized base, and all other lines are copied back
* from `originalContent`. The actual replacement ranges drive preservation so
* duplicate normalized lines cannot be aligned to the wrong occurrence.
*/
export function applyReplacementsPreservingUnchangedLines(
originalContent: string,
baseContent: string,
replacements: TextReplacement[],
): string {
const originalLines = splitLinesWithEndings(originalContent);
const baseLines = getLineSpans(baseContent);
if (originalLines.length !== baseLines.length) {
throw new Error("Cannot preserve unchanged lines because the base content has a different line count.");
}
const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = [];
const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex);
for (const replacement of sortedReplacements) {
const range = getReplacementLineRange(baseLines, replacement);
const current = groups[groups.length - 1];
if (current && range.startLine < current.endLine) {
current.endLine = Math.max(current.endLine, range.endLine);
current.replacements.push(replacement);
continue;
}
groups.push({ ...range, replacements: [replacement] });
}
let originalLineIndex = 0;
let result = "";
for (const group of groups) {
result += originalLines.slice(originalLineIndex, group.startLine).join("");
const groupStartOffset = baseLines[group.startLine].start;
const groupEndOffset = baseLines[group.endLine - 1].end;
result += applyReplacements(
baseContent.slice(groupStartOffset, groupEndOffset),
group.replacements,
groupStartOffset,
);
originalLineIndex = group.endLine;
}
result += originalLines.slice(originalLineIndex).join("");
return result;
}
export interface FuzzyMatchResult {
/** Whether a match was found */
found: boolean;
@@ -74,13 +192,6 @@ export interface Edit {
newText: string;
}
interface MatchedEdit {
editIndex: number;
matchIndex: number;
matchLength: number;
newText: string;
}
export interface AppliedEditsResult {
baseContent: string;
newContent: string;
@@ -120,9 +231,9 @@ export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResul
};
}
// When fuzzy matching, we work in the normalized space for replacement.
// This means the output will have normalized whitespace/quotes/dashes,
// which is acceptable since we're fixing minor formatting differences anyway.
// When fuzzy matching, return offsets in normalized space. Callers can use
// the normalized content to compute replacements, then decide how much of
// that normalized output should be written back.
return {
found: true,
index: fuzzyIndex,
@@ -186,8 +297,9 @@ function getNoChangeError(path: string, totalEdits: number): Error {
*
* All edits are matched against the same original content. Replacements are
* then applied in reverse order so offsets remain stable. If any edit needs
* fuzzy matching, the operation runs in fuzzy-normalized content space to
* preserve current single-edit behavior.
* fuzzy matching, the operation runs in fuzzy-normalized content space and then
* overlays those line-level changes onto the original content so unchanged line
* blocks keep their original bytes.
*/
export function applyEditsToNormalizedContent(
normalizedContent: string,
@@ -206,19 +318,18 @@ export function applyEditsToNormalizedContent(
}
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
const baseContent = initialMatches.some((match) => match.usedFuzzyMatch)
? normalizeForFuzzyMatch(normalizedContent)
: normalizedContent;
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
const matchedEdits: MatchedEdit[] = [];
for (let i = 0; i < normalizedEdits.length; i++) {
const edit = normalizedEdits[i];
const matchResult = fuzzyFindText(baseContent, edit.oldText);
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
if (!matchResult.found) {
throw getNotFoundError(path, i, normalizedEdits.length);
}
const occurrences = countOccurrences(baseContent, edit.oldText);
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
if (occurrences > 1) {
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
}
@@ -242,14 +353,10 @@ export function applyEditsToNormalizedContent(
}
}
let newContent = baseContent;
for (let i = matchedEdits.length - 1; i >= 0; i--) {
const edit = matchedEdits[i];
newContent =
newContent.substring(0, edit.matchIndex) +
edit.newText +
newContent.substring(edit.matchIndex + edit.matchLength);
}
const baseContent = normalizedContent;
const newContent = usedFuzzyMatch
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
: applyReplacements(replacementBaseContent, matchedEdits);
if (baseContent === newContent) {
throw getNoChangeError(path, normalizedEdits.length);