Skip to main content

sley_diff_merge/
render.rs

1//! Unified-diff / patch RENDERER: turn a computed file diff (the old/new
2//! blob contents) into the textual unified-diff hunk body git's `diff.c`
3//! emit path produces (`emit_diff_symbol` / `fn_out_consume`).
4//!
5//! This is the byte-for-byte port of git's hunk emitter: `@@ -os,oc +ns,nc @@
6//! <heading>` hunk headers, the `+`/`-`/context lines, and the
7//! `\ No newline at end of file` marker. It owns hunk *grouping* (combining
8//! changes whose context windows overlap, `xdl_get_hunk`'s `distance >
9//! max_common` break) and hunk *range* computation, then emits each hunk.
10//!
11//! What this module deliberately does NOT own (those stay with the caller,
12//! which has the repository/userdiff/config context):
13//!
14//! * **The per-file metainfo header** (`diff --git`, `index`, `---`/`+++`,
15//!   mode/similarity lines). That is repository- and option-shaped; the
16//!   renderer only produces the hunk body that follows it.
17//! * **Funcname section-heading resolution.** The caller supplies a
18//!   [`HeadingFn`] closure that, given a candidate line, returns its section
19//!   heading (git's `def_ff` default heuristic or a userdiff `xfuncname`
20//!   pattern). The renderer does the *scan upward* for the nearest heading
21//!   line; the caller only classifies a single line.
22//! * **Word-diff body rendering.** When [`HunkRenderOptions::word_diff`] is
23//!   set, the renderer delegates each hunk's body to a [`HunkWordDiff`] hook,
24//!   which the caller implements over its own word-diff machinery.
25//!
26//! The seams keep the byte-shaping (ranges, headers, prefixes, no-newline
27//! markers, color spans) here — the part every diff-emitting command used to
28//! re-derive — while leaving the repository-coupled concerns in the consumer.
29
30use crate::line_diff::{
31    DiffAlgorithm, DiffLine, DiffOp, WsIgnore, line_is_blank, myers_diff_lines_ws,
32    patience_diff_lines_anchored, split_lines,
33};
34use std::collections::HashMap;
35
36/// git's default hunk context (`-U3`).
37pub const DEFAULT_CONTEXT: usize = 3;
38const FUNCTION_CONTEXT_FLAG: usize = 1usize << (usize::BITS - 1);
39const CONTEXT_VALUE_MASK: usize = !FUNCTION_CONTEXT_FLAG;
40
41/// Encode `-W` / `--function-context` into the context field without changing
42/// the option shape used by the existing renderer call sites.
43pub fn enable_function_context(context: usize) -> usize {
44    (context & CONTEXT_VALUE_MASK) | FUNCTION_CONTEXT_FLAG
45}
46
47fn decode_context(context: usize) -> (usize, bool) {
48    (
49        context & CONTEXT_VALUE_MASK,
50        context & FUNCTION_CONTEXT_FLAG != 0,
51    )
52}
53
54fn replace_context_value(encoded: usize, context: usize) -> usize {
55    (encoded & !CONTEXT_VALUE_MASK) | (context & CONTEXT_VALUE_MASK)
56}
57
58/// The per-line origin marker for an emitted diff line.
59#[derive(Clone, Copy, PartialEq, Eq, Debug)]
60pub enum LineKind {
61    /// An unchanged (` `) line, present on both sides.
62    Context,
63    /// A removed (`-`) line, present only on the old side.
64    Delete,
65    /// An added (`+`) line, present only on the new side.
66    Insert,
67}
68
69/// One line of the unified diff, with its origin and 0-based positions in the
70/// old/new files (used to compute hunk ranges and feed the word-diff hook).
71#[derive(Clone, Copy)]
72pub struct TaggedLine<'a> {
73    /// Whether the line is context / a deletion / an insertion.
74    pub kind: LineKind,
75    /// The raw line bytes, including the trailing `\n` when present.
76    pub content: &'a [u8],
77    /// 0-based index of this line on the old side.
78    pub old_index: usize,
79    /// 0-based index of this line on the new side.
80    pub new_index: usize,
81}
82
83/// ANSI color palette for a unified diff, mirroring git's `diff_get_color`
84/// slots. Each field is the raw escape sequence (empty string = no color).
85///
86/// The renderer only consults the slots it paints in the hunk body; the
87/// per-file metainfo slot (`meta`) lives with the caller's header emitter and
88/// is intentionally absent here.
89#[derive(Clone, Copy)]
90pub struct RenderColors<'a> {
91    /// `color.diff.frag` — the `@@ .. @@` span.
92    pub frag: &'a str,
93    /// `color.diff.func` — the section heading after the frag.
94    pub func: &'a str,
95    /// `color.diff.old` — removed (`-`) lines.
96    pub old: &'a str,
97    /// `color.diff.new` — added (`+`) lines.
98    pub new: &'a str,
99    /// `color.diff.context` — context (` `) lines and the no-newline marker.
100    pub context: &'a str,
101    /// The reset sequence terminating each colored span.
102    pub reset: &'a str,
103    /// `color.diff.whitespace` — the highlight for whitespace errors
104    /// (`--ws-error-highlight`).
105    pub whitespace: &'a str,
106    /// `color.diff.oldMoved` — removed lines detected as moved.
107    pub old_moved: &'a str,
108    /// `color.diff.oldMovedAlternative` — alternate removed moved block.
109    pub old_moved_alt: &'a str,
110    /// `color.diff.oldMovedDimmed` — uninteresting removed moved line.
111    pub old_moved_dim: &'a str,
112    /// `color.diff.oldMovedAlternativeDimmed` — alternate dimmed removed line.
113    pub old_moved_alt_dim: &'a str,
114    /// `color.diff.newMoved` — added lines detected as moved.
115    pub new_moved: &'a str,
116    /// `color.diff.newMovedAlternative` — alternate added moved block.
117    pub new_moved_alt: &'a str,
118    /// `color.diff.newMovedDimmed` — uninteresting added moved line.
119    pub new_moved_dim: &'a str,
120    /// `color.diff.newMovedAlternativeDimmed` — alternate dimmed added line.
121    pub new_moved_alt_dim: &'a str,
122}
123
124/// `--color-moved=<mode>` hunk-body mode.
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum ColorMovedMode {
127    /// Mark every matching `+`/`-` line.
128    Plain,
129    /// Mark moved blocks, without alternating colors.
130    Blocks,
131    /// Mark moved blocks, alternating adjacent blocks.
132    Zebra,
133    /// Like zebra, but dim interior lines.
134    DimmedZebra,
135}
136
137/// `--color-moved-ws=<mode>` comparison mode.
138#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
139pub struct ColorMovedWs {
140    /// Whitespace-ignore flags used when interning moved lines.
141    pub ignore: WsIgnore,
142    /// Allow a constant indentation delta across a moved block.
143    pub allow_indentation_change: bool,
144}
145
146/// Moved-code coloring configuration.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub struct ColorMoved {
149    /// Which moved-code coloring style to use.
150    pub mode: ColorMovedMode,
151    /// Whitespace handling for moved-code matching.
152    pub ws: ColorMovedWs,
153}
154
155/// Resolve the section heading for one candidate line.
156///
157/// Returns `Some(heading)` when `line` is a heading line (git's `def_ff`
158/// default heuristic or a userdiff `xfuncname` match) and `None` otherwise.
159/// The renderer scans upward from each hunk's first line and uses the first
160/// `Some` it finds — the caller only has to classify a single line, so it can
161/// keep its userdiff-driver / config resolution out of this crate.
162pub type HeadingFn<'a> = dyn FnMut(&[u8]) -> Option<Vec<u8>> + 'a;
163
164/// A hook that renders a single hunk's body when `--word-diff` is active.
165///
166/// The renderer feeds the hunk's tagged lines through this in order
167/// (`fn_out_consume`'s `diff_words` branch): each removed line is pushed to
168/// the minus buffer, each added line to the plus buffer, and a context line
169/// flushes the accumulated word diff before emitting the context line itself.
170/// The implementor owns the actual word-level rendering and color spans; this
171/// keeps the word-diff machinery in the consumer.
172pub trait HunkWordDiff {
173    /// Buffer one removed line's content for the next word-diff flush.
174    fn push_minus(&mut self, content: &[u8]);
175    /// Buffer one added line's content for the next word-diff flush.
176    fn push_plus(&mut self, content: &[u8]);
177    /// Word-diff the accumulated minus/plus buffers into `out` and reset them.
178    fn flush(&mut self, out: &mut Vec<u8>);
179    /// Emit one context line (the `--word-diff` context style).
180    fn emit_context_line(&mut self, out: &mut Vec<u8>, content: &[u8]);
181}
182
183/// Hunk-shaping and styling options for [`render_hunks`].
184///
185/// Lifetimes are split so the funcname / word-diff hooks can be borrowed
186/// mutably while `colors` is borrowed shared.
187pub struct HunkRenderOptions<'a, 'h> {
188    /// Lines of context around each change (`-U<n>`, default
189    /// [`DEFAULT_CONTEXT`]).
190    pub context: usize,
191    /// Extra inter-hunk merging distance (`--inter-hunk-context`).
192    pub interhunk: usize,
193    /// Per-line section-heading classifier; `None` emits headerless hunks.
194    pub heading: Option<&'a mut HeadingFn<'h>>,
195    /// ANSI palette when color output is enabled.
196    pub colors: Option<RenderColors<'a>>,
197    /// Word-diff body hook (replaces the `+`/`-` line bodies of each hunk).
198    pub word_diff: Option<&'a mut dyn HunkWordDiff>,
199    /// Hunk body line indicators (` `, `-`, `+` by default).
200    pub line_indicators: LineIndicators,
201    /// `--ws-error-highlight` configuration: when set and colors are on, the
202    /// renderer paints whitespace errors on the selected line kinds with
203    /// `colors.whitespace` (git's `emit_line_ws_markup`). `None` disables it.
204    pub ws_error: Option<WsErrorHighlight>,
205    /// Whitespace-ignore flags (`-w`, `-b`, `--ignore-space-at-eol`,
206    /// `--ignore-cr-at-eol`): applied to the line-level comparison so
207    /// whitespace-only changes do not appear as diffs (git's
208    /// `XDF_WHITESPACE_FLAGS`).
209    pub ws_ignore: WsIgnore,
210    /// The line-diff algorithm to use (Myers / patience / histogram).
211    pub algorithm: DiffAlgorithm,
212    /// Indent heuristic (`--indent-heuristic` / `diff.indentHeuristic`): when
213    /// set, change groups that can slide within surrounding identical lines are
214    /// shifted to the most readable boundary (git's `XDF_INDENT_HEURISTIC`
215    /// scoring in `xdl_change_compact`). The base change compaction — sliding
216    /// groups as far down as possible and aligning add/delete pairs — always
217    /// runs; this flag only enables the indent-based slider scoring. Defaults to
218    /// `true` to match git's `diff.indentHeuristic` default.
219    pub indent_heuristic: bool,
220    /// Change-group suppression (`--ignore-blank-lines`, `-I<regex>`): when
221    /// set, change groups all of whose old and new lines are blank (and/or
222    /// match a `-I` regex) are dropped from hunk emission, mirroring git's
223    /// `xdl_mark_ignorable_lines` / `xdl_mark_ignorable_regex` + `xdl_get_hunk`.
224    pub change_ignore: Option<&'a ChangeIgnore<'a>>,
225    /// `log -L`: restrict the emitted hunks to the new-side (post-image) line
226    /// ranges. Each range is 0-based, `[start, end)`. When set, the renderer
227    /// inflates context to the widest range span (so every change inside a
228    /// range merges into one xdiff hunk), then clips the emitted lines back to
229    /// the range boundaries — a port of diff.c's `line_range_*` callbacks.
230    /// Ranges must be sorted and disjoint. `None` disables the filter (every
231    /// non-line-log caller).
232    pub line_ranges: Option<&'a [LineRange]>,
233    /// `--color-moved`: classify moved `+`/`-` lines and paint them with the
234    /// moved-color slots. `None` disables moved-code coloring.
235    pub color_moved: Option<ColorMoved>,
236    /// `--anchored=<text>` prefixes (git's patience anchors). Only consulted when
237    /// `algorithm` is [`DiffAlgorithm::Patience`]; empty (the default) is plain
238    /// patience. Forces the matching unique lines to stay aligned.
239    pub anchors: &'a [Vec<u8>],
240}
241
242#[derive(Clone, Copy, Debug, PartialEq, Eq)]
243pub struct LineIndicators {
244    pub context: u8,
245    pub old: u8,
246    pub new: u8,
247}
248
249impl Default for LineIndicators {
250    fn default() -> Self {
251        Self {
252            context: b' ',
253            old: b'-',
254            new: b'+',
255        }
256    }
257}
258
259/// A half-open `[start, end)` line range (0-based) for `log -L` hunk
260/// restriction. Mirrors diff.c's `struct range`.
261#[derive(Clone, Copy, Debug, PartialEq, Eq)]
262pub struct LineRange {
263    /// 0-based inclusive start line (post-image).
264    pub start: i64,
265    /// 0-based exclusive end line (post-image).
266    pub end: i64,
267}
268
269/// Configuration for change-group suppression (`--ignore-blank-lines` and
270/// `-I<regex>`). A change group is *ignorable* iff every old line and every new
271/// line it touches is blank (when `ignore_blank_lines`) or matches one of the
272/// `-I` regexes (`regex_match`). Ignorable groups are kept out of hunk emission
273/// per `xdl_get_hunk`'s leading/isolated-ignorable removal.
274pub type ChangeIgnoreRegex<'a> = &'a dyn Fn(&[u8]) -> bool;
275
276pub struct ChangeIgnore<'a> {
277    /// `--ignore-blank-lines`: blank change groups are ignorable.
278    pub ignore_blank_lines: bool,
279    /// `-I<regex>`: a line is regex-ignorable when this returns `true`. The
280    /// closure receives the raw line bytes (including the trailing `\n`). When
281    /// `None`, no regex suppression applies.
282    pub regex_match: Option<ChangeIgnoreRegex<'a>>,
283}
284
285/// Which line kinds get whitespace-error highlighting, plus the rule to check
286/// against. git's `--ws-error-highlight` defaults to highlighting only new
287/// (`+`) lines.
288#[derive(Clone, Copy)]
289pub struct WsErrorHighlight {
290    /// The resolved whitespace rule to check each line against.
291    pub rule: crate::ws::WsRule,
292    /// Highlight errors on removed (`-`) lines.
293    pub old: bool,
294    /// Highlight errors on added (`+`) lines.
295    pub new: bool,
296    /// Highlight errors on context (` `) lines.
297    pub context: bool,
298}
299
300impl Default for HunkRenderOptions<'_, '_> {
301    fn default() -> Self {
302        Self {
303            context: DEFAULT_CONTEXT,
304            interhunk: 0,
305            heading: None,
306            colors: None,
307            word_diff: None,
308            line_indicators: LineIndicators::default(),
309            ws_error: None,
310            ws_ignore: WsIgnore::default(),
311            algorithm: DiffAlgorithm::Myers,
312            indent_heuristic: true,
313            change_ignore: None,
314            line_ranges: None,
315            color_moved: None,
316            anchors: &[],
317        }
318    }
319}
320
321/// Render the unified-diff hunk body for a single file change into `out`.
322///
323/// `old_content` / `new_content` are the full blob contents (`None` for an
324/// absent side — a created or deleted file). The function computes the
325/// line-level Myers diff, groups changes into hunks with `options.context`
326/// lines of surrounding context (merging nearby groups per
327/// `options.interhunk`), and emits each hunk: the `@@` header (with git's
328/// section heading), then the context / `-` / `+` lines including
329/// `\ No newline at end of file` markers.
330///
331/// Nothing is written when the contents are identical (no changed lines).
332/// This is the body *after* the per-file metainfo header the caller emits.
333pub fn render_hunks(
334    out: &mut Vec<u8>,
335    old_content: Option<&[u8]>,
336    new_content: Option<&[u8]>,
337    options: &mut HunkRenderOptions<'_, '_>,
338) {
339    let (context, function_context) = decode_context(options.context);
340    // `log -L` hunk restriction: render with inflated context into a scratch
341    // buffer, then clip the emitted lines to the tracked ranges (diff.c's
342    // `line_range_*` callbacks). The widest range span is the upper bound on
343    // the context needed for every change in a range to land in one hunk.
344    if let Some(ranges) = options.line_ranges {
345        let max_span = ranges
346            .iter()
347            .map(|r| r.end - r.start)
348            .max()
349            .unwrap_or(0)
350            .max(0) as usize;
351        let saved_context = options.context;
352        let line_indicators = options.line_indicators;
353        let word_diff = options.word_diff.is_some();
354        options.context = replace_context_value(saved_context, context.max(max_span));
355        options.line_ranges = None;
356        let mut full = Vec::new();
357        render_hunks(&mut full, old_content, new_content, options);
358        options.context = saved_context;
359        options.line_ranges = Some(ranges);
360        filter_hunks_to_ranges(out, &full, ranges, line_indicators, word_diff);
361        return;
362    }
363    let old = split_lines(old_content.unwrap_or_default());
364    let new = split_lines(new_content.unwrap_or_default());
365    // `--anchored` only applies to the patience algorithm and on the raw
366    // (no whitespace-ignore) comparison — git forces patience and matches anchor
367    // text against the line bytes. Everything else takes the normal path.
368    let mut ops = if options.algorithm == DiffAlgorithm::Patience
369        && !options.anchors.is_empty()
370        && options.ws_ignore.is_empty()
371    {
372        patience_diff_lines_anchored(&old, &new, options.anchors)
373    } else {
374        myers_diff_lines_ws(&old, &new, options.ws_ignore, options.algorithm)
375    };
376
377    // git's `xdl_change_compact`: slide each change group as far down as
378    // possible, snap add/delete pairs back into alignment, and (under the
379    // indent heuristic) shift to the most readable split. Runs on the raw edit
380    // script before it is flattened into tagged lines.
381    change_compact(
382        &mut ops,
383        &old,
384        &new,
385        options.ws_ignore,
386        options.indent_heuristic,
387    );
388
389    // Flatten the edit script into a tagged line stream carrying old/new
390    // positions.
391    let mut tagged: Vec<TaggedLine<'_>> = Vec::new();
392    let mut old_idx = 0usize;
393    let mut new_idx = 0usize;
394    for op in ops {
395        match op {
396            DiffOp::Equal(n) => {
397                for _ in 0..n {
398                    // When records match only under whitespace-ignore flags,
399                    // git emits the postimage bytes as context.
400                    tagged.push(TaggedLine {
401                        kind: LineKind::Context,
402                        content: new[new_idx].content,
403                        old_index: old_idx,
404                        new_index: new_idx,
405                    });
406                    old_idx += 1;
407                    new_idx += 1;
408                }
409            }
410            DiffOp::Delete(n) => {
411                for _ in 0..n {
412                    tagged.push(TaggedLine {
413                        kind: LineKind::Delete,
414                        content: old[old_idx].content,
415                        old_index: old_idx,
416                        new_index: new_idx,
417                    });
418                    old_idx += 1;
419                }
420            }
421            DiffOp::Insert(n) => {
422                for _ in 0..n {
423                    tagged.push(TaggedLine {
424                        kind: LineKind::Insert,
425                        content: new[new_idx].content,
426                        old_index: old_idx,
427                        new_index: new_idx,
428                    });
429                    new_idx += 1;
430                }
431            }
432        }
433    }
434
435    // Build the change list (git's xdchange script): each maximal run of
436    // consecutive `-`/`+` tagged lines is one change, carrying its old/new line
437    // ranges and the tagged-stream span it occupies.
438    let changes = build_changes(&tagged);
439    if changes.is_empty() {
440        return;
441    }
442
443    // Mark each change ignorable when `--ignore-blank-lines` / `-I<regex>`
444    // applies and every old and new line it touches is blank / regex-matched
445    // (git's xdl_mark_ignorable_lines + xdl_mark_ignorable_regex).
446    let mut changes = changes;
447    if let Some(ci) = options.change_ignore {
448        mark_ignorable_changes(&mut changes, &old, &new, options.ws_ignore, ci);
449    }
450
451    // Group changes into hunks (xdl_get_hunk): the `distance > max_common`
452    // break plus leading/isolated-ignorable-change removal. Each hunk is a
453    // tagged-stream `(first_change_pos, last_change_pos)` span of *real*
454    // (emitted) changes.
455    let mut groups = group_changes_into_hunks(&changes, context, options.interhunk);
456    if function_context {
457        groups = expand_hunks_to_function_context(
458            &groups,
459            &tagged,
460            &old,
461            &new,
462            options.heading.as_deref_mut(),
463        );
464    }
465
466    let moved_styles = options
467        .color_moved
468        .filter(|_| options.colors.is_some() && options.word_diff.is_none())
469        .map(|color_moved| mark_color_as_moved(&tagged, color_moved));
470
471    for (first_change, last_change) in groups {
472        let (hunk_start, hunk_end) = if function_context {
473            (first_change, (last_change + 1).min(tagged.len()))
474        } else {
475            (
476                first_change.saturating_sub(context),
477                (last_change + context + 1).min(tagged.len()),
478            )
479        };
480        render_one_hunk(
481            out,
482            &tagged,
483            moved_styles.as_deref(),
484            &old,
485            hunk_start,
486            hunk_end,
487            options,
488        );
489    }
490}
491
492// ===========================================================================
493// Change compaction: a faithful port of git's `xdl_change_compact`
494// (xdiff/xdiffi.c), including the `XDF_INDENT_HEURISTIC` slider scoring.
495//
496// git represents a diff as two per-file boolean "changed" arrays (`xdf1.rchg`
497// for the old file, `xdf2.rchg` for the new). A *group* is a maximal run of
498// changed lines (a deletion run in the old file, an insertion run in the new
499// file), separated by runs of unchanged lines. `xdl_change_compact` walks the
500// groups of one file while keeping a synchronized cursor over the groups of the
501// other, sliding each group up/down within identical surrounding lines to a
502// canonical (and, under the indent heuristic, more readable) position.
503//
504// We reconstruct the two `changed[]` arrays from the [`DiffOp`] script, run the
505// algorithm on each file (old then new, exactly as git does), and rebuild the
506// script. Line equality for sliding (`recs_match`) uses the same
507// whitespace-canonicalized bytes the line-level diff used, so a group only
508// slides across lines the diff itself considered identical.
509// ===========================================================================
510
511/// If a line is indented more than this, [`get_indent`] returns this value
512/// (git's `MAX_INDENT`).
513const MAX_INDENT: i32 = 200;
514/// Cap on consecutive blank lines counted around a split (git's `MAX_BLANKS`).
515const MAX_BLANKS: i32 = 20;
516
517// Empirically-determined weight factors from git's xdiffi.c.
518const START_OF_FILE_PENALTY: i32 = 1;
519const END_OF_FILE_PENALTY: i32 = 21;
520const TOTAL_BLANK_WEIGHT: i32 = -30;
521const POST_BLANK_WEIGHT: i32 = 6;
522const RELATIVE_INDENT_PENALTY: i32 = -4;
523const RELATIVE_INDENT_WITH_BLANK_PENALTY: i32 = 10;
524const RELATIVE_OUTDENT_PENALTY: i32 = 24;
525const RELATIVE_OUTDENT_WITH_BLANK_PENALTY: i32 = 17;
526const RELATIVE_DEDENT_PENALTY: i32 = 23;
527const RELATIVE_DEDENT_WITH_BLANK_PENALTY: i32 = 17;
528const INDENT_WEIGHT: i32 = 60;
529const INDENT_HEURISTIC_MAX_SLIDING: i64 = 100;
530
531/// One file's record set for compaction: the whitespace-canonicalized line
532/// bytes (for `recs_match` and `get_indent`) and the per-line `changed` flags.
533/// `nrec` is the number of records; index `-1` and `nrec` are treated as
534/// "unchanged" sentinels, matching git's zero-padded `rchg`.
535struct CompactFile {
536    recs: Vec<Vec<u8>>,
537    changed: Vec<bool>,
538}
539
540impl CompactFile {
541    fn nrec(&self) -> i64 {
542        self.recs.len() as i64
543    }
544
545    /// `xdf->changed[i]` with git's out-of-range sentinels: positions `-1` and
546    /// `nrec` are unchanged (`false`).
547    fn changed(&self, i: i64) -> bool {
548        if i < 0 || i >= self.nrec() {
549            false
550        } else {
551            self.changed[i as usize]
552        }
553    }
554
555    fn set_changed(&mut self, i: i64, v: bool) {
556        self.changed[i as usize] = v;
557    }
558}
559
560/// git's `get_indent`: indentation columns of `rec` treating TAB as advancing
561/// to the next multiple of 8; `-1` for a blank (whitespace-only / empty) line;
562/// clamped at [`MAX_INDENT`].
563fn get_indent(rec: &[u8]) -> i32 {
564    let mut ret: i32 = 0;
565    for &c in rec {
566        if !xdl_isspace(c) {
567            return ret;
568        } else if c == b' ' {
569            ret += 1;
570        } else if c == b'\t' {
571            ret += 8 - ret % 8;
572        }
573        // other whitespace (e.g. CR) is ignored, matching git.
574        if ret >= MAX_INDENT {
575            return MAX_INDENT;
576        }
577    }
578    // The line contains only whitespace.
579    -1
580}
581
582/// git's `XDL_ISSPACE`: space, tab, newline, vertical tab, form feed, carriage
583/// return.
584fn xdl_isspace(c: u8) -> bool {
585    matches!(c, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r')
586}
587
588/// git's `struct split_measurement`.
589#[derive(Default)]
590struct SplitMeasurement {
591    end_of_file: bool,
592    indent: i32,
593    pre_blank: i32,
594    pre_indent: i32,
595    post_blank: i32,
596    post_indent: i32,
597}
598
599/// git's `struct split_score`.
600#[derive(Default, Clone, Copy)]
601struct SplitScore {
602    effective_indent: i32,
603    penalty: i32,
604}
605
606/// git's `measure_split`: characteristics of a hypothetical split above line
607/// `split` in `xdf`.
608fn measure_split(xdf: &CompactFile, split: i64) -> SplitMeasurement {
609    let mut m = SplitMeasurement::default();
610    if split >= xdf.nrec() {
611        m.end_of_file = true;
612        m.indent = -1;
613    } else {
614        m.end_of_file = false;
615        m.indent = get_indent(&xdf.recs[split as usize]);
616    }
617
618    m.pre_blank = 0;
619    m.pre_indent = -1;
620    let mut i = split - 1;
621    while i >= 0 {
622        m.pre_indent = get_indent(&xdf.recs[i as usize]);
623        if m.pre_indent != -1 {
624            break;
625        }
626        m.pre_blank += 1;
627        if m.pre_blank == MAX_BLANKS {
628            m.pre_indent = 0;
629            break;
630        }
631        i -= 1;
632    }
633
634    m.post_blank = 0;
635    m.post_indent = -1;
636    let mut i = split + 1;
637    while i < xdf.nrec() {
638        m.post_indent = get_indent(&xdf.recs[i as usize]);
639        if m.post_indent != -1 {
640            break;
641        }
642        m.post_blank += 1;
643        if m.post_blank == MAX_BLANKS {
644            m.post_indent = 0;
645            break;
646        }
647        i += 1;
648    }
649
650    m
651}
652
653/// git's `score_add_split`: accumulate the badness of split `m` into `s`.
654fn score_add_split(m: &SplitMeasurement, s: &mut SplitScore) {
655    if m.pre_indent == -1 && m.pre_blank == 0 {
656        s.penalty += START_OF_FILE_PENALTY;
657    }
658    if m.end_of_file {
659        s.penalty += END_OF_FILE_PENALTY;
660    }
661
662    let post_blank = if m.indent == -1 { 1 + m.post_blank } else { 0 };
663    let total_blank = m.pre_blank + post_blank;
664
665    s.penalty += TOTAL_BLANK_WEIGHT * total_blank;
666    s.penalty += POST_BLANK_WEIGHT * post_blank;
667
668    let indent = if m.indent != -1 {
669        m.indent
670    } else {
671        m.post_indent
672    };
673    let any_blanks = total_blank != 0;
674
675    s.effective_indent += indent;
676
677    if indent == -1 || m.pre_indent == -1 {
678        // End of file, or no non-blank predecessor: no adjustment needed
679        // (git's two separate `indent == -1` / `pre_indent == -1` no-op arms).
680    } else if indent > m.pre_indent {
681        s.penalty += if any_blanks {
682            RELATIVE_INDENT_WITH_BLANK_PENALTY
683        } else {
684            RELATIVE_INDENT_PENALTY
685        };
686    } else if indent == m.pre_indent {
687        // Same indentation as predecessor; no adjustment.
688    } else if m.post_indent != -1 && m.post_indent > indent {
689        s.penalty += if any_blanks {
690            RELATIVE_OUTDENT_WITH_BLANK_PENALTY
691        } else {
692            RELATIVE_OUTDENT_PENALTY
693        };
694    } else {
695        s.penalty += if any_blanks {
696            RELATIVE_DEDENT_WITH_BLANK_PENALTY
697        } else {
698            RELATIVE_DEDENT_PENALTY
699        };
700    }
701}
702
703/// git's `score_cmp`: `<0` when `s1` is the better (lower-badness) split.
704fn score_cmp(s1: &SplitScore, s2: &SplitScore) -> i32 {
705    let cmp_indents = (s1.effective_indent > s2.effective_indent) as i32
706        - (s1.effective_indent < s2.effective_indent) as i32;
707    INDENT_WEIGHT * cmp_indents + (s1.penalty - s2.penalty)
708}
709
710/// git's `struct xdlgroup`: a (possibly empty) group spanning `[start, end)` of
711/// changed lines.
712struct XdlGroup {
713    start: i64,
714    end: i64,
715}
716
717/// git's `recs_match`: the two records hash-equal (here: canonicalized bytes
718/// equal).
719fn recs_match(xdf: &CompactFile, a: i64, b: i64) -> bool {
720    xdf.recs[a as usize] == xdf.recs[b as usize]
721}
722
723/// git's `group_init`: point `g` at the first group in `xdf`.
724fn group_init(xdf: &CompactFile) -> XdlGroup {
725    let mut end = 0i64;
726    while xdf.changed(end) {
727        end += 1;
728    }
729    XdlGroup { start: 0, end }
730}
731
732/// git's `group_next`: advance to the next group; `false` if already at EOF.
733fn group_next(xdf: &CompactFile, g: &mut XdlGroup) -> bool {
734    if g.end == xdf.nrec() {
735        return false;
736    }
737    g.start = g.end + 1;
738    g.end = g.start;
739    while xdf.changed(g.end) {
740        g.end += 1;
741    }
742    true
743}
744
745/// git's `group_previous`: step back to the previous group; `false` if at BOF.
746fn group_previous(xdf: &CompactFile, g: &mut XdlGroup) -> bool {
747    if g.start == 0 {
748        return false;
749    }
750    g.end = g.start - 1;
751    g.start = g.end;
752    while xdf.changed(g.start - 1) {
753        g.start -= 1;
754    }
755    true
756}
757
758/// git's `group_slide_down`: slide `g` toward EOF if the line below equals the
759/// group's first line, absorbing any group it bumps into. `false` if it cannot
760/// slide.
761fn group_slide_down(xdf: &mut CompactFile, g: &mut XdlGroup) -> bool {
762    if g.end < xdf.nrec() && recs_match(xdf, g.start, g.end) {
763        xdf.set_changed(g.start, false);
764        xdf.set_changed(g.end, true);
765        g.start += 1;
766        g.end += 1;
767        while xdf.changed(g.end) {
768            g.end += 1;
769        }
770        true
771    } else {
772        false
773    }
774}
775
776/// git's `group_slide_up`: slide `g` toward BOF if the line above equals the
777/// group's last line, absorbing any group it bumps into. `false` if it cannot
778/// slide.
779fn group_slide_up(xdf: &mut CompactFile, g: &mut XdlGroup) -> bool {
780    if g.start > 0 && recs_match(xdf, g.start - 1, g.end - 1) {
781        g.start -= 1;
782        g.end -= 1;
783        xdf.set_changed(g.start, true);
784        xdf.set_changed(g.end, false);
785        while xdf.changed(g.start - 1) {
786            g.start -= 1;
787        }
788        true
789    } else {
790        false
791    }
792}
793
794/// Compact the change groups of `xdf`, keeping `xdfo` (the other file) in sync.
795/// A faithful port of the per-file body of git's `xdl_change_compact`. The
796/// `xdfo` re-diff tail (only reachable for histogram diff) is omitted: this
797/// compaction never merges groups in a way that creates new matching lines for
798/// the Myers/patience/histogram scripts produced here, so the re-diff is a
799/// no-op for our outputs.
800fn compact_one(xdf: &mut CompactFile, xdfo: &mut CompactFile, indent_heuristic: bool) {
801    let mut g = group_init(xdf);
802    let mut go = group_init(xdfo);
803
804    loop {
805        // Skip empty groups in the to-be-compacted file.
806        if g.end == g.start {
807            if !group_next(xdf, &mut g) {
808                break;
809            }
810            if !group_next(xdfo, &mut go) {
811                break;
812            }
813            continue;
814        }
815
816        let mut groupsize;
817        let mut earliest_end;
818        let mut end_matching_other;
819
820        loop {
821            groupsize = g.end - g.start;
822            end_matching_other = -1i64;
823
824            // Shift the group backward as far as possible.
825            while group_slide_up(xdf, &mut g) {
826                let ok = group_previous(xdfo, &mut go);
827                debug_assert!(ok, "group sync broken sliding up");
828            }
829            // Highest this group can be shifted; record its end.
830            earliest_end = g.end;
831            if go.end > go.start {
832                end_matching_other = g.end;
833            }
834            // Now shift the group forward as far as possible.
835            loop {
836                if !group_slide_down(xdf, &mut g) {
837                    break;
838                }
839                let ok = group_next(xdfo, &mut go);
840                debug_assert!(ok, "group sync broken sliding down");
841                if go.end > go.start {
842                    end_matching_other = g.end;
843                }
844            }
845            if groupsize == g.end - g.start {
846                break;
847            }
848        }
849
850        // The group is now shifted as far down as possible; only upward shifts
851        // remain to consider.
852        if g.end == earliest_end {
853            // No shifting was possible.
854        } else if end_matching_other != -1 {
855            // Move the (possibly merged) group back to line up with the last
856            // group of changes from the other file it can align with. Avoids
857            // splitting one change into a separate add/delete.
858            while go.end == go.start {
859                let ok = group_slide_up(xdf, &mut g);
860                debug_assert!(ok, "match disappeared");
861                let ok = group_previous(xdfo, &mut go);
862                debug_assert!(ok, "group sync broken sliding to match");
863            }
864        } else if indent_heuristic {
865            // Pick the shift with the lowest indent-heuristic score.
866            let mut best_shift = -1i64;
867            let mut best_score = SplitScore::default();
868
869            let mut shift = earliest_end;
870            if g.end - groupsize - 1 > shift {
871                shift = g.end - groupsize - 1;
872            }
873            if g.end - INDENT_HEURISTIC_MAX_SLIDING > shift {
874                shift = g.end - INDENT_HEURISTIC_MAX_SLIDING;
875            }
876            while shift <= g.end {
877                let mut score = SplitScore::default();
878                let m = measure_split(xdf, shift);
879                score_add_split(&m, &mut score);
880                let m = measure_split(xdf, shift - groupsize);
881                score_add_split(&m, &mut score);
882                if best_shift == -1 || score_cmp(&score, &best_score) <= 0 {
883                    best_score = score;
884                    best_shift = shift;
885                }
886                shift += 1;
887            }
888
889            while g.end > best_shift {
890                let ok = group_slide_up(xdf, &mut g);
891                debug_assert!(ok, "best shift unreached");
892                let ok = group_previous(xdfo, &mut go);
893                debug_assert!(ok, "group sync broken sliding to blank line");
894            }
895        }
896
897        // Advance to the next group pair.
898        if !group_next(xdf, &mut g) {
899            break;
900        }
901        if !group_next(xdfo, &mut go) {
902            break;
903        }
904    }
905}
906
907/// Run git's `xdl_change_compact` over the [`DiffOp`] script in place.
908///
909/// Reconstructs the per-file `changed[]` flags from `ops`, compacts the old
910/// file then the new file (each synchronized against the other, as git does),
911/// and rebuilds `ops` from the recompacted flags.
912fn change_compact(
913    ops: &mut Vec<DiffOp>,
914    old: &[DiffLine<'_>],
915    new: &[DiffLine<'_>],
916    ws_ignore: WsIgnore,
917    indent_heuristic: bool,
918) {
919    // Fast path: no changes (or a single trivial run) cannot be slid.
920    if ops.iter().all(|op| matches!(op, DiffOp::Equal(_))) {
921        return;
922    }
923
924    // Canonicalized record bytes — the same equality the line-level diff used.
925    let canon = |lines: &[DiffLine<'_>]| -> Vec<Vec<u8>> {
926        if ws_ignore.is_empty() {
927            lines.iter().map(|l| l.content.to_vec()).collect()
928        } else {
929            lines
930                .iter()
931                .map(|l| crate::canonicalize_line_for_match(l.content, ws_ignore))
932                .collect()
933        }
934    };
935
936    let mut xdf1 = CompactFile {
937        recs: canon(old),
938        changed: vec![false; old.len()],
939    };
940    let mut xdf2 = CompactFile {
941        recs: canon(new),
942        changed: vec![false; new.len()],
943    };
944
945    // Reconstruct git's two `changed[]` arrays from the run-length script.
946    let mut oi = 0usize;
947    let mut ni = 0usize;
948    for op in ops.iter() {
949        match *op {
950            DiffOp::Equal(n) => {
951                oi += n;
952                ni += n;
953            }
954            DiffOp::Delete(n) => {
955                for _ in 0..n {
956                    xdf1.changed[oi] = true;
957                    oi += 1;
958                }
959            }
960            DiffOp::Insert(n) => {
961                for _ in 0..n {
962                    xdf2.changed[ni] = true;
963                    ni += 1;
964                }
965            }
966        }
967    }
968
969    // git compacts xdf1 (synced against xdf2) then xdf2 (synced against xdf1).
970    compact_one(&mut xdf1, &mut xdf2, indent_heuristic);
971    compact_one(&mut xdf2, &mut xdf1, indent_heuristic);
972
973    // Rebuild the coalesced op script by walking both files' changed flags in
974    // lockstep, emitting deletes for the old side and inserts for the new side.
975    let n_old = xdf1.changed.len();
976    let n_new = xdf2.changed.len();
977    let mut rebuilt: Vec<DiffOp> = Vec::with_capacity(ops.len());
978    let mut i = 0usize; // old index
979    let mut j = 0usize; // new index
980    while i < n_old || j < n_new {
981        let del = i < n_old && xdf1.changed[i];
982        let ins = j < n_new && xdf2.changed[j];
983        if del {
984            let mut run = 0usize;
985            while i < n_old && xdf1.changed[i] {
986                run += 1;
987                i += 1;
988            }
989            push_op(&mut rebuilt, DiffOp::Delete(run));
990        } else if ins {
991            let mut run = 0usize;
992            while j < n_new && xdf2.changed[j] {
993                run += 1;
994                j += 1;
995            }
996            push_op(&mut rebuilt, DiffOp::Insert(run));
997        } else {
998            // Both sides are unchanged here: an equal run.
999            let mut run = 0usize;
1000            while i < n_old && j < n_new && !xdf1.changed[i] && !xdf2.changed[j] {
1001                run += 1;
1002                i += 1;
1003                j += 1;
1004            }
1005            debug_assert!(run > 0, "change_compact stalled rebuilding script");
1006            push_op(&mut rebuilt, DiffOp::Equal(run));
1007        }
1008    }
1009
1010    *ops = rebuilt;
1011}
1012
1013/// Append `op` to `out`, coalescing with a same-kind run at the tail.
1014fn push_op(out: &mut Vec<DiffOp>, op: DiffOp) {
1015    match (out.last_mut(), op) {
1016        (Some(DiffOp::Equal(prev)), DiffOp::Equal(n)) => *prev += n,
1017        (Some(DiffOp::Delete(prev)), DiffOp::Delete(n)) => *prev += n,
1018        (Some(DiffOp::Insert(prev)), DiffOp::Insert(n)) => *prev += n,
1019        _ => out.push(op),
1020    }
1021}
1022
1023/// State for [`filter_hunks_to_ranges`], a port of diff.c's
1024/// `struct line_range_callback`. We drive it over the already-rendered
1025/// unified-diff lines (with inflated context) rather than xdiff's raw line
1026/// callback, but the algorithm is the same: buffer pending removals, open a
1027/// range hunk when an in-range post-image line is seen, and emit the clipped
1028/// `@@` header + body for each range.
1029struct RangeFilter<'r> {
1030    ranges: &'r [LineRange],
1031    cur_range: usize,
1032    /// Post/pre-image 1-based line counters seeded from each `@@` header.
1033    lno_post: i64,
1034    lno_pre: i64,
1035    /// The old/new line counts from the current xdiff hunk header.
1036    hunk_old_count: i64,
1037    hunk_new_count: i64,
1038    /// Function-name heading carried from the current `@@` header (the suffix
1039    /// after `@@ ... @@ `), reused verbatim on every emitted range hunk.
1040    func: Vec<u8>,
1041    /// Range hunk being accumulated.
1042    rhunk: Vec<u8>,
1043    rhunk_old_begin: i64,
1044    rhunk_old_count: i64,
1045    rhunk_new_begin: i64,
1046    rhunk_new_count: i64,
1047    rhunk_active: bool,
1048    rhunk_has_changes: bool,
1049    /// Removal lines not yet known to be in-range.
1050    pending_rm: Vec<u8>,
1051    pending_rm_count: i64,
1052    pending_rm_pre_begin: i64,
1053}
1054
1055#[derive(Clone, Copy, PartialEq, Eq)]
1056enum RangeBodyKind {
1057    Context,
1058    Delete,
1059    Insert,
1060    Change,
1061    NoNewline,
1062}
1063
1064impl RangeFilter<'_> {
1065    fn discard_pending_rm(&mut self) {
1066        self.pending_rm.clear();
1067        self.pending_rm_count = 0;
1068    }
1069
1070    /// Port of diff.c:flush_rhunk — emit the accumulated range hunk (header +
1071    /// body) into `out`, dropping context-only hunks.
1072    fn flush_rhunk(&mut self, out: &mut Vec<u8>) {
1073        if !self.rhunk_active {
1074            return;
1075        }
1076        if self.pending_rm_count != 0 {
1077            self.rhunk.extend_from_slice(&self.pending_rm);
1078            self.rhunk_old_count += self.pending_rm_count;
1079            self.rhunk_has_changes = true;
1080            self.discard_pending_rm();
1081        }
1082        if !self.rhunk_has_changes {
1083            self.rhunk_active = false;
1084            self.rhunk.clear();
1085            return;
1086        }
1087        // git's flush_rhunk uses `@@ -%ld,%ld +%ld,%ld @@` unconditionally —
1088        // the count is ALWAYS shown (unlike the normal emitter, which omits a
1089        // count of 1).
1090        out.extend_from_slice(
1091            format!(
1092                "@@ -{},{} +{},{} @@",
1093                self.rhunk_old_begin,
1094                self.rhunk_old_count,
1095                self.rhunk_new_begin,
1096                self.rhunk_new_count
1097            )
1098            .as_bytes(),
1099        );
1100        if !self.func.is_empty() {
1101            out.push(b' ');
1102            out.extend_from_slice(&self.func);
1103        }
1104        out.push(b'\n');
1105        out.extend_from_slice(&self.rhunk);
1106        self.rhunk_active = false;
1107        self.rhunk.clear();
1108    }
1109
1110    /// Port of diff.c:line_range_line_fn for one rendered body line. `kind`
1111    /// is the logical role of the rendered line after accounting for custom
1112    /// output indicators, ANSI color, and word-diff body rendering.
1113    fn body_line(&mut self, out: &mut Vec<u8>, kind: RangeBodyKind, line: &[u8]) {
1114        if kind == RangeBodyKind::Delete {
1115            if self.pending_rm_count == 0 {
1116                self.pending_rm_pre_begin = self.lno_pre;
1117            }
1118            self.lno_pre += 1;
1119            self.pending_rm.extend_from_slice(line);
1120            self.pending_rm_count += 1;
1121            return;
1122        }
1123        if kind == RangeBodyKind::NoNewline {
1124            if self.pending_rm_count != 0 {
1125                self.pending_rm.extend_from_slice(line);
1126            } else if self.rhunk_active {
1127                self.rhunk.extend_from_slice(line);
1128            }
1129            return;
1130        }
1131        // Context, insertion, and word-diff replacement lines all consume a
1132        // post-image line. Context/replacement lines also consume a pre-image
1133        // line; insertions do not.
1134        let lno_0 = self.lno_post - 1;
1135        let cur_pre = self.lno_pre;
1136        self.lno_post += 1;
1137        if matches!(kind, RangeBodyKind::Context | RangeBodyKind::Change) {
1138            self.lno_pre += 1;
1139        }
1140
1141        while self.cur_range < self.ranges.len() && lno_0 >= self.ranges[self.cur_range].end {
1142            if self.rhunk_active {
1143                self.flush_rhunk(out);
1144            }
1145            self.discard_pending_rm();
1146            self.cur_range += 1;
1147        }
1148        if self.cur_range >= self.ranges.len() {
1149            self.discard_pending_rm();
1150            return;
1151        }
1152        let cur = self.ranges[self.cur_range];
1153        if lno_0 < cur.start {
1154            self.discard_pending_rm();
1155            return;
1156        }
1157        if !self.rhunk_active {
1158            self.rhunk_active = true;
1159            self.rhunk_has_changes = false;
1160            self.rhunk_new_begin = lno_0 + 1;
1161            self.rhunk_old_begin = if self.pending_rm_count != 0 {
1162                self.pending_rm_pre_begin
1163            } else {
1164                cur_pre
1165            };
1166            self.rhunk_old_count = 0;
1167            self.rhunk_new_count = 0;
1168            self.rhunk.clear();
1169        }
1170        if self.pending_rm_count != 0 {
1171            self.rhunk.extend_from_slice(&self.pending_rm);
1172            self.rhunk_old_count += self.pending_rm_count;
1173            self.rhunk_has_changes = true;
1174            self.discard_pending_rm();
1175        }
1176        self.rhunk.extend_from_slice(line);
1177        self.rhunk_new_count += 1;
1178        if matches!(kind, RangeBodyKind::Insert | RangeBodyKind::Change) {
1179            self.rhunk_has_changes = true;
1180        }
1181        if matches!(kind, RangeBodyKind::Context | RangeBodyKind::Change) {
1182            self.rhunk_old_count += 1;
1183        }
1184    }
1185}
1186
1187fn skip_ansi_prefix(mut line: &[u8]) -> &[u8] {
1188    loop {
1189        let Some(rest) = line.strip_prefix(b"\x1b[") else {
1190            return line;
1191        };
1192        let Some(end) = rest.iter().position(|&b| (0x40..=0x7e).contains(&b)) else {
1193            return line;
1194        };
1195        line = &rest[end + 1..];
1196    }
1197}
1198
1199fn strip_ansi(line: &[u8]) -> Vec<u8> {
1200    let mut stripped = Vec::with_capacity(line.len());
1201    let mut idx = 0usize;
1202    while idx < line.len() {
1203        if line[idx] == b'\x1b'
1204            && line.get(idx + 1) == Some(&b'[')
1205            && let Some(end) = line[idx + 2..]
1206                .iter()
1207                .position(|&b| (0x40..=0x7e).contains(&b))
1208        {
1209            idx += end + 3;
1210            continue;
1211        }
1212        stripped.push(line[idx]);
1213        idx += 1;
1214    }
1215    stripped
1216}
1217
1218fn classify_word_diff_line(visible: &[u8]) -> RangeBodyKind {
1219    if visible.starts_with(b"\\") {
1220        return RangeBodyKind::NoNewline;
1221    }
1222    let has_old = find_subslice(visible, b"[-").is_some();
1223    let has_new = find_subslice(visible, b"{+").is_some();
1224    match (has_old, has_new) {
1225        (true, false) if visible.starts_with(b"[-") => return RangeBodyKind::Delete,
1226        (false, true) if visible.starts_with(b"{+") => return RangeBodyKind::Insert,
1227        (true, _) | (_, true) => return RangeBodyKind::Change,
1228        _ => {}
1229    }
1230    // Porcelain word-diff keeps line-level markers.
1231    match visible.first().copied() {
1232        Some(b'-') => RangeBodyKind::Delete,
1233        Some(b'+') => RangeBodyKind::Insert,
1234        Some(b' ') => RangeBodyKind::Context,
1235        Some(b'~') => RangeBodyKind::NoNewline,
1236        _ => RangeBodyKind::Context,
1237    }
1238}
1239
1240fn classify_range_body_line(
1241    line: &[u8],
1242    indicators: LineIndicators,
1243    word_diff: bool,
1244    hunk_old_count: i64,
1245    hunk_new_count: i64,
1246) -> RangeBodyKind {
1247    let visible = skip_ansi_prefix(line);
1248    if word_diff {
1249        if hunk_old_count == 0 {
1250            return RangeBodyKind::Insert;
1251        }
1252        if hunk_new_count == 0 {
1253            return RangeBodyKind::Delete;
1254        }
1255        return classify_word_diff_line(visible);
1256    }
1257    match visible.first().copied() {
1258        Some(b'\\') => RangeBodyKind::NoNewline,
1259        Some(marker) if marker == indicators.old => RangeBodyKind::Delete,
1260        Some(marker) if marker == indicators.new => RangeBodyKind::Insert,
1261        Some(marker) if marker == indicators.context => RangeBodyKind::Context,
1262        _ => RangeBodyKind::Context,
1263    }
1264}
1265
1266/// Clip a fully-rendered unified-diff hunk body (`full`, produced with
1267/// inflated context) down to the tracked `ranges`, mirroring diff.c's
1268/// `line_range_hunk_fn` / `line_range_line_fn` / `flush_rhunk`. The renderer's
1269/// `@@` header already carries the funcname suffix; we parse it back out and
1270/// reuse it on every emitted range hunk.
1271fn filter_hunks_to_ranges(
1272    out: &mut Vec<u8>,
1273    full: &[u8],
1274    ranges: &[LineRange],
1275    line_indicators: LineIndicators,
1276    word_diff: bool,
1277) {
1278    if ranges.is_empty() {
1279        return;
1280    }
1281    let mut filter = RangeFilter {
1282        ranges,
1283        cur_range: 0,
1284        lno_post: 0,
1285        lno_pre: 0,
1286        hunk_old_count: 0,
1287        hunk_new_count: 0,
1288        func: Vec::new(),
1289        rhunk: Vec::new(),
1290        rhunk_old_begin: 0,
1291        rhunk_old_count: 0,
1292        rhunk_new_begin: 0,
1293        rhunk_new_count: 0,
1294        rhunk_active: false,
1295        rhunk_has_changes: false,
1296        pending_rm: Vec::new(),
1297        pending_rm_count: 0,
1298        pending_rm_pre_begin: 0,
1299    };
1300    for line in split_keep_newline(full) {
1301        let parse_line = if line.starts_with(b"@@ ") {
1302            line.to_vec()
1303        } else {
1304            strip_ansi(line)
1305        };
1306        if parse_line.starts_with(b"@@ ") {
1307            // New xdiff hunk: any pending removals from the previous hunk are
1308            // left in place (diff.c does the same — the next body line decides
1309            // their fate), and the range hunk cursor is NOT reset across xdiff
1310            // hunks. Parse the begin line numbers + funcname suffix.
1311            if let Some((old_begin, old_count, new_begin, new_count, func)) =
1312                parse_hunk_header(&parse_line)
1313            {
1314                filter.lno_post = new_begin;
1315                filter.lno_pre = old_begin;
1316                filter.hunk_old_count = old_count;
1317                filter.hunk_new_count = new_count;
1318                filter.func = func;
1319            }
1320            continue;
1321        }
1322        let kind = classify_range_body_line(
1323            line,
1324            line_indicators,
1325            word_diff,
1326            filter.hunk_old_count,
1327            filter.hunk_new_count,
1328        );
1329        filter.body_line(out, kind, line);
1330    }
1331    filter.flush_rhunk(out);
1332}
1333
1334/// Split `buf` into lines, each INCLUDING its trailing `\n` (the final line may
1335/// lack one). Empty input yields no lines.
1336fn split_keep_newline(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
1337    let mut start = 0usize;
1338    std::iter::from_fn(move || {
1339        if start >= buf.len() {
1340            return None;
1341        }
1342        let rel = buf[start..].iter().position(|&b| b == b'\n');
1343        let end = match rel {
1344            Some(pos) => start + pos + 1,
1345            None => buf.len(),
1346        };
1347        let line = &buf[start..end];
1348        start = end;
1349        Some(line)
1350    })
1351}
1352
1353/// Parse a rendered `@@ -o[,c] +n[,c] @@[ func]` header, returning the 1-based
1354/// old/new begin line numbers, old/new counts, and the trailing funcname bytes
1355/// (without the leading space, without a trailing newline). Begin is the value
1356/// xdiff emits: 1-based when the count is non-zero, one-less when zero.
1357fn parse_hunk_header(line: &[u8]) -> Option<(i64, i64, i64, i64, Vec<u8>)> {
1358    // line = "@@ -A,B +C,D @@ func\n" (or "@@ -A +C @@\n", etc.)
1359    let rest = line.strip_prefix(b"@@ -")?;
1360    let plus = rest.iter().position(|&b| b == b'+')?;
1361    let old_part = &rest[..plus];
1362    // skip "+", parse new part up to " @@"
1363    let after_plus = &rest[plus + 1..];
1364    let close = find_subslice(after_plus, b" @@")?;
1365    let new_part = &after_plus[..close];
1366    let (old_begin, old_count) = parse_range_side(old_part)?;
1367    let (new_begin, new_count) = parse_range_side(new_part)?;
1368    // Funcname suffix: everything after " @@ " (a single space separates it).
1369    let tail = &after_plus[close + 3..];
1370    let func = if let Some(f) = tail.strip_prefix(b" ") {
1371        let mut f = f.to_vec();
1372        if f.last() == Some(&b'\n') {
1373            f.pop();
1374        }
1375        f
1376    } else {
1377        Vec::new()
1378    };
1379    Some((old_begin, old_count, new_begin, new_count, func))
1380}
1381
1382/// Parse an `@@` range side field, `A` or `A,B`, into `(A, B)`. A missing
1383/// count means one line.
1384fn parse_range_side(field: &[u8]) -> Option<(i64, i64)> {
1385    let field = field.split(|&b| b == b' ').next().unwrap_or(field);
1386    let mut parts = field.splitn(2, |&b| b == b',');
1387    let begin = std::str::from_utf8(parts.next()?)
1388        .ok()?
1389        .trim()
1390        .parse::<i64>()
1391        .ok()?;
1392    let count = match parts.next() {
1393        Some(count) => std::str::from_utf8(count)
1394            .ok()?
1395            .trim()
1396            .parse::<i64>()
1397            .ok()?,
1398        None => 1,
1399    };
1400    Some((begin, count))
1401}
1402
1403fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1404    if needle.is_empty() || haystack.len() < needle.len() {
1405        return None;
1406    }
1407    (0..=haystack.len() - needle.len()).find(|&i| &haystack[i..i + needle.len()] == needle)
1408}
1409
1410/// One contiguous change in the edit script (git's `xdchange`): the old/new
1411/// line ranges it covers, the tagged-stream positions of its first/last
1412/// non-context line, and whether it is ignorable (`--ignore-blank-lines` /
1413/// `-I<regex>`).
1414#[derive(Clone, Copy)]
1415struct Change {
1416    /// 0-based first old line in this change (`i1`).
1417    i1: usize,
1418    /// Number of old lines deleted (`chg1`).
1419    chg1: usize,
1420    /// 0-based first new line in this change (`i2`).
1421    i2: usize,
1422    /// Number of new lines inserted (`chg2`).
1423    chg2: usize,
1424    /// Tagged-stream index of this change's first non-context line.
1425    tag_first: usize,
1426    /// Tagged-stream index of this change's last non-context line.
1427    tag_last: usize,
1428    /// Whether this change is ignorable (blank-only / regex-only).
1429    ignore: bool,
1430}
1431
1432/// Build the change list (xdchange script) from the flattened tagged stream.
1433/// Each maximal run of consecutive `-`/`+` lines becomes one [`Change`].
1434fn build_changes(tagged: &[TaggedLine<'_>]) -> Vec<Change> {
1435    let mut changes: Vec<Change> = Vec::new();
1436    let mut idx = 0usize;
1437    while idx < tagged.len() {
1438        if tagged[idx].kind == LineKind::Context {
1439            idx += 1;
1440            continue;
1441        }
1442        let tag_first = idx;
1443        let i1 = tagged[idx].old_index;
1444        let i2 = tagged[idx].new_index;
1445        let mut chg1 = 0usize;
1446        let mut chg2 = 0usize;
1447        while idx < tagged.len() && tagged[idx].kind != LineKind::Context {
1448            match tagged[idx].kind {
1449                LineKind::Delete => chg1 += 1,
1450                LineKind::Insert => chg2 += 1,
1451                LineKind::Context => unreachable!(),
1452            }
1453            idx += 1;
1454        }
1455        changes.push(Change {
1456            i1,
1457            chg1,
1458            i2,
1459            chg2,
1460            tag_first,
1461            tag_last: idx - 1,
1462            ignore: false,
1463        });
1464    }
1465    changes
1466}
1467
1468/// Mark each change ignorable when its old and new lines are all blank
1469/// (`--ignore-blank-lines`) or all regex-matched (`-I<regex>`), mirroring
1470/// git's `xdl_mark_ignorable_lines` then `xdl_mark_ignorable_regex` (the regex
1471/// pass never overrides a blank-marked change).
1472fn mark_ignorable_changes(
1473    changes: &mut [Change],
1474    old: &[DiffLine<'_>],
1475    new: &[DiffLine<'_>],
1476    ws_ignore: WsIgnore,
1477    ci: &ChangeIgnore<'_>,
1478) {
1479    for change in changes.iter_mut() {
1480        if ci.ignore_blank_lines {
1481            let blank = (change.i1..change.i1 + change.chg1)
1482                .all(|i| line_is_blank(old[i].content, ws_ignore))
1483                && (change.i2..change.i2 + change.chg2)
1484                    .all(|i| line_is_blank(new[i].content, ws_ignore));
1485            change.ignore = blank;
1486        }
1487        if !change.ignore
1488            && let Some(regex_match) = ci.regex_match
1489        {
1490            let matched = (change.i1..change.i1 + change.chg1).all(|i| regex_match(old[i].content))
1491                && (change.i2..change.i2 + change.chg2).all(|i| regex_match(new[i].content));
1492            change.ignore = matched;
1493        }
1494    }
1495}
1496
1497/// Group the change list into hunks, returning each hunk's
1498/// `(first_real_change_tag, last_real_change_tag)` tagged-stream span. This is
1499/// a behavioural port of git's `xemit.c:xdl_get_hunk` driving
1500/// `xdl_call_hunk_func`'s loop: it applies the `distance > max_common` hunk
1501/// break, drops leading ignorable changes, and excludes trailing/isolated
1502/// ignorable changes from the emitted span.
1503fn group_changes_into_hunks(
1504    changes: &[Change],
1505    context: usize,
1506    interhunk: usize,
1507) -> Vec<(usize, usize)> {
1508    let max_common = context.saturating_add(context).saturating_add(interhunk);
1509    let max_ignorable = context;
1510
1511    let mut hunks: Vec<(usize, usize)> = Vec::new();
1512    // `start` is the index into `changes` of the first change still to emit
1513    // (xdl_call_hunk_func's `xch` cursor).
1514    let mut start = 0usize;
1515    while start < changes.len() {
1516        // Remove ignorable changes that are too far before other changes
1517        // (xdl_get_hunk's leading-ignorable loop). Faithful port: `xchp`
1518        // iterates over EVERY leading ignorable change; whenever the gap to the
1519        // next change is ≥ max_ignorable (or there is no next change), the hunk
1520        // start advances to that next change (`None` ⇒ the whole tail is
1521        // ignorable ⇒ no hunk). Unlike a break-on-first-keep loop, this still
1522        // advances past a run of ignorables when the FINAL one has no successor.
1523        {
1524            let mut xchp = start;
1525            while xchp < changes.len() && changes[xchp].ignore {
1526                let cur = &changes[xchp];
1527                match changes.get(xchp + 1) {
1528                    None => {
1529                        start = changes.len();
1530                    }
1531                    Some(next) => {
1532                        if next.i1 - (cur.i1 + cur.chg1) >= max_ignorable {
1533                            start = xchp + 1;
1534                        }
1535                    }
1536                }
1537                xchp += 1;
1538            }
1539        }
1540        if start >= changes.len() {
1541            break;
1542        }
1543
1544        // Walk forward extending the hunk; `last` tracks the last *real*
1545        // (non-ignorable, or the very first) change that defines the hunk end.
1546        let mut last = start;
1547        let mut ignored = 0usize; // ignored new-line count accumulated
1548        let mut prev = start;
1549        let mut idx = start + 1;
1550        while idx < changes.len() {
1551            let xch = &changes[idx];
1552            let xchp = &changes[prev];
1553            let distance = xch.i1 - (xchp.i1 + xchp.chg1);
1554            if distance > max_common {
1555                break;
1556            }
1557            if distance < max_ignorable && (!xch.ignore || last == prev) {
1558                last = idx;
1559                ignored = 0;
1560            } else if distance < max_ignorable && xch.ignore {
1561                ignored += xch.chg2;
1562            } else if last != prev
1563                && xch.i1 + ignored - (changes[last].i1 + changes[last].chg1) > max_common
1564            {
1565                break;
1566            } else if !xch.ignore {
1567                last = idx;
1568                ignored = 0;
1569            } else {
1570                ignored += xch.chg2;
1571            }
1572            prev = idx;
1573            idx += 1;
1574        }
1575
1576        let first_change = &changes[start];
1577        let last_change = &changes[last];
1578        hunks.push((first_change.tag_first, last_change.tag_last));
1579        start = last + 1;
1580    }
1581
1582    hunks
1583}
1584
1585fn expand_hunks_to_function_context(
1586    groups: &[(usize, usize)],
1587    tagged: &[TaggedLine<'_>],
1588    old: &[DiffLine<'_>],
1589    new: &[DiffLine<'_>],
1590    mut heading: Option<&mut HeadingFn<'_>>,
1591) -> Vec<(usize, usize)> {
1592    let Some(classifier) = heading.as_mut() else {
1593        return groups.to_vec();
1594    };
1595    let mut expanded = Vec::with_capacity(groups.len());
1596    for &(start, end) in groups {
1597        let first = tagged[start];
1598        let last = tagged[end];
1599        let old_changed = tagged[start..=end]
1600            .iter()
1601            .any(|line| line.kind == LineKind::Delete);
1602        let (side, range) = if old_changed {
1603            (
1604                FunctionSide::Old,
1605                function_context_range(old, first.old_index, false, classifier),
1606            )
1607        } else {
1608            (
1609                FunctionSide::New,
1610                function_context_range(new, first.new_index, true, classifier),
1611            )
1612        };
1613        let Some((range_start, range_end)) = range else {
1614            expanded.push((start, end));
1615            continue;
1616        };
1617        let mut hunk_start = expand_tag_start(tagged, start, side, range_start);
1618        let mut hunk_end = expand_tag_end(tagged, end, side, range_end);
1619        if old_changed {
1620            if last.old_index >= range_end {
1621                hunk_end = end;
1622            }
1623        } else if last.new_index >= range_end {
1624            hunk_end = end;
1625        }
1626        if hunk_start > start {
1627            hunk_start = start;
1628        }
1629        if hunk_end < end {
1630            hunk_end = end;
1631        }
1632        if let Some(prev) = expanded.last_mut()
1633            && hunk_start <= prev.1 + 1
1634        {
1635            prev.1 = prev.1.max(hunk_end);
1636            continue;
1637        }
1638        expanded.push((hunk_start, hunk_end));
1639    }
1640    expanded
1641}
1642
1643#[derive(Clone, Copy)]
1644enum FunctionSide {
1645    Old,
1646    New,
1647}
1648
1649fn function_context_range(
1650    lines: &[DiffLine<'_>],
1651    anchor: usize,
1652    prefer_forward: bool,
1653    heading: &mut HeadingFn<'_>,
1654) -> Option<(usize, usize)> {
1655    if lines.is_empty() {
1656        return None;
1657    }
1658    let anchor = anchor.min(lines.len() - 1);
1659    let mut heading_idx = None;
1660    for idx in (0..=anchor).rev() {
1661        if heading(lines[idx].content).is_some() {
1662            heading_idx = Some(idx);
1663            break;
1664        }
1665    }
1666    if heading_idx.is_none() && prefer_forward {
1667        for (idx, line) in lines.iter().enumerate().skip(anchor) {
1668            if heading(line.content).is_some() {
1669                heading_idx = Some(idx);
1670                break;
1671            }
1672        }
1673    }
1674
1675    let (mut start, mut end) = if let Some(idx) = heading_idx {
1676        let mut start = idx;
1677        while start > 0 && !line_is_blank(lines[start - 1].content, WsIgnore::default()) {
1678            start -= 1;
1679        }
1680        let mut end = lines.len();
1681        for (next, line) in lines.iter().enumerate().skip(idx + 1) {
1682            if heading(line.content).is_some() {
1683                end = next;
1684                break;
1685            }
1686        }
1687        (start, end)
1688    } else {
1689        (0, lines.len())
1690    };
1691
1692    while start < end && line_is_blank(lines[start].content, WsIgnore::default()) {
1693        start += 1;
1694    }
1695    while end > start && line_is_blank(lines[end - 1].content, WsIgnore::default()) {
1696        end -= 1;
1697    }
1698    (start < end).then_some((start, end))
1699}
1700
1701fn expand_tag_start(
1702    tagged: &[TaggedLine<'_>],
1703    current: usize,
1704    side: FunctionSide,
1705    range_start: usize,
1706) -> usize {
1707    let mut start = current;
1708    while start > 0 {
1709        let prev = tagged[start - 1];
1710        let line_index = match side {
1711            FunctionSide::Old => prev.old_index,
1712            FunctionSide::New => prev.new_index,
1713        };
1714        if line_index < range_start {
1715            break;
1716        }
1717        start -= 1;
1718    }
1719    start
1720}
1721
1722fn expand_tag_end(
1723    tagged: &[TaggedLine<'_>],
1724    current: usize,
1725    side: FunctionSide,
1726    range_end: usize,
1727) -> usize {
1728    let mut end = current;
1729    while end + 1 < tagged.len() {
1730        let next = tagged[end + 1];
1731        let line_index = match side {
1732            FunctionSide::Old => next.old_index,
1733            FunctionSide::New => next.new_index,
1734        };
1735        if line_index >= range_end {
1736            break;
1737        }
1738        end += 1;
1739    }
1740    end
1741}
1742
1743const COLOR_MOVED_MIN_ALNUM_COUNT: usize = 20;
1744const INDENT_BLANKLINE: i32 = i32::MIN;
1745
1746#[derive(Clone, Copy, Default)]
1747struct MovedStyle {
1748    moved: bool,
1749    alt: bool,
1750    uninteresting: bool,
1751}
1752
1753#[derive(Clone)]
1754struct MovedEntry {
1755    tag_idx: usize,
1756    next_line: Option<usize>,
1757    next_match: Option<usize>,
1758    id: usize,
1759    indent_width: i32,
1760}
1761
1762#[derive(Clone, Copy, Default)]
1763struct MovedEntryList {
1764    add: Option<usize>,
1765    del: Option<usize>,
1766}
1767
1768#[derive(Clone, Copy)]
1769struct MovedBlock {
1770    match_entry: usize,
1771    wsd: i32,
1772}
1773
1774fn mark_color_as_moved(tagged: &[TaggedLine<'_>], color_moved: ColorMoved) -> Vec<MovedStyle> {
1775    let (entries, entry_for_tag, entry_list) = add_lines_to_move_detection(tagged, color_moved.ws);
1776    let mut styles = vec![MovedStyle::default(); tagged.len()];
1777    let mut pmb: Vec<MovedBlock> = Vec::new();
1778    let mut n = 0usize;
1779    let mut flipped_block = false;
1780    let mut block_length = 0usize;
1781    let mut moved_symbol: Option<LineKind> = None;
1782
1783    while n < tagged.len() {
1784        let line = tagged[n];
1785        let line_entry = entry_for_tag[n];
1786        let mut line_match = line_entry.and_then(|entry_idx| {
1787            let id = entries[entry_idx].id;
1788            match line.kind {
1789                LineKind::Insert => entry_list.get(id).and_then(|list| list.del),
1790                LineKind::Delete => entry_list.get(id).and_then(|list| list.add),
1791                LineKind::Context => None,
1792            }
1793        });
1794
1795        if line.kind == LineKind::Context {
1796            flipped_block = false;
1797        }
1798
1799        if !pmb.is_empty() && (line_match.is_none() || Some(line.kind) != moved_symbol) {
1800            if !adjust_last_block(&mut styles, tagged, color_moved.mode, n, block_length)
1801                && block_length > 1
1802            {
1803                line_match = None;
1804                n -= block_length;
1805            }
1806            pmb.clear();
1807            block_length = 0;
1808            flipped_block = false;
1809        }
1810
1811        let Some(line_match) = line_match else {
1812            moved_symbol = None;
1813            n += 1;
1814            continue;
1815        };
1816
1817        if color_moved.mode == ColorMovedMode::Plain {
1818            styles[n].moved = true;
1819            n += 1;
1820            continue;
1821        }
1822
1823        pmb_advance_or_null(
1824            &mut pmb,
1825            &entries,
1826            tagged,
1827            line_entry.expect("plus/minus line has move-detection entry"),
1828            color_moved.ws,
1829        );
1830
1831        if pmb.is_empty() {
1832            let contiguous =
1833                adjust_last_block(&mut styles, tagged, color_moved.mode, n, block_length);
1834            if !contiguous && block_length > 1 {
1835                n -= block_length;
1836            } else {
1837                fill_potential_moved_blocks(
1838                    line_match,
1839                    &entries,
1840                    tagged,
1841                    n,
1842                    color_moved.ws,
1843                    &mut pmb,
1844                );
1845            }
1846
1847            if contiguous && !pmb.is_empty() && moved_symbol == Some(line.kind) {
1848                flipped_block = !flipped_block;
1849            } else {
1850                flipped_block = false;
1851            }
1852
1853            moved_symbol = (!pmb.is_empty()).then_some(line.kind);
1854            block_length = 0;
1855        }
1856
1857        if !pmb.is_empty() {
1858            block_length += 1;
1859            styles[n].moved = true;
1860            if flipped_block && color_moved.mode != ColorMovedMode::Blocks {
1861                styles[n].alt = true;
1862            }
1863        }
1864        n += 1;
1865    }
1866
1867    adjust_last_block(&mut styles, tagged, color_moved.mode, n, block_length);
1868    if color_moved.mode == ColorMovedMode::DimmedZebra {
1869        dim_moved_lines(&mut styles, tagged);
1870    }
1871    styles
1872}
1873
1874fn add_lines_to_move_detection(
1875    tagged: &[TaggedLine<'_>],
1876    ws: ColorMovedWs,
1877) -> (Vec<MovedEntry>, Vec<Option<usize>>, Vec<MovedEntryList>) {
1878    let mut entries: Vec<MovedEntry> = Vec::new();
1879    let mut entry_for_tag = vec![None; tagged.len()];
1880    let mut entry_list = Vec::<MovedEntryList>::new();
1881    let mut interned = HashMap::<Vec<u8>, usize>::new();
1882    let mut prev_line: Option<usize> = None;
1883
1884    for (tag_idx, line) in tagged.iter().enumerate() {
1885        if line.kind != LineKind::Insert && line.kind != LineKind::Delete {
1886            prev_line = None;
1887            continue;
1888        }
1889        let indent = if ws.allow_indentation_change {
1890            moved_indent_data(line.content)
1891        } else {
1892            (0, 0)
1893        };
1894        let key = moved_line_key(line.content, indent.0, ws.ignore);
1895        let id = match interned.get(&key) {
1896            Some(id) => *id,
1897            None => {
1898                let id = interned.len();
1899                interned.insert(key, id);
1900                entry_list.push(MovedEntryList::default());
1901                id
1902            }
1903        };
1904
1905        let entry_idx = entries.len();
1906        if let Some(prev) = prev_line
1907            && tagged[entries[prev].tag_idx].kind == line.kind
1908        {
1909            entries[prev].next_line = Some(entry_idx);
1910        }
1911        let next_match = match line.kind {
1912            LineKind::Insert => entry_list[id].add,
1913            LineKind::Delete => entry_list[id].del,
1914            LineKind::Context => None,
1915        };
1916        entries.push(MovedEntry {
1917            tag_idx,
1918            next_line: None,
1919            next_match,
1920            id,
1921            indent_width: indent.1,
1922        });
1923        match line.kind {
1924            LineKind::Insert => entry_list[id].add = Some(entry_idx),
1925            LineKind::Delete => entry_list[id].del = Some(entry_idx),
1926            LineKind::Context => {}
1927        }
1928        entry_for_tag[tag_idx] = Some(entry_idx);
1929        prev_line = Some(entry_idx);
1930    }
1931
1932    (entries, entry_for_tag, entry_list)
1933}
1934
1935fn moved_line_key(line: &[u8], indent_off: usize, ignore: WsIgnore) -> Vec<u8> {
1936    let bytes = line.get(indent_off..).unwrap_or_default();
1937    if ignore.is_empty() {
1938        bytes.to_vec()
1939    } else {
1940        canonicalize_moved_line(bytes, ignore)
1941    }
1942}
1943
1944fn canonicalize_moved_line(line: &[u8], ignore: WsIgnore) -> Vec<u8> {
1945    let is_ws = |b: u8| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c);
1946    if ignore.all_space {
1947        return line.iter().copied().filter(|b| !is_ws(*b)).collect();
1948    }
1949    if ignore.space_change {
1950        let mut out = Vec::with_capacity(line.len());
1951        let mut i = 0usize;
1952        while i < line.len() {
1953            if is_ws(line[i]) {
1954                out.push(b' ');
1955                while i < line.len() && is_ws(line[i]) {
1956                    i += 1;
1957                }
1958            } else {
1959                out.push(line[i]);
1960                i += 1;
1961            }
1962        }
1963        while out.last() == Some(&b' ') {
1964            out.pop();
1965        }
1966        return out;
1967    }
1968    if ignore.space_at_eol {
1969        let mut end = line.len();
1970        if end > 0 && line[end - 1] == b'\n' {
1971            end -= 1;
1972        }
1973        while end > 0 && matches!(line[end - 1], b' ' | b'\t') {
1974            end -= 1;
1975        }
1976        let mut out = line[..end].to_vec();
1977        if line.ends_with(b"\n") {
1978            out.push(b'\n');
1979        }
1980        return out;
1981    }
1982    if ignore.cr_at_eol {
1983        let mut out = line.to_vec();
1984        if out.ends_with(b"\r\n") {
1985            let len = out.len();
1986            out.remove(len - 2);
1987        }
1988        return out;
1989    }
1990    line.to_vec()
1991}
1992
1993fn moved_indent_data(line: &[u8]) -> (usize, i32) {
1994    let mut off = 0usize;
1995    let mut width = 0i32;
1996    while off < line.len() && matches!(line[off], b'\x0c' | b'\x0b' | b'\r') {
1997        off += 1;
1998    }
1999    while off < line.len() {
2000        match line[off] {
2001            b' ' => {
2002                width += 1;
2003                off += 1;
2004            }
2005            b'\t' => {
2006                width += 8 - (width % 8);
2007                off += 1;
2008                while off < line.len() && line[off] == b'\t' {
2009                    width += 8;
2010                    off += 1;
2011                }
2012            }
2013            _ => break,
2014        }
2015    }
2016    if line[off..].iter().all(|b| b.is_ascii_whitespace()) {
2017        (line.len(), INDENT_BLANKLINE)
2018    } else {
2019        (off, width)
2020    }
2021}
2022
2023fn pmb_advance_or_null(
2024    pmb: &mut Vec<MovedBlock>,
2025    entries: &[MovedEntry],
2026    tagged: &[TaggedLine<'_>],
2027    line_entry: usize,
2028    ws: ColorMovedWs,
2029) {
2030    let mut kept = Vec::with_capacity(pmb.len());
2031    for mut block in pmb.iter().copied() {
2032        let Some(cur) = entries[block.match_entry].next_line else {
2033            continue;
2034        };
2035        let matched = if ws.allow_indentation_change {
2036            !cmp_in_block_with_wsd(entries, cur, tagged, line_entry, &mut block)
2037        } else {
2038            entries[cur].id == entries[line_entry].id
2039        };
2040        if matched {
2041            block.match_entry = cur;
2042            kept.push(block);
2043        }
2044    }
2045    *pmb = kept;
2046}
2047
2048fn fill_potential_moved_blocks(
2049    mut line_match: usize,
2050    entries: &[MovedEntry],
2051    tagged: &[TaggedLine<'_>],
2052    tag_idx: usize,
2053    ws: ColorMovedWs,
2054    pmb: &mut Vec<MovedBlock>,
2055) {
2056    loop {
2057        let entry = &entries[line_match];
2058        let wsd = if ws.allow_indentation_change {
2059            compute_ws_delta(tagged[tag_idx].content, entry.indent_width)
2060        } else {
2061            0
2062        };
2063        pmb.push(MovedBlock {
2064            match_entry: line_match,
2065            wsd,
2066        });
2067        match entry.next_match {
2068            Some(next) => line_match = next,
2069            None => break,
2070        }
2071    }
2072}
2073
2074fn compute_ws_delta(line: &[u8], match_indent_width: i32) -> i32 {
2075    let (_, width) = moved_indent_data(line);
2076    if width == INDENT_BLANKLINE && match_indent_width == INDENT_BLANKLINE {
2077        INDENT_BLANKLINE
2078    } else {
2079        width - match_indent_width
2080    }
2081}
2082
2083fn cmp_in_block_with_wsd(
2084    entries: &[MovedEntry],
2085    cur: usize,
2086    tagged: &[TaggedLine<'_>],
2087    line_entry: usize,
2088    block: &mut MovedBlock,
2089) -> bool {
2090    let cur_entry = &entries[cur];
2091    if cur_entry.id != entries[line_entry].id {
2092        return true;
2093    }
2094    if cur_entry.indent_width == INDENT_BLANKLINE {
2095        return false;
2096    }
2097    let (_, line_width) = moved_indent_data(tagged[entries[line_entry].tag_idx].content);
2098    let delta = line_width - cur_entry.indent_width;
2099    if block.wsd == INDENT_BLANKLINE {
2100        block.wsd = delta;
2101    }
2102    delta != block.wsd
2103}
2104
2105fn adjust_last_block(
2106    styles: &mut [MovedStyle],
2107    tagged: &[TaggedLine<'_>],
2108    mode: ColorMovedMode,
2109    n: usize,
2110    block_length: usize,
2111) -> bool {
2112    if mode == ColorMovedMode::Plain {
2113        return block_length != 0;
2114    }
2115    let mut alnum_count = 0usize;
2116    for i in 1..=block_length {
2117        for byte in tagged[n - i].content {
2118            if byte.is_ascii_alphanumeric() {
2119                alnum_count += 1;
2120                if alnum_count >= COLOR_MOVED_MIN_ALNUM_COUNT {
2121                    return true;
2122                }
2123            }
2124        }
2125    }
2126    for i in 1..=block_length {
2127        styles[n - i] = MovedStyle::default();
2128    }
2129    false
2130}
2131
2132fn dim_moved_lines(styles: &mut [MovedStyle], tagged: &[TaggedLine<'_>]) {
2133    for n in 0..tagged.len() {
2134        if tagged[n].kind != LineKind::Insert && tagged[n].kind != LineKind::Delete {
2135            continue;
2136        }
2137        if !styles[n].moved {
2138            continue;
2139        }
2140        let prev = (n > 0
2141            && (tagged[n - 1].kind == LineKind::Insert || tagged[n - 1].kind == LineKind::Delete))
2142            .then_some(n - 1);
2143        let next = (n + 1 < tagged.len()
2144            && (tagged[n + 1].kind == LineKind::Insert || tagged[n + 1].kind == LineKind::Delete))
2145            .then_some(n + 1);
2146
2147        if prev.is_some_and(|i| moved_zebra_mask(styles[i]) == moved_zebra_mask(styles[n]))
2148            && next.is_some_and(|i| moved_zebra_mask(styles[i]) == moved_zebra_mask(styles[n]))
2149        {
2150            styles[n].uninteresting = true;
2151            continue;
2152        }
2153        if prev.is_some_and(|i| styles[i].moved && styles[i].alt != styles[n].alt) {
2154            continue;
2155        }
2156        if next.is_some_and(|i| styles[i].moved && styles[i].alt != styles[n].alt) {
2157            continue;
2158        }
2159        styles[n].uninteresting = true;
2160    }
2161}
2162
2163fn moved_zebra_mask(style: MovedStyle) -> (bool, bool) {
2164    (style.moved, style.alt)
2165}
2166
2167/// Emit a single hunk covering `tagged[start..end]`: the `@@ -os,oc +ns,nc @@
2168/// <heading>` header followed by the context/`-`/`+` lines, including the
2169/// `\ No newline at end of file` markers.
2170fn render_one_hunk(
2171    out: &mut Vec<u8>,
2172    tagged: &[TaggedLine<'_>],
2173    moved_styles: Option<&[MovedStyle]>,
2174    old_lines: &[DiffLine<'_>],
2175    start: usize,
2176    end: usize,
2177    options: &mut HunkRenderOptions<'_, '_>,
2178) {
2179    let slice = &tagged[start..end];
2180    let mut old_count = 0usize;
2181    let mut new_count = 0usize;
2182    for line in slice {
2183        match line.kind {
2184            LineKind::Context => {
2185                old_count += 1;
2186                new_count += 1;
2187            }
2188            LineKind::Delete => old_count += 1,
2189            LineKind::Insert => new_count += 1,
2190        }
2191    }
2192    // 1-based starting line numbers; an empty side starts at 0.
2193    let old_start = if old_count == 0 {
2194        slice.first().map(|line| line.old_index).unwrap_or(0)
2195    } else {
2196        slice
2197            .iter()
2198            .find(|line| line.kind != LineKind::Insert)
2199            .map(|line| line.old_index + 1)
2200            .unwrap_or(1)
2201    };
2202    let new_start = if new_count == 0 {
2203        slice.first().map(|line| line.new_index).unwrap_or(0)
2204    } else {
2205        slice
2206            .iter()
2207            .find(|line| line.kind != LineKind::Delete)
2208            .map(|line| line.new_index + 1)
2209            .unwrap_or(1)
2210    };
2211
2212    let heading = hunk_section_heading(
2213        old_lines,
2214        slice.first().map(|line| line.old_index),
2215        options.heading.as_deref_mut(),
2216    );
2217    let frag = format!(
2218        "@@ -{} +{} @@",
2219        format_hunk_range(old_start, old_count),
2220        format_hunk_range(new_start, new_count)
2221    );
2222    match options.colors {
2223        // Port of emit_hunk_header: the "@@ .. @@" span in the frag color,
2224        // the separating blank in the context color, the heading in the func
2225        // color (each reset-terminated).
2226        Some(colors) => {
2227            out.extend_from_slice(colors.frag.as_bytes());
2228            out.extend_from_slice(frag.as_bytes());
2229            out.extend_from_slice(colors.reset.as_bytes());
2230            if let Some(heading) = &heading {
2231                out.extend_from_slice(colors.context.as_bytes());
2232                out.push(b' ');
2233                out.extend_from_slice(colors.reset.as_bytes());
2234                out.extend_from_slice(colors.func.as_bytes());
2235                out.extend_from_slice(heading);
2236                out.extend_from_slice(colors.reset.as_bytes());
2237            }
2238            out.push(b'\n');
2239        }
2240        None => {
2241            out.extend_from_slice(frag.as_bytes());
2242            if let Some(heading) = &heading {
2243                out.push(b' ');
2244                out.extend_from_slice(heading);
2245            }
2246            out.push(b'\n');
2247        }
2248    }
2249
2250    if let Some(word_diff) = options.word_diff.as_deref_mut() {
2251        // Word-diff rendering: minus/plus runs accumulate and flush at
2252        // context lines (fn_out_consume's diff_words branch); the
2253        // "\ No newline" markers are eaten.
2254        for line in slice {
2255            match line.kind {
2256                LineKind::Delete => word_diff.push_minus(line.content),
2257                LineKind::Insert => word_diff.push_plus(line.content),
2258                LineKind::Context => {
2259                    word_diff.flush(out);
2260                    word_diff.emit_context_line(out, line.content);
2261                }
2262            }
2263        }
2264        word_diff.flush(out);
2265        return;
2266    }
2267
2268    for (offset, line) in slice.iter().enumerate() {
2269        let prefix = match line.kind {
2270            LineKind::Context => options.line_indicators.context,
2271            LineKind::Delete => options.line_indicators.old,
2272            LineKind::Insert => options.line_indicators.new,
2273        };
2274        match options.colors {
2275            Some(colors) => {
2276                // Whitespace-error highlighting applies to the selected line
2277                // kinds (default: new lines only).
2278                let ws_rule = options.ws_error.and_then(|ws| {
2279                    let enabled = match line.kind {
2280                        LineKind::Context => ws.context,
2281                        LineKind::Delete => ws.old,
2282                        LineKind::Insert => ws.new,
2283                    };
2284                    enabled.then_some(ws.rule)
2285                });
2286                let moved = moved_styles
2287                    .and_then(|styles| styles.get(start + offset))
2288                    .copied()
2289                    .filter(|style| style.moved);
2290                write_patch_line_colored(out, prefix, line.content, colors, ws_rule, moved);
2291            }
2292            None => write_patch_line(out, prefix, line.content),
2293        }
2294    }
2295}
2296
2297/// Format one `start,count` side of an `@@` header. git omits the count when
2298/// it is exactly 1 (e.g. `+5` rather than `+5,1`).
2299fn format_hunk_range(start: usize, count: usize) -> String {
2300    if count == 1 {
2301        start.to_string()
2302    } else {
2303        format!("{start},{count}")
2304    }
2305}
2306
2307/// git's section heading for a hunk: the nearest line *before* the hunk's
2308/// first line accepted by the caller's `heading` classifier. Headings are
2309/// produced by the classifier (already capped/trimmed by the caller's
2310/// userdiff machinery). Returns `None` when no such line precedes the hunk or
2311/// no classifier was supplied.
2312fn hunk_section_heading(
2313    old_lines: &[DiffLine<'_>],
2314    first_old_index: Option<usize>,
2315    mut heading: Option<&mut HeadingFn<'_>>,
2316) -> Option<Vec<u8>> {
2317    let first = first_old_index?;
2318    let classifier = heading.as_mut()?;
2319    // Scan upward from the line just above the hunk.
2320    for idx in (0..first).rev() {
2321        if let Some(found) = classifier(old_lines[idx].content) {
2322            return Some(found);
2323        }
2324    }
2325    None
2326}
2327
2328/// Write a single diff line with its `prefix` marker, appending the
2329/// `\ No newline at end of file` note when the source line lacks a trailing
2330/// LF.
2331fn write_patch_line(out: &mut Vec<u8>, prefix: u8, line: &[u8]) {
2332    out.push(prefix);
2333    out.extend_from_slice(line);
2334    if !line.ends_with(b"\n") {
2335        out.extend_from_slice(b"\n\\ No newline at end of file\n");
2336    }
2337}
2338
2339/// [`write_patch_line`] in color, optionally painting whitespace errors.
2340///
2341/// When `ws_rule` is `Some`, the line body is emitted through
2342/// [`crate::ws::ws_check_emit`] (git's `emit_line_ws_markup` highlighted
2343/// branch): the sign is painted in the line color, then the body's non-error
2344/// segments in the line color and its whitespace-error segments in
2345/// `colors.whitespace`. A clean line produces no whitespace spans, so it stays
2346/// visually plain.
2347///
2348/// When `ws_rule` is `None`, context/old lines paint the sign and body in one
2349/// span; new lines paint the sign and body as separate spans (the default
2350/// `ws-error-highlight` path with no rule).
2351fn write_patch_line_colored(
2352    out: &mut Vec<u8>,
2353    prefix: u8,
2354    line: &[u8],
2355    colors: RenderColors<'_>,
2356    ws_rule: Option<crate::ws::WsRule>,
2357    moved: Option<MovedStyle>,
2358) {
2359    let (body, terminated) = match line.split_last() {
2360        Some((b'\n', body)) => (body, true),
2361        _ => (line, false),
2362    };
2363    let color = match (prefix, moved) {
2364        (b'-', Some(style)) if style.uninteresting && style.alt => colors.old_moved_alt_dim,
2365        (b'-', Some(style)) if style.uninteresting => colors.old_moved_dim,
2366        (b'-', Some(style)) if style.alt => colors.old_moved_alt,
2367        (b'-', Some(_)) => colors.old_moved,
2368        (b'+', Some(style)) if style.uninteresting && style.alt => colors.new_moved_alt_dim,
2369        (b'+', Some(style)) if style.uninteresting => colors.new_moved_dim,
2370        (b'+', Some(style)) if style.alt => colors.new_moved_alt,
2371        (b'+', Some(_)) => colors.new_moved,
2372        (b'-', _) => colors.old,
2373        (b'+', _) => colors.new,
2374        _ => colors.context,
2375    };
2376
2377    if let Some(rule) = ws_rule {
2378        if rule == 0 {
2379            out.extend_from_slice(color.as_bytes());
2380            out.push(prefix);
2381            out.extend_from_slice(body);
2382            out.extend_from_slice(colors.reset.as_bytes());
2383            out.push(b'\n');
2384            if !terminated {
2385                out.extend_from_slice(colors.context.as_bytes());
2386                out.extend_from_slice(b"\\ No newline at end of file");
2387                out.extend_from_slice(colors.reset.as_bytes());
2388                out.push(b'\n');
2389            }
2390            return;
2391        }
2392        // Sign in the line color, then the body through ws_check_emit (no
2393        // trailing newline in `body`, so the emit's own LF handling is inert).
2394        out.extend_from_slice(color.as_bytes());
2395        out.push(prefix);
2396        out.extend_from_slice(colors.reset.as_bytes());
2397        let emit_colors = crate::ws::WsEmitColors {
2398            set: color,
2399            reset: colors.reset,
2400            ws: colors.whitespace,
2401        };
2402        crate::ws::ws_check_emit(body, rule, out, &emit_colors);
2403        out.push(b'\n');
2404        if !terminated {
2405            let marker_color = if rule & crate::ws::WS_INCOMPLETE_LINE != 0 {
2406                colors.whitespace
2407            } else {
2408                colors.context
2409            };
2410            out.extend_from_slice(marker_color.as_bytes());
2411            out.extend_from_slice(b"\\ No newline at end of file");
2412            out.extend_from_slice(colors.reset.as_bytes());
2413            out.push(b'\n');
2414        }
2415        return;
2416    }
2417
2418    if prefix == b'+' {
2419        out.extend_from_slice(color.as_bytes());
2420        out.push(prefix);
2421        out.extend_from_slice(colors.reset.as_bytes());
2422        if !body.is_empty() {
2423            out.extend_from_slice(color.as_bytes());
2424            out.extend_from_slice(body);
2425            out.extend_from_slice(colors.reset.as_bytes());
2426        }
2427    } else {
2428        out.extend_from_slice(color.as_bytes());
2429        out.push(prefix);
2430        out.extend_from_slice(body);
2431        out.extend_from_slice(colors.reset.as_bytes());
2432    }
2433    out.push(b'\n');
2434    if !terminated {
2435        out.extend_from_slice(colors.context.as_bytes());
2436        out.extend_from_slice(b"\\ No newline at end of file");
2437        out.extend_from_slice(colors.reset.as_bytes());
2438        out.push(b'\n');
2439    }
2440}
2441
2442// ===========================================================================
2443// Combined / merge-commit diff renderer (`-c` / `--cc`).
2444//
2445// This is the byte-for-byte port of git's `combine-diff.c`
2446// (`combine_diff` / `make_hunks` / `dump_sline`): the multi-parent
2447// `@@@ -p1 -p2 +out @@@` hunk header (with one extra `@` per parent and one
2448// `-pN,cN` column per parent), the per-parent prefix columns on each body
2449// line, and the `--cc` "dense" simplification that drops hunks whose result
2450// matches at least one parent ("uninteresting" hunks).
2451//
2452// The renderer is repository-agnostic, exactly like the unified-diff renderer
2453// above: the caller supplies the *result* blob and one blob per parent (plus
2454// the line-diff algorithm / whitespace flags), and we emit only the hunk body
2455// — the per-file `diff --cc`/`index`/`---`/`+++` metainfo header stays with the
2456// command, which owns the repository / oid / mode context.
2457//
2458// Data-structure correspondence with combine-diff.c:
2459//   * `Sline`  <-> `struct sline` (one per result line, plus a trailing
2460//     sentinel `sline[cnt]` whose `bol` is empty),
2461//   * `Sline.lost` <-> `struct lline` list (lines deleted relative to some
2462//     parent, hung before the surviving result line),
2463//   * the `flag` bitset: bit `n` set => parent `n` did NOT have this result
2464//     line (i.e. it was added relative to parent `n`); bit `num_parent`
2465//     ("mark") => the line is part of a shown hunk; bit `num_parent+1`
2466//     ("no_pre_delete") => suppress the leading deletions before this line.
2467// ===========================================================================
2468
2469/// One result line in the combined-diff `sline` array, plus the deletions
2470/// ("lost" lines) that hang in front of it.
2471struct CdLine {
2472    /// The surviving result line bytes, WITHOUT the trailing newline.
2473    bol: Vec<u8>,
2474    /// Deletions hung before this line, in display order. `parent_map` is the
2475    /// bitset of parents that had the deleted line (git coalesces these across
2476    /// parents via an LCS; we replicate that with [`coalesce_lost`]).
2477    lost: Vec<CdLost>,
2478    /// Pre-coalesce deletions accumulated for the parent currently being
2479    /// folded (drained into `lost` after each parent, like git's `plost`).
2480    plost: Vec<Vec<u8>>,
2481    /// `flag` bitset (see module comment).
2482    flag: u64,
2483    /// `p_lno[n]` = 1-based line number in parent `n` at which a hunk starting
2484    /// at this result line begins. Sized `num_parent`.
2485    p_lno: Vec<u64>,
2486}
2487
2488/// A single deleted ("lost") line and the set of parents it was removed from.
2489struct CdLost {
2490    line: Vec<u8>,
2491    parent_map: u64,
2492}
2493
2494/// Options controlling the combined-diff body emission.
2495pub struct CombinedRenderOptions {
2496    /// `--cc` dense simplification (drop hunks the result shares with a parent);
2497    /// `-c` (plain combined) leaves it `false`.
2498    pub dense: bool,
2499    /// Unified-context line count (`-U`, default 3).
2500    pub context: usize,
2501    /// Line-diff algorithm used for each parent-vs-result 2-way diff.
2502    pub algorithm: DiffAlgorithm,
2503    /// Whitespace-ignore flags applied to each parent-vs-result 2-way diff and
2504    /// to the lost-line coalescing match.
2505    pub ws_ignore: WsIgnore,
2506}
2507
2508impl Default for CombinedRenderOptions {
2509    fn default() -> Self {
2510        Self {
2511            dense: true,
2512            context: DEFAULT_CONTEXT,
2513            algorithm: DiffAlgorithm::Myers,
2514            ws_ignore: WsIgnore::default(),
2515        }
2516    }
2517}
2518
2519/// Render a combined / merge diff body into `out`.
2520///
2521/// `result` is the merge-result blob; `parents` holds one blob per parent (in
2522/// parent order). Returns `true` when at least one hunk survives the
2523/// "interesting" filter — the caller uses this to decide whether to print the
2524/// metainfo header at all (git only prints `diff --cc <path>` + body when
2525/// `show_hunks || mode_differs`).
2526///
2527/// Mirrors `show_patch_diff`'s body half: build the `sline` array, fold each
2528/// parent into it via [`combine_one_parent`], run [`make_hunks`], then
2529/// [`dump_sline`].
2530pub fn render_combined(out: &mut Vec<u8>, result: &[u8], parents: &[&[u8]]) -> bool {
2531    render_combined_with(out, result, parents, &CombinedRenderOptions::default())
2532}
2533
2534/// [`render_combined`] with explicit options.
2535pub fn render_combined_with(
2536    out: &mut Vec<u8>,
2537    result: &[u8],
2538    parents: &[&[u8]],
2539    options: &CombinedRenderOptions,
2540) -> bool {
2541    let num_parent = parents.len();
2542    debug_assert!(num_parent >= 1);
2543
2544    // Split the result into lines (without trailing newline), counting an
2545    // unterminated final line as its own line — git's `cnt` counts '\n' plus an
2546    // incomplete trailing line.
2547    let result_lines = split_lines(result);
2548    let cnt = result_lines.len();
2549
2550    // git allocates `cnt + 2` slines: indices `0..cnt-1` are the result lines,
2551    // `sline[cnt]` is the trailing sentinel (where end-of-file deletions hang),
2552    // and `sline[cnt+1]` carries the per-parent trailer p_lno that
2553    // `show_parent_lno` reads for a hunk whose end touches the last line.
2554    let mut sline: Vec<CdLine> = Vec::with_capacity(cnt + 2);
2555    for line in &result_lines {
2556        sline.push(CdLine {
2557            bol: line.bytes_without_newline().to_vec(),
2558            lost: Vec::new(),
2559            plost: Vec::new(),
2560            flag: 0,
2561            p_lno: vec![0; num_parent],
2562        });
2563    }
2564    for _ in 0..2 {
2565        sline.push(CdLine {
2566            bol: Vec::new(),
2567            lost: Vec::new(),
2568            plost: Vec::new(),
2569            flag: 0,
2570            p_lno: vec![0; num_parent],
2571        });
2572    }
2573
2574    // Fold each parent into the sline array. git reuses an earlier parent's
2575    // result when two parents have the identical blob (`reuse_combine_diff`);
2576    // we replicate that to keep p_lno / flags identical.
2577    for n in 0..num_parent {
2578        let mut reused = None;
2579        for j in 0..n {
2580            if parents[j] == parents[n] {
2581                reused = Some(j);
2582                break;
2583            }
2584        }
2585        match reused {
2586            Some(j) => reuse_combine_diff(&mut sline, cnt, n, j),
2587            None => combine_one_parent(&mut sline, &result_lines, parents[n], n, options),
2588        }
2589    }
2590
2591    let show_hunks = make_hunks(&mut sline, cnt, num_parent, options.dense, options.context);
2592    if show_hunks {
2593        dump_sline(out, &sline, cnt, num_parent, options.context);
2594    }
2595    show_hunks
2596}
2597
2598/// Fold one parent's 2-way diff against the result into the `sline` array
2599/// (git's `combine_diff` + the consume_hunk/consume_line callbacks).
2600fn combine_one_parent(
2601    sline: &mut [CdLine],
2602    result_lines: &[DiffLine<'_>],
2603    parent: &[u8],
2604    n: usize,
2605    options: &CombinedRenderOptions,
2606) {
2607    let cnt = result_lines.len();
2608    let nmask = 1u64 << n;
2609    let parent_lines = split_lines(parent);
2610    let ops = myers_diff_lines_ws(
2611        &parent_lines,
2612        result_lines,
2613        options.ws_ignore,
2614        options.algorithm,
2615    );
2616
2617    // Walk the edit script, tracking the 1-based result line number (`lno`,
2618    // git's `state->lno`) and the parent line number (`p_lno`/`ob`). For each
2619    // hunk: deletions hang on `lost_bucket`; insertions set the nmask flag on
2620    // the result line; the hunk start records the parent line number into
2621    // `p_lno[n]` of the result line preceding the hunk.
2622    //
2623    // git groups the script into hunks (runs separated by Equal context); we
2624    // mirror consume_hunk by detecting the boundary at each non-Equal run.
2625    let mut old_idx: usize = 0; // 0-based parent line consumed
2626    let mut new_idx: usize = 0; // 0-based result line consumed
2627    let mut i = 0;
2628    while i < ops.len() {
2629        match ops[i] {
2630            DiffOp::Equal(k) => {
2631                old_idx += k;
2632                new_idx += k;
2633                i += 1;
2634            }
2635            _ => {
2636                // Collect a maximal run of consecutive Delete/Insert ops as one
2637                // hunk (git's xdiff emits one @@ hunk per such run).
2638                let hunk_old_start = old_idx; // 0-based
2639                let hunk_new_start = new_idx; // 0-based
2640                let mut dels: Vec<&[u8]> = Vec::new();
2641                while i < ops.len() {
2642                    match ops[i] {
2643                        DiffOp::Delete(k) => {
2644                            for _ in 0..k {
2645                                dels.push(parent_lines[old_idx].bytes_without_newline());
2646                                old_idx += 1;
2647                            }
2648                            i += 1;
2649                        }
2650                        DiffOp::Insert(k) => {
2651                            new_idx += k;
2652                            i += 1;
2653                        }
2654                        DiffOp::Equal(_) => break,
2655                    }
2656                }
2657                let _ = hunk_old_start;
2658
2659                // Lost bucket: deletions hang on the result line at the hunk
2660                // start (sline[hunk_new_start]). git's distinction between the
2661                // additions-present (`nb-1`) and pure-deletion (`nb`) cases
2662                // collapses to the same index here because our `hunk_new_start`
2663                // is the 0-based result line immediately *after* the preceding
2664                // context, matching git's `nb-1` for the additions case and the
2665                // bucket-after for the pure-deletion case once the result line
2666                // numbering (1-based `nb`) is accounted for. The authoritative
2667                // p_lno values are recomputed in the loop below, so we do not
2668                // record them here.
2669                for d in &dels {
2670                    sline[hunk_new_start].plost.push(d.to_vec());
2671                }
2672                // Mark inserted result lines: flag bit n set => parent n lacked
2673                // this line.
2674                for line in sline.iter_mut().take(new_idx.min(cnt)).skip(hunk_new_start) {
2675                    line.flag |= nmask;
2676                }
2677            }
2678        }
2679    }
2680
2681    // Coalesce the plost lines into lost (git's coalesce_lines), then assign
2682    // p_lno numbers per parent — git's second loop in combine_diff.
2683    let mut p_lno: u64 = 1;
2684    for (lno, line) in sline.iter_mut().enumerate().take(cnt + 1) {
2685        line.p_lno[n] = p_lno;
2686        if !line.plost.is_empty() {
2687            let plost = std::mem::take(&mut line.plost);
2688            coalesce_lost(&mut line.lost, plost, n, options);
2689        }
2690        // How many parent lines does this sline advance?
2691        for ll in &line.lost {
2692            if ll.parent_map & nmask != 0 {
2693                p_lno += 1; // '-' means parent had it
2694            }
2695        }
2696        if lno < cnt && (line.flag & nmask) == 0 {
2697            p_lno += 1; // no '+' means parent had it
2698        }
2699    }
2700    sline[cnt + 1].p_lno[n] = p_lno; // trailer (git's sline[cnt+1])
2701}
2702
2703/// Coalesce a parent's freshly-collected deletions into the line's existing
2704/// lost list (git's `coalesce_lines` LCS merge). A deletion that matches an
2705/// already-present lost line (under the active whitespace flags) gets its
2706/// parent bit OR'd into that line's `parent_map` instead of being added again.
2707fn coalesce_lost(
2708    base: &mut Vec<CdLost>,
2709    newlines: Vec<Vec<u8>>,
2710    n: usize,
2711    options: &CombinedRenderOptions,
2712) {
2713    let pmask = 1u64 << n;
2714    if newlines.is_empty() {
2715        return;
2716    }
2717    if base.is_empty() {
2718        for line in newlines {
2719            base.push(CdLost {
2720                line,
2721                parent_map: pmask,
2722            });
2723        }
2724        return;
2725    }
2726
2727    // LCS over (base lines, new lines) by whitespace-aware equality, exactly
2728    // like git: MATCH => OR the parent bit into the base line; NEW => insert the
2729    // new line at that position; BASE => keep base line as-is.
2730    let m = base.len();
2731    let k = newlines.len();
2732    let mut lcs = vec![vec![0i32; k + 1]; m + 1];
2733    for i in 1..=m {
2734        for j in 1..=k {
2735            if combined_lines_match(&base[i - 1].line, &newlines[j - 1], options.ws_ignore) {
2736                lcs[i][j] = lcs[i - 1][j - 1] + 1;
2737            } else if lcs[i][j - 1] >= lcs[i - 1][j] {
2738                lcs[i][j] = lcs[i][j - 1];
2739            } else {
2740                lcs[i][j] = lcs[i - 1][j];
2741            }
2742        }
2743    }
2744
2745    // Backtrack, building the merged list in reverse.
2746    let mut merged: Vec<CdLost> = Vec::with_capacity(m + k);
2747    let mut i = m;
2748    let mut j = k;
2749    while i > 0 || j > 0 {
2750        if i > 0
2751            && j > 0
2752            && combined_lines_match(&base[i - 1].line, &newlines[j - 1], options.ws_ignore)
2753        {
2754            let mut entry = std::mem::replace(
2755                &mut base[i - 1],
2756                CdLost {
2757                    line: Vec::new(),
2758                    parent_map: 0,
2759                },
2760            );
2761            entry.parent_map |= pmask;
2762            merged.push(entry);
2763            i -= 1;
2764            j -= 1;
2765        } else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) {
2766            merged.push(CdLost {
2767                line: newlines[j - 1].clone(),
2768                parent_map: pmask,
2769            });
2770            j -= 1;
2771        } else {
2772            let entry = std::mem::replace(
2773                &mut base[i - 1],
2774                CdLost {
2775                    line: Vec::new(),
2776                    parent_map: 0,
2777                },
2778            );
2779            merged.push(entry);
2780            i -= 1;
2781        }
2782    }
2783    merged.reverse();
2784    *base = merged;
2785}
2786
2787/// Whitespace-aware line equality used by the lost-line coalescer
2788/// (git's `match_string_spaces`). Only the all-space / space-change flavours
2789/// affect the comparison; otherwise it is a byte compare.
2790fn combined_lines_match(a: &[u8], b: &[u8], ws: WsIgnore) -> bool {
2791    if ws.all_space || ws.space_change || ws.space_at_eol {
2792        let at = strip_trailing_ws(a);
2793        let bt = strip_trailing_ws(b);
2794        if !ws.all_space && !ws.space_change {
2795            return at == bt;
2796        }
2797        return ws_squash_eq(at, bt, ws.space_change);
2798    }
2799    a == b
2800}
2801
2802fn strip_trailing_ws(s: &[u8]) -> &[u8] {
2803    let mut end = s.len();
2804    while end > 0 && (s[end - 1] == b' ' || s[end - 1] == b'\t') {
2805        end -= 1;
2806    }
2807    &s[..end]
2808}
2809
2810/// Compare two lines ignoring whitespace runs (`-w`) or treating runs as a
2811/// single space (`-b`).
2812fn ws_squash_eq(a: &[u8], b: &[u8], change_only: bool) -> bool {
2813    let is_ws = |c: u8| c == b' ' || c == b'\t';
2814    let (mut ia, mut ib) = (0usize, 0usize);
2815    while ia < a.len() && ib < b.len() {
2816        let (ca, cb) = (a[ia], b[ib]);
2817        if is_ws(ca) || is_ws(cb) {
2818            if change_only && (!is_ws(ca) || !is_ws(cb)) {
2819                return false;
2820            }
2821            // For -b, a whitespace run on both sides counts as equal; for -w,
2822            // whitespace is skipped entirely. Skip the runs on both sides.
2823            if change_only {
2824                while ia < a.len() && is_ws(a[ia]) {
2825                    ia += 1;
2826                }
2827                while ib < b.len() && is_ws(b[ib]) {
2828                    ib += 1;
2829                }
2830                continue;
2831            } else {
2832                if is_ws(ca) {
2833                    ia += 1;
2834                    continue;
2835                }
2836                if is_ws(cb) {
2837                    ib += 1;
2838                    continue;
2839                }
2840            }
2841        }
2842        if ca != cb {
2843            return false;
2844        }
2845        ia += 1;
2846        ib += 1;
2847    }
2848    // Consume trailing whitespace.
2849    while ia < a.len() && is_ws(a[ia]) {
2850        ia += 1;
2851    }
2852    while ib < b.len() && is_ws(b[ib]) {
2853        ib += 1;
2854    }
2855    ia == a.len() && ib == b.len()
2856}
2857
2858/// git's `reuse_combine_diff`: when parent `i` has the same blob as a
2859/// previously-folded parent `j`, copy `j`'s flags / lost parent-bits / p_lno
2860/// across instead of re-diffing.
2861fn reuse_combine_diff(sline: &mut [CdLine], cnt: usize, i: usize, j: usize) {
2862    let imask = 1u64 << i;
2863    let jmask = 1u64 << j;
2864    for line in sline.iter_mut().take(cnt + 1) {
2865        line.p_lno[i] = line.p_lno[j];
2866        for ll in &mut line.lost {
2867            if ll.parent_map & jmask != 0 {
2868                ll.parent_map |= imask;
2869            }
2870        }
2871        if line.flag & jmask != 0 {
2872            line.flag |= imask;
2873        }
2874    }
2875    // The overall trailer (sline[cnt+1]).
2876    sline[cnt + 1].p_lno[i] = sline[cnt + 1].p_lno[j];
2877}
2878
2879/// Is this result line "interesting" — does any parent lack it, or does it have
2880/// deletions hung in front (git's `interesting`).
2881fn cd_interesting(sline: &CdLine, all_mask: u64) -> bool {
2882    (sline.flag & all_mask) != 0 || !sline.lost.is_empty()
2883}
2884
2885/// git's `adjust_hunk_tail`.
2886fn adjust_hunk_tail(sline: &[CdLine], all_mask: u64, hunk_begin: usize, mut i: usize) -> usize {
2887    if hunk_begin < i && (sline[i - 1].flag & all_mask) == 0 {
2888        i -= 1;
2889    }
2890    i
2891}
2892
2893/// git's `find_next`.
2894fn find_next(
2895    sline: &[CdLine],
2896    mark: u64,
2897    mut i: usize,
2898    cnt: usize,
2899    look_for_uninteresting: bool,
2900) -> usize {
2901    while i <= cnt {
2902        let marked = (sline[i].flag & mark) != 0;
2903        if look_for_uninteresting {
2904            if !marked {
2905                return i;
2906            }
2907        } else if marked {
2908            return i;
2909        }
2910        i += 1;
2911    }
2912    i
2913}
2914
2915/// git's `give_context`: paint context lines (and bridge small gaps) around the
2916/// interesting lines, using the `mark` bit. Returns whether any hunk shows.
2917fn give_context(sline: &mut [CdLine], cnt: usize, num_parent: usize, context: usize) -> bool {
2918    let all_mask = (1u64 << num_parent) - 1;
2919    let mark = 1u64 << num_parent;
2920    let no_pre_delete = 2u64 << num_parent;
2921
2922    let mut i = find_next(sline, mark, 0, cnt, false);
2923    if cnt < i {
2924        return false;
2925    }
2926
2927    while i <= cnt {
2928        let mut j = i.saturating_sub(context);
2929        // Paint a few lines before the first interesting line.
2930        while j < i {
2931            if (sline[j].flag & mark) == 0 {
2932                sline[j].flag |= no_pre_delete;
2933            }
2934            sline[j].flag |= mark;
2935            j += 1;
2936        }
2937
2938        loop {
2939            // Where does the next uninteresting line start?
2940            j = find_next(sline, mark, i, cnt, true);
2941            if cnt < j {
2942                // The rest are all interesting.
2943                return true;
2944            }
2945            // Lookahead context lines.
2946            let k = find_next(sline, mark, j, cnt, false);
2947            let j2 = adjust_hunk_tail(sline, all_mask, i, j);
2948
2949            if k < j2 + context {
2950                // Small gap: paint it interesting and continue.
2951                let mut jj = j2;
2952                while jj < k {
2953                    sline[jj].flag |= mark;
2954                    jj += 1;
2955                }
2956                i = k;
2957                continue;
2958            }
2959
2960            // No overlap within context: paint the trailing edge a bit.
2961            i = k;
2962            let kk = if j2 + context < cnt + 1 {
2963                j2 + context
2964            } else {
2965                cnt + 1
2966            };
2967            let mut jj = j2;
2968            while jj < kk {
2969                sline[jj].flag |= mark;
2970                jj += 1;
2971            }
2972            break;
2973        }
2974    }
2975    true
2976}
2977
2978/// git's `make_hunks`: mark interesting lines, run the `--cc` dense
2979/// simplification when requested, then `give_context`.
2980fn make_hunks(
2981    sline: &mut [CdLine],
2982    cnt: usize,
2983    num_parent: usize,
2984    dense: bool,
2985    context: usize,
2986) -> bool {
2987    let all_mask = (1u64 << num_parent) - 1;
2988    let mark = 1u64 << num_parent;
2989
2990    for line in sline.iter_mut().take(cnt + 1) {
2991        if cd_interesting(line, all_mask) {
2992            line.flag |= mark;
2993        } else {
2994            line.flag &= !mark;
2995        }
2996    }
2997    if !dense {
2998        return give_context(sline, cnt, num_parent, context);
2999    }
3000
3001    // Dense simplification: for each marked hunk, drop it when the result
3002    // differs from a single parent only (or matches all but one parent the
3003    // same way) — git's "interesting" recomputation.
3004    let mut i = 0;
3005    while i <= cnt {
3006        while i <= cnt && (sline[i].flag & mark) == 0 {
3007            i += 1;
3008        }
3009        if cnt < i {
3010            break;
3011        }
3012        let hunk_begin = i;
3013        let mut j = i + 1;
3014        while j <= cnt {
3015            if (sline[j].flag & mark) == 0 {
3016                // Look beyond the end for an interesting line within context.
3017                let mut la = adjust_hunk_tail(sline, all_mask, hunk_begin, j);
3018                la = if la + context < cnt + 1 {
3019                    la + context
3020                } else {
3021                    cnt + 1
3022                };
3023                let mut contin = false;
3024                while la > 0 && j < la {
3025                    la -= 1;
3026                    if (sline[la].flag & mark) != 0 {
3027                        contin = true;
3028                        break;
3029                    }
3030                }
3031                if !contin {
3032                    break;
3033                }
3034                j = la;
3035            }
3036            j += 1;
3037        }
3038        let hunk_end = j;
3039
3040        // Is the hunk "really" interesting? Check whether all changed lines
3041        // record the same set of parents.
3042        let mut same_diff: u64 = 0;
3043        let mut has_interesting = false;
3044        let mut jj = i;
3045        while jj < hunk_end && !has_interesting {
3046            let this_diff = sline[jj].flag & all_mask;
3047            if this_diff != 0 {
3048                if same_diff == 0 {
3049                    same_diff = this_diff;
3050                } else if same_diff != this_diff {
3051                    has_interesting = true;
3052                    break;
3053                }
3054            }
3055            for ll in &sline[jj].lost {
3056                if has_interesting {
3057                    break;
3058                }
3059                let td = ll.parent_map;
3060                if same_diff == 0 {
3061                    same_diff = td;
3062                } else if same_diff != td {
3063                    has_interesting = true;
3064                }
3065            }
3066            jj += 1;
3067        }
3068
3069        if !has_interesting && same_diff != all_mask {
3070            // Not interesting after all: unmark the whole hunk.
3071            for line in sline.iter_mut().take(hunk_end).skip(hunk_begin) {
3072                line.flag &= !mark;
3073            }
3074        }
3075        i = hunk_end;
3076    }
3077
3078    give_context(sline, cnt, num_parent, context)
3079}
3080
3081/// git's `show_parent_lno`: emit one `-l0,len` column for parent `n`.
3082fn show_parent_lno(
3083    out: &mut Vec<u8>,
3084    sline: &[CdLine],
3085    l0: usize,
3086    l1: usize,
3087    n: usize,
3088    null_context: u64,
3089) {
3090    let a = sline[l0].p_lno[n];
3091    let b = sline[l1].p_lno[n];
3092    out.extend_from_slice(format!(" -{},{}", a, b - a - null_context).as_bytes());
3093}
3094
3095/// git's `hunk_comment_line` test (used to append a function-context comment
3096/// to the `@@@ ... @@@` header).
3097fn hunk_comment_line(bol: &[u8]) -> bool {
3098    if bol.is_empty() {
3099        return false;
3100    }
3101    let ch = bol[0];
3102    ch.is_ascii_alphabetic() || ch == b'_' || ch == b'$'
3103}
3104
3105/// git's `show_line_to_eol`: emit a line, preserving a trailing CR. The bytes
3106/// here never include the newline; we add it.
3107fn show_line_to_eol(out: &mut Vec<u8>, line: &[u8]) {
3108    let saw_cr = line.last() == Some(&b'\r');
3109    if saw_cr {
3110        out.extend_from_slice(&line[..line.len() - 1]);
3111        out.push(b'\r');
3112    } else {
3113        out.extend_from_slice(line);
3114    }
3115    out.push(b'\n');
3116}
3117
3118/// git's `dump_sline`: emit the combined-diff hunk bodies for all marked hunks.
3119fn dump_sline(out: &mut Vec<u8>, sline: &[CdLine], cnt: usize, num_parent: usize, context: usize) {
3120    let mark = 1u64 << num_parent;
3121    let no_pre_delete = 2u64 << num_parent;
3122    let mut lno: usize = 0;
3123
3124    loop {
3125        let mut hunk_comment: Option<&[u8]> = None;
3126        while lno <= cnt && (sline[lno].flag & mark) == 0 {
3127            if hunk_comment_line(&sline[lno].bol) {
3128                hunk_comment = Some(&sline[lno].bol);
3129            }
3130            lno += 1;
3131        }
3132        if cnt < lno {
3133            break;
3134        }
3135        let mut hunk_end = lno + 1;
3136        while hunk_end <= cnt {
3137            if (sline[hunk_end].flag & mark) == 0 {
3138                break;
3139            }
3140            hunk_end += 1;
3141        }
3142
3143        let mut rlines = (hunk_end - lno) as u64;
3144        if cnt < hunk_end {
3145            rlines -= 1; // pointing at the last delete hunk
3146        }
3147
3148        let mut null_context: u64 = 0;
3149        if context == 0 {
3150            // --unified=0: count the all-blank-context result lines so the
3151            // header line counts exclude them.
3152            for sl in sline.iter().take(hunk_end).skip(lno) {
3153                if (sl.flag & (mark - 1)) == 0 {
3154                    null_context += 1;
3155                }
3156            }
3157            rlines -= null_context;
3158        }
3159
3160        // Header: `@@@`... (num_parent+1 markers), one -l,c column per parent,
3161        // ` +out_start,out_len `, num_parent+1 markers again.
3162        for _ in 0..=num_parent {
3163            out.push(b'@');
3164        }
3165        for i in 0..num_parent {
3166            show_parent_lno(out, sline, lno, hunk_end, i, null_context);
3167        }
3168        out.extend_from_slice(format!(" +{},{} ", lno + 1, rlines).as_bytes());
3169        for _ in 0..=num_parent {
3170            out.push(b'@');
3171        }
3172
3173        if let Some(comment) = hunk_comment {
3174            let mut comment_end = 0;
3175            for (idx, &ch) in comment.iter().take(40).enumerate() {
3176                if ch == b'\n' {
3177                    break;
3178                }
3179                if !ch.is_ascii_whitespace() {
3180                    comment_end = idx + 1;
3181                }
3182            }
3183            if comment_end != 0 {
3184                out.push(b' ');
3185                out.extend_from_slice(&comment[..comment_end]);
3186            }
3187        }
3188        out.push(b'\n');
3189
3190        // Body.
3191        while lno < hunk_end {
3192            let sl = &sline[lno];
3193            lno += 1;
3194            // Lost (deleted) lines hung before this result line.
3195            if (sl.flag & no_pre_delete) == 0 {
3196                for ll in &sl.lost {
3197                    for j in 0..num_parent {
3198                        if ll.parent_map & (1u64 << j) != 0 {
3199                            out.push(b'-');
3200                        } else {
3201                            out.push(b' ');
3202                        }
3203                    }
3204                    show_line_to_eol(out, &ll.line);
3205                }
3206            }
3207            if cnt < lno {
3208                break;
3209            }
3210            if (sl.flag & (mark - 1)) == 0 {
3211                // This sline only existed to hang the lost lines in front.
3212                if context == 0 {
3213                    continue;
3214                }
3215            }
3216            let mut p_mask = 1u64;
3217            for _ in 0..num_parent {
3218                if p_mask & sl.flag != 0 {
3219                    out.push(b'+');
3220                } else {
3221                    out.push(b' ');
3222                }
3223                p_mask <<= 1;
3224            }
3225            show_line_to_eol(out, &sl.bol);
3226        }
3227    }
3228}
3229
3230#[cfg(test)]
3231mod tests {
3232    use super::*;
3233
3234    fn render_plain(old: Option<&[u8]>, new: Option<&[u8]>) -> Vec<u8> {
3235        let mut out = Vec::new();
3236        let mut options = HunkRenderOptions::default();
3237        render_hunks(&mut out, old, new, &mut options);
3238        out
3239    }
3240
3241    #[test]
3242    fn identical_content_renders_nothing() {
3243        assert!(render_plain(Some(b"a\nb\n"), Some(b"a\nb\n")).is_empty());
3244    }
3245
3246    #[test]
3247    fn single_line_change_basic_hunk() {
3248        let out = render_plain(Some(b"alpha\nbeta\ngamma\n"), Some(b"alpha\nBETA\ngamma\n"));
3249        assert_eq!(
3250            out,
3251            b"@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n".to_vec(),
3252        );
3253    }
3254
3255    #[test]
3256    fn count_omitted_when_one() {
3257        // A single-line file changed in place yields `-1 +1` (no `,1`).
3258        let out = render_plain(Some(b"old\n"), Some(b"new\n"));
3259        assert_eq!(out, b"@@ -1 +1 @@\n-old\n+new\n".to_vec());
3260    }
3261
3262    #[test]
3263    fn no_newline_marker_on_old_side() {
3264        let out = render_plain(Some(b"only line no newline"), None);
3265        assert_eq!(
3266            out,
3267            b"@@ -1 +0,0 @@\n-only line no newline\n\\ No newline at end of file\n".to_vec(),
3268        );
3269    }
3270
3271    #[test]
3272    fn no_newline_marker_on_new_side() {
3273        let out = render_plain(Some(b"beta\n"), Some(b"beta-notail"));
3274        assert_eq!(
3275            out,
3276            b"@@ -1 +1 @@\n-beta\n+beta-notail\n\\ No newline at end of file\n".to_vec(),
3277        );
3278    }
3279
3280    #[test]
3281    fn pure_insertion_into_empty() {
3282        let out = render_plain(None, Some(b"x\ny\n"));
3283        assert_eq!(out, b"@@ -0,0 +1,2 @@\n+x\n+y\n".to_vec());
3284    }
3285
3286    #[test]
3287    fn distant_changes_split_into_two_hunks() {
3288        let old: &[u8] = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
3289        let new: &[u8] = b"A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
3290        let out = render_plain(Some(old), Some(new));
3291        // Two changes 9 lines apart (> 2*3+1) produce two separate hunks.
3292        let text = String::from_utf8(out).expect("rendered output is valid UTF-8");
3293        assert_eq!(text.matches("@@ ").count(), 2, "expected two hunks: {text}");
3294    }
3295
3296    #[test]
3297    fn heading_callback_supplies_section() {
3298        // The change is far enough below `fn foo()` that the funcname line
3299        // precedes the hunk (the heading scan looks *above* the hunk's first
3300        // line, so a change touching line 1 would correctly find no heading).
3301        let old: &[u8] = b"fn foo() {\n    a\n    b\n    c\n    d\n    e\n    f\n    g\n}\n";
3302        let new: &[u8] = b"fn foo() {\n    a\n    b\n    c\n    d\n    CHANGED\n    f\n    g\n}\n";
3303        let mut out = Vec::new();
3304        // Classifier accepts any line whose first byte is an ASCII letter
3305        // (a crude def_ff stand-in for the test).
3306        let mut heading_fn = |line: &[u8]| -> Option<Vec<u8>> {
3307            if line.first().is_some_and(u8::is_ascii_alphabetic) {
3308                Some(line.strip_suffix(b"\n").unwrap_or(line).to_vec())
3309            } else {
3310                None
3311            }
3312        };
3313        let mut options = HunkRenderOptions {
3314            heading: Some(&mut heading_fn),
3315            ..Default::default()
3316        };
3317        render_hunks(&mut out, Some(old), Some(new), &mut options);
3318        let text = String::from_utf8(out).expect("rendered output is valid UTF-8");
3319        assert!(
3320            text.starts_with("@@ -3,7 +3,7 @@ fn foo() {\n"),
3321            "expected funcname heading: {text}",
3322        );
3323    }
3324
3325    fn render_cc(result: &[u8], parents: &[&[u8]], dense: bool) -> String {
3326        let mut out = Vec::new();
3327        let opts = CombinedRenderOptions {
3328            dense,
3329            ..Default::default()
3330        };
3331        render_combined_with(&mut out, result, parents, &opts);
3332        String::from_utf8(out).expect("combined output is valid UTF-8")
3333    }
3334
3335    #[test]
3336    fn combined_two_parent_dense_header_and_columns() {
3337        // A merge result that adds lines on top of two parents, the t4013
3338        // dir/sub shape: parent0 = "A\nB\nC\nD\nE\nF\n", parent1 = "A\nB\n1\n2\n",
3339        // result = "A\nB\nC\nD\nE\nF\n1\n2\n". git emits one combined hunk with
3340        // the `@@@ -1,6 -1,4 +1,8 @@@` header and two prefix columns.
3341        let p0 = b"A\nB\nC\nD\nE\nF\n";
3342        let p1 = b"A\nB\n1\n2\n";
3343        let result = b"A\nB\nC\nD\nE\nF\n1\n2\n";
3344        let text = render_cc(result, &[p0, p1], true);
3345        assert_eq!(
3346            text, "@@@ -1,6 -1,4 +1,8 @@@\n  A\n  B\n +C\n +D\n +E\n +F\n+ 1\n+ 2\n",
3347            "combined dense output:\n{text}",
3348        );
3349    }
3350
3351    #[test]
3352    fn combined_identical_to_one_parent_dense_drops_hunk() {
3353        // When the result is identical to one parent, the dense (`--cc`) filter
3354        // drops the hunk (the change is "interesting" only against the other
3355        // parent), so nothing is emitted; the non-dense (`-c`) form still shows
3356        // every parent.
3357        let p0 = b"x\ny\n";
3358        let p1 = b"x\nCHANGED\n";
3359        let result = b"x\ny\n"; // identical to p0
3360        assert_eq!(render_cc(result, &[p0, p1], true), "");
3361        // Non-dense still shows the hunk (differs from p1).
3362        assert!(render_cc(result, &[p0, p1], false).starts_with("@@@"));
3363    }
3364
3365    #[test]
3366    fn combined_reuse_identical_parents() {
3367        // Two parents with the identical blob must produce identical columns
3368        // (git's reuse_combine_diff path); the result adds a line relative to
3369        // both, so both columns carry `+`.
3370        let parent = b"a\nb\n";
3371        let result = b"a\nb\nc\n";
3372        let text = render_cc(result, &[parent, parent], true);
3373        assert_eq!(
3374            text, "@@@ -1,2 -1,2 +1,3 @@@\n  a\n  b\n++c\n",
3375            "reuse output:\n{text}",
3376        );
3377    }
3378}