import fs from "node:fs/promises"; type UpdateFileChunk = { changeContext?: string; oldLines: string[]; newLines: string[]; isEndOfFile: boolean; }; export async function applyUpdateHunk( filePath: string, chunks: UpdateFileChunk[], ): Promise { const originalContents = await fs.readFile(filePath, "utf8").catch((err) => { throw new Error(`Failed to read file to update ${filePath}: ${err}`); }); const originalLines = originalContents.split("\n"); if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { originalLines.pop(); } const replacements = computeReplacements(originalLines, filePath, chunks); let newLines = applyReplacements(originalLines, replacements); if (newLines.length === 0 || newLines[newLines.length - 1] !== "") { newLines = [...newLines, ""]; } return newLines.join("\n"); } function computeReplacements( originalLines: string[], filePath: string, chunks: UpdateFileChunk[], ): Array<[number, number, string[]]> { const replacements: Array<[number, number, string[]]> = []; let lineIndex = 0; for (const chunk of chunks) { if (chunk.changeContext) { const ctxIndex = seekSequence(originalLines, [chunk.changeContext], lineIndex, false); if (ctxIndex === null) { throw new Error(`Failed to find context '${chunk.changeContext}' in ${filePath}`); } lineIndex = ctxIndex + 1; } if (chunk.oldLines.length === 0) { const insertionIndex = originalLines.length > 0 && originalLines[originalLines.length - 1] === "" ? originalLines.length - 1 : originalLines.length; replacements.push([insertionIndex, 0, chunk.newLines]); continue; } let pattern = chunk.oldLines; let newSlice = chunk.newLines; let found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile); if (found === null && pattern[pattern.length - 1] === "") { pattern = pattern.slice(0, -1); if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { newSlice = newSlice.slice(0, -1); } found = seekSequence(originalLines, pattern, lineIndex, chunk.isEndOfFile); } if (found === null) { throw new Error( `Failed to find expected lines in ${filePath}:\n${chunk.oldLines.join("\n")}`, ); } replacements.push([found, pattern.length, newSlice]); lineIndex = found + pattern.length; } replacements.sort((a, b) => a[0] - b[0]); return replacements; } function applyReplacements( lines: string[], replacements: Array<[number, number, string[]]>, ): string[] { const result = [...lines]; for (const [startIndex, oldLen, newLines] of [...replacements].reverse()) { for (let i = 0; i < oldLen; i += 1) { if (startIndex < result.length) { result.splice(startIndex, 1); } } for (let i = 0; i < newLines.length; i += 1) { result.splice(startIndex + i, 0, newLines[i]); } } return result; } function seekSequence( lines: string[], pattern: string[], start: number, eof: boolean, ): number | null { if (pattern.length === 0) return start; if (pattern.length > lines.length) return null; const maxStart = lines.length - pattern.length; const searchStart = eof && lines.length >= pattern.length ? maxStart : start; if (searchStart > maxStart) return null; for (let i = searchStart; i <= maxStart; i += 1) { if (linesMatch(lines, pattern, i, (value) => value)) return i; } for (let i = searchStart; i <= maxStart; i += 1) { if (linesMatch(lines, pattern, i, (value) => value.trimEnd())) return i; } for (let i = searchStart; i <= maxStart; i += 1) { if (linesMatch(lines, pattern, i, (value) => value.trim())) return i; } for (let i = searchStart; i <= maxStart; i += 1) { if (linesMatch(lines, pattern, i, (value) => normalizePunctuation(value.trim()))) { return i; } } return null; } function linesMatch( lines: string[], pattern: string[], start: number, normalize: (value: string) => string, ): boolean { for (let idx = 0; idx < pattern.length; idx += 1) { if (normalize(lines[start + idx]) !== normalize(pattern[idx])) { return false; } } return true; } function normalizePunctuation(value: string): string { return Array.from(value) .map((char) => { switch (char) { case "\u2010": case "\u2011": case "\u2012": case "\u2013": case "\u2014": case "\u2015": case "\u2212": return "-"; case "\u2018": case "\u2019": case "\u201A": case "\u201B": return "'"; case "\u201C": case "\u201D": case "\u201E": case "\u201F": return '"'; case "\u00A0": case "\u2002": case "\u2003": case "\u2004": case "\u2005": case "\u2006": case "\u2007": case "\u2008": case "\u2009": case "\u200A": case "\u202F": case "\u205F": case "\u3000": return " "; default: return char; } }) .join(""); }