/** * 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 { diffLines, structuredPatchHunks } from "@oh-my-pi/pi-natives"; import { resolveToCwd } from "../tools/path-utils"; import { type BlockContextSource, findBlockContextLines } from "../utils/block-context"; import { DEFAULT_FUZZY_THRESHOLD, EditMatchError, findMatch } from "./modes/replace"; import { adjustIndentation, normalizeToLF, stripBom } from "./normalize"; import { readEditFileText } from "./read-file"; export interface DiffResult { diff: string; firstChangedLine: number | undefined; } export interface DiffError { error: string; } export interface DiffHunk { changeContext?: string; oldStartLine?: number; newStartLine?: number; hasContextLines: boolean; oldLines: string[]; newLines: string[]; isEndOfFile: boolean; } export class ParseError extends Error { constructor( message: string, readonly lineNumber?: number, ) { super(lineNumber !== undefined ? `Line ${lineNumber}: ${message}` : message); this.name = "ParseError"; } } export class ApplyPatchError extends Error { constructor(message: string) { super(message); this.name = "ApplyPatchError"; } } // ═══════════════════════════════════════════════════════════════════════════ // Diff String Generation // ═══════════════════════════════════════════════════════════════════════════ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, content: string): string { return `${prefix}${lineNum}|${content}`; } interface ParsedNumberedDiffRow { prefix: "+" | "-" | " "; lineNumber: number; content: string; } function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined { const match = /^([+\- ])(\d+)\|(.*)$/s.exec(row); if (!match) return undefined; const prefix = match[1] as "+" | "-" | " "; const lineNumber = Number.parseInt(match[2], 10); if (!Number.isFinite(lineNumber)) return undefined; return { prefix, lineNumber, content: match[3] ?? "" }; } function isDiffChangeRow(row: string | undefined): boolean { return row !== undefined && (row.startsWith("+") || row.startsWith("-")); } /** Blank row separating non-contiguous regions of a numbered diff. */ const DIFF_GAP_ROW = ""; /** Old-file line number of a source-visible row (`-` or context); `+`/gap/other rows yield undefined. */ function parseSourceRowLineNumber(row: string): number | undefined { const parsed = parseNumberedDiffRow(row); return parsed === undefined || parsed.prefix === "+" ? undefined : parsed.lineNumber; } /** * Drop gap rows that no longer separate anything. Context rows are inserted * one at a time, each adding its own gap rows from a snapshot of the diff, so * the raw result can contain adjacent gap rows, gap rows whose neighbors * became contiguous after a later insert filled the hole, and gap rows at the * diff edges. The sweep keeps a gap row only when it sits between two * source-numbered rows (old-file coordinates — the same numbering the * insertion gap test uses) that are actually non-contiguous, and never keeps * two in a row. */ function normalizeDiffGapRows(rows: string[]): void { const kept: string[] = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; if (row !== DIFF_GAP_ROW) { kept.push(row); continue; } if (kept.length === 0 || kept[kept.length - 1] === DIFF_GAP_ROW) continue; let before: number | undefined; for (let j = kept.length - 1; j >= 0 && before === undefined; j--) { before = parseSourceRowLineNumber(kept[j]); } let after: number | undefined; for (let j = i + 1; j < rows.length && after === undefined; j++) { if (rows[j] === DIFF_GAP_ROW) continue; after = parseSourceRowLineNumber(rows[j]); } if (before === undefined || after === undefined || after <= before + 1) continue; kept.push(row); } if (kept.length !== rows.length) rows.splice(0, rows.length, ...kept); } function adjustedContextInsertIndex(rows: readonly string[], index: number): number { let start = index; while (start > 0 && isDiffChangeRow(rows[start - 1])) start--; let end = index; while (end < rows.length && isDiffChangeRow(rows[end])) end++; return index > start && index < end ? end : index; } function insertBracketContextRows( rows: string[], contextLines: ReadonlyMap, seenRows: Set, ): void { const context = [...contextLines].sort(([left], [right]) => left - right); for (const [lineNumber, text] of context) { const row = formatNumberedDiffLine(" ", lineNumber, text); if (seenRows.has(row)) continue; let insertIndex = rows.length; let previousSourceLine: number | undefined; let nextSourceLine: number | undefined; for (let i = 0; i < rows.length; i++) { const parsed = parseNumberedDiffRow(rows[i]); if (!parsed || parsed.prefix === "+") continue; if (parsed.lineNumber < lineNumber) { previousSourceLine = parsed.lineNumber; continue; } nextSourceLine = parsed.lineNumber; insertIndex = i; break; } const chunk: string[] = []; if (previousSourceLine !== undefined && lineNumber > previousSourceLine + 1) chunk.push(DIFF_GAP_ROW); chunk.push(row); if (nextSourceLine !== undefined && nextSourceLine > lineNumber + 1) chunk.push(DIFF_GAP_ROW); const adjustedIndex = adjustedContextInsertIndex(rows, insertIndex); rows.splice(adjustedIndex, 0, ...chunk); seenRows.add(row); } } /** * Insert off-window block-boundary rows (enclosing header, matching closing * bracket, …) into a numbered diff. Context rows carry pre-edit line numbers — * the renumbering contract of `buildCompactDiffPreview` — so boundary lines * discovered in the new file are translated back to their pre-edit numbers * and merged with the old-file pass before a single insertion sweep. Without * the translation, a context line sitting in a net-offset region would be * re-inserted under its post-edit number: duplicated, out of order, and * renumbered incorrectly by the preview. */ function addMatchingBracketContextRows( rows: string[], oldLines: readonly string[], newLines: readonly string[], source: BlockContextSource, ): void { const oldVisible: number[] = []; const newVisible: number[] = []; const seenRows = new Set(rows); // Change positions in new-file coordinates, used to translate an unchanged // new-file line number back to its pre-edit equivalent. const changes: { newPos: number; delta: 1 | -1 }[] = []; let offset = 0; for (const row of rows) { const parsed = parseNumberedDiffRow(row); if (!parsed) continue; switch (parsed.prefix) { case "-": oldVisible.push(parsed.lineNumber); changes.push({ newPos: parsed.lineNumber + offset, delta: -1 }); offset--; break; case "+": newVisible.push(parsed.lineNumber); changes.push({ newPos: parsed.lineNumber, delta: 1 }); offset++; break; default: // Context rows are visible in BOTH files: pre-edit number as // written, post-edit number shifted by the net change so far. oldVisible.push(parsed.lineNumber); newVisible.push(parsed.lineNumber + offset); break; } } const toOldLineNumber = (newLineNumber: number): number => { let shift = 0; for (const change of changes) { if (change.newPos <= newLineNumber) shift += change.delta; } return newLineNumber - shift; }; const contextRows = findBlockContextLines(oldLines, oldVisible, source); for (const [lineNumber, text] of findBlockContextLines(newLines, newVisible, source)) { const oldLineNumber = toOldLineNumber(lineNumber); if (!contextRows.has(oldLineNumber)) contextRows.set(oldLineNumber, text); } insertBracketContextRows(rows, contextRows, seenRows); normalizeDiffGapRows(rows); } /** * 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 = 2, source: BlockContextSource = {}, ): DiffResult { const parts = diffLines(oldContent, newContent); const output: string[] = []; 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, line)); newLineNum++; } else { output.push(formatNumberedDiffLine("-", oldLineNum, 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) { const contextLimit = Math.max(0, contextLines); let leadingSkip = 0; let middleSkip = 0; let trailingSkip = 0; let linesToShow: string[]; if (lastWasChange && nextPartIsChange) { if (raw.length > contextLimit * 2) { const leadingContext = raw.slice(0, contextLimit); const trailingContext = raw.slice(raw.length - contextLimit); middleSkip = raw.length - leadingContext.length - trailingContext.length; linesToShow = [...leadingContext, ...trailingContext]; } else { linesToShow = raw; } } else if (nextPartIsChange) { leadingSkip = Math.max(0, raw.length - contextLimit); linesToShow = raw.slice(leadingSkip); } else { trailingSkip = Math.max(0, raw.length - contextLimit); linesToShow = raw.slice(0, contextLimit); } // Leading-skip placeholder is omitted: the first emitted line's // number already conveys that earlier lines were trimmed. if (leadingSkip > 0) { oldLineNum += leadingSkip; newLineNum += leadingSkip; } const firstChunkLength = middleSkip > 0 ? contextLimit : linesToShow.length; for (const line of linesToShow.slice(0, firstChunkLength)) { output.push(formatNumberedDiffLine(" ", oldLineNum, line)); oldLineNum++; newLineNum++; } // Mid-skip placeholder is omitted too: the jump between the trailing // number of the leading context and the leading number of the // trailing context conveys the gap, just like leading/trailing skips. if (middleSkip > 0) { oldLineNum += middleSkip; newLineNum += middleSkip; for (const line of linesToShow.slice(firstChunkLength)) { output.push(formatNumberedDiffLine(" ", oldLineNum, line)); oldLineNum++; newLineNum++; } } // Trailing-skip placeholder is omitted for the same reason: the // final emitted line's number tells the reader the file continues. if (trailingSkip > 0) { oldLineNum += trailingSkip; newLineNum += trailingSkip; } } else { // Skip these context lines entirely oldLineNum += raw.length; newLineNum += raw.length; } lastWasChange = false; } } addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source); 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, source: BlockContextSource = {}, ): DiffResult { const hunks = structuredPatchHunks(oldContent, newContent, contextLines); const output: string[] = []; let firstChangedLine: number | undefined; for (const hunk of 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, line.slice(1))); oldLine++; continue; } if (line.startsWith("+")) { if (firstChangedLine === undefined) firstChangedLine = newLine; output.push(formatNumberedDiffLine("+", newLine, line.slice(1))); newLine++; continue; } if (line.startsWith(" ")) { output.push(formatNumberedDiffLine(" ", oldLine, line.slice(1))); oldLine++; newLine++; continue; } output.push(line); } } addMatchingBracketContextRows(output, oldContent.split("\n"), newContent.split("\n"), source); return { diff: output.join("\n"), firstChangedLine }; } const EOF_MARKER = "*** End of File"; const CHANGE_CONTEXT_MARKER = "@@ "; const EMPTY_CHANGE_CONTEXT_MARKER = "@@"; const UNIFIED_HUNK_HEADER_REGEX = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@(?:\s*(.*))?$/; const LINE_HINT_REGEX = /^lines?\s+(\d+)(?:\s*-\s*(\d+))?(?:\s*@@)?$/i; const TOP_OF_FILE_REGEX = /^(top|start|beginning)\s+of\s+file$/i; const MULTI_FILE_MARKERS = ["*** Update File:", "*** Add File:", "*** Delete File:", "diff --git "]; const DIFF_METADATA_PREFIXES = [ "*** Update File:", "*** Add File:", "*** Delete File:", "diff --git ", "index ", "--- ", "+++ ", "new file mode ", "deleted file mode ", "rename from ", "rename to ", "similarity index ", "dissimilarity index ", "old mode ", "new mode ", ]; const PATCH_WRAPPER_PREFIXES = ["*** Begin Patch", "*** End Patch"]; const MAX_OCCURRENCE_PREVIEWS = 5; function isDiffContentLine(line: string): boolean { const firstChar = line[0]; if (firstChar === " ") return true; if (firstChar === "+") { return !line.startsWith("+++ "); } if (firstChar === "-") { return !line.startsWith("--- "); } return false; } function matchesTrimmedPrefix(line: string, prefixes: string[]): boolean { return prefixes.some(prefix => line.startsWith(prefix)); } function isPatchWrapperLine(line: string): boolean { return line === "***" || matchesTrimmedPrefix(line, PATCH_WRAPPER_PREFIXES); } function formatOccurrenceMatchError( occurrences: number, occurrencePreviews: string[] | undefined, path?: string, ): string { const previews = occurrencePreviews?.join("\n\n") ?? ""; const moreMsg = occurrences > MAX_OCCURRENCE_PREVIEWS ? ` (showing first ${MAX_OCCURRENCE_PREVIEWS} of ${occurrences})` : ""; const pathSuffix = path ? ` in ${path}` : ""; return `Found ${occurrences} occurrences${pathSuffix}${moreMsg}:\n\n${previews}\n\nAdd more context lines to disambiguate.`; } export function normalizeDiff(diff: string): string { let lines = diff.split("\n"); while (lines.length > 0) { const lastLine = lines[lines.length - 1]; if (lastLine === "" || (lastLine?.trim() === "" && !isDiffContentLine(lastLine ?? ""))) { lines = lines.slice(0, -1); } else { break; } } if (lines[0] && isPatchWrapperLine(lines[0].trim())) { lines = lines.slice(1); } if (lines.length > 0 && isPatchWrapperLine(lines[lines.length - 1]?.trim() ?? "")) { lines = lines.slice(0, -1); } lines = lines.filter(line => { if (isDiffContentLine(line)) { return true; } return !matchesTrimmedPrefix(line.trim(), DIFF_METADATA_PREFIXES); }); return lines.join("\n"); } export function normalizeCreateContent(content: string): string { const lines = content.split("\n"); const nonEmptyLines = lines.filter(line => line.length > 0); if (nonEmptyLines.length > 0 && nonEmptyLines.every(line => line.startsWith("+ ") || line.startsWith("+"))) { return lines .map(line => { if (line.startsWith("+ ")) return line.slice(2); if (line.startsWith("+")) return line.slice(1); return line; }) .join("\n"); } return content; } interface UnifiedHunkHeader { oldStartLine: number; oldLineCount: number; newStartLine: number; newLineCount: number; changeContext?: string; } function parseUnifiedHunkHeader(line: string): UnifiedHunkHeader | undefined { const match = line.match(UNIFIED_HUNK_HEADER_REGEX); if (!match) return undefined; const oldStartLine = Number(match[1]); const oldLineCount = match[2] ? Number(match[2]) : 1; const newStartLine = Number(match[3]); const newLineCount = match[4] ? Number(match[4]) : 1; const changeContext = match[5]?.trim(); return { oldStartLine, oldLineCount, newStartLine, newLineCount, changeContext: changeContext && changeContext.length > 0 ? changeContext : undefined, }; } function isUnifiedDiffMetadataLine(line: string): boolean { return matchesTrimmedPrefix( line, DIFF_METADATA_PREFIXES.filter(prefix => !prefix.startsWith("*** ")), ); } interface ParseHunkResult { hunk: DiffHunk; linesConsumed: number; } function parseOneHunk(lines: string[], lineNumber: number, allowMissingContext: boolean): ParseHunkResult { if (lines.length === 0) { throw new ParseError("Diff does not contain any lines", lineNumber); } const changeContexts: string[] = []; let oldStartLine: number | undefined; let newStartLine: number | undefined; let startIndex: number; const headerLine = lines[0]; const headerTrimmed = headerLine.trimEnd(); const isHeaderLine = headerLine.startsWith("@@"); const unifiedHeader = isHeaderLine ? parseUnifiedHunkHeader(headerTrimmed) : undefined; const isEmptyContextMarker = /^@@\s*@@$/.test(headerTrimmed); if (isHeaderLine && (headerTrimmed === EMPTY_CHANGE_CONTEXT_MARKER || isEmptyContextMarker)) { startIndex = 1; } else if (unifiedHeader) { if (unifiedHeader.oldStartLine < 1 || unifiedHeader.newStartLine < 1) { throw new ParseError("Line numbers in @@ header must be >= 1", lineNumber); } if (unifiedHeader.changeContext) { changeContexts.push(unifiedHeader.changeContext); } oldStartLine = unifiedHeader.oldStartLine; newStartLine = unifiedHeader.newStartLine; startIndex = 1; } else if (isHeaderLine && headerTrimmed.startsWith(CHANGE_CONTEXT_MARKER)) { const contextValue = headerTrimmed.slice(CHANGE_CONTEXT_MARKER.length); const trimmedContextValue = contextValue.trim(); const normalizedContextValue = trimmedContextValue.replace(/^@@\s*/u, ""); const lineHintMatch = normalizedContextValue.match(LINE_HINT_REGEX); if (lineHintMatch) { oldStartLine = Number(lineHintMatch[1]); newStartLine = oldStartLine; if (oldStartLine < 1) { throw new ParseError("Line hint must be >= 1", lineNumber); } } else if (TOP_OF_FILE_REGEX.test(normalizedContextValue)) { oldStartLine = 1; newStartLine = 1; } else if (trimmedContextValue.length > 0) { changeContexts.push(contextValue); } startIndex = 1; } else if (isHeaderLine) { const contextValue = headerTrimmed.slice(2).trim(); if (contextValue.length > 0) { changeContexts.push(contextValue); } startIndex = 1; } else { if (!allowMissingContext) { throw new ParseError(`Expected hunk to start with @@ context marker, got: '${lines[0]}'`, lineNumber); } startIndex = 0; } if (oldStartLine !== undefined && oldStartLine < 1) { throw new ParseError(`Line numbers must be >= 1 (got ${oldStartLine})`, lineNumber); } if (newStartLine !== undefined && newStartLine < 1) { throw new ParseError(`Line numbers must be >= 1 (got ${newStartLine})`, lineNumber); } while (startIndex < lines.length) { const nextLine = lines[startIndex]; if (!nextLine.startsWith("@@")) { break; } const trimmed = nextLine.trimEnd(); if (trimmed.startsWith(CHANGE_CONTEXT_MARKER)) { const nestedContext = trimmed.slice(CHANGE_CONTEXT_MARKER.length); if (nestedContext.trim().length > 0) { changeContexts.push(nestedContext); } startIndex++; } else if (trimmed === EMPTY_CHANGE_CONTEXT_MARKER) { startIndex++; } else { break; } } if (startIndex >= lines.length) { throw new ParseError("Hunk does not contain any lines", lineNumber + 1); } const changeContext = changeContexts.length > 0 ? changeContexts.join("\n") : undefined; const hunk: DiffHunk = { changeContext, oldStartLine, newStartLine, hasContextLines: false, oldLines: [], newLines: [], isEndOfFile: false, }; let parsedLines = 0; for (let i = startIndex; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); const nextLine = lines[i + 1]; if (line === "" && parsedLines > 0 && nextLine?.trimStart().startsWith("@@")) { break; } if (!isDiffContentLine(line) && line.trimEnd() === EOF_MARKER && line.startsWith(EOF_MARKER)) { if (parsedLines === 0) { throw new ParseError("Hunk does not contain any lines", lineNumber + 1); } hunk.isEndOfFile = true; parsedLines++; break; } if (trimmed === "..." || trimmed === "…") { hunk.hasContextLines = true; parsedLines++; continue; } const firstChar = line[0]; if (firstChar === undefined || firstChar === "") { hunk.hasContextLines = true; hunk.oldLines.push(""); hunk.newLines.push(""); } else if (firstChar === " ") { hunk.hasContextLines = true; hunk.oldLines.push(line.slice(1)); hunk.newLines.push(line.slice(1)); } else if (firstChar === "+") { hunk.newLines.push(line.slice(1)); } else if (firstChar === "-") { hunk.oldLines.push(line.slice(1)); } else if (!line.startsWith("@@")) { hunk.hasContextLines = true; hunk.oldLines.push(line); hunk.newLines.push(line); } else { if (parsedLines === 0) { throw new ParseError( `Unexpected line in hunk: '${line}'. Lines must start with ' ' (context), '+' (add), or '-' (remove)`, lineNumber + 1, ); } break; } parsedLines++; } if (parsedLines === 0) { throw new ParseError("Hunk does not contain any lines", lineNumber + startIndex); } stripLineNumberPrefixes(hunk); return { hunk, linesConsumed: parsedLines + startIndex }; } function stripLineNumberPrefixes(hunk: DiffHunk): void { const allLines = [...hunk.oldLines, ...hunk.newLines].filter(line => line.trim().length > 0); if (allLines.length < 2) return; const numberMatches = allLines .map(line => line.match(/^\s*(\d{1,6})\s+(.+)$/u)) .filter((match): match is RegExpMatchArray => match !== null); if (numberMatches.length < Math.max(2, Math.ceil(allLines.length * 0.6))) { return; } const numbers = numberMatches.map(match => Number(match[1])); let sequential = 0; for (let i = 1; i < numbers.length; i++) { if (numbers[i] === numbers[i - 1] + 1) { sequential++; } } if (numbers.length >= 3 && sequential < Math.max(1, numbers.length - 2)) { return; } const strip = (line: string): string => { const match = line.match(/^\s*\d{1,6}\s+(.+)$/u); return match ? match[1] : line; }; hunk.oldLines = hunk.oldLines.map(strip); hunk.newLines = hunk.newLines.map(strip); } function countMultiFileMarkers(diff: string): number { const counts = new Map(); const paths = new Set(); const lines = diff.split("\n"); for (const line of lines) { if (isDiffContentLine(line)) { continue; } const trimmed = line.trim(); for (const marker of MULTI_FILE_MARKERS) { if (trimmed.startsWith(marker)) { const filePath = extractMarkerPath(trimmed); if (filePath) { paths.add(filePath); } counts.set(marker, (counts.get(marker) ?? 0) + 1); break; } } } if (paths.size > 0) { return paths.size; } let maxCount = 0; for (const count of counts.values()) { if (count > maxCount) { maxCount = count; } } return maxCount; } function extractMarkerPath(line: string): string | undefined { if (line.startsWith("diff --git ")) { const parts = line.split(/\s+/); const candidate = parts[3] ?? parts[2]; if (!candidate) return undefined; return candidate.replace(/^(a|b)\//, ""); } if (line.startsWith("*** Update File:")) { return line.slice("*** Update File:".length).trim(); } if (line.startsWith("*** Add File:")) { return line.slice("*** Add File:".length).trim(); } if (line.startsWith("*** Delete File:")) { return line.slice("*** Delete File:".length).trim(); } return undefined; } export function parseDiffHunks(diff: string): DiffHunk[] { const multiFileCount = countMultiFileMarkers(diff); if (multiFileCount > 1) { throw new ApplyPatchError( `Diff contains ${multiFileCount} file markers. Single-file patches cannot contain multi-file markers.`, ); } const normalizedDiff = normalizeDiff(diff); const lines = normalizedDiff.split("\n"); const hunks: DiffHunk[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); if (trimmed === "") { i++; continue; } const firstChar = line[0]; const isDiffContent = firstChar === " " || firstChar === "+" || firstChar === "-"; if (!isDiffContent && isUnifiedDiffMetadataLine(trimmed)) { i++; continue; } if (trimmed.startsWith("@@") && lines.slice(i + 1).every(next => next.trim() === "")) { break; } const { hunk, linesConsumed } = parseOneHunk(lines.slice(i), i + 1, true); hunks.push(hunk); i += linesConsumed; } return hunks; } /** * 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) { throw new Error(formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews)); } 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 { if (oldText.length === 0) { return { error: "oldText must not be empty." }; } try { const absolutePath = resolveToCwd(path, cwd); let rawContent: string; try { rawContent = await readEditFileText(absolutePath, path); } 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) { return { error: formatOccurrenceMatchError(matchOutcome.occurrences, matchOutcome.occurrencePreviews, path), }; } 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, undefined, { path }); } catch (err) { return { error: err instanceof Error ? err.message : String(err) }; } }