|
|
|
|
@@ -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);
|
|
|
|
|
|