diff --git a/.changeset/diff-rework.md b/.changeset/diff-rework.md new file mode 100644 index 00000000..d5bffc3c --- /dev/null +++ b/.changeset/diff-rework.md @@ -0,0 +1,11 @@ +--- +'@fuzdev/fuz_util': minor +--- + +Rebuild `diff.ts` around greedy Myers (breaking): + +- `diff_lines` replaces the LCS table with greedy Myers over interned lines — linear memory, common prefix/suffix trimming, and a `max_cost` cap that degrades gracefully to a replace block on unrelated inputs. Changed regions are normalized to removes-before-adds. +- `DiffLine` gains 1-based `a_line`/`b_line` and `no_newline` (git's "no newline at end of file" semantics, no phantom empty final line); `line` is renamed to `text`. +- New `diff_hunks`/`DiffHunk` group changes with context, replacing `filter_diff_context` and its `'...'` sentinel line. +- New `diff_segments` computes intra-line changed-character ranges for paired remove/add lines, with a similarity gate. +- `format_diff` takes hunks and emits unified-diff `@@` headers; colors now go through `print.ts`'s `st` seam (`use_color` removed). diff --git a/CLAUDE.md b/CLAUDE.md index 29f7c3cf..7a94f30a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,10 @@ fuz_util is a **foundational utility library**: without rebalancing (`fractional_index_between`, `fractional_indices_between`; base62 alphabet, helper-side jitter, `FRACTIONAL_INDEX_REGEX` / `_LENGTH_MAX`) +- `diff.ts` - line + intra-line char diffs via greedy Myers (`diff_lines` + with 1-based line numbers and `no_newline`, `diff_hunks` context grouping, + `diff_segments` changed-char ranges, `format_diff`/`generate_diff` + unified-diff text with `@@` headers) ### Async and timing diff --git a/src/lib/diff.ts b/src/lib/diff.ts index d2f331c2..74a4c2f4 100644 --- a/src/lib/diff.ts +++ b/src/lib/diff.ts @@ -1,225 +1,578 @@ /** - * Line-based diff utilities using LCS (Longest Common Subsequence). + * Line and intra-line character diffs via greedy Myers over interned + * sequences, plus hunk grouping and unified-diff text formatting. * * @module */ import {string_is_binary} from './string.ts'; +import {st} from './print.ts'; -/** Line diff result */ +/** + * One line of a line diff. Line numbers are 1-based; the absent side of an + * add/remove is `null`. + */ export interface DiffLine { type: 'same' | 'add' | 'remove'; - line: string; + /** + * The line's content, without its newline terminator. + */ + text: string; + a_line: number | null; + b_line: number | null; + /** + * Set on the final line of a side that lacks a trailing newline (git's + * `\ No newline at end of file` semantics). A terminated and an + * unterminated line never match, so a newline-only change shows as + * remove+add of the final line. + */ + no_newline?: boolean; +} + +export interface DiffOptions { + /** + * Maximum edit cost (Myers `D`) before the remaining changed region + * degrades to one whole replace block — bounds worst-case time and memory + * on unrelated inputs. The result is always a valid diff. + * + * @default 2048 + */ + max_cost?: number; +} + +const MAX_COST_DEFAULT = 2048; + +/** + * A group of changed lines with surrounding context. Starts are 1-based; a + * side with zero lines (pure insertion/deletion at `context_lines: 0`) starts + * at the line *before* the hunk on that side, 0 at the very beginning — + * unified-diff `@@` conventions. + */ +export interface DiffHunk { + a_start: number; + a_count: number; + b_start: number; + b_count: number; + /** + * The hunk's lines, shared (not copied) from the input diff. + */ + lines: Array; } /** - * Generate a line-based diff between two strings using LCS algorithm. + * Changed-character ranges from an intra-line diff of one paired + * remove/add line. Ranges are `[start, end)` offsets into the respective + * line's text, in order and non-adjacent. + */ +export interface DiffSegments { + a_ranges: Array<[number, number]>; + b_ranges: Array<[number, number]>; +} + +export interface DiffSegmentsOptions { + /** + * See `DiffOptions.max_cost`. + * + * @default 2048 + */ + max_cost?: number; + /** + * Lines longer than this return `null` (no emphasis) — perf guard. + * + * @default 1000 + */ + max_length?: number; + /** + * Minimum Dice similarity (`2 * matched_chars / total_chars`) for a + * result — below it the pair is considered a rewrite and `null` is + * returned so unrelated lines don't get noise emphasis. + * + * @default 0.3 + */ + min_similarity?: number; + /** + * Changed ranges separated by at most this many matched characters merge + * (per side, with the matched characters absorbed into the range) — + * avoids fragmented emphasis when a rewrite happens to match stray + * characters. 0 keeps exact ranges. + * + * @default 2 + */ + join_gap?: number; +} + +// run-length edit op over abstract sequences, the shared core's output +interface DiffEditOp { + type: 'same' | 'add' | 'remove'; + count: number; +} + +/** + * Merges adjacent ops and normalizes each changed region (a maximal run of + * non-`same` ops) to removes-before-adds — patch-equivalent, and the shape + * viewers want for pairing. + */ +const normalize_ops = (ops: Array): Array => { + const out: Array = []; + let removes = 0; + let adds = 0; + const flush = (): void => { + if (removes > 0) { + out.push({type: 'remove', count: removes}); + removes = 0; + } + if (adds > 0) { + out.push({type: 'add', count: adds}); + adds = 0; + } + }; + for (const op of ops) { + if (op.type === 'same') { + flush(); + const last = out[out.length - 1]; + if (last && last.type === 'same') { + last.count += op.count; + } else { + out.push({type: 'same', count: op.count}); + } + } else if (op.type === 'remove') { + removes += op.count; + } else { + adds += op.count; + } + } + flush(); + return out; +}; + +/** + * Greedy Myers over `a[a_off .. a_off + n)` and `b[b_off .. b_off + m)`, + * returning single-step ops in order. Assumes `n > 0` and `m > 0`. When the + * edit cost exceeds `max_cost`, degrades to one remove+add replace block. + */ +const myers_middle = ( + a: ArrayLike, + a_off: number, + n: number, + b: ArrayLike, + b_off: number, + m: number, + max_cost: number, +): Array => { + const cap = Math.min(n + m, max_cost); + // endpoints indexed by k + cap; per-round compact copies for the backtrack + const v = new Int32Array(2 * cap + 1); + const trace: Array = []; + let found_d = -1; + for (let d = 0; d <= cap; d++) { + for (let k = -d; k <= d; k += 2) { + let x = + k === -d || (k !== d && v[cap + k - 1]! < v[cap + k + 1]!) + ? v[cap + k + 1]! // down: consume one of b + : v[cap + k - 1]! + 1; // right: consume one of a + let y = x - k; + while (x < n && y < m && a[a_off + x] === b[b_off + y]) { + x++; + y++; + } + v[cap + k] = x; + if (x >= n && y >= m) { + found_d = d; + break; + } + } + // row[i] is the endpoint for k = -d + 2i (slots past an early break are + // stale but never read) + const row = new Int32Array(d + 1); + for (let k = -d, i = 0; k <= d; k += 2, i++) row[i] = v[cap + k]!; + trace.push(row); + if (found_d !== -1) break; + } + if (found_d === -1) { + return [ + {type: 'remove', count: n}, + {type: 'add', count: m}, + ]; + } + const rev: Array = []; + let x = n; + let y = m; + for (let d = found_d; d > 0; d--) { + const k = x - y; + const prow = trace[d - 1]!; + const prev_of = (kk: number): number => prow[(kk + d - 1) >> 1]!; + const from_down = k === -d || (k !== d && prev_of(k - 1) < prev_of(k + 1)); + const prev_k = from_down ? k + 1 : k - 1; + const prev_x = prev_of(prev_k); + const after_x = from_down ? prev_x : prev_x + 1; + const snake = x - after_x; + if (snake > 0) rev.push({type: 'same', count: snake}); + rev.push(from_down ? {type: 'add', count: 1} : {type: 'remove', count: 1}); + x = prev_x; + y = prev_x - prev_k; + } + if (x > 0) rev.push({type: 'same', count: x}); + rev.reverse(); + return rev; +}; + +/** + * Diffs two abstract number sequences (interned lines or char codes) into + * normalized run-length ops: common prefix/suffix trimming, then greedy Myers + * on the middle. + */ +const diff_ops = ( + a: ArrayLike, + b: ArrayLike, + max_cost: number, +): Array => { + const n_total = a.length; + const m_total = b.length; + let pre = 0; + while (pre < n_total && pre < m_total && a[pre] === b[pre]) pre++; + let suf = 0; + while ( + suf < n_total - pre && + suf < m_total - pre && + a[n_total - 1 - suf] === b[m_total - 1 - suf] + ) { + suf++; + } + const n = n_total - pre - suf; + const m = m_total - pre - suf; + const ops: Array = []; + if (pre > 0) ops.push({type: 'same', count: pre}); + if (n === 0) { + if (m > 0) ops.push({type: 'add', count: m}); + } else if (m === 0) { + ops.push({type: 'remove', count: n}); + } else { + const middle = myers_middle(a, pre, n, b, pre, m, max_cost); + for (const op of middle) ops.push(op); + } + if (suf > 0) ops.push({type: 'same', count: suf}); + return normalize_ops(ops); +}; + +/** + * Splits into lines without terminators: a text ending in `\n` contributes + * exactly its terminated lines (no phantom empty final line), one not ending + * in `\n` has an unterminated final line, and `''` has no lines at all. + */ +const split_lines = (text: string): {lines: Array; ends_newline: boolean} => { + if (text === '') return {lines: [], ends_newline: true}; + const lines = text.split('\n'); + if (lines[lines.length - 1] === '') { + lines.pop(); + return {lines, ends_newline: true}; + } + return {lines, ends_newline: false}; +}; + +/** + * Interns lines to ids in `ids`, shared across both sides so equal lines get + * equal ids. An unterminated final line interns in a separate key namespace, + * so it never matches a terminated line of the same text. + */ +const intern_lines = ( + lines: Array, + ends_newline: boolean, + ids: Map, +): Int32Array => { + const out = new Int32Array(lines.length); + const last = lines.length - 1; + for (let i = 0; i < lines.length; i++) { + const key = (!ends_newline && i === last ? 'u' : 't') + lines[i]!; + let id = ids.get(key); + if (id === undefined) { + id = ids.size; + ids.set(key, id); + } + out[i] = id; + } + return out; +}; + +/** + * Generates a line diff between two strings — greedy Myers over interned + * lines, with each changed region normalized to removes-before-adds. + * Splits on `\n` only (`\r` stays in line text). * - * @param a - the original/current content - * @param b - the new/desired content + * @param a - the original content + * @param b - the updated content */ -export const diff_lines = (a: string, b: string): Array => { - const a_lines = a.split('\n'); - const b_lines = b.split('\n'); +export const diff_lines = (a: string, b: string, options: DiffOptions = {}): Array => { + const {max_cost = MAX_COST_DEFAULT} = options; + const a_split = split_lines(a); + const b_split = split_lines(b); + const a_lines = a_split.lines; + const b_lines = b_split.lines; + const ids: Map = new Map(); + const a_ids = intern_lines(a_lines, a_split.ends_newline, ids); + const b_ids = intern_lines(b_lines, b_split.ends_newline, ids); + const ops = diff_ops(a_ids, b_ids, max_cost); + const a_last_unterminated = a_split.ends_newline ? -1 : a_lines.length - 1; + const b_last_unterminated = b_split.ends_newline ? -1 : b_lines.length - 1; const result: Array = []; - - const lcs = compute_lcs(a_lines, b_lines); let ai = 0; let bi = 0; - let li = 0; - - while (ai < a_lines.length || bi < b_lines.length) { - if (li < lcs.length && ai < a_lines.length && a_lines[ai] === lcs[li]) { - if (bi < b_lines.length && b_lines[bi] === lcs[li]) { - result.push({type: 'same', line: a_lines[ai]!}); + for (const op of ops) { + for (let i = 0; i < op.count; i++) { + if (op.type === 'same') { + const line: DiffLine = {type: 'same', text: a_lines[ai]!, a_line: ai + 1, b_line: bi + 1}; + if (ai === a_last_unterminated) line.no_newline = true; + result.push(line); ai++; bi++; - li++; + } else if (op.type === 'remove') { + const line: DiffLine = {type: 'remove', text: a_lines[ai]!, a_line: ai + 1, b_line: null}; + if (ai === a_last_unterminated) line.no_newline = true; + result.push(line); + ai++; } else { - result.push({type: 'add', line: b_lines[bi]!}); + const line: DiffLine = {type: 'add', text: b_lines[bi]!, a_line: null, b_line: bi + 1}; + if (bi === b_last_unterminated) line.no_newline = true; + result.push(line); bi++; } - } else if (ai < a_lines.length && (li >= lcs.length || a_lines[ai] !== lcs[li])) { - result.push({type: 'remove', line: a_lines[ai]!}); - ai++; - } else if (bi < b_lines.length) { - result.push({type: 'add', line: b_lines[bi]!}); - bi++; } } - return result; }; /** - * Compute longest common subsequence of two string arrays. + * Groups a line diff into hunks — changed lines plus `context_lines` of + * surrounding context, with overlapping or adjacent windows merged. Returns + * `[]` when nothing changed. * - * Uses dynamic programming with O(m*n) time and space complexity. + * @param context_lines - number of context lines around changes (default: 3) */ -const compute_lcs = (a: Array, b: Array): Array => { - const m = a.length; - const n = b.length; - const dp: Array> = Array.from({length: m + 1}, () => Array(n + 1).fill(0)); - - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - if (a[i - 1] === b[j - 1]) { - dp[i]![j] = dp[i - 1]![j - 1]! + 1; - } else { - dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); +export const diff_hunks = (lines: Array, context_lines = 3): Array => { + const len = lines.length; + const hunks: Array = []; + let window_start = -1; + let window_end = -1; // inclusive + const flush = (): void => { + if (window_start === -1) return; + const hunk_lines = lines.slice(window_start, window_end + 1); + let a_start = 0; + let b_start = 0; + let a_count = 0; + let b_count = 0; + for (const l of hunk_lines) { + if (l.a_line !== null) { + if (a_count === 0) a_start = l.a_line; + a_count++; + } + if (l.b_line !== null) { + if (b_count === 0) b_start = l.b_line; + b_count++; } } - } - - // Backtrack to find LCS - const lcs: Array = []; - let i = m; - let j = n; - while (i > 0 && j > 0) { - if (a[i - 1] === b[j - 1]) { - lcs.unshift(a[i - 1]!); - i--; - j--; - } else if (dp[i - 1]![j]! > dp[i]![j - 1]!) { - i--; + // a zero-count side anchors to the last line before the hunk on that side + if (a_count === 0) { + for (let i = window_start - 1; i >= 0; i--) { + const a_line = lines[i]!.a_line; + if (a_line !== null) { + a_start = a_line; + break; + } + } + } + if (b_count === 0) { + for (let i = window_start - 1; i >= 0; i--) { + const b_line = lines[i]!.b_line; + if (b_line !== null) { + b_start = b_line; + break; + } + } + } + hunks.push({a_start, a_count, b_start, b_count, lines: hunk_lines}); + window_start = -1; + window_end = -1; + }; + for (let i = 0; i < len; i++) { + if (lines[i]!.type === 'same') continue; + const start = Math.max(0, i - context_lines); + const end = Math.min(len - 1, i + context_lines); + if (window_start === -1) { + window_start = start; + window_end = end; + } else if (start <= window_end + 1) { + window_end = end; } else { - j--; + flush(); + window_start = start; + window_end = end; } } - - return lcs; + flush(); + return hunks; }; /** - * Filter diff to only include lines within N lines of context around changes. + * Computes intra-line changed-character ranges for one paired remove/add + * line — char-level greedy Myers. Returns `null` when the pair reads as a + * rewrite (below `min_similarity`) or a line exceeds `max_length`, so + * callers skip emphasis rather than highlight noise. * - * @param context_lines - number of context lines to show around changes (default: 3) - * @returns filtered diff with ellipsis markers for skipped regions + * @param a - the removed line's text + * @param b - the added line's text */ -export const filter_diff_context = (diff: Array, context_lines = 3): Array => { - if (diff.length === 0) return []; - - // Find indices of all changed lines - const changed_indices: Array = []; - for (let i = 0; i < diff.length; i++) { - if (diff[i]!.type !== 'same') { - changed_indices.push(i); +/** + * Merges ranges separated by at most `join_gap` positions, mutating and + * returning `ranges`. + */ +const join_ranges = ( + ranges: Array<[number, number]>, + join_gap: number, +): Array<[number, number]> => { + if (join_gap <= 0 || ranges.length < 2) return ranges; + let last = ranges[0]!; + const out: Array<[number, number]> = [last]; + for (let i = 1; i < ranges.length; i++) { + const r = ranges[i]!; + if (r[0] - last[1] <= join_gap) { + last[1] = r[1]; + } else { + out.push(r); + last = r; } } + return out; +}; - if (changed_indices.length === 0) return []; - - // Build set of indices to include (changed lines + context) - const include_indices: Set = new Set(); - for (const idx of changed_indices) { - for ( - let i = Math.max(0, idx - context_lines); - i <= Math.min(diff.length - 1, idx + context_lines); - i++ - ) { - include_indices.add(i); - } +export const diff_segments = ( + a: string, + b: string, + options: DiffSegmentsOptions = {}, +): DiffSegments | null => { + const { + max_cost = MAX_COST_DEFAULT, + max_length = 1000, + min_similarity = 0.3, + join_gap = 2, + } = options; + if (a.length > max_length || b.length > max_length) return null; + if (a === b) return {a_ranges: [], b_ranges: []}; + const a_codes = new Int32Array(a.length); + for (let i = 0; i < a.length; i++) a_codes[i] = a.charCodeAt(i); + const b_codes = new Int32Array(b.length); + for (let i = 0; i < b.length; i++) b_codes[i] = b.charCodeAt(i); + const ops = diff_ops(a_codes, b_codes, max_cost); + let matched = 0; + for (const op of ops) { + if (op.type === 'same') matched += op.count; } - - // Build result with ellipsis markers for gaps - const result: Array = []; - let last_included = -1; - - for (let i = 0; i < diff.length; i++) { - if (include_indices.has(i)) { - // Add ellipsis if there's a gap - if (last_included >= 0 && i > last_included + 1) { - result.push({type: 'same', line: '...'}); - } - result.push(diff[i]!); - last_included = i; + if ((2 * matched) / (a.length + b.length) < min_similarity) return null; + const a_ranges: Array<[number, number]> = []; + const b_ranges: Array<[number, number]> = []; + let ai = 0; + let bi = 0; + for (const op of ops) { + if (op.type === 'same') { + ai += op.count; + bi += op.count; + } else if (op.type === 'remove') { + a_ranges.push([ai, ai + op.count]); + ai += op.count; + } else { + b_ranges.push([bi, bi + op.count]); + bi += op.count; } } - - return result; + return {a_ranges: join_ranges(a_ranges, join_gap), b_ranges: join_ranges(b_ranges, join_gap)}; }; -/** ANSI color codes */ -const colors = { - red: '\x1b[31m', - green: '\x1b[32m', - reset: '\x1b[0m', -} as const; - /** * Format options for diff output. */ export interface FormatDiffOptions { - /** Prefix for each line (for indentation in plan output). */ + /** + * Prefix for each line (for indentation in plan output). + */ prefix?: string; - /** Whether to use ANSI colors. */ - use_color?: boolean; - /** Maximum number of diff lines to show (0 = unlimited). */ + /** + * Maximum number of content lines to show, 0 for unlimited. + * + * @default 50 + */ max_lines?: number; } /** - * Format a diff for display. + * Formats hunks as unified-diff text with `@@` headers. Colors go through + * `print.ts`'s `st` seam — plain text until `configure_print_colors` opts in. * - * @param current_path - path label for "current" content - * @param desired_path - path label for "desired" content + * @param a_path - path label for the original content + * @param b_path - path label for the updated content */ export const format_diff = ( - diff: Array, - current_path: string, - desired_path: string, + hunks: Array, + a_path: string, + b_path: string, options: FormatDiffOptions = {}, ): string => { - const {prefix = '', use_color = true, max_lines = 50} = options; - - const lines: Array = [ - `${prefix}--- ${current_path} (current)`, - `${prefix}+++ ${desired_path} (desired)`, - ]; - + const {prefix = '', max_lines = 50} = options; + const out: Array = [`${prefix}--- ${a_path}`, `${prefix}+++ ${b_path}`]; + let total = 0; + for (const h of hunks) total += h.lines.length; let count = 0; - for (const d of diff) { - if (max_lines > 0 && count >= max_lines) { - const remaining = diff.length - count; - lines.push(`${prefix}... (${remaining} more lines)`); - break; - } - - const line_prefix = d.type === 'add' ? '+' : d.type === 'remove' ? '-' : ' '; - - if (use_color && d.type !== 'same') { - const color = d.type === 'add' ? colors.green : colors.red; - lines.push(`${prefix}${color}${line_prefix}${d.line}${colors.reset}`); - } else { - lines.push(`${prefix}${line_prefix}${d.line}`); + outer: for (const h of hunks) { + out.push(prefix + st('cyan', `@@ -${h.a_start},${h.a_count} +${h.b_start},${h.b_count} @@`)); + for (const line of h.lines) { + if (max_lines > 0 && count >= max_lines) { + out.push(`${prefix}... (${total - count} more lines)`); + break outer; + } + if (line.type === 'add') { + out.push(prefix + st('green', '+' + line.text)); + } else if (line.type === 'remove') { + out.push(prefix + st('red', '-' + line.text)); + } else { + out.push(`${prefix} ${line.text}`); + } + if (line.no_newline) out.push(`${prefix}\\ No newline at end of file`); + count++; } - - count++; } - - return lines.join('\n'); + return out.join('\n'); }; /** - * Generate a formatted diff between two strings. - * - * Combines `diff_lines`, `filter_diff_context`, and `format_diff` for convenience. - * Returns null if content is binary. + * Options for `generate_diff`. + */ +export interface GenerateDiffOptions extends FormatDiffOptions { + /** + * See `diff_hunks`. + * + * @default 3 + */ + context_lines?: number; + /** + * See `DiffOptions.max_cost`. + * + * @default 2048 + */ + max_cost?: number; +} + +/** + * Generates formatted unified-diff text between two strings — `diff_lines` → + * `diff_hunks` → `format_diff`. Returns `null` if either side is binary. * - * @param path - file path for labels + * @param path - file path used for both labels */ export const generate_diff = ( - current: string, - desired: string, + a: string, + b: string, path: string, - options: FormatDiffOptions = {}, + options: GenerateDiffOptions = {}, ): string | null => { - // Skip binary files - if (string_is_binary(current) || string_is_binary(desired)) { - return null; - } - - const diff = diff_lines(current, desired); - const filtered = filter_diff_context(diff); - return format_diff(filtered, path, path, options); + if (string_is_binary(a) || string_is_binary(b)) return null; + const {context_lines, max_cost, ...format_options} = options; + const lines = diff_lines(a, b, {max_cost}); + const hunks = diff_hunks(lines, context_lines); + return format_diff(hunks, path, path, format_options); }; diff --git a/src/test/diff.test.ts b/src/test/diff.test.ts index 986a1edf..7b1182c1 100644 --- a/src/test/diff.test.ts +++ b/src/test/diff.test.ts @@ -1,471 +1,634 @@ /** - * Tests for line-based diff utilities. + * Tests for line and character diff utilities. * * @module */ import {assert, test, describe} from 'vitest'; +import {styleText} from 'node:util'; import { diff_lines, - filter_diff_context, + diff_hunks, + diff_segments, format_diff, generate_diff, type DiffLine, } from '$lib/diff.ts'; +import {configure_print_colors} from '$lib/print.ts'; +import {create_random_alea} from '$lib/random_alea.ts'; + +/** + * Rebuilds the `a` text from a diff — `same` + `remove` lines, terminated + * unless the side's final line carries `no_newline`. + */ +const reconstruct_a = (diff: Array): string => { + const lines: Array = []; + let ends_newline = true; + for (const d of diff) { + if (d.a_line !== null) { + lines.push(d.text); + if (d.no_newline) ends_newline = false; + } + } + if (lines.length === 0) return ''; + return lines.join('\n') + (ends_newline ? '\n' : ''); +}; + +/** + * Rebuilds the `b` text from a diff — `same` + `add` lines. + */ +const reconstruct_b = (diff: Array): string => { + const lines: Array = []; + let ends_newline = true; + for (const d of diff) { + if (d.b_line !== null) { + lines.push(d.text); + if (d.no_newline) ends_newline = false; + } + } + if (lines.length === 0) return ''; + return lines.join('\n') + (ends_newline ? '\n' : ''); +}; + +/** + * Asserts 1-based, gapless, monotonic line numbers per side. + */ +const assert_line_numbers = (diff: Array): void => { + let ai = 0; + let bi = 0; + for (const d of diff) { + if (d.a_line !== null) { + ai++; + assert.strictEqual(d.a_line, ai); + } + if (d.b_line !== null) { + bi++; + assert.strictEqual(d.b_line, bi); + } + } +}; + +/** + * Asserts each changed region is normalized to removes-before-adds. + */ +const assert_normalized = (diff: Array): void => { + let seen_add = false; + for (const d of diff) { + if (d.type === 'same') { + seen_add = false; + } else if (d.type === 'add') { + seen_add = true; + } else { + assert.isFalse(seen_add, 'remove after add within a changed region'); + } + } +}; describe('diff_lines', () => { describe('identical content', () => { test('empty strings produce empty diff', () => { - const result = diff_lines('', ''); - assert.lengthOf(result, 1); - assert.strictEqual(result[0]!.type, 'same'); - assert.strictEqual(result[0]!.line, ''); + assert.deepEqual(diff_lines('', ''), []); }); - test('identical single line', () => { + test('identical single unterminated line', () => { const result = diff_lines('hello', 'hello'); - assert.lengthOf(result, 1); - assert.strictEqual(result[0]!.type, 'same'); - assert.strictEqual(result[0]!.line, 'hello'); + assert.deepEqual(result, [ + {type: 'same', text: 'hello', a_line: 1, b_line: 1, no_newline: true}, + ]); }); - test('identical multiline', () => { - const content = 'line 1\nline 2\nline 3'; + test('identical multiline with trailing newline has no phantom line', () => { + const content = 'line 1\nline 2\nline 3\n'; const result = diff_lines(content, content); assert.lengthOf(result, 3); for (const line of result) { assert.strictEqual(line.type, 'same'); + assert.isUndefined(line.no_newline); } }); + + test('duplicate lines', () => { + const result = diff_lines('a\na\na\n', 'a\na\na\n'); + assert.lengthOf(result, 3); + assert_line_numbers(result); + }); }); describe('additions', () => { test('add to empty', () => { - const result = diff_lines('', 'new line'); - const adds = result.filter((d) => d.type === 'add'); - assert.isAtLeast(adds.length, 1); - assert.isTrue(adds.some((d) => d.line === 'new line')); + const result = diff_lines('', 'new line\n'); + assert.deepEqual(result, [{type: 'add', text: 'new line', a_line: null, b_line: 1}]); }); test('add line at end', () => { - const result = diff_lines('line 1', 'line 1\nline 2'); - const types = result.map((d) => d.type); - assert.include(types, 'same'); - assert.include(types, 'add'); - assert.strictEqual(result.find((d) => d.type === 'add')!.line, 'line 2'); + const result = diff_lines('line 1\n', 'line 1\nline 2\n'); + assert.deepEqual(result, [ + {type: 'same', text: 'line 1', a_line: 1, b_line: 1}, + {type: 'add', text: 'line 2', a_line: null, b_line: 2}, + ]); }); test('add line at beginning', () => { - const result = diff_lines('line 2', 'line 1\nline 2'); - const adds = result.filter((d) => d.type === 'add'); - assert.isTrue(adds.some((d) => d.line === 'line 1')); + const result = diff_lines('line 2\n', 'line 1\nline 2\n'); + assert.deepEqual(result, [ + {type: 'add', text: 'line 1', a_line: null, b_line: 1}, + {type: 'same', text: 'line 2', a_line: 1, b_line: 2}, + ]); }); test('add multiple lines', () => { - const result = diff_lines('a\nc', 'a\nb1\nb2\nc'); + const result = diff_lines('a\nc\n', 'a\nb1\nb2\nc\n'); const adds = result.filter((d) => d.type === 'add'); - assert.lengthOf(adds, 2); - assert.strictEqual(adds[0]!.line, 'b1'); - assert.strictEqual(adds[1]!.line, 'b2'); + assert.deepEqual( + adds.map((d) => d.text), + ['b1', 'b2'], + ); + assert_line_numbers(result); }); }); describe('removals', () => { test('remove all content', () => { - const result = diff_lines('old line', ''); - const removes = result.filter((d) => d.type === 'remove'); - assert.isAtLeast(removes.length, 1); - assert.isTrue(removes.some((d) => d.line === 'old line')); + const result = diff_lines('old line\n', ''); + assert.deepEqual(result, [{type: 'remove', text: 'old line', a_line: 1, b_line: null}]); }); test('remove line from middle', () => { - const result = diff_lines('a\nb\nc', 'a\nc'); - const removes = result.filter((d) => d.type === 'remove'); - assert.lengthOf(removes, 1); - assert.strictEqual(removes[0]!.line, 'b'); + const result = diff_lines('a\nb\nc\n', 'a\nc\n'); + assert.deepEqual(result, [ + {type: 'same', text: 'a', a_line: 1, b_line: 1}, + {type: 'remove', text: 'b', a_line: 2, b_line: null}, + {type: 'same', text: 'c', a_line: 3, b_line: 2}, + ]); }); test('remove multiple lines', () => { - const result = diff_lines('a\nb1\nb2\nc', 'a\nc'); + const result = diff_lines('a\nb1\nb2\nc\n', 'a\nc\n'); const removes = result.filter((d) => d.type === 'remove'); - assert.lengthOf(removes, 2); - assert.strictEqual(removes[0]!.line, 'b1'); - assert.strictEqual(removes[1]!.line, 'b2'); + assert.deepEqual( + removes.map((d) => d.text), + ['b1', 'b2'], + ); }); }); describe('modifications', () => { - test('single line change', () => { - const result = diff_lines('old', 'new'); - const removes = result.filter((d) => d.type === 'remove'); - const adds = result.filter((d) => d.type === 'add'); - assert.lengthOf(removes, 1); - assert.lengthOf(adds, 1); - assert.strictEqual(removes[0]!.line, 'old'); - assert.strictEqual(adds[0]!.line, 'new'); + test('change in middle emits remove before add with correct numbers', () => { + const result = diff_lines('a\nold\nc\n', 'a\nnew\nc\n'); + assert.deepEqual(result, [ + {type: 'same', text: 'a', a_line: 1, b_line: 1}, + {type: 'remove', text: 'old', a_line: 2, b_line: null}, + {type: 'add', text: 'new', a_line: null, b_line: 2}, + {type: 'same', text: 'c', a_line: 3, b_line: 3}, + ]); }); - test('change in middle preserves context', () => { - const result = diff_lines('a\nold\nc', 'a\nnew\nc'); - const sames = result.filter((d) => d.type === 'same'); - const removes = result.filter((d) => d.type === 'remove'); - const adds = result.filter((d) => d.type === 'add'); - assert.lengthOf(sames, 2); - assert.lengthOf(removes, 1); - assert.lengthOf(adds, 1); - assert.strictEqual(removes[0]!.line, 'old'); - assert.strictEqual(adds[0]!.line, 'new'); + test('completely different content', () => { + const result = diff_lines('a\nb\nc\n', 'x\ny\nz\n'); + assert.lengthOf( + result.filter((d) => d.type === 'remove'), + 3, + ); + assert.lengthOf( + result.filter((d) => d.type === 'add'), + 3, + ); + assert_normalized(result); }); - test('completely different content', () => { - const result = diff_lines('a\nb\nc', 'x\ny\nz'); - const removes = result.filter((d) => d.type === 'remove'); - const adds = result.filter((d) => d.type === 'add'); - assert.lengthOf(removes, 3); - assert.lengthOf(adds, 3); + test('minimal edit script on the classic Myers case', () => { + // abcabba -> cbabac has edit distance 5 + const a = 'a\nb\nc\na\nb\nb\na\n'; + const b = 'c\nb\na\nb\na\nc\n'; + const result = diff_lines(a, b); + assert.lengthOf( + result.filter((d) => d.type !== 'same'), + 5, + ); + assert.strictEqual(reconstruct_a(result), a); + assert.strictEqual(reconstruct_b(result), b); }); }); - describe('mixed operations', () => { - test('add and remove in same diff', () => { - const result = diff_lines('a\nb\nc', 'a\nx\nc'); - assert.isTrue(result.some((d) => d.type === 'remove' && d.line === 'b')); - assert.isTrue(result.some((d) => d.type === 'add' && d.line === 'x')); - assert.isTrue(result.some((d) => d.type === 'same' && d.line === 'a')); - assert.isTrue(result.some((d) => d.type === 'same' && d.line === 'c')); + describe('trailing newlines', () => { + test('newline-only change shows as remove+add of the final line', () => { + const result = diff_lines('a', 'a\n'); + assert.deepEqual(result, [ + {type: 'remove', text: 'a', a_line: 1, b_line: null, no_newline: true}, + {type: 'add', text: 'a', a_line: null, b_line: 1}, + ]); }); - test('reconstructing b from diff', () => { - const a = 'line 1\nline 2\nline 3\nline 4'; - const b = 'line 1\nnew line\nline 3\nline 4\nline 5'; - const result = diff_lines(a, b); - - // Reconstruct b from the diff - const reconstructed: Array = []; - for (const d of result) { - if (d.type === 'same' || d.type === 'add') { - reconstructed.push(d.line); - } - } - assert.deepEqual(reconstructed, b.split('\n')); + test('removing the trailing newline', () => { + const result = diff_lines('a\n', 'a'); + assert.deepEqual(result, [ + {type: 'remove', text: 'a', a_line: 1, b_line: null}, + {type: 'add', text: 'a', a_line: null, b_line: 1, no_newline: true}, + ]); }); - test('reconstructing a from diff', () => { - const a = 'first\nsecond\nthird'; - const b = 'first\nmodified\nthird\nfourth'; - const result = diff_lines(a, b); - - // Reconstruct a from the diff - const reconstructed: Array = []; - for (const d of result) { - if (d.type === 'same' || d.type === 'remove') { - reconstructed.push(d.line); - } - } - assert.deepEqual(reconstructed, a.split('\n')); + test('matching unterminated final lines stay same', () => { + const result = diff_lines('a\nb', 'a\nb'); + assert.deepEqual(result, [ + {type: 'same', text: 'a', a_line: 1, b_line: 1}, + {type: 'same', text: 'b', a_line: 2, b_line: 2, no_newline: true}, + ]); }); }); - describe('edge cases', () => { - test('trailing newline handling', () => { - const result = diff_lines('a\n', 'a\n'); - assert.lengthOf(result, 2); // 'a' and '' (after trailing newline) - assert.strictEqual(result[0]!.type, 'same'); - assert.strictEqual(result[1]!.type, 'same'); + describe('max_cost degradation', () => { + test('disjoint content degrades to a replace block that still round-trips', () => { + const a = 'a\nb\nc\nd\n'; + const b = 'w\nx\ny\nz\n'; + const result = diff_lines(a, b, {max_cost: 1}); + assert.deepEqual( + result.map((d) => d.type), + ['remove', 'remove', 'remove', 'remove', 'add', 'add', 'add', 'add'], + ); + assert.strictEqual(reconstruct_a(result), a); + assert.strictEqual(reconstruct_b(result), b); }); - test('duplicate lines', () => { - const result = diff_lines('a\na\na', 'a\na\na'); - assert.lengthOf(result, 3); - for (const d of result) { - assert.strictEqual(d.type, 'same'); - } + test('common prefix and suffix survive the cap', () => { + const result = diff_lines('p\nx\ny\nq\n', 'p\nz\nw\nq\n', {max_cost: 1}); + assert.strictEqual(result[0]!.type, 'same'); + assert.strictEqual(result[0]!.text, 'p'); + assert.strictEqual(result[result.length - 1]!.type, 'same'); + assert.strictEqual(result[result.length - 1]!.text, 'q'); + assert_normalized(result); }); + }); - test('whitespace-only differences', () => { - const result = diff_lines(' a', 'a'); - const removes = result.filter((d) => d.type === 'remove'); - const adds = result.filter((d) => d.type === 'add'); - assert.lengthOf(removes, 1); - assert.lengthOf(adds, 1); + describe('reconstruction', () => { + test('reconstructing both sides from a mixed diff', () => { + const a = 'line 1\nline 2\nline 3\nline 4\n'; + const b = 'line 1\nnew line\nline 3\nline 4\nline 5\n'; + const result = diff_lines(a, b); + assert.strictEqual(reconstruct_a(result), a); + assert.strictEqual(reconstruct_b(result), b); }); }); -}); -describe('filter_diff_context', () => { - /** - * Helper to create a diff with many same lines and a change at a specific index. - */ - const make_long_diff = (length: number, change_indices: Array): Array => { - const diff: Array = []; - for (let i = 0; i < length; i++) { - if (change_indices.includes(i)) { - diff.push({type: 'remove', line: `old line ${i}`}); - diff.push({type: 'add', line: `new line ${i}`}); - } else { - diff.push({type: 'same', line: `line ${i}`}); - } - } - return diff; - }; + test('determinism', () => { + const a = 'a\nb\nc\nx\n'; + const b = 'a\nc\ny\nx\n'; + assert.deepEqual(diff_lines(a, b), diff_lines(a, b)); + }); +}); - test('empty diff returns empty', () => { - const result = filter_diff_context([]); - assert.deepEqual(result, []); - }); - - test('no changes returns empty', () => { - const diff: Array = [ - {type: 'same', line: 'a'}, - {type: 'same', line: 'b'}, - {type: 'same', line: 'c'}, - ]; - const result = filter_diff_context(diff); - assert.deepEqual(result, []); - }); - - test('all changes are included', () => { - const diff: Array = [ - {type: 'remove', line: 'old'}, - {type: 'add', line: 'new'}, - ]; - const result = filter_diff_context(diff); - assert.lengthOf(result, 2); - assert.strictEqual(result[0]!.type, 'remove'); - assert.strictEqual(result[1]!.type, 'add'); - }); - - test('includes context lines around changes', () => { - const diff: Array = [ - {type: 'same', line: 'ctx before 3'}, - {type: 'same', line: 'ctx before 2'}, - {type: 'same', line: 'ctx before 1'}, - {type: 'remove', line: 'changed'}, - {type: 'same', line: 'ctx after 1'}, - {type: 'same', line: 'ctx after 2'}, - {type: 'same', line: 'ctx after 3'}, - ]; - const result = filter_diff_context(diff, 3); - assert.lengthOf(result, 7); // 3 before + 1 change + 3 after - }); - - test('custom context_lines parameter', () => { - const diff: Array = [ - {type: 'same', line: 'far before'}, - {type: 'same', line: 'near before'}, - {type: 'add', line: 'new'}, - {type: 'same', line: 'near after'}, - {type: 'same', line: 'far after'}, - ]; - const result = filter_diff_context(diff, 1); - assert.lengthOf(result, 3); // 1 before + 1 change + 1 after - assert.strictEqual(result[0]!.line, 'near before'); - assert.strictEqual(result[1]!.line, 'new'); - assert.strictEqual(result[2]!.line, 'near after'); - }); - - test('adds ellipsis for gaps', () => { - const diff = make_long_diff(20, [2, 17]); - const result = filter_diff_context(diff, 1); - const ellipses = result.filter((d) => d.line === '...'); - assert.isAtLeast(ellipses.length, 1); - }); - - test('no ellipsis when changes are close together', () => { - const diff: Array = [ - {type: 'add', line: 'a'}, - {type: 'same', line: 'between'}, - {type: 'add', line: 'b'}, - ]; - const result = filter_diff_context(diff, 3); - const ellipses = result.filter((d) => d.line === '...'); - assert.lengthOf(ellipses, 0); - }); - - test('context does not exceed diff boundaries', () => { - const diff: Array = [ - {type: 'add', line: 'at start'}, - {type: 'same', line: 'after'}, - ]; - const result = filter_diff_context(diff, 5); - // Should not crash or produce out-of-bounds results - assert.isAtLeast(result.length, 1); - assert.isTrue(result.some((d) => d.line === 'at start')); +describe('diff_hunks', () => { + test('empty diff returns no hunks', () => { + assert.deepEqual(diff_hunks([]), []); + }); + + test('no changes returns no hunks', () => { + const diff = diff_lines('a\nb\nc\n', 'a\nb\nc\n'); + assert.deepEqual(diff_hunks(diff), []); + }); + + test('single change gets context on both sides', () => { + const a_lines = Array.from({length: 21}, (_, i) => `line ${i + 1}`); + const b_lines = [...a_lines]; + b_lines[10] = 'modified'; + const diff = diff_lines(a_lines.join('\n') + '\n', b_lines.join('\n') + '\n'); + const hunks = diff_hunks(diff, 3); + assert.lengthOf(hunks, 1); + const h = hunks[0]!; + assert.strictEqual(h.a_start, 8); + assert.strictEqual(h.a_count, 7); + assert.strictEqual(h.b_start, 8); + assert.strictEqual(h.b_count, 7); + assert.lengthOf(h.lines, 8); // 3 context + remove + add + 3 context + assert.strictEqual(h.lines[0]!.text, 'line 8'); + assert.strictEqual(h.lines[7]!.text, 'line 14'); + }); + + test('context clamps at boundaries', () => { + const diff = diff_lines('a\nb\n', 'x\nb\n'); + const hunks = diff_hunks(diff, 5); + assert.lengthOf(hunks, 1); + assert.strictEqual(hunks[0]!.a_start, 1); + assert.strictEqual(hunks[0]!.b_start, 1); + }); + + test('nearby changes merge into one hunk, distant ones split', () => { + const a_lines = Array.from({length: 20}, (_, i) => `line ${i + 1}`); + const b_lines = [...a_lines]; + b_lines[2] = 'x'; + b_lines[17] = 'y'; + const diff = diff_lines(a_lines.join('\n') + '\n', b_lines.join('\n') + '\n'); + assert.lengthOf(diff_hunks(diff, 1), 2); + assert.lengthOf(diff_hunks(diff, 10), 1); + }); + + test('pure insertion anchors the empty side to the preceding line', () => { + const diff = diff_lines('a\nb\n', 'a\nx\nb\n'); + const hunks = diff_hunks(diff, 0); + assert.lengthOf(hunks, 1); + const h = hunks[0]!; + assert.strictEqual(h.a_start, 1); + assert.strictEqual(h.a_count, 0); + assert.strictEqual(h.b_start, 2); + assert.strictEqual(h.b_count, 1); + }); + + test('pure insertion at the very beginning anchors to 0', () => { + const diff = diff_lines('b\n', 'a\nb\n'); + const hunks = diff_hunks(diff, 0); + assert.lengthOf(hunks, 1); + assert.strictEqual(hunks[0]!.a_start, 0); + assert.strictEqual(hunks[0]!.a_count, 0); + assert.strictEqual(hunks[0]!.b_start, 1); + }); + + test('pure deletion anchors the empty side to the preceding line', () => { + const diff = diff_lines('a\nx\nb\n', 'a\nb\n'); + const hunks = diff_hunks(diff, 0); + assert.lengthOf(hunks, 1); + const h = hunks[0]!; + assert.strictEqual(h.a_start, 2); + assert.strictEqual(h.a_count, 1); + assert.strictEqual(h.b_start, 1); + assert.strictEqual(h.b_count, 0); + }); + + test('hunk lines are shared with the input diff, not copied', () => { + const diff = diff_lines('a\nx\nb\n', 'a\ny\nb\n'); + const hunks = diff_hunks(diff, 1); + assert.strictEqual(hunks[0]!.lines[0], diff[0]); }); test('context_lines 0 includes only changed lines', () => { - const diff: Array = [ - {type: 'same', line: 'before'}, - {type: 'remove', line: 'old'}, - {type: 'add', line: 'new'}, - {type: 'same', line: 'after'}, - ]; - const result = filter_diff_context(diff, 0); - assert.lengthOf(result, 2); - assert.strictEqual(result[0]!.type, 'remove'); - assert.strictEqual(result[1]!.type, 'add'); + const diff = diff_lines('before\nold\nafter\n', 'before\nnew\nafter\n'); + const hunks = diff_hunks(diff, 0); + assert.lengthOf(hunks, 1); + assert.deepEqual( + hunks[0]!.lines.map((l) => l.type), + ['remove', 'add'], + ); }); }); -describe('format_diff', () => { - test('includes file path headers', () => { - const diff: Array = [{type: 'same', line: 'content'}]; - const result = format_diff(diff, 'file.txt', 'file.txt'); - assert.include(result, '--- file.txt (current)'); - assert.include(result, '+++ file.txt (desired)'); +describe('diff_segments', () => { + test('identical lines produce empty ranges', () => { + assert.deepEqual(diff_segments('same', 'same'), {a_ranges: [], b_ranges: []}); + }); + + test('both empty produce empty ranges', () => { + assert.deepEqual(diff_segments('', ''), {a_ranges: [], b_ranges: []}); }); - test('uses different paths for current and desired', () => { - const diff: Array = [{type: 'same', line: 'x'}]; - const result = format_diff(diff, 'old.txt', 'new.txt'); - assert.include(result, '--- old.txt (current)'); - assert.include(result, '+++ new.txt (desired)'); + test('single char substitution', () => { + assert.deepEqual(diff_segments('const x = 1;', 'const y = 1;'), { + a_ranges: [[6, 7]], + b_ranges: [[6, 7]], + }); }); - test('prefixes added lines with +', () => { - const diff: Array = [{type: 'add', line: 'new line'}]; - const result = format_diff(diff, 'a', 'b', {use_color: false}); - assert.include(result, '+new line'); + test('insertion only', () => { + assert.deepEqual(diff_segments('ab', 'axb'), {a_ranges: [], b_ranges: [[1, 2]]}); }); - test('prefixes removed lines with -', () => { - const diff: Array = [{type: 'remove', line: 'old line'}]; - const result = format_diff(diff, 'a', 'b', {use_color: false}); - assert.include(result, '-old line'); + test('deletion only', () => { + assert.deepEqual(diff_segments('axb', 'ab'), {a_ranges: [[1, 2]], b_ranges: []}); }); - test('prefixes same lines with space', () => { - const diff: Array = [{type: 'same', line: 'unchanged'}]; - const result = format_diff(diff, 'a', 'b', {use_color: false}); - assert.include(result, ' unchanged'); + test('dissimilar lines return null', () => { + assert.isNull(diff_segments('abc', 'xyz')); }); - test('applies ANSI colors by default', () => { - const diff: Array = [ - {type: 'add', line: 'added'}, - {type: 'remove', line: 'removed'}, - ]; - const result = format_diff(diff, 'a', 'b'); - assert.include(result, '\x1b[32m'); // green for add - assert.include(result, '\x1b[31m'); // red for remove - assert.include(result, '\x1b[0m'); // reset + test('empty vs nonempty returns null', () => { + assert.isNull(diff_segments('', 'something')); }); - test('no ANSI colors when use_color is false', () => { - const diff: Array = [ - {type: 'add', line: 'added'}, - {type: 'remove', line: 'removed'}, - ]; - const result = format_diff(diff, 'a', 'b', {use_color: false}); - assert.notInclude(result, '\x1b['); + test('min_similarity 0 accepts a full rewrite', () => { + assert.deepEqual(diff_segments('abc', 'xyz', {min_similarity: 0}), { + a_ranges: [[0, 3]], + b_ranges: [[0, 3]], + }); }); - test('no ANSI colors on same lines even with use_color', () => { - const diff: Array = [{type: 'same', line: 'unchanged'}]; - const result = format_diff(diff, 'a', 'b', {use_color: true}); - const lines = result.split('\n'); - const same_line = lines.find((l) => l.includes('unchanged'))!; - assert.notInclude(same_line, '\x1b['); + test('max_length returns null for long lines', () => { + const long = 'x'.repeat(50); + assert.isNull(diff_segments(long, long + 'y', {max_length: 10})); + }); + + test('join_gap merges ranges across tiny matched gaps by default', () => { + assert.deepEqual(diff_segments('abcdef', 'aXcYef'), { + a_ranges: [[1, 4]], + b_ranges: [[1, 4]], + }); + }); + + test('join_gap 0 keeps exact ranges', () => { + assert.deepEqual(diff_segments('abcdef', 'aXcYef', {join_gap: 0}), { + a_ranges: [ + [1, 2], + [3, 4], + ], + b_ranges: [ + [1, 2], + [3, 4], + ], + }); + }); + + test('join_gap does not merge across wide matched gaps', () => { + const result = diff_segments('one two three', 'one 2wo threX'); + assert.deepEqual(result, { + a_ranges: [ + [4, 5], + [12, 13], + ], + b_ranges: [ + [4, 5], + [12, 13], + ], + }); + }); + + test('changed-char total matches the edit distance on the classic case', () => { + const result = diff_segments('abcabba', 'cbabac', {min_similarity: 0, join_gap: 0}); + assert.isNotNull(result); + const total = + result.a_ranges.reduce((sum, [s, e]) => sum + e - s, 0) + + result.b_ranges.reduce((sum, [s, e]) => sum + e - s, 0); + assert.strictEqual(total, 5); + }); +}); + +describe('format_diff', () => { + const single_change_hunks = () => { + const a_lines = Array.from({length: 21}, (_, i) => `line ${i + 1}`); + const b_lines = [...a_lines]; + b_lines[10] = 'modified'; + const diff = diff_lines(a_lines.join('\n') + '\n', b_lines.join('\n') + '\n'); + return diff_hunks(diff, 3); + }; + + test('includes file path headers', () => { + const result = format_diff([], 'old.txt', 'new.txt'); + assert.strictEqual(result, '--- old.txt\n+++ new.txt'); + }); + + test('emits @@ hunk headers', () => { + const result = format_diff(single_change_hunks(), 'a', 'b'); + assert.include(result, '@@ -8,7 +8,7 @@'); + }); + + test('prefixes added, removed, and context lines', () => { + const result = format_diff(single_change_hunks(), 'a', 'b'); + assert.include(result, '+modified'); + assert.include(result, '-line 11'); + assert.include(result, ' line 8'); + }); + + test('plain text by default, colored once print colors are configured', () => { + const hunks = single_change_hunks(); + assert.notInclude(format_diff(hunks, 'a', 'b'), '\x1b['); + // node's styleText no-ops on non-TTY streams, so use a deterministic styler + const fake_st = ((format: unknown, text: string) => + `[${String(format)}]${text}[/]`) as typeof styleText; + configure_print_colors(fake_st); + try { + const colored = format_diff(hunks, 'a', 'b'); + assert.include(colored, '[green]+modified[/]'); + assert.include(colored, '[red]-line 11[/]'); + assert.include(colored, '[cyan]@@ -8,7 +8,7 @@[/]'); + } finally { + configure_print_colors(null); + } }); test('respects prefix option', () => { - const diff: Array = [{type: 'same', line: 'content'}]; - const result = format_diff(diff, 'a', 'b', {prefix: ' '}); - const lines = result.split('\n'); - for (const line of lines) { + const result = format_diff(single_change_hunks(), 'a', 'b', {prefix: ' '}); + for (const line of result.split('\n')) { assert.isTrue(line.startsWith(' ')); } }); test('respects max_lines option', () => { - const diff: Array = Array.from({length: 100}, (_, i) => ({ - type: 'add' as const, - line: `line ${i}`, - })); - const result = format_diff(diff, 'a', 'b', {use_color: false, max_lines: 5}); + const diff = diff_lines('', Array.from({length: 100}, (_, i) => `line ${i}`).join('\n') + '\n'); + const hunks = diff_hunks(diff); + const result = format_diff(hunks, 'a', 'b', {max_lines: 5}); assert.include(result, '... (95 more lines)'); }); test('max_lines 0 shows all lines', () => { - const diff: Array = Array.from({length: 10}, (_, i) => ({ - type: 'add' as const, - line: `line ${i}`, - })); - const result = format_diff(diff, 'a', 'b', {use_color: false, max_lines: 0}); + const diff = diff_lines('', Array.from({length: 10}, (_, i) => `line ${i}`).join('\n') + '\n'); + const hunks = diff_hunks(diff); + const result = format_diff(hunks, 'a', 'b', {max_lines: 0}); assert.notInclude(result, 'more lines'); - // 2 header lines + 10 content lines - assert.lengthOf(result.split('\n'), 12); + // 2 headers + 1 @@ + 10 content lines + assert.lengthOf(result.split('\n'), 13); }); - test('empty diff shows only headers', () => { - const result = format_diff([], 'a', 'b'); - const lines = result.split('\n'); - assert.lengthOf(lines, 2); + test('emits the no-newline marker', () => { + const diff = diff_lines('a\n', 'a'); + const result = format_diff(diff_hunks(diff), 'a', 'b'); + assert.include(result, '\\ No newline at end of file'); }); }); describe('generate_diff', () => { test('returns formatted diff for text content', () => { - const result = generate_diff('old\nline', 'new\nline', 'test.txt'); + const result = generate_diff('old\nline\n', 'new\nline\n', 'test.txt'); assert.isString(result); - assert.include(result!, '--- test.txt (current)'); - assert.include(result!, '+++ test.txt (desired)'); - }); - - test('returns null for binary current content', () => { - const result = generate_diff('binary\0content', 'text', 'file.bin'); - assert.isNull(result); + assert.include(result!, '--- test.txt'); + assert.include(result!, '+++ test.txt'); + assert.include(result!, '-old'); + assert.include(result!, '+new'); }); - test('returns null for binary desired content', () => { - const result = generate_diff('text', 'binary\0content', 'file.bin'); - assert.isNull(result); + test('returns null for binary content on either side', () => { + assert.isNull(generate_diff('binary\0content', 'text', 'file.bin')); + assert.isNull(generate_diff('text', 'binary\0content', 'file.bin')); + assert.isNull(generate_diff('a\0b', 'c\0d', 'file.bin')); }); - test('returns null when both are binary', () => { - const result = generate_diff('a\0b', 'c\0d', 'file.bin'); - assert.isNull(result); + test('identical content produces only headers', () => { + const content = 'same\ncontent\n'; + const result = generate_diff(content, content, 'file.txt'); + assert.strictEqual(result, '--- file.txt\n+++ file.txt'); }); - test('passes options through to format_diff', () => { - const result = generate_diff('old', 'new', 'file.txt', {use_color: false}); + test('context filtering keeps distant lines out', () => { + const a_lines = Array.from({length: 20}, (_, i) => `line ${i}`); + const b_lines = [...a_lines]; + b_lines[10] = 'modified line 10'; + const result = generate_diff(a_lines.join('\n') + '\n', b_lines.join('\n') + '\n', 'file.txt'); assert.isString(result); - assert.notInclude(result!, '\x1b['); + assert.include(result!, 'modified line 10'); + assert.notInclude(result!, 'line 0'); + assert.include(result!, '@@'); }); - test('identical content produces empty-looking diff', () => { - const content = 'same\ncontent'; - const result = generate_diff(content, content, 'file.txt', {use_color: false}); + test('passes format options through', () => { + const result = generate_diff('a\n', 'b\n', 'file.txt', {prefix: '> '}); assert.isString(result); - // filter_diff_context returns [] for no changes, so format_diff returns just headers - const lines = result!.split('\n'); - assert.lengthOf(lines, 2); // just headers + for (const line of result!.split('\n')) { + assert.isTrue(line.startsWith('> ')); + } }); - test('uses path for both current and desired labels', () => { - const result = generate_diff('a', 'b', 'src/file.ts', {use_color: false}); - assert.isString(result); - assert.include(result!, '--- src/file.ts (current)'); - assert.include(result!, '+++ src/file.ts (desired)'); + test('both empty strings produce only headers', () => { + assert.strictEqual(generate_diff('', '', 'file.txt'), '--- file.txt\n+++ file.txt'); }); +}); - test('handles multiline changes with context filtering', () => { - const lines_a = Array.from({length: 20}, (_, i) => `line ${i}`); - const lines_b = [...lines_a]; - lines_b[10] = 'modified line 10'; - const result = generate_diff(lines_a.join('\n'), lines_b.join('\n'), 'file.txt', { - use_color: false, - }); - assert.isString(result); - assert.include(result!, 'modified line 10'); +describe('round-trip property', () => { + const random = create_random_alea('diff-rework'); + const random_int = (max: number): number => Math.floor(random() * max); + const alphabet = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', '']; + + const random_case = (): {a: string; b: string} => { + const a_lines: Array = []; + const a_len = random_int(40); + for (let i = 0; i < a_len; i++) a_lines.push(alphabet[random_int(alphabet.length)]!); + const b_lines = [...a_lines]; + const mutations = random_int(9); + for (let i = 0; i < mutations; i++) { + const kind = random_int(3); + const at = random_int(b_lines.length + 1); + if (kind === 0) { + b_lines.splice(at, random_int(4)); + } else if (kind === 1) { + b_lines.splice(at, 0, alphabet[random_int(alphabet.length)]!); + } else if (at < b_lines.length) { + b_lines[at] = alphabet[random_int(alphabet.length)]!; + } + } + const terminate = (lines: Array): string => { + if (lines.length === 0) return ''; + // avoid the ambiguous unterminated-empty-final-line case: an empty + // final line without a trailing newline is indistinguishable from a + // terminated text under this test's reconstruction + if (lines[lines.length - 1] === '' || random_int(2) === 0) return lines.join('\n') + '\n'; + return lines.join('\n'); + }; + return {a: terminate(a_lines), b: terminate(b_lines)}; + }; + + test('diffs reconstruct both sides exactly', () => { + for (let i = 0; i < 100; i++) { + const {a, b} = random_case(); + const result = diff_lines(a, b); + assert.strictEqual(reconstruct_a(result), a, `a mismatch for case ${i}`); + assert.strictEqual(reconstruct_b(result), b, `b mismatch for case ${i}`); + assert_line_numbers(result); + assert_normalized(result); + } }); - test('both empty strings', () => { - const result = generate_diff('', '', 'file.txt', {use_color: false}); - assert.isString(result); - // Identical content → just headers - const lines = result!.split('\n'); - assert.lengthOf(lines, 2); + test('capped diffs still reconstruct both sides exactly', () => { + for (let i = 0; i < 100; i++) { + const {a, b} = random_case(); + const result = diff_lines(a, b, {max_cost: 3}); + assert.strictEqual(reconstruct_a(result), a, `a mismatch for case ${i}`); + assert.strictEqual(reconstruct_b(result), b, `b mismatch for case ${i}`); + assert_line_numbers(result); + assert_normalized(result); + } }); });