Files
oh-my-pi/packages/coding-agent/src/patch/diff.ts
T
can1357 6c42ad43b7 feat(coding-agent): no warning necessary for move
- Modified no-op detection logic to allow move-only operations with unchanged content.
2026-02-26 11:04:42 +01:00

426 lines
14 KiB
TypeScript

/**
* Diff generation and replace-mode utilities for the edit tool.
*
* Provides diff string generation and the replace-mode edit logic
* used when not in patch mode.
*/
import * as Diff from "diff";
import { resolveToCwd } from "../tools/path-utils";
import { previewPatch } from "./applicator";
import { DEFAULT_FUZZY_THRESHOLD, findMatch } from "./fuzzy";
import type { HashlineEdit } from "./hashline";
import { applyHashlineEdits } from "./hashline";
import { adjustIndentation, normalizeToLF, stripBom } from "./normalize";
import type { DiffError, DiffResult, PatchInput } from "./types";
import { EditMatchError } from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// Diff String Generation
// ═══════════════════════════════════════════════════════════════════════════
function countContentLines(content: string): number {
const lines = content.split("\n");
if (lines.length > 1 && lines[lines.length - 1] === "") {
lines.pop();
}
return Math.max(1, lines.length);
}
function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, width: number, content: string): string {
const padded = String(lineNum).padStart(width, " ");
return `${prefix}${padded}|${content}`;
}
/**
* Generate a unified diff string with line numbers and context.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateDiffString(oldContent: string, newContent: string, contextLines = 4): DiffResult {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
const lineNumWidth = String(maxLineNum).length;
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const raw = part.value.split("\n");
if (raw[raw.length - 1] === "") {
raw.pop();
}
if (part.added || part.removed) {
// Capture the first changed line (in the new file)
if (firstChangedLine === undefined) {
firstChangedLine = newLineNum;
}
// Show the change
for (const line of raw) {
if (part.added) {
output.push(formatNumberedDiffLine("+", newLineNum, lineNumWidth, line));
newLineNum++;
} else {
output.push(formatNumberedDiffLine("-", oldLineNum, lineNumWidth, line));
oldLineNum++;
}
}
lastWasChange = true;
} else {
// Context lines - only show a few before/after changes
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
if (lastWasChange || nextPartIsChange) {
let linesToShow = raw;
let skipStart = 0;
let skipEnd = 0;
if (!lastWasChange) {
// Show only last N lines as leading context
skipStart = Math.max(0, raw.length - contextLines);
linesToShow = raw.slice(skipStart);
}
if (!nextPartIsChange && linesToShow.length > contextLines) {
// Show only first N lines as trailing context
skipEnd = linesToShow.length - contextLines;
linesToShow = linesToShow.slice(0, contextLines);
}
// Add ellipsis if we skipped lines at start
if (skipStart > 0) {
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
oldLineNum += skipStart;
newLineNum += skipStart;
}
for (const line of linesToShow) {
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, line));
oldLineNum++;
newLineNum++;
}
// Add ellipsis if we skipped lines at end
if (skipEnd > 0) {
output.push(formatNumberedDiffLine(" ", oldLineNum, lineNumWidth, "..."));
oldLineNum += skipEnd;
newLineNum += skipEnd;
}
} else {
// Skip these context lines entirely
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
}
return { diff: output.join("\n"), firstChangedLine };
}
// ═══════════════════════════════════════════════════════════════════════════
// Replace Mode Logic
// ═══════════════════════════════════════════════════════════════════════════
export interface ReplaceOptions {
/** Allow fuzzy matching */
fuzzy: boolean;
/** Replace all occurrences */
all: boolean;
/** Similarity threshold for fuzzy matching */
threshold?: number;
}
export interface ReplaceResult {
/** The new content after replacements */
content: string;
/** Number of replacements made */
count: number;
}
/**
* Generate a unified diff string without file headers.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateUnifiedDiffString(oldContent: string, newContent: string, contextLines = 3): DiffResult {
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: contextLines });
const output: string[] = [];
let firstChangedLine: number | undefined;
const maxLineNum = Math.max(countContentLines(oldContent), countContentLines(newContent));
const lineNumWidth = String(maxLineNum).length;
for (const hunk of patch.hunks) {
output.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
let oldLine = hunk.oldStart;
let newLine = hunk.newStart;
for (const line of hunk.lines) {
if (line.startsWith("-")) {
if (firstChangedLine === undefined) firstChangedLine = newLine;
output.push(formatNumberedDiffLine("-", oldLine, lineNumWidth, line.slice(1)));
oldLine++;
continue;
}
if (line.startsWith("+")) {
if (firstChangedLine === undefined) firstChangedLine = newLine;
output.push(formatNumberedDiffLine("+", newLine, lineNumWidth, line.slice(1)));
newLine++;
continue;
}
if (line.startsWith(" ")) {
output.push(formatNumberedDiffLine(" ", oldLine, lineNumWidth, line.slice(1)));
oldLine++;
newLine++;
continue;
}
output.push(line);
}
}
return { diff: output.join("\n"), firstChangedLine };
}
/**
* Find and replace text in content using fuzzy matching.
*/
export function replaceText(content: string, oldText: string, newText: string, options: ReplaceOptions): ReplaceResult {
if (oldText.length === 0) {
throw new Error("oldText must not be empty.");
}
const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
let normalizedContent = normalizeToLF(content);
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
let count = 0;
if (options.all) {
// Check for exact matches first
const exactCount = normalizedContent.split(normalizedOldText).length - 1;
if (exactCount > 0) {
return {
content: normalizedContent.split(normalizedOldText).join(normalizedNewText),
count: exactCount,
};
}
// No exact matches - try fuzzy matching iteratively
while (true) {
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
allowFuzzy: options.fuzzy,
threshold,
});
const shouldUseClosest =
options.fuzzy &&
matchOutcome.closest &&
matchOutcome.closest.confidence >= threshold &&
(matchOutcome.fuzzyMatches === undefined || matchOutcome.fuzzyMatches <= 1);
const match = matchOutcome.match || (shouldUseClosest ? matchOutcome.closest : undefined);
if (!match) {
break;
}
const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
if (adjustedNewText === match.actualText) {
break;
}
normalizedContent =
normalizedContent.substring(0, match.startIndex) +
adjustedNewText +
normalizedContent.substring(match.startIndex + match.actualText.length);
count++;
}
return { content: normalizedContent, count };
}
// Single replacement mode
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
allowFuzzy: options.fuzzy,
threshold,
});
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
throw new Error(
`Found ${matchOutcome.occurrences} occurrences${moreMsg}:\n\n${previews}\n\n` +
`Add more context lines to disambiguate.`,
);
}
if (!matchOutcome.match) {
return { content: normalizedContent, count: 0 };
}
const match = matchOutcome.match;
const adjustedNewText = adjustIndentation(normalizedOldText, match.actualText, normalizedNewText);
normalizedContent =
normalizedContent.substring(0, match.startIndex) +
adjustedNewText +
normalizedContent.substring(match.startIndex + match.actualText.length);
return { content: normalizedContent, count: 1 };
}
// ═══════════════════════════════════════════════════════════════════════════
// Preview/Diff Computation
// ═══════════════════════════════════════════════════════════════════════════
/**
* Compute the diff for an edit operation without applying it.
* Used for preview rendering in the TUI before the tool executes.
*/
export async function computeEditDiff(
path: string,
oldText: string,
newText: string,
cwd: string,
fuzzy = true,
all = false,
threshold?: number,
): Promise<DiffResult | DiffError> {
if (oldText.length === 0) {
return { error: "oldText must not be empty." };
}
const absolutePath = resolveToCwd(path, cwd);
try {
const file = Bun.file(absolutePath);
try {
if (!(await file.exists())) {
return { error: `File not found: ${path}` };
}
} catch {
return { error: `File not found: ${path}` };
}
let rawContent: string;
try {
rawContent = await file.text();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { error: message || `Unable to read ${path}` };
}
const { text: content } = stripBom(rawContent);
const normalizedContent = normalizeToLF(content);
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, {
fuzzy,
all,
threshold,
});
if (result.count === 0) {
// Get closest match for error message
const matchOutcome = findMatch(normalizedContent, normalizedOldText, {
allowFuzzy: fuzzy,
threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
});
if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? "";
const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : "";
return {
error: `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`,
};
}
return {
error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
allowFuzzy: fuzzy,
threshold: threshold ?? DEFAULT_FUZZY_THRESHOLD,
fuzzyMatches: matchOutcome.fuzzyMatches,
}),
};
}
if (normalizedContent === result.content) {
return {
error: `No changes would be made to ${path}. The replacement produces identical content.`,
};
}
return generateDiffString(normalizedContent, result.content);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Compute the diff for a patch operation without applying it.
* Used for preview rendering in the TUI before patch-mode edits execute.
*/
export async function computePatchDiff(
input: PatchInput,
cwd: string,
options?: { fuzzyThreshold?: number; allowFuzzy?: boolean },
): Promise<DiffResult | DiffError> {
try {
const result = await previewPatch(input, {
cwd,
fuzzyThreshold: options?.fuzzyThreshold,
allowFuzzy: options?.allowFuzzy,
});
const oldContent = result.change.oldContent ?? "";
const newContent = result.change.newContent ?? "";
const normalizedOld = normalizeToLF(stripBom(oldContent).text);
const normalizedNew = normalizeToLF(stripBom(newContent).text);
if (!normalizedOld && !normalizedNew) {
return { diff: "", firstChangedLine: undefined };
}
return generateUnifiedDiffString(normalizedOld, normalizedNew);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Compute the diff for a hashline operation without applying it.
* Used for preview rendering in the TUI before hashline-mode edits execute.
*/
export async function computeHashlineDiff(
input: { path: string; edits: HashlineEdit[]; move?: string },
cwd: string,
): Promise<DiffResult | DiffError> {
const { path, edits, move } = input;
const absolutePath = resolveToCwd(path, cwd);
try {
const file = Bun.file(absolutePath);
try {
if (!(await file.exists())) {
return { error: `File not found: ${path}` };
}
} catch {
return { error: `File not found: ${path}` };
}
let rawContent: string;
try {
rawContent = await file.text();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { error: message || `Unable to read ${path}` };
}
const { text: content } = stripBom(rawContent);
const normalizedContent = normalizeToLF(content);
const result = applyHashlineEdits(normalizedContent, edits);
if (normalizedContent === result.lines && !move) {
return { error: `No changes would be made to ${path}. The edits produce identical content.` };
}
return generateDiffString(normalizedContent, result.lines);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}