@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed fuzzy `edit` matches to preserve untouched line blocks instead of rewriting the whole file through normalized content ([#5899](https://github.com/earendil-works/pi/issues/5899)).
|
||||||
- Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
|
- Fixed bash commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
|
||||||
- Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)).
|
- Fixed `/model` to hide GitHub Copilot models that are unavailable to the authenticated account ([#5897](https://github.com/earendil-works/pi/issues/5897)).
|
||||||
- Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)).
|
- Fixed `/model` selector search to rank exact provider-prefixed matches before proxy-provider model ID matches ([#5892](https://github.com/earendil-works/pi/issues/5892)).
|
||||||
|
|||||||
@@ -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 {
|
export interface FuzzyMatchResult {
|
||||||
/** Whether a match was found */
|
/** Whether a match was found */
|
||||||
found: boolean;
|
found: boolean;
|
||||||
@@ -74,13 +192,6 @@ export interface Edit {
|
|||||||
newText: string;
|
newText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MatchedEdit {
|
|
||||||
editIndex: number;
|
|
||||||
matchIndex: number;
|
|
||||||
matchLength: number;
|
|
||||||
newText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppliedEditsResult {
|
export interface AppliedEditsResult {
|
||||||
baseContent: string;
|
baseContent: string;
|
||||||
newContent: 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.
|
// When fuzzy matching, return offsets in normalized space. Callers can use
|
||||||
// This means the output will have normalized whitespace/quotes/dashes,
|
// the normalized content to compute replacements, then decide how much of
|
||||||
// which is acceptable since we're fixing minor formatting differences anyway.
|
// that normalized output should be written back.
|
||||||
return {
|
return {
|
||||||
found: true,
|
found: true,
|
||||||
index: fuzzyIndex,
|
index: fuzzyIndex,
|
||||||
@@ -186,8 +297,9 @@ function getNoChangeError(path: string, totalEdits: number): Error {
|
|||||||
*
|
*
|
||||||
* All edits are matched against the same original content. Replacements are
|
* All edits are matched against the same original content. Replacements are
|
||||||
* then applied in reverse order so offsets remain stable. If any edit needs
|
* then applied in reverse order so offsets remain stable. If any edit needs
|
||||||
* fuzzy matching, the operation runs in fuzzy-normalized content space to
|
* fuzzy matching, the operation runs in fuzzy-normalized content space and then
|
||||||
* preserve current single-edit behavior.
|
* overlays those line-level changes onto the original content so unchanged line
|
||||||
|
* blocks keep their original bytes.
|
||||||
*/
|
*/
|
||||||
export function applyEditsToNormalizedContent(
|
export function applyEditsToNormalizedContent(
|
||||||
normalizedContent: string,
|
normalizedContent: string,
|
||||||
@@ -206,19 +318,18 @@ export function applyEditsToNormalizedContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
|
const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText));
|
||||||
const baseContent = initialMatches.some((match) => match.usedFuzzyMatch)
|
const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch);
|
||||||
? normalizeForFuzzyMatch(normalizedContent)
|
const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent;
|
||||||
: normalizedContent;
|
|
||||||
|
|
||||||
const matchedEdits: MatchedEdit[] = [];
|
const matchedEdits: MatchedEdit[] = [];
|
||||||
for (let i = 0; i < normalizedEdits.length; i++) {
|
for (let i = 0; i < normalizedEdits.length; i++) {
|
||||||
const edit = normalizedEdits[i];
|
const edit = normalizedEdits[i];
|
||||||
const matchResult = fuzzyFindText(baseContent, edit.oldText);
|
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
|
||||||
if (!matchResult.found) {
|
if (!matchResult.found) {
|
||||||
throw getNotFoundError(path, i, normalizedEdits.length);
|
throw getNotFoundError(path, i, normalizedEdits.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
const occurrences = countOccurrences(baseContent, edit.oldText);
|
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
|
||||||
if (occurrences > 1) {
|
if (occurrences > 1) {
|
||||||
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
|
throw getDuplicateError(path, i, normalizedEdits.length, occurrences);
|
||||||
}
|
}
|
||||||
@@ -242,14 +353,10 @@ export function applyEditsToNormalizedContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let newContent = baseContent;
|
const baseContent = normalizedContent;
|
||||||
for (let i = matchedEdits.length - 1; i >= 0; i--) {
|
const newContent = usedFuzzyMatch
|
||||||
const edit = matchedEdits[i];
|
? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits)
|
||||||
newContent =
|
: applyReplacements(replacementBaseContent, matchedEdits);
|
||||||
newContent.substring(0, edit.matchIndex) +
|
|
||||||
edit.newText +
|
|
||||||
newContent.substring(edit.matchIndex + edit.matchLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseContent === newContent) {
|
if (baseContent === newContent) {
|
||||||
throw getNoChangeError(path, normalizedEdits.length);
|
throw getNoChangeError(path, normalizedEdits.length);
|
||||||
|
|||||||
@@ -1031,6 +1031,57 @@ describe("edit tool fuzzy matching", () => {
|
|||||||
|
|
||||||
expect(readFileSync(testFile, "utf-8")).toBe("console.log('world');\nhello universe\n");
|
expect(readFileSync(testFile, "utf-8")).toBe("console.log('world');\nhello universe\n");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should preserve the correct occurrence when fuzzy replacement equals a nearby line", async () => {
|
||||||
|
const testFile = join(testDir, "fuzzy-preserve-duplicate-line.txt");
|
||||||
|
const originalContent = ["replace me\u0020\u0020\u0020", "after\u0020\u0020\u0020", ""].join("\n");
|
||||||
|
writeFileSync(testFile, originalContent);
|
||||||
|
|
||||||
|
const result = await editTool.execute("test-fuzzy-preserve-duplicate-line", {
|
||||||
|
path: testFile,
|
||||||
|
edits: [{ oldText: "replace me\n", newText: "after\n" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedContent = ["after", "after\u0020\u0020\u0020", ""].join("\n");
|
||||||
|
expect(readFileSync(testFile, "utf-8")).toBe(expectedContent);
|
||||||
|
expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve untouched lines and produce an applicable patch for fuzzy multi-edits", async () => {
|
||||||
|
const testFile = join(testDir, "fuzzy-preserve-multi.txt");
|
||||||
|
const originalContent = [
|
||||||
|
"keep before\u0020\u0020",
|
||||||
|
"first target\u0020\u0020",
|
||||||
|
"first after",
|
||||||
|
"keep middle\u0020\u0020\u0020",
|
||||||
|
"second target\u0020\u0020",
|
||||||
|
"second after",
|
||||||
|
"keep after\u0020\u0020",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
writeFileSync(testFile, originalContent);
|
||||||
|
|
||||||
|
const result = await editTool.execute("test-fuzzy-preserve-multi", {
|
||||||
|
path: testFile,
|
||||||
|
edits: [
|
||||||
|
{ oldText: "first target\nfirst after", newText: "FIRST\nFIRST2" },
|
||||||
|
{ oldText: "second target\nsecond after", newText: "SECOND\nSECOND2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const expectedContent = [
|
||||||
|
"keep before\u0020\u0020",
|
||||||
|
"FIRST",
|
||||||
|
"FIRST2",
|
||||||
|
"keep middle\u0020\u0020\u0020",
|
||||||
|
"SECOND",
|
||||||
|
"SECOND2",
|
||||||
|
"keep after\u0020\u0020",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
expect(readFileSync(testFile, "utf-8")).toBe(expectedContent);
|
||||||
|
expect(applyPatch(originalContent, result.details?.patch ?? "")).toBe(expectedContent);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("edit tool CRLF handling", () => {
|
describe("edit tool CRLF handling", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user