Skip to main content

sley_diff_merge/
line_diff.rs

1//! Line-level diff algorithms (Myers, patience, histogram).
2
3use std::collections::HashMap;
4
5/// A single line of a blob, slicing into the original buffer.
6///
7/// `content` includes the line's own trailing newline byte when present;
8/// `has_newline` records whether this line ended with `\n` in the source. Only
9/// the final line of a blob can have `has_newline == false` (a file with "no
10/// newline at end of file"). Comparing two `DiffLine`s for equality compares
11/// both the bytes and the trailing-newline flag, so a line that gained or lost
12/// its terminating newline is treated as a real change, matching git.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct DiffLine<'a> {
15    /// The raw bytes of the line, including the trailing `\n` if it had one.
16    pub content: &'a [u8],
17    /// Whether the line was terminated by a newline in the source blob.
18    pub has_newline: bool,
19}
20
21impl<'a> DiffLine<'a> {
22    /// The line bytes without any trailing newline.
23    pub fn bytes_without_newline(&self) -> &'a [u8] {
24        if self.has_newline {
25            self.content.strip_suffix(b"\n").unwrap_or(self.content)
26        } else {
27            self.content
28        }
29    }
30}
31
32/// Split a blob into lines, preserving the exact bytes of each line.
33///
34/// Each returned [`DiffLine`] borrows from `blob`; its `content` includes the
35/// terminating `\n`. The returned vector is empty for an empty blob. A blob
36/// whose final byte is not `\n` yields a final line with `has_newline ==
37/// false` — git's "\ No newline at end of file" case.
38pub fn split_lines(blob: &[u8]) -> Vec<DiffLine<'_>> {
39    let mut lines = Vec::new();
40    let mut start = 0usize;
41    let len = blob.len();
42    let mut idx = 0usize;
43    while idx < len {
44        if blob[idx] == b'\n' {
45            lines.push(DiffLine {
46                content: &blob[start..=idx],
47                has_newline: true,
48            });
49            idx += 1;
50            start = idx;
51        } else {
52            idx += 1;
53        }
54    }
55    if start < len {
56        lines.push(DiffLine {
57            content: &blob[start..len],
58            has_newline: false,
59        });
60    }
61    lines
62}
63
64/// A run-length entry in a Myers edit script.
65///
66/// Each variant carries the number of consecutive lines it applies to:
67/// - [`DiffOp::Equal`] — `n` lines common to both `old` and `new`.
68/// - [`DiffOp::Delete`] — `n` lines present in `old` but not `new`.
69/// - [`DiffOp::Insert`] — `n` lines present in `new` but not `old`.
70///
71/// Walking the script in order and consuming `old`/`new` lines accordingly
72/// reconstructs `new` from `old`.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum DiffOp {
75    /// `n` lines are identical in both sequences.
76    Equal(usize),
77    /// `n` lines are removed from the old sequence.
78    Delete(usize),
79    /// `n` lines are added in the new sequence.
80    Insert(usize),
81}
82
83/// Compute a minimal line-level edit script transforming `old` into `new`
84/// using Myers' O(ND) difference algorithm.
85///
86/// Lines are compared for equality by their full bytes (see [`DiffLine`]). The
87/// result is a coalesced sequence of [`DiffOp`] runs; consecutive ops of the
88/// same kind are merged so the script is compact. The script is a standard
89/// (shortest-edit-script) diff: the number of `Delete` + `Insert` lines is
90/// minimal.
91pub fn myers_diff_lines(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> Vec<DiffOp> {
92    // Trim a common prefix and suffix first. This keeps the O(ND) search small
93    // for the typical case of a localized edit and does not affect minimality.
94    let n_total = old.len();
95    let m_total = new.len();
96    let mut prefix = 0usize;
97    while prefix < n_total && prefix < m_total && old[prefix] == new[prefix] {
98        prefix += 1;
99    }
100    let mut suffix = 0usize;
101    while suffix < n_total - prefix
102        && suffix < m_total - prefix
103        && old[n_total - 1 - suffix] == new[m_total - 1 - suffix]
104    {
105        suffix += 1;
106    }
107
108    let old_mid = &old[prefix..n_total - suffix];
109    let new_mid = &new[prefix..m_total - suffix];
110
111    let mut ops: Vec<DiffOp> = Vec::new();
112    if prefix > 0 {
113        ops.push(DiffOp::Equal(prefix));
114    }
115    myers_core(old_mid, new_mid, &mut ops);
116    if suffix > 0 {
117        ops.push(DiffOp::Equal(suffix));
118    }
119    coalesce_ops(ops)
120}
121
122/// Classic forward Myers O(ND) shortest-edit-script search over the trimmed
123/// sub-problem, followed by a backtrack through the stored traces.
124///
125/// `old`/`new` are the trimmed (no common prefix/suffix) line slices. Per-line
126/// ops are appended to `out` in order; they are coalesced by the caller. This
127/// is the algorithm from Myers' 1986 paper, which yields a shortest edit script
128/// (minimal number of insertions + deletions).
129fn myers_core(old: &[DiffLine<'_>], new: &[DiffLine<'_>], out: &mut Vec<DiffOp>) {
130    let n = old.len() as isize;
131    let m = new.len() as isize;
132    if n == 0 {
133        if m > 0 {
134            out.push(DiffOp::Insert(m as usize));
135        }
136        return;
137    }
138    if m == 0 {
139        out.push(DiffOp::Delete(n as usize));
140        return;
141    }
142
143    let max = (n + m) as usize;
144    let offset = max as isize; // shift so diagonal k maps to index (k + offset)
145    let width = 2 * max + 1;
146    // v[k + offset] holds the furthest-reaching x on diagonal k for the current d.
147    let mut v = vec![0isize; width];
148    // Save a snapshot of v after each d so we can backtrack the chosen path.
149    let mut trace: Vec<Vec<isize>> = Vec::new();
150
151    let mut found_d: Option<usize> = None;
152    'search: for d in 0..=(max as isize) {
153        trace.push(v.clone());
154        let mut k = -d;
155        while k <= d {
156            let kidx = (k + offset) as usize;
157            // Decide whether we arrived here by moving down (insert, from k+1)
158            // or right (delete, from k-1). Prefer the move that reaches further.
159            let mut x = if k == -d
160                || (k != d && v[(k - 1 + offset) as usize] < v[(k + 1 + offset) as usize])
161            {
162                // Move down: x stays, y increases (insertion from new).
163                v[(k + 1 + offset) as usize]
164            } else {
165                // Move right: x increases (deletion from old).
166                v[(k - 1 + offset) as usize] + 1
167            };
168            let mut y = x - k;
169            // Follow the diagonal (matching lines) as far as possible.
170            while x < n && y < m && old[x as usize] == new[y as usize] {
171                x += 1;
172                y += 1;
173            }
174            v[kidx] = x;
175            if x >= n && y >= m {
176                found_d = Some(d as usize);
177                break 'search;
178            }
179            k += 2;
180        }
181    }
182
183    // A shortest edit path always exists, so found_d is set; if somehow not,
184    // fall back to a delete-all/insert-all script (still correct, not minimal).
185    let Some(d_end) = found_d else {
186        out.push(DiffOp::Delete(n as usize));
187        out.push(DiffOp::Insert(m as usize));
188        return;
189    };
190
191    backtrack(n, m, &trace, d_end, offset, out);
192}
193
194/// Reconstruct the edit script from the saved Myers traces.
195///
196/// Walks backward from `(n, m)` to `(0, 0)`, emitting per-line `Delete`,
197/// `Insert`, and `Equal` ops, then reverses them into forward order before
198/// appending to `out`. `n`/`m` are the lengths of the (trimmed) old/new slices.
199fn backtrack(
200    n: isize,
201    m: isize,
202    trace: &[Vec<isize>],
203    d_end: usize,
204    offset: isize,
205    out: &mut Vec<DiffOp>,
206) {
207    let mut x = n;
208    let mut y = m;
209    let mut rev: Vec<DiffOp> = Vec::new();
210
211    for d in (0..=d_end).rev() {
212        let v = &trace[d];
213        let k = x - y;
214        // Determine the predecessor diagonal, mirroring the forward step rule.
215        let prev_k = if k == -(d as isize)
216            || (k != d as isize && v[(k - 1 + offset) as usize] < v[(k + 1 + offset) as usize])
217        {
218            k + 1 // came from a down move (insert)
219        } else {
220            k - 1 // came from a right move (delete)
221        };
222        let prev_x = v[(prev_k + offset) as usize];
223        let prev_y = prev_x - prev_k;
224
225        // Emit the diagonal (equal) moves taken after reaching the predecessor.
226        while x > prev_x && y > prev_y {
227            rev.push(DiffOp::Equal(1));
228            x -= 1;
229            y -= 1;
230        }
231        if d > 0 {
232            if x == prev_x {
233                // Down move: an insertion of new[prev_y].
234                rev.push(DiffOp::Insert(1));
235            } else {
236                // Right move: a deletion of old[prev_x].
237                rev.push(DiffOp::Delete(1));
238            }
239            x = prev_x;
240            y = prev_y;
241        }
242    }
243
244    rev.reverse();
245    out.extend(rev);
246}
247
248/// Merge adjacent ops of the same kind so the script is compact.
249fn coalesce_ops(ops: Vec<DiffOp>) -> Vec<DiffOp> {
250    let mut out: Vec<DiffOp> = Vec::with_capacity(ops.len());
251    for op in ops {
252        match (out.last_mut(), op) {
253            (Some(DiffOp::Equal(prev)), DiffOp::Equal(n)) => *prev += n,
254            (Some(DiffOp::Delete(prev)), DiffOp::Delete(n)) => *prev += n,
255            (Some(DiffOp::Insert(prev)), DiffOp::Insert(n)) => *prev += n,
256            _ => out.push(op),
257        }
258    }
259    out
260}
261
262// ===========================================================================
263// Whitespace-ignoring line comparison (git xdiff's XDF_WHITESPACE_FLAGS).
264//
265// git's xdiff compares two records (lines, including the trailing `\n`) for
266// equality under whitespace-ignore flags via `xdl_recmatch`. Rather than
267// re-implement the Myers core to take a custom equality predicate, we map each
268// flavour to a *canonicalization* of the line bytes that produces identical
269// output iff `xdl_recmatch` would return 1, then diff on the canonicalized
270// lines while emitting the original bytes. This is exact: it is a behavioural
271// port of `xdiff/xutils.c:xdl_recmatch` and `xdl_blankline`.
272// ===========================================================================
273
274/// Whitespace-ignore flags for line comparison, mirroring git's
275/// `XDF_WHITESPACE_FLAGS` (`-w`, `-b`, `--ignore-space-at-eol`,
276/// `--ignore-cr-at-eol`). Only one of the whitespace flavours is honoured per
277/// git's precedence (`-w` ⊃ `-b` ⊃ `--ignore-space-at-eol` ⊃
278/// `--ignore-cr-at-eol`); when several are set, the strongest wins, matching
279/// the cascade in `xdl_recmatch`.
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
281pub struct WsIgnore {
282    /// `-w` / `--ignore-all-space`: ignore all whitespace when comparing lines.
283    pub all_space: bool,
284    /// `-b` / `--ignore-space-change`: ignore changes in amount of whitespace.
285    pub space_change: bool,
286    /// `--ignore-space-at-eol`: ignore whitespace at end of line.
287    pub space_at_eol: bool,
288    /// `--ignore-cr-at-eol`: ignore a carriage-return at end of line.
289    pub cr_at_eol: bool,
290}
291
292impl WsIgnore {
293    /// No whitespace-ignore flavour active (the exact, byte-for-byte comparison).
294    pub const EMPTY: Self = Self {
295        all_space: false,
296        space_change: false,
297        space_at_eol: false,
298        cr_at_eol: false,
299    };
300
301    /// True when no whitespace-ignore flavour is active.
302    pub fn is_empty(&self) -> bool {
303        !(self.all_space || self.space_change || self.space_at_eol || self.cr_at_eol)
304    }
305}
306
307/// `XDL_ISSPACE` — git uses C `isspace` over the unsigned byte (space, `\t`,
308/// `\n`, `\r`, `\x0b` vertical tab, `\x0c` form feed).
309#[inline]
310fn xdl_isspace(c: u8) -> bool {
311    matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
312}
313
314/// Canonicalize a line's bytes (including any trailing `\n`) for whitespace-
315/// insensitive comparison, exactly mirroring `xdl_recmatch`'s acceptance set:
316/// two original lines are equal under `ignore` iff their canonical forms are
317/// byte-identical.
318///
319/// * `all_space` (`-w`): drop every whitespace byte.
320/// * `space_change` (`-b`): collapse each run of whitespace to a single `' '`
321///   and strip trailing whitespace (a run on one side matches a run on the
322///   other regardless of length; leading/internal whitespace must still align,
323///   trailing whitespace is dropped entirely).
324/// * `space_at_eol`: strip trailing whitespace only.
325/// * `cr_at_eol`: drop a single `\r` immediately before a terminating `\n`.
326///
327/// Exposed crate-internally so the change-compaction pass in [`crate::render`]
328/// can compare lines for sliding under the exact same equality the line-level
329/// diff uses (git's `recs_match` on the whitespace-canonicalized record).
330pub(crate) fn canonicalize_line_for_match(line: &[u8], ignore: WsIgnore) -> Vec<u8> {
331    canonicalize_line(line, ignore)
332}
333
334pub(crate) fn canonicalize_line(line: &[u8], ignore: WsIgnore) -> Vec<u8> {
335    if ignore.all_space {
336        return line.iter().copied().filter(|&c| !xdl_isspace(c)).collect();
337    }
338    if ignore.space_change {
339        let mut out = Vec::with_capacity(line.len());
340        let mut i = 0usize;
341        while i < line.len() {
342            if xdl_isspace(line[i]) {
343                // Collapse the whole whitespace run to a single space.
344                while i < line.len() && xdl_isspace(line[i]) {
345                    i += 1;
346                }
347                out.push(b' ');
348            } else {
349                out.push(line[i]);
350                i += 1;
351            }
352        }
353        // Strip a trailing collapsed-space (trailing whitespace is ignored).
354        if out.last() == Some(&b' ') {
355            out.pop();
356        }
357        return out;
358    }
359    if ignore.space_at_eol {
360        let mut end = line.len();
361        while end > 0 && xdl_isspace(line[end - 1]) {
362            end -= 1;
363        }
364        return line[..end].to_vec();
365    }
366    if ignore.cr_at_eol {
367        // Drop a `\r` directly before a terminating `\n`.
368        if let Some(stripped) = line.strip_suffix(b"\n") {
369            if let Some(without_cr) = stripped.strip_suffix(b"\r") {
370                let mut out = without_cr.to_vec();
371                out.push(b'\n');
372                return out;
373            }
374        } else if let Some(without_cr) = line.strip_suffix(b"\r") {
375            // Incomplete final line: a bare trailing `\r` is also ignored.
376            return without_cr.to_vec();
377        }
378        return line.to_vec();
379    }
380    line.to_vec()
381}
382
383/// `xdl_blankline`: a line is "blank" when, after applying the active
384/// whitespace flags, it has no content. With no whitespace flags, git treats a
385/// record of size ≤ 1 (empty, or a lone `\n`) as blank; with flags, a line all
386/// of whose bytes are whitespace is blank.
387pub(crate) fn line_is_blank(line: &[u8], ignore: WsIgnore) -> bool {
388    if ignore.is_empty() {
389        line.len() <= 1
390    } else {
391        line.iter().all(|&c| xdl_isspace(c))
392    }
393}
394
395/// Compute a line-level edit script transforming `old` into `new`, comparing
396/// lines under the whitespace-ignore flags `ignore` while the returned ops
397/// still index the *original* lines position-for-position.
398///
399/// When `ignore.is_empty()`, this is identical to [`myers_diff_lines`]. With
400/// flags, lines are canonicalized (see [`canonicalize_line`]) for the equality
401/// test only; the ops consume the same number of old/new lines as the originals
402/// so the caller can render the original bytes.
403pub fn myers_diff_lines_ws(
404    old: &[DiffLine<'_>],
405    new: &[DiffLine<'_>],
406    ignore: WsIgnore,
407    algorithm: DiffAlgorithm,
408) -> Vec<DiffOp> {
409    if ignore.is_empty() {
410        return diff_lines_with_algorithm(old, new, algorithm);
411    }
412    let old_canon: Vec<Vec<u8>> = old
413        .iter()
414        .map(|l| canonicalize_line(l.content, ignore))
415        .collect();
416    let new_canon: Vec<Vec<u8>> = new
417        .iter()
418        .map(|l| canonicalize_line(l.content, ignore))
419        .collect();
420    let old_lines: Vec<DiffLine<'_>> = old_canon
421        .iter()
422        .map(|c| DiffLine {
423            content: c.as_slice(),
424            has_newline: true,
425        })
426        .collect();
427    let new_lines: Vec<DiffLine<'_>> = new_canon
428        .iter()
429        .map(|c| DiffLine {
430            content: c.as_slice(),
431            has_newline: true,
432        })
433        .collect();
434    diff_lines_with_algorithm(&old_lines, &new_lines, algorithm)
435}
436
437// ===========================================================================
438// Alternative diff algorithms: patience and histogram.
439//
440// Both share the recursive "anchor and recurse" shape used by git's xdiff
441// implementations of `--patience` and `--histogram`:
442//
443//   1. trim the common prefix and suffix of the current line range,
444//   2. pick one or more common lines that are confidently aligned (the
445//      "anchors") according to the algorithm's rule,
446//   3. recurse on the gaps to the left of, between, and to the right of the
447//      anchors,
448//   4. when no anchor can be found, fall back to the Myers shortest-edit-script
449//      search for that range so the result is still a valid LCS-correct diff.
450//
451// They operate purely on slices of [`DiffLine`]s and emit the same coalesced
452// [`DiffOp`] run sequence as [`myers_diff_lines`], so any caller can swap
453// algorithms freely. The two functions differ only in the anchor-selection
454// rule in steps 2/3.
455// ===========================================================================
456
457/// A hashable key for a line, used to bucket equal lines when finding anchors.
458///
459/// Mirrors [`DiffLine`]'s `PartialEq`: two lines are the same iff their bytes
460/// and their trailing-newline flag match. Keying on this tuple lets us hash
461/// lines without changing the public [`DiffLine`] type.
462type LineKey<'a> = (&'a [u8], bool);
463
464#[inline]
465fn line_key<'a>(line: &DiffLine<'a>) -> LineKey<'a> {
466    (line.content, line.has_newline)
467}
468
469/// Compute a line-level edit script transforming `old` into `new` using the
470/// patience diff algorithm (Bram Cohen's algorithm, as in `git diff
471/// --patience`).
472///
473/// Patience diff anchors on lines that occur *exactly once* in both `old` and
474/// `new`; it aligns those unique lines via a longest-increasing-subsequence
475/// ("patience sorting") pass and recurses into the gaps, falling back to Myers
476/// when a gap has no unique common line. The result is a valid LCS-correct edit
477/// script with the same shape as [`myers_diff_lines`]: walking it reconstructs
478/// `new` from `old`, and every [`DiffOp::Equal`] run covers genuinely equal
479/// lines. Patience tends to produce more human-readable hunks than Myers when
480/// blocks of lines are moved or repeated, though it is not guaranteed to be a
481/// shortest edit script.
482pub fn patience_diff_lines(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> Vec<DiffOp> {
483    patience_diff_lines_anchored(old, new, &[])
484}
485
486/// As [`patience_diff_lines`], but pins lines whose content has any of `anchors`
487/// as a byte prefix into the common subsequence (git's `--anchored=<text>`).
488///
489/// Mirrors xdiff's `xpatience.c`: an anchor line that is unique in both ranges is
490/// forced to remain aligned (so *other* lines are moved instead), taken greedily
491/// in old-side order; an anchor that would break the increasing order with an
492/// already-pinned anchor is dropped. Anchors that are non-unique or absent have
493/// no effect, exactly as in git. With `anchors` empty this is plain patience.
494pub fn patience_diff_lines_anchored(
495    old: &[DiffLine<'_>],
496    new: &[DiffLine<'_>],
497    anchors: &[Vec<u8>],
498) -> Vec<DiffOp> {
499    let mut ops: Vec<DiffOp> = Vec::new();
500    patience_recurse(old, new, 0, old.len(), 0, new.len(), anchors, &mut ops);
501    coalesce_ops(ops)
502}
503
504/// Compute a line-level edit script transforming `old` into `new` using the
505/// histogram diff algorithm (as in `git diff --histogram`, derived from JGit).
506///
507/// Histogram diff is a patience-style unique-anchor algorithm with a fallback:
508/// it builds an occurrence histogram of `old` and, scanning `new`, picks the
509/// longest run of matching lines whose `old` line has the *fewest* occurrences
510/// (preferring truly unique lines, like patience, but still able to anchor on
511/// low-frequency lines when no globally-unique line exists). It then recurses
512/// on the regions on either side of that run, falling back to Myers only when
513/// no common line exists in a region. The result is a valid LCS-correct edit
514/// script with the same shape as [`myers_diff_lines`].
515pub fn histogram_diff_lines(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> Vec<DiffOp> {
516    let mut ops: Vec<DiffOp> = Vec::new();
517    histogram_recurse(old, new, 0, old.len(), 0, new.len(), &mut ops);
518    coalesce_ops(ops)
519}
520
521/// Dispatch to the line-diff implementation selected by `algorithm`.
522///
523/// All variants return the same coalesced [`DiffOp`] run sequence as
524/// [`myers_diff_lines`], so callers can switch algorithms without changing how
525/// they consume the result.
526///
527/// - [`DiffAlgorithm::Myers`] and [`DiffAlgorithm::Minimal`] use the Myers
528///   O(ND) shortest-edit-script search ([`myers_diff_lines`]); that search is
529///   already minimal in deletions + insertions, so `Minimal` is an alias for
530///   it here rather than a distinct slower mode.
531/// - [`DiffAlgorithm::Patience`] uses [`patience_diff_lines`].
532/// - [`DiffAlgorithm::Histogram`] uses [`histogram_diff_lines`].
533pub fn diff_lines_with_algorithm(
534    old: &[DiffLine<'_>],
535    new: &[DiffLine<'_>],
536    algorithm: DiffAlgorithm,
537) -> Vec<DiffOp> {
538    match algorithm {
539        DiffAlgorithm::Myers | DiffAlgorithm::Minimal => myers_diff_lines(old, new),
540        DiffAlgorithm::Patience => patience_diff_lines(old, new),
541        DiffAlgorithm::Histogram => histogram_diff_lines(old, new),
542    }
543}
544
545/// Emit ops for an empty-on-one-side range; returns `true` if it handled it.
546///
547/// Covers the recursion base cases where one side of `old[a0..a1]` /
548/// `new[b0..b1]` is empty: a pure deletion, a pure insertion, or nothing at
549/// all. Used by both the patience and histogram recursions before they look
550/// for an anchor.
551fn emit_trivial_range(a0: usize, a1: usize, b0: usize, b1: usize, out: &mut Vec<DiffOp>) -> bool {
552    let old_len = a1 - a0;
553    let new_len = b1 - b0;
554    if old_len == 0 && new_len == 0 {
555        return true;
556    }
557    if old_len == 0 {
558        out.push(DiffOp::Insert(new_len));
559        return true;
560    }
561    if new_len == 0 {
562        out.push(DiffOp::Delete(old_len));
563        return true;
564    }
565    false
566}
567
568/// Trim the common prefix/suffix of `old[a0..a1]` vs `new[b0..b1]`.
569///
570/// Emits an `Equal` for the matched prefix immediately, returns the inner
571/// (still-differing) range, and reports the matched-suffix length so the caller
572/// can emit its `Equal` *after* it has processed the inner range. This keeps
573/// the per-range work proportional to the actual edit, mirroring the prefix /
574/// suffix trim in [`myers_diff_lines`].
575fn trim_common(
576    old: &[DiffLine<'_>],
577    new: &[DiffLine<'_>],
578    mut a0: usize,
579    mut a1: usize,
580    mut b0: usize,
581    mut b1: usize,
582    out: &mut Vec<DiffOp>,
583) -> (usize, usize, usize, usize, usize) {
584    let mut prefix = 0usize;
585    while a0 < a1 && b0 < b1 && old[a0] == new[b0] {
586        a0 += 1;
587        b0 += 1;
588        prefix += 1;
589    }
590    if prefix > 0 {
591        out.push(DiffOp::Equal(prefix));
592    }
593    let mut suffix = 0usize;
594    while a1 > a0 && b1 > b0 && old[a1 - 1] == new[b1 - 1] {
595        a1 -= 1;
596        b1 -= 1;
597        suffix += 1;
598    }
599    (a0, a1, b0, b1, suffix)
600}
601
602/// Recursive patience-diff worker over `old[a0..a1]` vs `new[b0..b1]`.
603///
604/// `anchors` carries the `--anchored=<text>` prefixes (empty for plain
605/// patience); they are re-evaluated at every recursion level, since a line that
606/// is non-unique in the whole file can become unique within a sub-range.
607#[allow(clippy::too_many_arguments)]
608fn patience_recurse(
609    old: &[DiffLine<'_>],
610    new: &[DiffLine<'_>],
611    a0: usize,
612    a1: usize,
613    b0: usize,
614    b1: usize,
615    anchors: &[Vec<u8>],
616    out: &mut Vec<DiffOp>,
617) {
618    if emit_trivial_range(a0, a1, b0, b1, out) {
619        return;
620    }
621    let (a0, a1, b0, b1, suffix) = trim_common(old, new, a0, a1, b0, b1, out);
622    if !emit_trivial_range(a0, a1, b0, b1, out) {
623        match patience_anchors(old, new, a0, a1, b0, b1, anchors) {
624            Some(aligned) => {
625                // Walk the aligned anchors in order, recursing into each gap
626                // before emitting the anchor line as Equal.
627                let mut cur_a = a0;
628                let mut cur_b = b0;
629                for (ai, bi) in aligned {
630                    patience_recurse(old, new, cur_a, ai, cur_b, bi, anchors, out);
631                    out.push(DiffOp::Equal(1));
632                    cur_a = ai + 1;
633                    cur_b = bi + 1;
634                }
635                // Tail after the last anchor.
636                patience_recurse(old, new, cur_a, a1, cur_b, b1, anchors, out);
637            }
638            // No unique common line in this range: defer to Myers, which always
639            // yields a valid (and minimal) script for the leftover block.
640            None => myers_core(&old[a0..a1], &new[b0..b1], out),
641        }
642    }
643    if suffix > 0 {
644        out.push(DiffOp::Equal(suffix));
645    }
646}
647
648/// Find the patience anchors for `old[a0..a1]` vs `new[b0..b1]`.
649///
650/// An anchor is a line that occurs exactly once in `old[a0..a1]` and exactly
651/// once in `new[b0..b1]`. The matched (old_index, new_index) pairs are reduced
652/// to their longest increasing subsequence by new-index (the patience-sort LCS)
653/// so the returned anchors are strictly increasing in *both* indices and can be
654/// used as split points. Returns `None` when there are no such unique common
655/// lines (the caller then falls back to Myers).
656fn patience_anchors(
657    old: &[DiffLine<'_>],
658    new: &[DiffLine<'_>],
659    a0: usize,
660    a1: usize,
661    b0: usize,
662    b1: usize,
663    anchors: &[Vec<u8>],
664) -> Option<Vec<(usize, usize)>> {
665    // Count occurrences and remember the (single) position of each line in each
666    // side's range. `count > 1` poisons the position so we can ignore it.
667    struct Occ {
668        count: usize,
669        pos: usize,
670    }
671    let mut in_old: HashMap<LineKey<'_>, Occ> = HashMap::new();
672    for (i, line) in old.iter().enumerate().take(a1).skip(a0) {
673        in_old
674            .entry(line_key(line))
675            .and_modify(|o| o.count += 1)
676            .or_insert(Occ { count: 1, pos: i });
677    }
678    let mut in_new: HashMap<LineKey<'_>, Occ> = HashMap::new();
679    for (j, line) in new.iter().enumerate().take(b1).skip(b0) {
680        in_new
681            .entry(line_key(line))
682            .and_modify(|o| o.count += 1)
683            .or_insert(Occ { count: 1, pos: j });
684    }
685
686    // Collect lines unique in both, ordered by their position in `old`.
687    let mut pairs: Vec<(usize, usize)> = Vec::new();
688    for (i, line) in old.iter().enumerate().take(a1).skip(a0) {
689        let key = line_key(line);
690        let Some(o) = in_old.get(&key) else { continue };
691        if o.count != 1 || o.pos != i {
692            continue;
693        }
694        // A line unique in both ranges is a candidate anchor.
695        if let Some(n) = in_new.get(&key)
696            && n.count == 1
697        {
698            pairs.push((i, n.pos));
699        }
700    }
701    if pairs.is_empty() {
702        return None;
703    }
704
705    // Patience sort: longest increasing subsequence of new-indices. `pairs` is
706    // already sorted by old-index, so an LIS by new-index yields a set of
707    // anchors increasing in both coordinates. With `--anchored` text(s) present,
708    // pin the matching (unique-in-both) lines into the subsequence instead.
709    let lis = if anchors.is_empty() {
710        longest_increasing_by_new(&pairs)
711    } else {
712        let is_anchor: Vec<bool> = pairs
713            .iter()
714            .map(|&(_, nj)| line_matches_anchor(new[nj].content, anchors))
715            .collect();
716        longest_increasing_by_new_anchored(&pairs, &is_anchor)
717    };
718    if lis.is_empty() { None } else { Some(lis) }
719}
720
721/// Whether `line` begins with any of the `--anchored` prefixes (git's
722/// `is_anchor`: a byte-prefix `strncmp` against the line's content, trailing
723/// newline included). An empty anchor prefix matches every line, matching git.
724fn line_matches_anchor(line: &[u8], anchors: &[Vec<u8>]) -> bool {
725    anchors.iter().any(|anchor| line.starts_with(anchor))
726}
727
728/// Longest increasing subsequence of `pairs` (sorted by old-index) keyed on the
729/// new-index, returned as the chosen (old_index, new_index) pairs in order.
730///
731/// This is the patience-sorting core: standard O(k log k) LIS with predecessor
732/// links so the actual subsequence (not just its length) is recovered. Because
733/// the input is pre-sorted by old-index and the new-indices are distinct, the
734/// result is strictly increasing in both coordinates.
735fn longest_increasing_by_new(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
736    if pairs.is_empty() {
737        return Vec::new();
738    }
739    // tails[len-1] = index into `pairs` of the smallest possible tail value of
740    // an increasing subsequence of length `len`.
741    let mut tails: Vec<usize> = Vec::new();
742    // prev[i] = index into `pairs` of the predecessor of pairs[i] in its LIS.
743    let mut prev: Vec<Option<usize>> = vec![None; pairs.len()];
744
745    for i in 0..pairs.len() {
746        let val = pairs[i].1;
747        // Binary search for the first tail whose new-index is >= val.
748        let mut lo = 0usize;
749        let mut hi = tails.len();
750        while lo < hi {
751            let mid = lo + (hi - lo) / 2;
752            if pairs[tails[mid]].1 < val {
753                lo = mid + 1;
754            } else {
755                hi = mid;
756            }
757        }
758        if lo > 0 {
759            prev[i] = Some(tails[lo - 1]);
760        }
761        if lo == tails.len() {
762            tails.push(i);
763        } else {
764            tails[lo] = i;
765        }
766    }
767
768    // Reconstruct by following predecessor links from the last tail.
769    let mut result: Vec<(usize, usize)> = Vec::with_capacity(tails.len());
770    let mut cur = tails.last().copied();
771    while let Some(i) = cur {
772        result.push(pairs[i]);
773        cur = prev[i];
774    }
775    result.reverse();
776    result
777}
778
779/// Longest increasing subsequence of `pairs` (sorted by old-index, keyed on the
780/// new-index) that is *forced* to pass through every includible anchor.
781///
782/// A direct port of git's anchored `find_longest_common_sequence`
783/// (xdiff/xpatience.c): entries are processed in old-index order and placed into
784/// the patience-sort `sequence` by their new-index. When an anchor entry
785/// (`is_anchor[i]`) is placed at position `k`, `anchor_i` is pinned to `k` and
786/// the running length is forced to `k + 1`; thereafter positions `<= anchor_i`
787/// can never be overridden, so the result must contain that anchor. A later
788/// anchor whose placement would fall at or before `anchor_i` is skipped, exactly
789/// matching git's greedy handling of mutually-incompatible anchors.
790fn longest_increasing_by_new_anchored(
791    pairs: &[(usize, usize)],
792    is_anchor: &[bool],
793) -> Vec<(usize, usize)> {
794    if pairs.is_empty() {
795        return Vec::new();
796    }
797    // sequence[k] = index into `pairs` of the smallest-new-index tail of an
798    // increasing subsequence of length k+1; `prev` links to the predecessor.
799    let mut sequence: Vec<usize> = Vec::with_capacity(pairs.len());
800    let mut prev: Vec<Option<usize>> = vec![None; pairs.len()];
801    let mut longest: usize = 0;
802    let mut anchor_i: isize = -1;
803    for (e, &(_, val)) in pairs.iter().enumerate() {
804        // i = largest position in sequence[0..longest] whose new-index < val,
805        // or -1 if none (git's fast-path + `binary_search`).
806        let i: isize = if longest == 0 || val > pairs[sequence[longest - 1]].1 {
807            longest as isize - 1
808        } else {
809            let mut lo = 0usize;
810            let mut hi = longest;
811            while lo < hi {
812                let mid = lo + (hi - lo) / 2;
813                if pairs[sequence[mid]].1 < val {
814                    lo = mid + 1;
815                } else {
816                    hi = mid;
817                }
818            }
819            lo as isize - 1
820        };
821        prev[e] = if i < 0 {
822            None
823        } else {
824            Some(sequence[i as usize])
825        };
826        let pos = (i + 1) as usize;
827        if (pos as isize) <= anchor_i {
828            continue;
829        }
830        if pos == sequence.len() {
831            sequence.push(e);
832        } else {
833            sequence[pos] = e;
834        }
835        if is_anchor[e] {
836            anchor_i = pos as isize;
837            longest = pos + 1;
838        } else if pos == longest {
839            longest += 1;
840        }
841    }
842    if longest == 0 {
843        return Vec::new();
844    }
845    let mut result: Vec<(usize, usize)> = Vec::with_capacity(longest);
846    let mut cur = Some(sequence[longest - 1]);
847    while let Some(i) = cur {
848        result.push(pairs[i]);
849        cur = prev[i];
850    }
851    result.reverse();
852    result
853}
854
855/// Recursive histogram-diff worker over `old[a0..a1]` vs `new[b0..b1]`.
856fn histogram_recurse(
857    old: &[DiffLine<'_>],
858    new: &[DiffLine<'_>],
859    a0: usize,
860    a1: usize,
861    b0: usize,
862    b1: usize,
863    out: &mut Vec<DiffOp>,
864) {
865    if emit_trivial_range(a0, a1, b0, b1, out) {
866        return;
867    }
868    let (a0, a1, b0, b1, suffix) = trim_common(old, new, a0, a1, b0, b1, out);
869    if !emit_trivial_range(a0, a1, b0, b1, out) {
870        match histogram_region(old, new, a0, a1, b0, b1) {
871            Some(region) => {
872                // Recurse left of the matched run, emit the run as Equal, then
873                // recurse right of it.
874                histogram_recurse(old, new, a0, region.old_start, b0, region.new_start, out);
875                out.push(DiffOp::Equal(region.len));
876                histogram_recurse(
877                    old,
878                    new,
879                    region.old_start + region.len,
880                    a1,
881                    region.new_start + region.len,
882                    b1,
883                    out,
884                );
885            }
886            // No common line at all in this range: hand it to Myers.
887            None => myers_core(&old[a0..a1], &new[b0..b1], out),
888        }
889    }
890    if suffix > 0 {
891        out.push(DiffOp::Equal(suffix));
892    }
893}
894
895/// The longest common run chosen by the histogram heuristic for one range.
896struct HistogramRegion {
897    old_start: usize,
898    new_start: usize,
899    len: usize,
900}
901
902/// Choose the histogram anchor run for `old[a0..a1]` vs `new[b0..b1]`.
903///
904/// Builds an occurrence histogram of the `old` range, then scans the `new`
905/// range. For each `new` line that also appears in `old`, it extends a matching
906/// run backward and forward and scores candidate alignments, preferring the run
907/// whose anchoring `old` line has the *fewest* occurrences (ties broken by run
908/// length, then by earliest position). This is the JGit/`git --histogram`
909/// heuristic: rare lines make the most reliable anchors. Returns `None` if no
910/// `new` line appears in the `old` range.
911fn histogram_region(
912    old: &[DiffLine<'_>],
913    new: &[DiffLine<'_>],
914    a0: usize,
915    a1: usize,
916    b0: usize,
917    b1: usize,
918) -> Option<HistogramRegion> {
919    // Occurrence count and the list of positions of each line within old[a0..a1].
920    let mut buckets: HashMap<LineKey<'_>, Vec<usize>> = HashMap::new();
921    for (i, line) in old.iter().enumerate().take(a1).skip(a0) {
922        buckets.entry(line_key(line)).or_default().push(i);
923    }
924
925    let mut best: Option<HistogramRegion> = None;
926    // Lower occurrence count is better; among equal counts, longer run wins.
927    let mut best_count = usize::MAX;
928    let mut best_len = 0usize;
929
930    let mut bj = b0;
931    while bj < b1 {
932        let key = line_key(&new[bj]);
933        let Some(positions) = buckets.get(&key) else {
934            bj += 1;
935            continue;
936        };
937        let occ = positions.len();
938        // For every place this line sits in `old`, measure the maximal matching
939        // run that passes through (positions[*], bj).
940        let mut next_bj = bj + 1;
941        for &ai in positions {
942            // Extend backward while lines keep matching and we stay in range.
943            let mut start_a = ai;
944            let mut start_b = bj;
945            while start_a > a0 && start_b > b0 && old[start_a - 1] == new[start_b - 1] {
946                start_a -= 1;
947                start_b -= 1;
948            }
949            // Extend forward from the run start.
950            let mut len = 0usize;
951            while start_a + len < a1
952                && start_b + len < b1
953                && old[start_a + len] == new[start_b + len]
954            {
955                len += 1;
956            }
957            // Score this run by the rarest occurrence count along it; using the
958            // anchor line's own count is the standard, cheaper approximation.
959            let run_count = occ;
960            let better = run_count < best_count || (run_count == best_count && len > best_len);
961            if better && len > 0 {
962                best_count = run_count;
963                best_len = len;
964                best = Some(HistogramRegion {
965                    old_start: start_a,
966                    new_start: start_b,
967                    len,
968                });
969                // Skip past this matched run in `new` so we do not re-evaluate
970                // every interior line of the same run from scratch.
971                if start_b + len > next_bj {
972                    next_bj = start_b + len;
973                }
974            }
975        }
976        bj = next_bj.max(bj + 1);
977    }
978
979    best
980}
981#[derive(Debug, Clone, Copy, PartialEq, Eq)]
982pub enum DiffAlgorithm {
983    Myers,
984    Minimal,
985    Patience,
986    Histogram,
987}