Download src/agents/sessions/tools/edit-diff.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 25.4 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sessions/tools/edit-diff.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/agents/sessions/tools/edit-diff.ts
-
curl -L -o edit-diff.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/agents/sessions/tools/edit-diff.ts
25.4 kB
| /** | |
| * Shared diff computation utilities for the edit tool. | |
| * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). | |
| */ | |
| import { constants } from "node:fs"; | |
| import { access, readFile } from "node:fs/promises"; | |
| import { createPatch, FILE_HEADERS_ONLY, structuredPatch, type StructuredPatchHunk } from "diff"; | |
| import { levenshteinDistance } from "../../../shared/levenshtein-distance.js"; | |
| import { normalizeToLF } from "../../line-endings.js"; | |
| import { | |
| applyReplacements, | |
| applyReplacementsPreservingLineEndings, | |
| type TextReplacement, | |
| } from "./edit-replacements.js"; | |
| import { resolveLocalPathToCwd, resolveToCwd } from "./path-utils.js"; | |
| interface FuzzyBoundary { | |
| /** Original offset when the normalized boundary begins a replacement. */ | |
| readonly start?: number; | |
| /** Original offset when the normalized boundary ends a replacement. */ | |
| readonly end?: number; | |
| } | |
| interface FuzzyNormalizedFile { | |
| text: string; | |
| boundaries: Array<FuzzyBoundary | undefined> | undefined; | |
| } | |
| const fuzzyGraphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); | |
| function foldFuzzyCharacters(text: string): string { | |
| return text | |
| .replace(/[\u2018\u2019\u201A\u201B]/g, "'") | |
| .replace(/[\u201C\u201D\u201E\u201F]/g, '"') | |
| .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") | |
| .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "); | |
| } | |
| /** | |
| * Normalize text for fuzzy matching. Applies progressive transformations: | |
| * - Strip trailing whitespace from each line | |
| * - Normalize smart quotes to ASCII equivalents | |
| * - Normalize Unicode dashes/hyphens to ASCII hyphen | |
| * - Normalize special Unicode spaces to regular space | |
| * | |
| */ | |
| function normalizeForFuzzyMatch(text: string): string { | |
| return foldFuzzyCharacters( | |
| text | |
| .normalize("NFKC") | |
| .split("\n") | |
| .map((line) => line.trimEnd()) | |
| .join("\n"), | |
| ); | |
| } | |
| function buildNfkcBoundaries( | |
| text: string, | |
| authoritativeNfkc: string, | |
| ): FuzzyNormalizedFile["boundaries"] | undefined { | |
| const boundaries: Array<FuzzyBoundary | undefined> = []; | |
| const normalizedSegments: string[] = []; | |
| let normalizedOffset = 0; | |
| for (const segment of fuzzyGraphemeSegmenter.segment(text)) { | |
| const sourceStart = segment.index; | |
| const sourceEnd = sourceStart + segment.segment.length; | |
| const normalizedSegment = segment.segment.normalize("NFKC"); | |
| normalizedSegments.push(normalizedSegment); | |
| const boundary = boundaries[normalizedOffset] ?? {}; | |
| if (normalizedSegment.length === 0) { | |
| // Preserve omitted source text on either side of this collapsed boundary. | |
| boundaries[normalizedOffset] = { | |
| end: boundary.end ?? sourceStart, | |
| start: sourceEnd, | |
| }; | |
| continue; | |
| } | |
| boundaries[normalizedOffset] = { | |
| start: boundary.start ?? sourceStart, | |
| end: boundary.end ?? sourceStart, | |
| }; | |
| normalizedOffset += normalizedSegment.length; | |
| boundaries[normalizedOffset] = { start: sourceEnd, end: sourceEnd }; | |
| } | |
| // Grapheme segmentation is only a mapping aid. Whole-string NFKC remains | |
| // authoritative; fail closed if a runtime ever segments it differently. | |
| if (normalizedSegments.join("") !== authoritativeNfkc) { | |
| return undefined; | |
| } | |
| return boundaries; | |
| } | |
| function buildFuzzyBoundaries( | |
| text: string, | |
| normalizedText: string, | |
| ): FuzzyNormalizedFile["boundaries"] { | |
| const authoritativeNfkc = text.normalize("NFKC"); | |
| const nfkcBoundaries = buildNfkcBoundaries(text, authoritativeNfkc); | |
| if (!nfkcBoundaries) { | |
| return undefined; | |
| } | |
| const boundaries: Array<FuzzyBoundary | undefined> = []; | |
| let sourceLineStart = 0; | |
| let normalizedLineStart = 0; | |
| while (sourceLineStart <= authoritativeNfkc.length) { | |
| const newlineIndex = authoritativeNfkc.indexOf("\n", sourceLineStart); | |
| const sourceLineEnd = newlineIndex === -1 ? authoritativeNfkc.length : newlineIndex; | |
| const line = authoritativeNfkc.slice(sourceLineStart, sourceLineEnd); | |
| const keptLineEnd = sourceLineStart + line.trimEnd().length; | |
| for (let offset = sourceLineStart; offset <= keptLineEnd; offset++) { | |
| const boundary = nfkcBoundaries[offset]; | |
| if (boundary) { | |
| boundaries[normalizedLineStart + offset - sourceLineStart] = boundary; | |
| } | |
| } | |
| const normalizedLineEnd = normalizedLineStart + keptLineEnd - sourceLineStart; | |
| if (keptLineEnd < sourceLineEnd) { | |
| const beforeTrim = nfkcBoundaries[keptLineEnd]; | |
| const afterTrim = nfkcBoundaries[sourceLineEnd]; | |
| boundaries[normalizedLineEnd] = { | |
| end: beforeTrim?.end, | |
| start: afterTrim?.start, | |
| }; | |
| } | |
| if (newlineIndex === -1) { | |
| break; | |
| } | |
| const afterNewline = nfkcBoundaries[sourceLineEnd + 1]; | |
| boundaries[normalizedLineEnd + 1] = afterNewline; | |
| normalizedLineStart = normalizedLineEnd + 1; | |
| sourceLineStart = sourceLineEnd + 1; | |
| } | |
| if (boundaries.length > normalizedText.length + 1) { | |
| return undefined; | |
| } | |
| return boundaries; | |
| } | |
| function translateFuzzySpan( | |
| normalized: FuzzyNormalizedFile, | |
| fuzzyStart: number, | |
| fuzzyLength: number, | |
| ): { originalStart: number; originalLength: number } | undefined { | |
| const fuzzyEnd = fuzzyStart + fuzzyLength; | |
| const originalStart = normalized.boundaries?.[fuzzyStart]?.start; | |
| const originalEnd = normalized.boundaries?.[fuzzyEnd]?.end; | |
| if (originalStart === undefined || originalEnd === undefined) { | |
| return undefined; | |
| } | |
| return { | |
| originalStart, | |
| originalLength: originalEnd - originalStart, | |
| }; | |
| } | |
| interface FuzzyMatchResult { | |
| /** Whether a match was found */ | |
| found: boolean; | |
| /** The index where the match starts (in original-content coordinates) */ | |
| index: number; | |
| /** Length of the matched text (in original-content coordinates) */ | |
| matchLength: number; | |
| /** Whether fuzzy matching was used (false = exact match) */ | |
| usedFuzzyMatch: boolean; | |
| /** The normalized match exists but cannot map to an unambiguous source span. */ | |
| unsafeBoundary?: boolean; | |
| } | |
| export interface Edit { | |
| oldText: string; | |
| newText: string; | |
| } | |
| export class EditNoChangeError extends Error { | |
| constructor(message: string) { | |
| super(message); | |
| this.name = "EditNoChangeError"; | |
| } | |
| } | |
| interface MatchedEdit extends TextReplacement { | |
| editIndex: number; | |
| } | |
| interface AppliedEdits { | |
| baseContent: string; | |
| newContent: string; | |
| replacementBaseContent: string; | |
| replacements: MatchedEdit[]; | |
| } | |
| /** | |
| * Find oldText in content, trying exact match first, then fuzzy match. | |
| * When fuzzy matching is used and an offsetMap is provided, the returned | |
| * index and matchLength are translated back to original-content coordinates | |
| * so the caller can apply replacements against the un-normalized content. | |
| */ | |
| function fuzzyFindText( | |
| content: string, | |
| oldText: string, | |
| normalizedFile?: FuzzyNormalizedFile, | |
| ): FuzzyMatchResult { | |
| // Try exact match first | |
| const exactIndex = content.indexOf(oldText); | |
| if (exactIndex !== -1) { | |
| return { | |
| found: true, | |
| index: exactIndex, | |
| matchLength: oldText.length, | |
| usedFuzzyMatch: false, | |
| }; | |
| } | |
| // Try fuzzy match - work entirely in normalized space | |
| const fuzzyContent = normalizedFile?.text ?? normalizeForFuzzyMatch(content); | |
| const fuzzyOldText = normalizeForFuzzyMatch(oldText); | |
| if (!fuzzyOldText) { | |
| return { | |
| found: false, | |
| index: -1, | |
| matchLength: 0, | |
| usedFuzzyMatch: true, | |
| unsafeBoundary: true, | |
| }; | |
| } | |
| const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText); | |
| if (fuzzyIndex === -1) { | |
| return { | |
| found: false, | |
| index: -1, | |
| matchLength: 0, | |
| usedFuzzyMatch: false, | |
| }; | |
| } | |
| // Source boundaries matter only after normalized text actually matches. | |
| if (normalizedFile && !normalizedFile.boundaries) { | |
| normalizedFile.boundaries = buildFuzzyBoundaries(content, normalizedFile.text); | |
| } | |
| const translated = normalizedFile | |
| ? translateFuzzySpan(normalizedFile, fuzzyIndex, fuzzyOldText.length) | |
| : undefined; | |
| if (!translated) { | |
| return { | |
| found: false, | |
| index: -1, | |
| matchLength: 0, | |
| usedFuzzyMatch: true, | |
| unsafeBoundary: true, | |
| }; | |
| } | |
| return { | |
| found: true, | |
| index: translated.originalStart, | |
| matchLength: translated.originalLength, | |
| usedFuzzyMatch: true, | |
| }; | |
| } | |
| /** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ | |
| export function stripBom(content: string): { bom: string; text: string } { | |
| return content.startsWith("\uFEFF") | |
| ? { bom: "\uFEFF", text: content.slice(1) } | |
| : { bom: "", text: content }; | |
| } | |
| function countOccurrences(fuzzyContent: string, oldText: string): number { | |
| const fuzzyOldText = normalizeForFuzzyMatch(oldText); | |
| if (!fuzzyOldText) { | |
| return 0; | |
| } | |
| return fuzzyContent.split(fuzzyOldText).length - 1; | |
| } | |
| function countExactOccurrences(content: string, oldText: string): number { | |
| return content.split(oldText).length - 1; | |
| } | |
| const EDIT_CANDIDATE_LIMIT = 3; | |
| const EDIT_CANDIDATE_MAX_LINES = 1000; | |
| const EDIT_CANDIDATE_MAX_SCAN_CHARS = 128 * 1024; | |
| const EDIT_CANDIDATE_MAX_LINE_CHARS = 120; | |
| const EDIT_CANDIDATE_MIN_SCORE = 0.45; | |
| interface EditCandidate { | |
| lineNumber: number; | |
| line: string; | |
| score: number; | |
| } | |
| function truncateCandidateText(text: string, maxChars: number): string { | |
| if (text.length <= maxChars) { | |
| return text; | |
| } | |
| const cut = | |
| maxChars > 0 && | |
| /[\uD800-\uDBFF]/.test(text.charAt(maxChars - 1)) && | |
| /[\uDC00-\uDFFF]/.test(text.charAt(maxChars)) | |
| ? maxChars - 1 | |
| : maxChars; | |
| return text.slice(0, cut); | |
| } | |
| function getBoundedLines(text: string, maxLines: number, maxScanChars: number): string[] { | |
| return truncateCandidateText(text, maxScanChars) | |
| .split("\n", maxLines) | |
| .map((line) => truncateCandidateText(line, EDIT_CANDIDATE_MAX_LINE_CHARS)); | |
| } | |
| function scoreCandidate(expected: string, candidate: string): number { | |
| const normalizedExpected = expected.trim(); | |
| const normalizedCandidate = candidate.trim(); | |
| const maxLength = Math.max(normalizedExpected.length, normalizedCandidate.length); | |
| if (maxLength === 0) { | |
| return 0; | |
| } | |
| // Length alone sets an upper bound on the possible similarity score. | |
| if ( | |
| Math.min(normalizedExpected.length, normalizedCandidate.length) / maxLength < | |
| EDIT_CANDIDATE_MIN_SCORE | |
| ) { | |
| return 0; | |
| } | |
| return 1 - levenshteinDistance(normalizedExpected, normalizedCandidate) / maxLength; | |
| } | |
| function describeIndentation(line: string): string { | |
| const indentation = line.match(/^[ \t]*/)?.[0] ?? ""; | |
| if (!indentation) { | |
| return "none"; | |
| } | |
| const tabs = indentation.match(/\t/g)?.length ?? 0; | |
| const spaces = indentation.length - tabs; | |
| return tabs === 0 ? `${spaces} spaces` : `${spaces} spaces and ${tabs} tabs`; | |
| } | |
| function firstDifferenceIndex(left: string, right: string): number { | |
| const sharedLength = Math.min(left.length, right.length); | |
| for (let index = 0; index < sharedLength; index++) { | |
| if (left.charAt(index) !== right.charAt(index)) { | |
| return index; | |
| } | |
| } | |
| return left.length === right.length ? -1 : sharedLength; | |
| } | |
| function describeCandidateDifference(expected: string, found: string): string { | |
| const expectedIndentation = expected.match(/^[ \t]*/)?.[0] ?? ""; | |
| const foundIndentation = found.match(/^[ \t]*/)?.[0] ?? ""; | |
| if (expectedIndentation !== foundIndentation) { | |
| return `indentation differs (expected ${describeIndentation(expected)}, found ${describeIndentation(found)})`; | |
| } | |
| const expectedBackslashes = expected.match(/\\/g)?.length ?? 0; | |
| const foundBackslashes = found.match(/\\/g)?.length ?? 0; | |
| if (expectedBackslashes !== foundBackslashes) { | |
| return `escaping differs (expected ${expectedBackslashes} backslashes, found ${foundBackslashes})`; | |
| } | |
| const differenceIndex = firstDifferenceIndex(expected, found); | |
| return differenceIndex === -1 | |
| ? "this line matches; surrounding lines differ" | |
| : `first difference at column ${differenceIndex + 1}`; | |
| } | |
| function getCandidateHint(content: string, oldText: string): string { | |
| const expected = getBoundedLines(oldText, 32, 4096).reduce( | |
| (best, line) => (line.trim().length > best.trim().length ? line : best), | |
| "", | |
| ); | |
| if (!expected.trim()) { | |
| return ""; | |
| } | |
| const candidates = getBoundedLines( | |
| content, | |
| EDIT_CANDIDATE_MAX_LINES, | |
| EDIT_CANDIDATE_MAX_SCAN_CHARS, | |
| ) | |
| .map((line, index): EditCandidate | undefined => { | |
| const score = scoreCandidate(expected, line); | |
| return score >= EDIT_CANDIDATE_MIN_SCORE ? { lineNumber: index + 1, line, score } : undefined; | |
| }) | |
| .filter((candidate): candidate is EditCandidate => candidate !== undefined) | |
| .toSorted((left, right) => right.score - left.score || left.lineNumber - right.lineNumber) | |
| .slice(0, EDIT_CANDIDATE_LIMIT); | |
| if (candidates.length === 0) { | |
| return ""; | |
| } | |
| const expectedDisplay = JSON.stringify(expected); | |
| return ( | |
| "\nClosest matching lines:\n" + | |
| candidates | |
| .map((candidate) => { | |
| const foundDisplay = JSON.stringify(candidate.line); | |
| const differenceIndex = firstDifferenceIndex(expectedDisplay, foundDisplay); | |
| const markerIndex = | |
| differenceIndex === -1 | |
| ? Math.min(expectedDisplay.length, foundDisplay.length) | |
| : differenceIndex; | |
| const markerWidth = Math.max( | |
| 1, | |
| Math.min(12, Math.max(expectedDisplay.length, foundDisplay.length) - markerIndex), | |
| ); | |
| return [ | |
| ` near line ${candidate.lineNumber} (${Math.round(candidate.score * 100)}% match):`, | |
| ` expected: ${expectedDisplay}`, | |
| ` found: ${foundDisplay}`, | |
| ` ${" ".repeat(markerIndex)}${"^".repeat(markerWidth)}`, | |
| ` hint: ${describeCandidateDifference(expected, candidate.line)}`, | |
| ].join("\n"); | |
| }) | |
| .join("\n") | |
| ); | |
| } | |
| function getNotFoundError( | |
| path: string, | |
| editIndex: number, | |
| totalEdits: number, | |
| content: string, | |
| oldText: string, | |
| ): Error { | |
| const prefix = | |
| totalEdits === 1 ? "Could not find the exact text" : `Could not find edits[${editIndex}]`; | |
| const hint = getCandidateHint(content, oldText); | |
| return new Error( | |
| `${prefix} in ${path}. The old text must match exactly including all whitespace and newlines.${hint}`, | |
| ); | |
| } | |
| function getDuplicateError( | |
| path: string, | |
| editIndex: number, | |
| totalEdits: number, | |
| occurrences: number, | |
| ): Error { | |
| if (totalEdits === 1) { | |
| return new Error( | |
| `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, | |
| ); | |
| } | |
| return new Error( | |
| `Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, | |
| ); | |
| } | |
| function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error { | |
| if (totalEdits === 1) { | |
| return new Error(`oldText must not be empty in ${path}.`); | |
| } | |
| return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`); | |
| } | |
| function getNoChangeError(path: string, totalEdits: number): EditNoChangeError { | |
| if (totalEdits === 1) { | |
| return new EditNoChangeError( | |
| `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, | |
| ); | |
| } | |
| return new EditNoChangeError( | |
| `No changes made to ${path}. The replacements produced identical content.`, | |
| ); | |
| } | |
| function getUnsafeFuzzyBoundaryError(path: string, editIndex: number, totalEdits: number): Error { | |
| const target = totalEdits === 1 ? "The fuzzy match" : `The fuzzy match for edits[${editIndex}]`; | |
| return new Error( | |
| `${target} in ${path} crosses an ambiguous Unicode-normalization or trimmed-whitespace boundary. Copy the exact source text or use a span whose normalized boundaries map cleanly.`, | |
| ); | |
| } | |
| /** | |
| * Apply one or more exact-text replacements to LF-normalized content. | |
| * | |
| * All edits are matched against the same original content. Replacements are | |
| * assembled from original spans so offsets remain stable. Fuzzy matching is | |
| * lookup-only: replacements always splice into the original content. | |
| */ | |
| function applyEdits(normalizedContent: string, edits: Edit[], path: string): AppliedEdits { | |
| const normalizedEdits = edits.map((edit) => ({ | |
| oldText: normalizeToLF(edit.oldText), | |
| newText: normalizeToLF(edit.newText), | |
| })); | |
| for (const [i, edit] of normalizedEdits.entries()) { | |
| if (edit.oldText.length === 0) { | |
| throw getEmptyOldTextError(path, i, normalizedEdits.length); | |
| } | |
| } | |
| const needsFuzzyMapping = normalizedEdits.some( | |
| (edit) => !normalizedContent.includes(edit.oldText), | |
| ); | |
| const fuzzyFile: FuzzyNormalizedFile | undefined = needsFuzzyMapping | |
| ? { text: normalizeForFuzzyMatch(normalizedContent), boundaries: undefined } | |
| : undefined; | |
| const replacementBaseContent = normalizedContent; | |
| const matchedEdits: MatchedEdit[] = []; | |
| for (const [i, edit] of normalizedEdits.entries()) { | |
| const matchResult = fuzzyFindText(normalizedContent, edit.oldText, fuzzyFile); | |
| const occurrences = | |
| fuzzyFile && matchResult.usedFuzzyMatch | |
| ? countOccurrences(fuzzyFile.text, edit.oldText) | |
| : countExactOccurrences(replacementBaseContent, edit.oldText); | |
| if (occurrences > 1) { | |
| throw getDuplicateError(path, i, normalizedEdits.length, occurrences); | |
| } | |
| if (matchResult.unsafeBoundary) { | |
| throw getUnsafeFuzzyBoundaryError(path, i, normalizedEdits.length); | |
| } | |
| if (!matchResult.found) { | |
| throw getNotFoundError(path, i, normalizedEdits.length, normalizedContent, edit.oldText); | |
| } | |
| matchedEdits.push({ | |
| editIndex: i, | |
| matchIndex: matchResult.index, | |
| matchLength: matchResult.matchLength, | |
| newText: edit.newText, | |
| }); | |
| } | |
| matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex); | |
| for (let i = 1; i < matchedEdits.length; i++) { | |
| const previous = matchedEdits.at(i - 1); | |
| const current = matchedEdits.at(i); | |
| if (!previous || !current) { | |
| continue; | |
| } | |
| if (previous.matchIndex + previous.matchLength > current.matchIndex) { | |
| throw new Error( | |
| `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, | |
| ); | |
| } | |
| } | |
| const baseContent = normalizedContent; | |
| const newContent = applyReplacements(replacementBaseContent, matchedEdits); | |
| if (baseContent === newContent) { | |
| throw getNoChangeError(path, normalizedEdits.length); | |
| } | |
| return { | |
| baseContent, | |
| newContent, | |
| replacementBaseContent, | |
| replacements: matchedEdits, | |
| }; | |
| } | |
| function applyEditsToNormalizedContent( | |
| normalizedContent: string, | |
| edits: Edit[], | |
| path: string, | |
| ): { baseContent: string; newContent: string } { | |
| const { baseContent, newContent } = applyEdits(normalizedContent, edits, path); | |
| return { baseContent, newContent }; | |
| } | |
| export function applyEditsPreservingLineEndings( | |
| originalContent: string, | |
| edits: Edit[], | |
| path: string, | |
| ): { baseContent: string; newContent: string; finalContent: string } { | |
| const applied = applyEdits(normalizeToLF(originalContent), edits, path); | |
| const finalContent = applyReplacementsPreservingLineEndings( | |
| originalContent, | |
| applied.replacementBaseContent, | |
| applied.replacements, | |
| ); | |
| if (normalizeToLF(finalContent) !== applied.newContent) { | |
| throw new Error("Line-ending restoration changed the normalized edit result."); | |
| } | |
| return { | |
| baseContent: applied.baseContent, | |
| newContent: applied.newContent, | |
| finalContent, | |
| }; | |
| } | |
| /** Generate a standard unified patch. */ | |
| export function generateUnifiedPatch( | |
| path: string, | |
| oldContent: string, | |
| newContent: string, | |
| contextLines = 4, | |
| ): string { | |
| return createPatch(path, oldContent, newContent, undefined, undefined, { | |
| context: contextLines, | |
| headerOptions: FILE_HEADERS_ONLY, | |
| }); | |
| } | |
| /** | |
| * Generate a display-oriented diff string with line numbers and context. | |
| * Returns both the diff string and the first changed line number (in the new file). | |
| * Prepared hunks must describe these exact contents with the requested context. | |
| */ | |
| export function generateDiffString( | |
| oldContent: string, | |
| newContent: string, | |
| contextLines = 4, | |
| preparedHunks?: StructuredPatchHunk[], | |
| ): { diff: string; firstChangedLine: number | undefined } { | |
| const hunks = | |
| preparedHunks ?? | |
| structuredPatch("", "", oldContent, newContent, undefined, undefined, { | |
| context: contextLines, | |
| }).hunks; | |
| const oldLineCount = oldContent.split("\n").length; | |
| const newLineCount = newContent.split("\n").length; | |
| const lastNewLine = newContent === "" ? 0 : newLineCount - Number(newContent.endsWith("\n")); | |
| const maxLineNum = Math.max(oldLineCount, newLineCount); | |
| const lineNumWidth = String(maxLineNum).length; | |
| const ellipsis = ` ${"".padStart(lineNumWidth, " ")} ...`; | |
| const output: string[] = []; | |
| let firstChangedLine: number | undefined; | |
| for (const [hunkIndex, hunk] of hunks.entries()) { | |
| if (hunkIndex > 0 || hunk.newStart > 1) { | |
| output.push(ellipsis); | |
| } | |
| let oldLineNum = hunk.oldStart; | |
| let newLineNum = hunk.newStart; | |
| for (const line of hunk.lines) { | |
| const prefix = line[0]; | |
| if (prefix === "\\") { | |
| continue; | |
| } | |
| if (firstChangedLine === undefined && prefix !== " ") { | |
| firstChangedLine = newLineNum; | |
| } | |
| const lineNum = prefix === "-" ? oldLineNum : newLineNum; | |
| output.push(`${prefix}${String(lineNum).padStart(lineNumWidth, " ")} ${line.slice(1)}`); | |
| oldLineNum += prefix === "+" ? 0 : 1; | |
| newLineNum += prefix === "-" ? 0 : 1; | |
| } | |
| if (hunkIndex === hunks.length - 1 && hunk.newStart + hunk.newLines <= lastNewLine) { | |
| output.push(ellipsis); | |
| } | |
| } | |
| return { diff: output.join("\n"), firstChangedLine }; | |
| } | |
| export interface EditDiffResult { | |
| diff: string; | |
| firstChangedLine: number | undefined; | |
| } | |
| export interface EditDiffError { | |
| error: string; | |
| } | |
| export function validateNoOpEditTargets( | |
| normalizedContent: string, | |
| noOpEdits: Edit[], | |
| realEdits: Edit[], | |
| path: string, | |
| ): void { | |
| if (noOpEdits.length > 0) { | |
| applyEditsToNormalizedContent( | |
| normalizedContent, | |
| noOpEdits.map((edit) => ({ oldText: edit.oldText, newText: "" })), | |
| path, | |
| ); | |
| } | |
| const exactNoOpEdits = noOpEdits.filter((edit) => | |
| normalizedContent.includes(normalizeToLF(edit.oldText)), | |
| ); | |
| if (exactNoOpEdits.length > 0 && realEdits.length > 0) { | |
| applyEditsToNormalizedContent( | |
| normalizedContent, | |
| [...exactNoOpEdits, ...realEdits].map((edit) => ({ | |
| oldText: edit.oldText, | |
| newText: "", | |
| })), | |
| path, | |
| ); | |
| } | |
| } | |
| export function splitNoOpEdits( | |
| normalizedContent: string, | |
| edits: Edit[], | |
| path: string, | |
| ): { noOpEdits: Edit[]; realEdits: Edit[] } { | |
| const noOpEdits: Edit[] = []; | |
| const realEdits: Edit[] = []; | |
| for (const edit of edits) { | |
| if (edit.oldText === edit.newText) { | |
| applyEditsToNormalizedContent( | |
| normalizedContent, | |
| [{ oldText: edit.oldText, newText: "" }], | |
| path, | |
| ); | |
| noOpEdits.push(edit); | |
| } else { | |
| realEdits.push(edit); | |
| } | |
| } | |
| return { noOpEdits, realEdits }; | |
| } | |
| /** | |
| * Compute the diff for one or more edit operations without applying them. | |
| * Used for preview rendering in the TUI before the tool executes. | |
| */ | |
| export async function computeEditsDiff( | |
| path: string, | |
| edits: Edit[], | |
| cwd: string, | |
| operations?: { | |
| readFile: (absolutePath: string) => Promise<Buffer | string>; | |
| access: (absolutePath: string) => Promise<void>; | |
| }, | |
| resolvePath = operations ? resolveToCwd : resolveLocalPathToCwd, | |
| ): Promise<EditDiffResult | EditDiffError> { | |
| const absolutePath = resolvePath(path, cwd); | |
| try { | |
| // Check if file exists and is readable | |
| try { | |
| if (operations) { | |
| await operations.access(absolutePath); | |
| } else { | |
| await access(absolutePath, constants.R_OK); | |
| } | |
| } catch (error: unknown) { | |
| const errorMessage = | |
| error instanceof Error && "code" in error | |
| ? `Error code: ${String(error.code)}` | |
| : String(error); | |
| return { error: `Could not edit file: ${path}. ${errorMessage}.` }; | |
| } | |
| // Read the file | |
| const rawContentResult = operations | |
| ? await operations.readFile(absolutePath) | |
| : await readFile(absolutePath, "utf-8"); | |
| const rawContent = | |
| typeof rawContentResult === "string" ? rawContentResult : rawContentResult.toString("utf-8"); | |
| // Strip BOM before matching (LLM won't include invisible BOM in oldText) | |
| const { text: content } = stripBom(rawContent); | |
| const normalizedContent = normalizeToLF(content); | |
| const { noOpEdits, realEdits } = splitNoOpEdits(normalizedContent, edits, path); | |
| validateNoOpEditTargets(normalizedContent, noOpEdits, realEdits, path); | |
| if (realEdits.length === 0) { | |
| return { diff: "", firstChangedLine: undefined }; | |
| } | |
| const { baseContent, newContent } = applyEditsToNormalizedContent( | |
| normalizedContent, | |
| realEdits, | |
| path, | |
| ); | |
| // Generate the diff | |
| return generateDiffString(baseContent, newContent); | |
| } catch (err) { | |
| if (err instanceof EditNoChangeError) { | |
| return { diff: "", firstChangedLine: undefined }; | |
| } | |
| return { error: err instanceof Error ? err.message : String(err) }; | |
| } | |
| } | |