{"version":3,"file":"edit-diff.d.ts","sourceRoot":"","sources":["../../../src/core/tools/edit-diff.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAM/D;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAE9E;AAkOD,MAAM,WAAW,IAAI;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACrB;AAqBD,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACnB;AAsHD,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAEvE;AAyOD;;;;;;;;;;;GAWG;AACH,wBAAgB,6BAA6B,CAC5C,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,IAAI,EAAE,EACb,IAAI,EAAE,MAAM,GACV,kBAAkB,CA2FpB;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CACjC,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,YAAY,SAAI,GACd;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAuHxD;AAED,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC;AAED,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAC;CACd;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CACrC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,IAAI,EAAE,EACb,GAAG,EAAE,MAAM,GACT,OAAO,CAAC,cAAc,GAAG,aAAa,CAAC,CAyBzC","sourcesContent":["/**\n * Shared diff computation utilities for the edit tool.\n * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).\n */\n\nimport * as Diff from \"diff\";\nimport { constants } from \"fs\";\nimport { access, readFile } from \"fs/promises\";\nimport { resolveToCwd } from \"./path-utils.js\";\n\n/** Cache for normalized text to avoid redundant processing. Max 100 entries. */\nconst normalizeCache = new Map<string, string>();\nconst MAX_CACHE_SIZE = 100;\n\nexport function detectLineEnding(content: string): \"\\r\\n\" | \"\\n\" {\n\tconst crlfIdx = content.indexOf(\"\\r\\n\");\n\tconst lfIdx = content.indexOf(\"\\n\");\n\tif (lfIdx === -1) return \"\\n\";\n\tif (crlfIdx === -1) return \"\\n\";\n\treturn crlfIdx < lfIdx ? \"\\r\\n\" : \"\\n\";\n}\n\nexport function normalizeToLF(text: string): string {\n\treturn text.replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n}\n\nexport function restoreLineEndings(text: string, ending: \"\\r\\n\" | \"\\n\"): string {\n\treturn ending === \"\\r\\n\" ? text.replace(/\\n/g, \"\\r\\n\") : text;\n}\n\n/**\n * A normalized string plus, for every one of its indices, the index in the\n * source text that produced it. `map` has one extra trailing entry so a\n * half-open normalized span `[a, b)` maps to the source span `[map[a], map[b])`.\n *\n * This is what lets a fuzzy match be written back over exactly the bytes it\n * matched. Without it the only way to place a normalized match in the original\n * was to widen it to whole lines and re-emit the untouched remainder in\n * normalized form - which silently converted tabs, smart quotes and NFKC\n * lookalikes on any line an edit happened to land on.\n */\ninterface NormalizedWithMap {\n\ttext: string;\n\tmap: number[];\n}\n\n/** Combining marks attach to the preceding base character and normalize with it. */\nconst COMBINING_MARK = /\\p{Mn}/u;\n\n/**\n * NFKC, applied per grapheme cluster so each output character can be traced to\n * the cluster that produced it. Clustering keeps `e` + U+0301 composing into\n * `é` the way whole-string NFKC would, while `ﬁ` still expands to `fi` with both\n * characters pointing at the single source ligature.\n */\nfunction nfkcWithMap(text: string): NormalizedWithMap {\n\tlet out = \"\";\n\tconst map: number[] = [];\n\tlet i = 0;\n\twhile (i < text.length) {\n\t\tconst start = i;\n\t\tconst base = String.fromCodePoint(text.codePointAt(i) as number);\n\t\ti += base.length;\n\t\tlet cluster = base;\n\t\twhile (i < text.length) {\n\t\t\tconst next = String.fromCodePoint(text.codePointAt(i) as number);\n\t\t\tif (!COMBINING_MARK.test(next)) break;\n\t\t\tcluster += next;\n\t\t\ti += next.length;\n\t\t}\n\t\tconst composed = cluster.normalize(\"NFKC\");\n\t\tfor (let k = 0; k < composed.length; k++) map.push(start);\n\t\tout += composed;\n\t}\n\tmap.push(text.length);\n\treturn { text: out, map };\n}\n\n/** CRLF and lone CR collapse to LF. */\nfunction toLfWithMap(text: string): NormalizedWithMap {\n\tlet out = \"\";\n\tconst map: number[] = [];\n\tfor (let i = 0; i < text.length; i++) {\n\t\tconst ch = text[i];\n\t\tif (ch === \"\\r\") {\n\t\t\tmap.push(i);\n\t\t\tout += \"\\n\";\n\t\t\tif (text[i + 1] === \"\\n\") i++;\n\t\t\tcontinue;\n\t\t}\n\t\tmap.push(i);\n\t\tout += ch;\n\t}\n\tmap.push(text.length);\n\treturn { text: out, map };\n}\n\n/**\n * The per-line whitespace pass: tabs widen to two spaces, interior runs of two\n * or more spaces collapse to one (leading indentation is left alone), and\n * trailing whitespace is dropped.\n */\nfunction normalizeLineWhitespaceWithMap(text: string): NormalizedWithMap {\n\tlet out = \"\";\n\tconst map: number[] = [];\n\tlet lineStart = 0;\n\twhile (lineStart <= text.length) {\n\t\tlet lineEnd = text.indexOf(\"\\n\", lineStart);\n\t\tconst hasNewline = lineEnd !== -1;\n\t\tif (!hasNewline) lineEnd = text.length;\n\n\t\t// Tabs first, so indentation is measured the way the old chain measured it.\n\t\tlet expanded = \"\";\n\t\tconst expandedMap: number[] = [];\n\t\tfor (let i = lineStart; i < lineEnd; i++) {\n\t\t\tif (text[i] === \"\\t\") {\n\t\t\t\texpanded += \"  \";\n\t\t\t\texpandedMap.push(i, i);\n\t\t\t} else {\n\t\t\t\texpanded += text[i];\n\t\t\t\texpandedMap.push(i);\n\t\t\t}\n\t\t}\n\n\t\tconst leadingLength = (expanded.match(/^\\s*/)?.[0] ?? \"\").length;\n\t\tlet emitted = \"\";\n\t\tconst emittedMap: number[] = [];\n\t\tfor (let i = 0; i < expanded.length; i++) {\n\t\t\t// Collapse only runs that start past the indentation.\n\t\t\tif (i >= leadingLength && expanded[i] === \" \" && emitted.endsWith(\" \") && emitted.length > leadingLength) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\temitted += expanded[i];\n\t\t\temittedMap.push(expandedMap[i]);\n\t\t}\n\t\t// trimEnd\n\t\tlet end = emitted.length;\n\t\twhile (end > 0 && /\\s/.test(emitted[end - 1])) end--;\n\n\t\tout += emitted.slice(0, end);\n\t\tfor (let i = 0; i < end; i++) map.push(emittedMap[i]);\n\n\t\tif (hasNewline) {\n\t\t\tout += \"\\n\";\n\t\t\tmap.push(lineEnd);\n\t\t\tlineStart = lineEnd + 1;\n\t\t} else {\n\t\t\tbreak;\n\t\t}\n\t}\n\tmap.push(text.length);\n\treturn { text: out, map };\n}\n\n/** Compose `outer` (indices into `inner.text`) onto `inner`'s own source indices. */\nfunction composeMaps(outer: number[], inner: number[]): number[] {\n\treturn outer.map((i) => inner[i] ?? inner[inner.length - 1]);\n}\n\n/**\n * Normalize text for fuzzy matching, keeping an index back to the source for\n * every character produced. Same output text as `normalizeForFuzzyMatch`.\n */\nfunction normalizeForFuzzyMatchWithMap(text: string): NormalizedWithMap {\n\tconst nfkc = nfkcWithMap(text);\n\tconst lf = toLfWithMap(nfkc.text);\n\tconst lines = normalizeLineWhitespaceWithMap(lf.text);\n\t// The final substitutions are one-for-one, so they leave the map untouched.\n\tconst substituted = applyCharSubstitutions(lines.text);\n\treturn { text: substituted, map: composeMaps(composeMaps(lines.map, lf.map), nfkc.map) };\n}\n\n/** The one-for-one Unicode substitutions: quotes, dashes and exotic spaces. */\nfunction applyCharSubstitutions(text: string): string {\n\treturn text\n\t\t.replace(/[\\u2018\\u2019\\u201A\\u201B]/g, \"'\")\n\t\t.replace(/[\\u201C\\u201D\\u201E\\u201F]/g, '\"')\n\t\t.replace(/[\\u2010\\u2011\\u2012\\u2013\\u2014\\u2015\\u2212]/g, \"-\")\n\t\t.replace(/[\\u00A0\\u2002-\\u200A\\u202F\\u205F\\u3000]/g, \" \");\n}\n\n/**\n * Normalize text for fuzzy matching. Applies progressive transformations:\n * - Normalize line endings to LF\n * - Strip trailing whitespace from each line\n * - Normalize tabs to spaces (2 spaces per tab)\n * - Collapse multiple spaces to single space\n * - Normalize smart quotes to ASCII equivalents\n * - Normalize Unicode dashes/hyphens to ASCII hyphen\n * - Normalize special Unicode spaces to regular space\n */\nfunction normalizeForFuzzyMatch(text: string): string {\n\t// Check cache first\n\tconst cached = normalizeCache.get(text);\n\tif (cached !== undefined) return cached;\n\n\tconst normalized = text\n\t\t.normalize(\"NFKC\")\n\t\t// Normalize line endings to LF\n\t\t.replace(/\\r\\n/g, \"\\n\")\n\t\t.replace(/\\r/g, \"\\n\")\n\t\t// Strip trailing whitespace per line\n\t\t.split(\"\\n\")\n\t\t.map((line) => {\n\t\t\t// Normalize tabs to 2 spaces\n\t\t\tlet normalized = line.replace(/\\t/g, \"  \");\n\t\t\t// Collapse multiple spaces to single space (but preserve leading indentation pattern)\n\t\t\t// Only collapse spaces that are NOT at the start of the line (indentation)\n\t\t\tconst leadingSpaces = normalized.match(/^(\\s*)/)?.[1] ?? \"\";\n\t\t\tconst rest = normalized.slice(leadingSpaces.length);\n\t\t\tnormalized = leadingSpaces + rest.replace(/ {2,}/g, \" \");\n\t\t\treturn normalized.trimEnd();\n\t\t})\n\t\t.join(\"\\n\")\n\t\t// Smart single quotes → '\n\t\t.replace(/[\\u2018\\u2019\\u201A\\u201B]/g, \"'\")\n\t\t// Smart double quotes → \"\n\t\t.replace(/[\\u201C\\u201D\\u201E\\u201F]/g, '\"')\n\t\t// Various dashes/hyphens → -\n\t\t// U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,\n\t\t// U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus\n\t\t.replace(/[\\u2010\\u2011\\u2012\\u2013\\u2014\\u2015\\u2212]/g, \"-\")\n\t\t// Special spaces → regular space\n\t\t// U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,\n\t\t// U+205F medium math space, U+3000 ideographic space\n\t\t.replace(/[\\u00A0\\u2002-\\u200A\\u202F\\u205F\\u3000]/g, \" \");\n\n\t// Cache the result (with size limit)\n\tif (normalizeCache.size >= MAX_CACHE_SIZE) {\n\t\t// Remove oldest entry (first key)\n\t\tconst firstKey = normalizeCache.keys().next().value;\n\t\tif (firstKey !== undefined) {\n\t\t\tnormalizeCache.delete(firstKey);\n\t\t}\n\t}\n\tnormalizeCache.set(text, normalized);\n\n\treturn normalized;\n}\n\n/** Whether `index` in `text` is the first character of a line. */\nfunction isAtLineStart(text: string, index: number): boolean {\n\treturn index === 0 || text[index - 1] === \"\\n\";\n}\n\n/**\n * Indentation of `line`, with tabs widened the way `normalizeForFuzzyMatch`\n * widens them, so a tab-indented file and a two-space rendering of it compare\n * equal while genuinely different nesting levels do not.\n */\nfunction normalizedIndent(line: string): string {\n\treturn (line.match(/^[ \\t]*/)?.[0] ?? \"\").replace(/\\t/g, \"  \");\n}\n\nexport interface Edit {\n\toldText: string;\n\tnewText: string;\n\t/**\n\t * When true, replace every occurrence of oldText instead of requiring it to\n\t * be unique. Default (false/undefined) keeps the uniqueness guardrail.\n\t */\n\treplaceAll?: boolean;\n}\n\ninterface MatchedEdit {\n\teditIndex: number;\n\tmatchIndex: number;\n\tmatchLength: number;\n\t/**\n\t * Text written over the span. Usually the edit's `newText` verbatim; for a\n\t * fuzzy match that covered only part of a line, the untouched remainder of\n\t * the first/last line rides along in normalized form (see `resolveFuzzySpan`).\n\t */\n\treplacement: string;\n}\n\n/** A match located in, and expressed in coordinates of, the original content. */\ninterface ResolvedSpan {\n\tmatchIndex: number;\n\tmatchLength: number;\n\treplacement: string;\n}\n\nexport interface AppliedEditsResult {\n\tbaseContent: string;\n\tnewContent: string;\n}\n\n/**\n * Locate one edit's `oldText` in `content`, always returning spans in\n * `content`'s own coordinates.\n *\n * Three tiers, tried in order: exact, fuzzy-normalized, then the\n * indentation-tolerant line-block fallback. `occurrences` counts the matches the\n * winning tier found, which is what the uniqueness guardrail tests; `spans` can\n * be shorter, because two fuzzy matches sharing a line are emitted as one\n * rewrite of that line.\n */\nfunction findEditSpans(\n\tcontent: string,\n\tedit: { oldText: string; newText: string },\n\tfuzzyIndex: () => NormalizedWithMap | null,\n): { spans: ResolvedSpan[]; occurrences: number; noopFuzzySpan?: ResolvedSpan } {\n\t// An oldText that opens with indentation is a statement about a whole line, so\n\t// it must not match mid-line inside a more deeply indented one - that lands the\n\t// edit in a different block entirely. An oldText that opens with a non-blank\n\t// character claims nothing about indentation, so every tier stays tolerant.\n\tconst anchored = /^[ \\t]/.test(edit.oldText);\n\n\t// Tier 1: exact. Already in original coordinates.\n\tconst exact = collectMatchIndices(content, edit.oldText).filter((i) => !anchored || isAtLineStart(content, i));\n\tif (exact.length > 0) {\n\t\treturn {\n\t\t\tspans: exact.map((matchIndex) => ({\n\t\t\t\tmatchIndex,\n\t\t\t\tmatchLength: edit.oldText.length,\n\t\t\t\treplacement: edit.newText,\n\t\t\t})),\n\t\t\toccurrences: exact.length,\n\t\t};\n\t}\n\n\t// Tier 2: fuzzy. Located in normalized space, then mapped straight back onto\n\t// the bytes it matched - nothing outside the match is rewritten.\n\tconst normalized = fuzzyIndex();\n\tif (normalized) {\n\t\tconst fuzzyOldText = normalizeForFuzzyMatch(edit.oldText);\n\t\tconst normalizedText = normalized.text;\n\t\tconst fuzzy = collectMatchIndices(normalizedText, fuzzyOldText).filter(\n\t\t\t(i) => !anchored || isAtLineStart(normalizedText, i),\n\t\t);\n\t\tconst spanAt = (index: number, replacement: string): ResolvedSpan => {\n\t\t\tconst start = normalized.map[index];\n\t\t\tconst end = normalized.map[index + fuzzyOldText.length];\n\t\t\treturn { matchIndex: start, matchLength: end - start, replacement };\n\t\t};\n\n\t\t/**\n\t\t * Build the replacement for a fuzzy match.\n\t\t *\n\t\t * newText is written as the model wrote it, with one exception: indentation.\n\t\t * A fuzzy match means oldText was not on disk byte-for-byte, so the whitespace\n\t\t * in it is the model's rendering of the line rather than a statement about the\n\t\t * file - and newText inherits that rendering. Writing it back re-indents lines\n\t\t * the edit never meant to touch, which in a tab-indented file means every\n\t\t * fuzzy edit silently converts tabs to spaces.\n\t\t *\n\t\t * So when newText's indentation says the same thing oldText's did, the file's\n\t\t * own indentation is kept. An edit that means to re-indent says so by giving\n\t\t * newText a different indentation from oldText, and that still applies.\n\t\t */\n\t\tconst replacementFor = (index: number): string => {\n\t\t\tconst start = normalized.map[index];\n\t\t\tconst end = normalized.map[index + fuzzyOldText.length];\n\t\t\tif (!isAtLineStart(content, start)) return edit.newText;\n\t\t\tconst originalLines = content.slice(start, end).split(\"\\n\");\n\t\t\tconst newLines = edit.newText.split(\"\\n\");\n\t\t\tconst oldLines = edit.oldText.split(\"\\n\");\n\t\t\tif (originalLines.length !== newLines.length || oldLines.length !== newLines.length) {\n\t\t\t\treturn edit.newText;\n\t\t\t}\n\t\t\treturn newLines\n\t\t\t\t.map((line, i) => {\n\t\t\t\t\tconst newIndent = line.match(/^[ \\t]*/)?.[0] ?? \"\";\n\t\t\t\t\tconst oldIndent = oldLines[i].match(/^[ \\t]*/)?.[0] ?? \"\";\n\t\t\t\t\tif (normalizedIndent(newIndent) !== normalizedIndent(oldIndent)) return line;\n\t\t\t\t\treturn (originalLines[i].match(/^[ \\t]*/)?.[0] ?? \"\") + line.slice(newIndent.length);\n\t\t\t\t})\n\t\t\t\t.join(\"\\n\");\n\t\t};\n\n\t\tif (fuzzy.length > 0) {\n\t\t\t// oldText did not match the file byte-for-byte, so the whitespace the model\n\t\t\t// used is its own rendering rather than a statement about the file. If\n\t\t\t// newText normalizes to the same thing, it asked for no change: leave the\n\t\t\t// span exactly as it is so the no-change error fires, instead of rewriting\n\t\t\t// the line's indentation to match the model's rendering of it.\n\t\t\tif (normalizeForFuzzyMatch(edit.newText) === fuzzyOldText) {\n\t\t\t\t// oldText did not match byte-for-byte, so its whitespace is the model's\n\t\t\t\t// rendering rather than a statement about the file, and newText asks for\n\t\t\t\t// nothing the matcher can see. Leave the bytes alone and report the span\n\t\t\t\t// so the caller can name the character the model failed to reproduce.\n\t\t\t\tconst untouched = fuzzy.map((index) => {\n\t\t\t\t\tconst span = spanAt(index, \"\");\n\t\t\t\t\treturn { ...span, replacement: content.slice(span.matchIndex, span.matchIndex + span.matchLength) };\n\t\t\t\t});\n\t\t\t\treturn { spans: untouched, occurrences: fuzzy.length, noopFuzzySpan: untouched[0] };\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tspans: fuzzy.map((index) => spanAt(index, replacementFor(index))),\n\t\t\t\toccurrences: fuzzy.length,\n\t\t\t};\n\t\t}\n\t}\n\n\t// Tier 3: indentation-tolerant line blocks. Already in original coordinates.\n\tconst blocks = findLineBlockMatches(content, edit.oldText, anchored).map((span) => ({\n\t\tmatchIndex: span.matchIndex,\n\t\tmatchLength: span.matchLength,\n\t\treplacement: edit.newText,\n\t}));\n\treturn { spans: blocks, occurrences: blocks.length };\n}\n\n/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */\nexport function stripBom(content: string): { bom: string; text: string } {\n\treturn content.startsWith(\"\\uFEFF\") ? { bom: \"\\uFEFF\", text: content.slice(1) } : { bom: \"\", text: content };\n}\n\n/**\n * Collect the start index of every non-overlapping occurrence of needle in\n * haystack. The needle must already be in the same space as haystack (raw for\n * exact matches, fuzzy-normalized when haystack is fuzzy-normalized).\n */\nfunction collectMatchIndices(haystack: string, needle: string): number[] {\n\tconst indices: number[] = [];\n\tif (needle.length === 0) return indices;\n\tlet from = 0;\n\twhile (true) {\n\t\tconst idx = haystack.indexOf(needle, from);\n\t\tif (idx === -1) break;\n\t\tindices.push(idx);\n\t\tfrom = idx + needle.length; // non-overlapping\n\t}\n\treturn indices;\n}\n\n/**\n * Per-line normalization for indentation-tolerant block matching. Applies the\n * same Unicode/space normalization as fuzzy matching, then strips all leading\n * and trailing whitespace so indentation differences are ignored entirely.\n */\nfunction blockNormalizeLine(line: string): string {\n\treturn normalizeForFuzzyMatch(line).trim();\n}\n\ninterface LineBlockMatch {\n\tmatchIndex: number;\n\tmatchLength: number;\n}\n\n/**\n * Indentation-tolerant fallback matcher. Compares oldText against content line\n * by line, ignoring each line's leading/trailing whitespace (and Unicode\n * formatting). Returns character spans in `content` for every block whose\n * trimmed lines equal the trimmed oldText lines. Replacement still happens in\n * the original content space, so surrounding formatting is preserved.\n */\nfunction findLineBlockMatches(content: string, oldText: string, anchored = false): LineBlockMatch[] {\n\tconst hadTrailingNewline = oldText.endsWith(\"\\n\");\n\tconst oldLines = oldText.split(\"\\n\");\n\tif (hadTrailingNewline) oldLines.pop();\n\tif (oldLines.length === 0) return [];\n\tconst trimmedOld = oldLines.map(blockNormalizeLine);\n\n\tconst contentLines = content.split(\"\\n\");\n\tconst k = trimmedOld.length;\n\tif (k > contentLines.length) return [];\n\n\t// Char offset of each line start within content.\n\tconst offsets = new Array<number>(contentLines.length);\n\tlet acc = 0;\n\tfor (let i = 0; i < contentLines.length; i++) {\n\t\toffsets[i] = acc;\n\t\tacc += contentLines[i].length + 1; // + newline\n\t}\n\n\tconst matches: LineBlockMatch[] = [];\n\tfor (let i = 0; i + k <= contentLines.length; i++) {\n\t\tlet ok = true;\n\t\tfor (let j = 0; j < k; j++) {\n\t\t\tif (blockNormalizeLine(contentLines[i + j]) !== trimmedOld[j]) {\n\t\t\t\tok = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\t// Tier 3 ignores indentation by design. When oldText stated its own\n\t\t\t// indentation, honour that statement rather than matching any nesting level.\n\t\t\tif (anchored && normalizedIndent(contentLines[i + j]) !== normalizedIndent(oldLines[j])) {\n\t\t\t\tok = false;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\tif (!ok) continue;\n\t\tconst matchIndex = offsets[i];\n\t\tlet matchLength = 0;\n\t\tfor (let j = 0; j < k; j++) matchLength += contentLines[i + j].length + (j < k - 1 ? 1 : 0);\n\t\t// Include the trailing newline when oldText carried one and a line follows the block.\n\t\tif (hadTrailingNewline && i + k < contentLines.length) matchLength += 1;\n\t\tmatches.push({ matchIndex, matchLength });\n\t}\n\treturn matches;\n}\n\nfunction getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,\n\t\t);\n\t}\n\treturn new Error(\n\t\t`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,\n\t);\n}\n\nfunction getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,\n\t\t);\n\t}\n\treturn new Error(\n\t\t`Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`,\n\t);\n}\n\nfunction getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(`oldText must not be empty in ${path}.`);\n\t}\n\treturn new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`);\n}\n\n/**\n * Characters the fuzzy matcher erases. When an edit matched only fuzzily and its\n * newText normalizes to the same text, one of these is why: the file holds a\n * character the model reproduced as its plain-ASCII lookalike, so the change it\n * asked for is invisible to the matcher and can never be applied by retrying the\n * same text. Naming the character is the whole recovery - resend oldText with it.\n */\nconst NORMALIZED_AWAY_NAMES = new Map<number, string>([\n\t[0x0009, \"TAB\"],\n\t[0x00a0, \"NO-BREAK SPACE\"],\n\t[0x2002, \"EN SPACE\"],\n\t[0x2003, \"EM SPACE\"],\n\t[0x2009, \"THIN SPACE\"],\n\t[0x200a, \"HAIR SPACE\"],\n\t[0x2010, \"HYPHEN\"],\n\t[0x2011, \"NON-BREAKING HYPHEN\"],\n\t[0x2012, \"FIGURE DASH\"],\n\t[0x2013, \"EN DASH\"],\n\t[0x2014, \"EM DASH\"],\n\t[0x2015, \"HORIZONTAL BAR\"],\n\t[0x2018, \"LEFT SINGLE QUOTATION MARK\"],\n\t[0x2019, \"RIGHT SINGLE QUOTATION MARK\"],\n\t[0x201a, \"SINGLE LOW-9 QUOTATION MARK\"],\n\t[0x201b, \"SINGLE HIGH-REVERSED-9 QUOTATION MARK\"],\n\t[0x201c, \"LEFT DOUBLE QUOTATION MARK\"],\n\t[0x201d, \"RIGHT DOUBLE QUOTATION MARK\"],\n\t[0x201e, \"DOUBLE LOW-9 QUOTATION MARK\"],\n\t[0x201f, \"DOUBLE HIGH-REVERSED-9 QUOTATION MARK\"],\n\t[0x202f, \"NARROW NO-BREAK SPACE\"],\n\t[0x205f, \"MEDIUM MATHEMATICAL SPACE\"],\n\t[0x2212, \"MINUS SIGN\"],\n\t[0x3000, \"IDEOGRAPHIC SPACE\"],\n]);\n\nfunction formatCodePoint(ch: string): string {\n\tconst cp = ch.codePointAt(0) ?? 0;\n\tconst hex = `U+${cp.toString(16).toUpperCase().padStart(4, \"0\")}`;\n\tconst name = NORMALIZED_AWAY_NAMES.get(cp);\n\tif (name) return `${hex} ${name}`;\n\t// NFKC-only difference (ligature, full-width form, superscript, ...).\n\treturn `${hex} (normalizes to ${JSON.stringify(ch.normalize(\"NFKC\"))})`;\n}\n\n/** Cap on how many offending characters one error names before summarising. */\nconst MAX_REPORTED_CHARS = 5;\n\n/**\n * Every character in `text` that fuzzy normalization would not leave alone.\n *\n * All of them are listed rather than just the first: the match span is widened to\n * whole lines, so the first offender is often a leading tab the model never put\n * in its oldText, while the character it actually needs sits further along the\n * line. Naming one of them and guessing wrong is worse than naming them all.\n */\nfunction findNormalizedAwayChars(text: string): Array<{ ch: string; index: number }> {\n\tconst found: Array<{ ch: string; index: number }> = [];\n\tlet index = 0;\n\tfor (const ch of text) {\n\t\tconst cp = ch.codePointAt(0) ?? 0;\n\t\tif (NORMALIZED_AWAY_NAMES.has(cp) || ch.normalize(\"NFKC\") !== ch) {\n\t\t\tfound.push({ ch, index });\n\t\t}\n\t\tindex += ch.length;\n\t}\n\treturn found;\n}\n\n/** 1-indexed line and column of `offset` within `content`. */\nfunction lineAndColumn(content: string, offset: number): { line: number; column: number } {\n\tconst before = content.slice(0, offset);\n\tconst line = before.split(\"\\n\").length;\n\tconst column = offset - (before.lastIndexOf(\"\\n\") + 1) + 1;\n\treturn { line, column };\n}\n\n/**\n * The edit matched, but only after normalization erased the very difference it\n * asked for. Retrying the same oldText can never succeed, so say which character\n * is actually on disk and where.\n */\nfunction getFuzzyNoopError(\n\tpath: string,\n\teditIndex: number,\n\ttotalEdits: number,\n\tcontent: string,\n\tspan: ResolvedSpan,\n): Error {\n\tconst matched = content.slice(span.matchIndex, span.matchIndex + span.matchLength);\n\tconst which = totalEdits === 1 ? \"The edit\" : `edits[${editIndex}]`;\n\tconst offenders = findNormalizedAwayChars(matched);\n\tlet detail: string;\n\tif (offenders.length > 0) {\n\t\tconst shown = offenders.slice(0, MAX_REPORTED_CHARS).map((o) => {\n\t\t\tconst { line, column } = lineAndColumn(content, span.matchIndex + o.index);\n\t\t\treturn `${formatCodePoint(o.ch)} at line ${line}, column ${column}`;\n\t\t});\n\t\tconst more = offenders.length - shown.length;\n\t\tdetail =\n\t\t\t`the text it matched in ${path} contains ${shown.join(\"; \")}` +\n\t\t\t`${more > 0 ? `; and ${more} more` : \"\"}, which your oldText spelled as plain-ASCII lookalikes. ` +\n\t\t\t`Send oldText containing those exact characters and the replacement will apply.`;\n\t} else {\n\t\tdetail =\n\t\t\t`oldText matched ${path} only after whitespace normalization, and newText normalizes to the same text, ` +\n\t\t\t`so nothing would change. Send oldText exactly as the file spells it.`;\n\t}\n\treturn new Error(`No changes made to ${path}. ${which} asked for a change that is invisible to matching: ${detail}`);\n}\n\nfunction getNoChangeError(path: string, totalEdits: number): Error {\n\tif (totalEdits === 1) {\n\t\treturn new Error(\n\t\t\t`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.`,\n\t\t);\n\t}\n\treturn new Error(`No changes made to ${path}. The replacements produced identical content.`);\n}\n\n/**\n * Apply one or more exact-text replacements to LF-normalized content.\n *\n * All edits are matched against the same original content. Replacements are\n * then applied in reverse order so offsets remain stable.\n *\n * Every match, however loosely it was found, is resolved back to a span of the\n * *original* content before anything is written. A fuzzy or indentation-tolerant\n * match therefore rewrites only the lines it landed on: the rest of the file\n * keeps its exact bytes, and the diff callers render from `baseContent` is the\n * real change on disk rather than a normalized-vs-normalized view of it.\n */\nexport function applyEditsToNormalizedContent(\n\tnormalizedContent: string,\n\tedits: Edit[],\n\tpath: string,\n): AppliedEditsResult {\n\tconst normalizedEdits = edits.map((edit) => ({\n\t\toldText: normalizeToLF(edit.oldText),\n\t\tnewText: normalizeToLF(edit.newText),\n\t\treplaceAll: edit.replaceAll === true,\n\t}));\n\n\tfor (let i = 0; i < normalizedEdits.length; i++) {\n\t\tif (normalizedEdits[i].oldText.length === 0) {\n\t\t\tthrow getEmptyOldTextError(path, i, normalizedEdits.length);\n\t\t}\n\t}\n\n\tconst baseContent = normalizedContent;\n\n\t// Needed only if some edit reaches the fuzzy tier, and identical for every\n\t// edit, so build it at most once. A map that does not line up with its own\n\t// text would put spans in the wrong place, so the tier is skipped rather than\n\t// trusted if that ever fails to hold.\n\tlet normalizedCache: NormalizedWithMap | null | undefined;\n\tconst fuzzyIndex = () => {\n\t\tif (normalizedCache === undefined) {\n\t\t\tconst built = normalizeForFuzzyMatchWithMap(baseContent);\n\t\t\tnormalizedCache = built.map.length === built.text.length + 1 ? built : null;\n\t\t}\n\t\treturn normalizedCache;\n\t};\n\n\tconst matchedEdits: MatchedEdit[] = [];\n\tfor (let i = 0; i < normalizedEdits.length; i++) {\n\t\tconst edit = normalizedEdits[i];\n\t\tconst { spans, occurrences, noopFuzzySpan } = findEditSpans(baseContent, edit, fuzzyIndex);\n\t\tif (spans.length === 0) {\n\t\t\tthrow getNotFoundError(path, i, normalizedEdits.length);\n\t\t}\n\t\t// Raised per edit, not once for the whole call: a no-op hidden among edits\n\t\t// that do change bytes used to be swallowed and reported as a success.\n\t\tif (noopFuzzySpan) {\n\t\t\tthrow getFuzzyNoopError(path, i, normalizedEdits.length, baseContent, noopFuzzySpan);\n\t\t}\n\n\t\tif (edit.replaceAll) {\n\t\t\t// Replace every occurrence so the shared reverse-order applier rewrites them all.\n\t\t\tfor (const span of spans) {\n\t\t\t\tmatchedEdits.push({\n\t\t\t\t\teditIndex: i,\n\t\t\t\t\tmatchIndex: span.matchIndex,\n\t\t\t\t\tmatchLength: span.matchLength,\n\t\t\t\t\treplacement: span.replacement,\n\t\t\t\t});\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (occurrences > 1) {\n\t\t\tthrow getDuplicateError(path, i, normalizedEdits.length, occurrences);\n\t\t}\n\n\t\tmatchedEdits.push({\n\t\t\teditIndex: i,\n\t\t\tmatchIndex: spans[0].matchIndex,\n\t\t\tmatchLength: spans[0].matchLength,\n\t\t\treplacement: spans[0].replacement,\n\t\t});\n\t}\n\n\tmatchedEdits.sort((a, b) => a.matchIndex - b.matchIndex);\n\tfor (let i = 1; i < matchedEdits.length; i++) {\n\t\tconst previous = matchedEdits[i - 1];\n\t\tconst current = matchedEdits[i];\n\t\tif (previous.matchIndex + previous.matchLength > current.matchIndex) {\n\t\t\tthrow new Error(\n\t\t\t\t`edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`,\n\t\t\t);\n\t\t}\n\t}\n\n\tlet newContent = baseContent;\n\tfor (let i = matchedEdits.length - 1; i >= 0; i--) {\n\t\tconst edit = matchedEdits[i];\n\t\tnewContent =\n\t\t\tnewContent.substring(0, edit.matchIndex) +\n\t\t\tedit.replacement +\n\t\t\tnewContent.substring(edit.matchIndex + edit.matchLength);\n\t}\n\n\tif (baseContent === newContent) {\n\t\tthrow getNoChangeError(path, normalizedEdits.length);\n\t}\n\n\treturn { baseContent, newContent };\n}\n\n/**\n * Generate a unified diff string with line numbers and context.\n * Returns both the diff string and the first changed line number (in the new file).\n */\nexport function generateDiffString(\n\toldContent: string,\n\tnewContent: string,\n\tcontextLines = 4,\n): { diff: string; firstChangedLine: number | undefined } {\n\tconst parts = Diff.diffLines(oldContent, newContent);\n\tconst output: string[] = [];\n\n\tconst oldLines = oldContent.split(\"\\n\");\n\tconst newLines = newContent.split(\"\\n\");\n\tconst maxLineNum = Math.max(oldLines.length, newLines.length);\n\tconst lineNumWidth = String(maxLineNum).length;\n\n\tlet oldLineNum = 1;\n\tlet newLineNum = 1;\n\tlet lastWasChange = false;\n\tlet firstChangedLine: number | undefined;\n\n\tfor (let i = 0; i < parts.length; i++) {\n\t\tconst part = parts[i];\n\t\tconst raw = part.value.split(\"\\n\");\n\t\tif (raw[raw.length - 1] === \"\") {\n\t\t\traw.pop();\n\t\t}\n\n\t\tif (part.added || part.removed) {\n\t\t\t// Capture the first changed line (in the new file)\n\t\t\tif (firstChangedLine === undefined) {\n\t\t\t\tfirstChangedLine = newLineNum;\n\t\t\t}\n\n\t\t\t// Show the change\n\t\t\tfor (const line of raw) {\n\t\t\t\tif (part.added) {\n\t\t\t\t\tconst lineNum = String(newLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(`+${lineNum} ${line}`);\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t} else {\n\t\t\t\t\t// removed\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(`-${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t}\n\t\t\t}\n\t\t\tlastWasChange = true;\n\t\t} else {\n\t\t\t// Context lines - only show a few before/after changes\n\t\t\tconst nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);\n\t\t\tconst hasLeadingChange = lastWasChange;\n\t\t\tconst hasTrailingChange = nextPartIsChange;\n\n\t\t\tif (hasLeadingChange && hasTrailingChange) {\n\t\t\t\tif (raw.length <= contextLines * 2) {\n\t\t\t\t\tfor (const line of raw) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst leadingLines = raw.slice(0, contextLines);\n\t\t\t\t\tconst trailingLines = raw.slice(raw.length - contextLines);\n\t\t\t\t\tconst skippedLines = raw.length - leadingLines.length - trailingLines.length;\n\n\t\t\t\t\tfor (const line of leadingLines) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\n\t\t\t\t\tfor (const line of trailingLines) {\n\t\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\t\toldLineNum++;\n\t\t\t\t\t\tnewLineNum++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (hasLeadingChange) {\n\t\t\t\tconst shownLines = raw.slice(0, contextLines);\n\t\t\t\tconst skippedLines = raw.length - shownLines.length;\n\n\t\t\t\tfor (const line of shownLines) {\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t}\n\n\t\t\t\tif (skippedLines > 0) {\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\t\t\t\t}\n\t\t\t} else if (hasTrailingChange) {\n\t\t\t\tconst skippedLines = Math.max(0, raw.length - contextLines);\n\t\t\t\tif (skippedLines > 0) {\n\t\t\t\t\toutput.push(` ${\"\".padStart(lineNumWidth, \" \")} ...`);\n\t\t\t\t\toldLineNum += skippedLines;\n\t\t\t\t\tnewLineNum += skippedLines;\n\t\t\t\t}\n\n\t\t\t\tfor (const line of raw.slice(skippedLines)) {\n\t\t\t\t\tconst lineNum = String(oldLineNum).padStart(lineNumWidth, \" \");\n\t\t\t\t\toutput.push(` ${lineNum} ${line}`);\n\t\t\t\t\toldLineNum++;\n\t\t\t\t\tnewLineNum++;\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Skip these context lines entirely\n\t\t\t\toldLineNum += raw.length;\n\t\t\t\tnewLineNum += raw.length;\n\t\t\t}\n\n\t\t\tlastWasChange = false;\n\t\t}\n\t}\n\n\treturn { diff: output.join(\"\\n\"), firstChangedLine };\n}\n\nexport interface EditDiffResult {\n\tdiff: string;\n\tfirstChangedLine: number | undefined;\n}\n\nexport interface EditDiffError {\n\terror: string;\n}\n\n/**\n * Compute the diff for one or more edit operations without applying them.\n * Used for preview rendering in the TUI before the tool executes.\n */\nexport async function computeEditsDiff(\n\tpath: string,\n\tedits: Edit[],\n\tcwd: string,\n): Promise<EditDiffResult | EditDiffError> {\n\tconst absolutePath = resolveToCwd(path, cwd);\n\n\ttry {\n\t\t// Check if file exists and is readable\n\t\ttry {\n\t\t\tawait access(absolutePath, constants.R_OK);\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error && \"code\" in error ? `Error code: ${error.code}` : String(error);\n\t\t\treturn { error: `Could not edit file: ${path}. ${errorMessage}.` };\n\t\t}\n\n\t\t// Read the file\n\t\tconst rawContent = await readFile(absolutePath, \"utf-8\");\n\n\t\t// Strip BOM before matching (LLM won't include invisible BOM in oldText)\n\t\tconst { text: content } = stripBom(rawContent);\n\t\tconst normalizedContent = normalizeToLF(content);\n\t\tconst { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);\n\n\t\t// Generate the diff\n\t\treturn generateDiffString(baseContent, newContent);\n\t} catch (err) {\n\t\treturn { error: err instanceof Error ? err.message : String(err) };\n\t}\n}\n"]}