import * as Diff from "diff"; import { DIFF_REMOVED_CAP, DIFF_REMOVED_EDGE } from "./constants.js"; import { ANCHOR_LEN, HASH_SEP, defaultHashIdentity } from "./hashline/hash-identity.js"; import type { ServedRow } from "./hashline/served.js"; export type LineEnding = "\r\n" | "\n" | "\r"; export function detectEnding(content: string): LineEnding { const lfIdx = content.indexOf("\n"); if (lfIdx === -1) { return content.indexOf("\r") >= 0 ? "\r" : "\n"; } const crlfIdx = content.indexOf("\r\n"); if (crlfIdx === -1) return "\n"; return crlfIdx < lfIdx ? "\r\n" : "\n"; } export function toLF(text: string): string { return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } export function restoreEndings(text: string, ending: LineEnding): string { if (ending === "\r\n") return text.replace(/\n/g, "\r\n"); if (ending === "\r") return text.replace(/\n/g, "\r"); return text; } export function stripBOM(content: string): { bom: string; text: string } { return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; } function fmtDiffLine(prefix: " " | "+" | "-", line: string, hash: string | undefined): string { if (hash === undefined) { return `${prefix}${" ".repeat(ANCHOR_LEN)}${HASH_SEP}${line}`; } return `${prefix}${hash}${HASH_SEP}${line}`; } const ELLIPSIS_MARKER: unique symbol = Symbol("ellipsis"); const isEllipsisMarker = (line: string | symbol): line is symbol => line === ELLIPSIS_MARKER; function pushAddedLines( displayLines: string[], effectiveNewHashes: string[], newLineNum: { value: number }, output: string[], servedRows: ServedRow[], ): void { for (let k = 0; k < displayLines.length; k++) { const hash = effectiveNewHashes[newLineNum.value - 1]; output.push(fmtDiffLine("+", displayLines[k]!, hash)); if (hash !== undefined) servedRows.push({ position: newLineNum.value - 1, hash }); newLineNum.value++; } } function pushRemovedLines( displayLines: string[], oldContentHashes: string[] | undefined, oldLineNum: { value: number }, output: string[], ): void { const emit = (line: string): void => { const hash = oldContentHashes?.[oldLineNum.value - 1]; output.push(fmtDiffLine("-", line, hash)); oldLineNum.value++; }; if (displayLines.length <= DIFF_REMOVED_CAP) { for (const line of displayLines) emit(line); return; } const omitted = displayLines.length - DIFF_REMOVED_EDGE * 2; for (const line of displayLines.slice(0, DIFF_REMOVED_EDGE)) emit(line); // WHY: ADR-0024 — the model sees the deletion's head, tail, and exact size; the omitted rows still // WHY: advance the cursor so every later row keeps its exact old line number and hash. output.push(` - ... [${omitted} lines omitted] ...`); oldLineNum.value += omitted; for (const line of displayLines.slice(-DIFF_REMOVED_EDGE)) emit(line); } function contextLinesToShow( displayLines: string[], lastWasChange: boolean, nextPartIsChange: boolean, contextLines: number, ): { linesToShow: (string | symbol)[]; skipStart: number; skipMiddle: number } { let linesToShow: (string | symbol)[] = displayLines; let skipStart = 0; let skipMiddle = 0; if (!lastWasChange) { skipStart = Math.max(0, displayLines.length - contextLines); linesToShow = displayLines.slice(skipStart); } else if (nextPartIsChange && displayLines.length > contextLines * 2) { const tail = displayLines.slice(-contextLines); linesToShow = [...displayLines.slice(0, contextLines), ELLIPSIS_MARKER, ...tail]; skipMiddle = displayLines.length - contextLines * 2; } else if (linesToShow.length > contextLines) { linesToShow = linesToShow.slice(0, contextLines); } return { linesToShow, skipStart, skipMiddle }; } export function genDiff( oldContent: string, newContent: string, contextLines = 2, newContentHashes?: string[], oldContentHashes?: string[], ): { diff: string; firstChangedLine: number | undefined; servedRows: ServedRow[]; } { const effectiveNewHashes = newContentHashes ?? defaultHashIdentity.hashesForSync(newContent); const parts = Diff.diffLines(oldContent, newContent); const output: string[] = []; const servedRows: ServedRow[] = []; let newLineNum = 1; let oldLineNum = 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.at(-1) === "") raw.pop(); const displayLines = raw; if (part.added || part.removed) { if (firstChangedLine === undefined) firstChangedLine = newLineNum; if (part.added) { const n = { value: newLineNum }; pushAddedLines(displayLines, effectiveNewHashes, n, output, servedRows); newLineNum = n.value; } else { const o = { value: oldLineNum }; pushRemovedLines(displayLines, oldContentHashes, o, output); oldLineNum = o.value; } lastWasChange = true; continue; } const nextPartIsChange = i < parts.length - 1 && (parts[i + 1]!.added || parts[i + 1]!.removed); if (lastWasChange || nextPartIsChange) { const { linesToShow, skipStart, skipMiddle } = contextLinesToShow( displayLines, lastWasChange, nextPartIsChange, contextLines, ); if (skipStart > 0) { output.push(" ..."); newLineNum += skipStart; oldLineNum += skipStart; } for (const line of linesToShow) { if (isEllipsisMarker(line)) { output.push(" ..."); newLineNum += skipMiddle; oldLineNum += skipMiddle; continue; } const hash = effectiveNewHashes[newLineNum - 1]; output.push(fmtDiffLine(" ", line, hash)); if (hash !== undefined) { servedRows.push({ position: newLineNum - 1, hash }); } newLineNum++; oldLineNum++; } } else { newLineNum += displayLines.length; oldLineNum += displayLines.length; } lastWasChange = false; } return { diff: output.join("\n"), firstChangedLine, servedRows }; }