Skip to main content

vtcode_diff/
lib.rs

1#![warn(missing_docs)]
2#![expect(
3    clippy::indexing_slicing,
4    clippy::string_slice,
5    reason = "validated diff indexes and UTF-8 token boundaries are structural invariants"
6)]
7//! Bounded structured text diffs and renderer-neutral preview rows.
8//!
9//! The presentation architecture and underlying ideas (unified and side-by-side
10//! terminal previews with intraline emphasis) were informed by
11//! [OpenAI Codex](https://github.com/openai/codex) (Apache-2.0). This crate is
12//! an independent implementation for VT Code; no Codex source code was copied.
13
14use similar::{ChangeTag, TextDiff};
15use std::collections::VecDeque;
16use std::fmt;
17use std::time::{Duration, Instant};
18use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
19
20/// A contiguous borrowed character-level diff chunk.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Chunk<'a> {
23    /// Text present on both sides.
24    Equal(&'a str),
25    /// Text present only on the old side.
26    Delete(&'a str),
27    /// Text present only on the new side.
28    Insert(&'a str),
29}
30
31/// Algorithms suitable for interactive diff previews.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
34#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
35pub enum DiffAlgorithm {
36    /// Practical, heuristic Myers diff.
37    #[default]
38    Myers,
39    /// Patience diff, useful for source code with unique anchors.
40    Patience,
41    /// Histogram diff, useful for repeated source-code lines.
42    Histogram,
43}
44
45impl DiffAlgorithm {
46    fn similar(self) -> similar::Algorithm {
47        match self {
48            Self::Myers => similar::Algorithm::Myers,
49            Self::Patience => similar::Algorithm::Patience,
50            Self::Histogram => similar::Algorithm::Histogram,
51        }
52    }
53}
54
55/// Options controlling diff generation.
56#[derive(Debug, Clone)]
57pub struct DiffOptions<'a> {
58    /// Number of unchanged lines retained around a change.
59    pub context_lines: usize,
60    /// Optional old-side label used by formatters.
61    pub old_label: Option<&'a str>,
62    /// Optional new-side label used by formatters.
63    pub new_label: Option<&'a str>,
64    /// Whether formatters should emit missing-final-newline markers.
65    pub missing_newline_hint: bool,
66    /// Line diff algorithm.
67    pub algorithm: DiffAlgorithm,
68    /// Maximum line-diff computation time.
69    pub timeout: Duration,
70    /// Maximum total time spent on intraline refinement.
71    pub inline_timeout: Duration,
72}
73
74impl Default for DiffOptions<'_> {
75    fn default() -> Self {
76        Self {
77            context_lines: 3,
78            old_label: None,
79            new_label: None,
80            missing_newline_hint: true,
81            algorithm: DiffAlgorithm::Myers,
82            timeout: Duration::from_millis(200),
83            inline_timeout: Duration::from_millis(40),
84        }
85    }
86}
87
88/// A diff hunk with old/new ranges and semantic lines.
89#[derive(Debug, Clone, PartialEq, Eq)]
90#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
91pub struct DiffHunk {
92    /// One-based old-side start line.
93    pub old_start: usize,
94    /// Number of represented old-side lines.
95    pub old_lines: usize,
96    /// One-based new-side start line.
97    pub new_start: usize,
98    /// Number of represented new-side lines.
99    pub new_lines: usize,
100    /// Lines contained in the hunk.
101    pub lines: Vec<DiffLine>,
102}
103
104/// The semantic role of a line inside a hunk.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
108pub enum DiffLineKind {
109    /// Unchanged context.
110    Context,
111    /// New-side insertion.
112    Addition,
113    /// Old-side deletion.
114    Deletion,
115}
116
117/// A source line and its old/new positions.
118#[derive(Debug, Clone, PartialEq, Eq)]
119#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
120pub struct DiffLine {
121    /// Semantic line role.
122    pub kind: DiffLineKind,
123    /// One-based old-side line number, when present.
124    pub old_line: Option<u32>,
125    /// One-based new-side line number, when present.
126    pub new_line: Option<u32>,
127    /// Source text, including its original line terminator when present.
128    pub text: String,
129}
130
131/// Aggregate statistics for a complete document.
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct DiffStats {
135    /// Added lines.
136    pub additions: usize,
137    /// Deleted lines.
138    pub deletions: usize,
139    /// Context lines represented by hunks.
140    pub context: usize,
141    /// Number of hunks.
142    pub hunks: usize,
143    /// Semantic rows omitted by a bounded layout.
144    pub omitted_rows: usize,
145}
146
147/// A complete renderer-independent diff.
148#[derive(Debug, Clone, PartialEq, Eq)]
149#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
150pub struct DiffDocument {
151    /// Structured hunks.
152    pub hunks: Vec<DiffHunk>,
153    /// Whole-document statistics.
154    pub stats: DiffStats,
155    #[cfg_attr(feature = "serde", serde(default = "default_inline_timeout"))]
156    inline_timeout: Duration,
157}
158
159impl Default for DiffDocument {
160    fn default() -> Self {
161        Self {
162            hunks: Vec::new(),
163            stats: DiffStats::default(),
164            inline_timeout: default_inline_timeout(),
165        }
166    }
167}
168
169impl DiffDocument {
170    /// Computes a bounded diff from before/after text.
171    #[must_use]
172    pub fn between(old: &str, new: &str, options: DiffOptions<'_>) -> Self {
173        let old_lines = split_lines_with_terminator(old);
174        let new_lines = split_lines_with_terminator(new);
175        let old_line_refs: Vec<&str> = old_lines.iter().map(String::as_str).collect();
176        let new_line_refs: Vec<&str> = new_lines.iter().map(String::as_str).collect();
177        let mut config = TextDiff::configure();
178        let _ = config.algorithm(options.algorithm.similar()).timeout(options.timeout);
179        let diff = config.diff_slices(&old_line_refs, &new_line_refs);
180        let mut hunks = Vec::new();
181
182        for group in diff.grouped_ops(options.context_lines) {
183            let Some(_first) = group.first() else {
184                continue;
185            };
186            let mut lines = Vec::new();
187            for operation in &group {
188                for change in diff.iter_changes(operation) {
189                    let (kind, old_line, new_line) = match change.tag() {
190                        ChangeTag::Equal => (
191                            DiffLineKind::Context,
192                            change.old_index().and_then(one_based_u32),
193                            change.new_index().and_then(one_based_u32),
194                        ),
195                        ChangeTag::Delete => (DiffLineKind::Deletion, change.old_index().and_then(one_based_u32), None),
196                        ChangeTag::Insert => (DiffLineKind::Addition, None, change.new_index().and_then(one_based_u32)),
197                    };
198                    lines.push(DiffLine {
199                        kind,
200                        old_line,
201                        new_line,
202                        text: change.value().to_owned(),
203                    });
204                }
205            }
206            let old_lines = lines.iter().filter(|line| line.kind != DiffLineKind::Addition).count();
207            let new_lines = lines.iter().filter(|line| line.kind != DiffLineKind::Deletion).count();
208            let old_start = hunk_start_from_lines(&lines, true);
209            let new_start = hunk_start_from_lines(&lines, false);
210            hunks.push(DiffHunk { old_start, old_lines, new_start, new_lines, lines });
211        }
212        let mut document = Self::from_hunks(hunks);
213        document.inline_timeout = options.inline_timeout;
214        document
215    }
216
217    /// Constructs a document from precomputed hunks.
218    #[must_use]
219    pub fn from_hunks(hunks: Vec<DiffHunk>) -> Self {
220        let mut stats = DiffStats { hunks: hunks.len(), ..DiffStats::default() };
221        for line in hunks.iter().flat_map(|hunk| &hunk.lines) {
222            match line.kind {
223                DiffLineKind::Context => stats.context += 1,
224                DiffLineKind::Addition => stats.additions += 1,
225                DiffLineKind::Deletion => stats.deletions += 1,
226            }
227        }
228        Self {
229            hunks,
230            stats,
231            inline_timeout: default_inline_timeout(),
232        }
233    }
234
235    /// Parses unified-diff text into a structured document.
236    ///
237    /// File metadata is accepted and ignored. A body line before the first
238    /// valid hunk header is rejected instead of receiving invented numbers.
239    pub fn from_unified(input: &str) -> Result<Self, ParseDiffError> {
240        let mut hunks = Vec::new();
241        let mut current: Option<DiffHunk> = None;
242        let mut old_line = 0u32;
243        let mut new_line = 0u32;
244        let mut expected_old_lines = 0usize;
245        let mut expected_new_lines = 0usize;
246        let mut omitted_in_current_hunk = false;
247        let mut omitted_rows = 0usize;
248        let mut omitted_tail = Vec::new();
249
250        for raw_with_ending in split_line_slices(input) {
251            let raw = trim_line_ending(raw_with_ending);
252            if raw.starts_with("@@") {
253                if let Some(mut hunk) = current.take() {
254                    assign_omitted_hunk_tail_line_numbers(
255                        &mut hunk,
256                        &omitted_tail,
257                        expected_old_lines,
258                        expected_new_lines,
259                    );
260                    if !omitted_in_current_hunk {
261                        validate_hunk_counts(&hunk, expected_old_lines, expected_new_lines)?;
262                    }
263                    hunks.push(hunk);
264                }
265                omitted_tail.clear();
266                let (old_start, old_count, new_start, new_count) =
267                    parse_hunk_range(raw).ok_or_else(|| ParseDiffError::new("invalid hunk header"))?;
268                old_line = old_start;
269                new_line = new_start;
270                expected_old_lines = old_count;
271                expected_new_lines = new_count;
272                omitted_in_current_hunk = false;
273                current = Some(DiffHunk {
274                    old_start: old_start as usize,
275                    old_lines: 0,
276                    new_start: new_start as usize,
277                    new_lines: 0,
278                    lines: Vec::new(),
279                });
280                continue;
281            }
282            if raw.starts_with("diff ")
283                || raw.starts_with("index ")
284                || raw == r"\ No newline at end of file"
285                || raw.is_empty()
286            {
287                continue;
288            }
289            // Git's file/mode headers precede the first hunk in ordinary
290            // unified output. Keep them out of the body parser while still
291            // rejecting an actual `-...`/`+...` body before any hunk.
292            let hunk_complete = current
293                .as_ref()
294                .is_some_and(|hunk| hunk.old_lines >= expected_old_lines && hunk.new_lines >= expected_new_lines);
295            if (current.is_none() || hunk_complete) && is_unified_metadata_line(raw) {
296                continue;
297            }
298            if hunk_complete && (raw.starts_with("--- ") || raw.starts_with("+++ ")) {
299                continue;
300            }
301            let Some(hunk) = current.as_mut() else {
302                return Err(ParseDiffError::new("diff body appears before a hunk header"));
303            };
304            let is_body_line = matches!(raw.as_bytes().first().copied(), Some(b'-' | b'+' | b' '));
305            if !is_body_line && let Some(omitted) = parse_omitted_line_count(raw) {
306                omitted_in_current_hunk = true;
307                let marker_rows = omitted;
308                let advance = marker_rows
309                    .min(expected_old_lines.saturating_sub(hunk.old_lines))
310                    .min(expected_new_lines.saturating_sub(hunk.new_lines));
311                old_line = old_line.saturating_add(u32::try_from(advance).unwrap_or(u32::MAX));
312                new_line = new_line.saturating_add(u32::try_from(advance).unwrap_or(u32::MAX));
313                hunk.old_lines = hunk.old_lines.saturating_add(advance);
314                hunk.new_lines = hunk.new_lines.saturating_add(advance);
315                omitted_rows = omitted_rows.saturating_add(marker_rows);
316                continue;
317            }
318            let (kind, old_number, new_number) = match raw.as_bytes().first().copied() {
319                Some(b'-') => {
320                    if hunk.old_lines >= expected_old_lines {
321                        return Err(ParseDiffError::new("too many old-side lines for hunk header"));
322                    }
323                    let number = old_line;
324                    old_line = old_line.saturating_add(1);
325                    hunk.old_lines += 1;
326                    (DiffLineKind::Deletion, Some(number), None)
327                }
328                Some(b'+') => {
329                    if hunk.new_lines >= expected_new_lines {
330                        return Err(ParseDiffError::new("too many new-side lines for hunk header"));
331                    }
332                    let number = new_line;
333                    new_line = new_line.saturating_add(1);
334                    hunk.new_lines += 1;
335                    (DiffLineKind::Addition, None, Some(number))
336                }
337                Some(b' ') => {
338                    if hunk.old_lines >= expected_old_lines || hunk.new_lines >= expected_new_lines {
339                        return Err(ParseDiffError::new("too many context lines for hunk header"));
340                    }
341                    let old_number = old_line;
342                    let new_number = new_line;
343                    old_line = old_line.saturating_add(1);
344                    new_line = new_line.saturating_add(1);
345                    hunk.old_lines += 1;
346                    hunk.new_lines += 1;
347                    (DiffLineKind::Context, Some(old_number), Some(new_number))
348                }
349                _ => return Err(ParseDiffError::new("invalid unified diff body line")),
350            };
351            hunk.lines.push(DiffLine {
352                kind,
353                old_line: old_number,
354                new_line: new_number,
355                text: raw_with_ending[1..].to_owned(),
356            });
357            if omitted_in_current_hunk {
358                omitted_tail.push(hunk.lines.len() - 1);
359            }
360        }
361        if let Some(mut hunk) = current {
362            assign_omitted_hunk_tail_line_numbers(&mut hunk, &omitted_tail, expected_old_lines, expected_new_lines);
363            if !omitted_in_current_hunk {
364                validate_hunk_counts(&hunk, expected_old_lines, expected_new_lines)?;
365            }
366            hunks.push(hunk);
367        }
368        let mut document = Self::from_hunks(hunks);
369        document.stats.omitted_rows = omitted_rows;
370        Ok(document)
371    }
372
373    /// Lays out this document with terminal display-width wrapping.
374    #[must_use]
375    pub fn layout(&self, options: LayoutOptions) -> Vec<DiffRow> {
376        layout_document(self, options)
377    }
378}
379
380fn default_inline_timeout() -> Duration {
381    Duration::from_millis(40)
382}
383
384fn one_based_u32(index: usize) -> Option<u32> {
385    u32::try_from(index).ok()?.checked_add(1)
386}
387
388fn hunk_start_from_lines(lines: &[DiffLine], old_side: bool) -> usize {
389    let line_number = lines
390        .iter()
391        .find_map(|line| if old_side { line.old_line } else { line.new_line });
392    if let Some(line_number) = line_number {
393        return usize::try_from(line_number).unwrap_or(usize::MAX).max(1);
394    }
395
396    let counterpart = lines
397        .iter()
398        .find_map(|line| if old_side { line.new_line } else { line.old_line });
399    usize::try_from(counterpart.unwrap_or(1)).unwrap_or(usize::MAX).max(1)
400}
401
402fn validate_hunk_counts(
403    hunk: &DiffHunk,
404    expected_old_lines: usize,
405    expected_new_lines: usize,
406) -> Result<(), ParseDiffError> {
407    if hunk.old_lines == expected_old_lines && hunk.new_lines == expected_new_lines {
408        Ok(())
409    } else {
410        Err(ParseDiffError::new("hunk line counts do not match header"))
411    }
412}
413
414fn assign_omitted_hunk_tail_line_numbers(
415    hunk: &mut DiffHunk,
416    tail: &[usize],
417    expected_old_lines: usize,
418    expected_new_lines: usize,
419) {
420    if tail.is_empty() {
421        return;
422    }
423    let old_tail_count = tail
424        .iter()
425        .filter(|&&index| hunk.lines[index].kind != DiffLineKind::Addition)
426        .count();
427    let new_tail_count = tail
428        .iter()
429        .filter(|&&index| hunk.lines[index].kind != DiffLineKind::Deletion)
430        .count();
431    let mut old_line = u32::try_from(hunk.old_start)
432        .unwrap_or(u32::MAX)
433        .saturating_add(u32::try_from(expected_old_lines).unwrap_or(u32::MAX))
434        .saturating_sub(u32::try_from(old_tail_count).unwrap_or(u32::MAX));
435    let mut new_line = u32::try_from(hunk.new_start)
436        .unwrap_or(u32::MAX)
437        .saturating_add(u32::try_from(expected_new_lines).unwrap_or(u32::MAX))
438        .saturating_sub(u32::try_from(new_tail_count).unwrap_or(u32::MAX));
439    for &index in tail {
440        match hunk.lines[index].kind {
441            DiffLineKind::Addition => {
442                hunk.lines[index].new_line = Some(new_line);
443                new_line = new_line.saturating_add(1);
444            }
445            DiffLineKind::Deletion => {
446                hunk.lines[index].old_line = Some(old_line);
447                old_line = old_line.saturating_add(1);
448            }
449            DiffLineKind::Context => {
450                hunk.lines[index].old_line = Some(old_line);
451                hunk.lines[index].new_line = Some(new_line);
452                old_line = old_line.saturating_add(1);
453                new_line = new_line.saturating_add(1);
454            }
455        }
456    }
457}
458
459/// Error returned for malformed unified diff input.
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct ParseDiffError {
462    message: &'static str,
463}
464
465impl ParseDiffError {
466    fn new(message: &'static str) -> Self {
467        Self { message }
468    }
469}
470
471impl fmt::Display for ParseDiffError {
472    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
473        formatter.write_str(self.message)
474    }
475}
476
477impl std::error::Error for ParseDiffError {}
478
479/// A diff rendered with both structured hunks and formatted text.
480#[derive(Debug, Clone)]
481#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
482pub struct DiffBundle {
483    /// Structured hunks.
484    pub hunks: Vec<DiffHunk>,
485    /// Caller-formatted representation.
486    pub formatted: String,
487    /// Whether both inputs were identical.
488    pub is_empty: bool,
489}
490
491/// Computes a structured diff and passes non-empty hunks to a formatter.
492pub fn compute_diff<F>(old: &str, new: &str, options: DiffOptions<'_>, formatter: F) -> DiffBundle
493where
494    F: FnOnce(&[DiffHunk], &DiffOptions<'_>) -> String,
495{
496    if old == new {
497        return DiffBundle {
498            hunks: Vec::new(),
499            formatted: String::new(),
500            is_empty: true,
501        };
502    }
503    let document = DiffDocument::between(old, new, options.clone());
504    let formatted = formatter(&document.hunks, &options);
505    DiffBundle { hunks: document.hunks, formatted, is_empty: false }
506}
507
508/// Formats a structured diff as plain unified text.
509///
510/// Labels are emitted when both [`DiffOptions::old_label`] and
511/// [`DiffOptions::new_label`] are present. Source line terminators are
512/// normalized to `\n` in the formatted output while missing-final-newline
513/// hints remain controlled by [`DiffOptions::missing_newline_hint`].
514#[must_use]
515pub fn format_unified_diff(old: &str, new: &str, options: DiffOptions<'_>) -> String {
516    let document = DiffDocument::between(old, new, options.clone());
517    format_unified_hunks(&document.hunks, &options)
518}
519
520/// Formats precomputed hunks as plain unified text.
521#[must_use]
522pub fn format_unified_hunks(hunks: &[DiffHunk], options: &DiffOptions<'_>) -> String {
523    if hunks.is_empty() {
524        return String::new();
525    }
526
527    let mut output = String::new();
528    if let (Some(old_label), Some(new_label)) = (options.old_label, options.new_label) {
529        output.push_str("--- ");
530        output.push_str(old_label);
531        output.push('\n');
532        output.push_str("+++ ");
533        output.push_str(new_label);
534        output.push('\n');
535    }
536
537    for hunk in hunks {
538        output.push_str("@@ -");
539        output.push_str(&format_unified_range(hunk.old_start, hunk.old_lines));
540        output.push_str(" +");
541        output.push_str(&format_unified_range(hunk.new_start, hunk.new_lines));
542        output.push_str(" @@\n");
543        for line in &hunk.lines {
544            let prefix = match line.kind {
545                DiffLineKind::Context => ' ',
546                DiffLineKind::Addition => '+',
547                DiffLineKind::Deletion => '-',
548            };
549            output.push(prefix);
550            output.push_str(trim_line_ending(&line.text));
551            output.push('\n');
552
553            let has_line_terminator = line.text.ends_with('\n') || line.text.ends_with('\r');
554            if options.missing_newline_hint && !has_line_terminator {
555                output.push_str(r"\ No newline at end of file");
556                output.push('\n');
557            }
558        }
559    }
560    output
561}
562
563fn format_unified_range(start: usize, count: usize) -> String {
564    if count == 0 {
565        return format!("{},0", start.saturating_sub(1));
566    }
567    if count == 1 {
568        return start.to_string();
569    }
570    format!("{start},{count}")
571}
572
573/// Formats a git-compatible `@@ -old +new @@` hunk header.
574///
575/// Range counts are preserved rather than collapsed to start-only form: a
576/// pure deletion such as `@@ -65,19 +65,0 @@` must not read as a one-line
577/// change. Counts of one are elided (`@@ -1 +1 @@`) to match `git diff`.
578#[must_use]
579pub fn format_hunk_header(old_start: usize, old_count: usize, new_start: usize, new_count: usize) -> String {
580    format!(
581        "@@ -{} +{} @@",
582        format_unified_range(old_start, old_count),
583        format_unified_range(new_start, new_count)
584    )
585}
586
587/// Computes a character-level diff with adjacent chunks coalesced.
588#[must_use]
589pub fn compute_diff_chunks<'a>(old: &'a str, new: &'a str) -> Vec<Chunk<'a>> {
590    if old == new {
591        return (!old.is_empty()).then_some(Chunk::Equal(old)).into_iter().collect();
592    }
593    let diff = TextDiff::configure()
594        .algorithm(similar::Algorithm::Myers)
595        .timeout(Duration::from_millis(200))
596        .diff_chars(old, new);
597    let mut chunks = Vec::new();
598    let mut old_offset = 0usize;
599    let mut new_offset = 0usize;
600    let mut run: Option<(ChangeTag, usize, usize)> = None;
601
602    for change in diff.iter_all_changes() {
603        let value = change.value();
604        let byte_len = value.len();
605        let (start, end) = match change.tag() {
606            ChangeTag::Equal | ChangeTag::Delete => (old_offset, old_offset.saturating_add(byte_len)),
607            ChangeTag::Insert => (new_offset, new_offset.saturating_add(byte_len)),
608        };
609        if let Some((tag, run_start, run_end)) = run {
610            if tag == change.tag() && run_end == start {
611                run = Some((tag, run_start, end));
612            } else {
613                push_chunk(&mut chunks, tag, run_start, run_end, old, new);
614                run = Some((change.tag(), start, end));
615            }
616        } else {
617            run = Some((change.tag(), start, end));
618        }
619        match change.tag() {
620            ChangeTag::Equal => {
621                old_offset = old_offset.saturating_add(byte_len);
622                new_offset = new_offset.saturating_add(byte_len);
623            }
624            ChangeTag::Delete => old_offset = old_offset.saturating_add(byte_len),
625            ChangeTag::Insert => new_offset = new_offset.saturating_add(byte_len),
626        }
627    }
628    if let Some((tag, start, end)) = run {
629        push_chunk(&mut chunks, tag, start, end, old, new);
630    }
631    chunks
632}
633
634fn push_chunk<'a>(chunks: &mut Vec<Chunk<'a>>, tag: ChangeTag, start: usize, end: usize, old: &'a str, new: &'a str) {
635    let chunk = match tag {
636        ChangeTag::Equal => Chunk::Equal(&old[start..end]),
637        ChangeTag::Delete => Chunk::Delete(&old[start..end]),
638        ChangeTag::Insert => Chunk::Insert(&new[start..end]),
639    };
640    chunks.push(chunk);
641}
642
643fn split_lines_with_terminator(text: &str) -> Vec<String> {
644    split_line_slices(text).into_iter().map(str::to_owned).collect()
645}
646
647fn split_line_slices(text: &str) -> Vec<&str> {
648    let mut lines = Vec::new();
649    let mut start = 0usize;
650    let bytes = text.as_bytes();
651    let mut index = 0usize;
652    while index < bytes.len() {
653        if bytes[index] != b'\n' && bytes[index] != b'\r' {
654            index += 1;
655            continue;
656        }
657        let end = if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
658            index + 2
659        } else {
660            index + 1
661        };
662        lines.push(&text[start..end]);
663        start = end;
664        index = end;
665    }
666    if start < text.len() {
667        lines.push(&text[start..]);
668    }
669    lines
670}
671
672/// Intraline byte range, always aligned to UTF-8 boundaries.
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
675pub struct IntralineRange {
676    /// Inclusive byte start.
677    pub start: usize,
678    /// Exclusive byte end.
679    pub end: usize,
680}
681
682/// Intra-line highlight ranges retained for compatibility.
683pub type WordChangedRanges = Vec<(usize, usize)>;
684
685/// Aggregate addition/deletion counts retained for compatibility.
686#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
687pub struct DiffChangeCounts {
688    /// Added lines.
689    pub additions: usize,
690    /// Deleted lines.
691    pub deletions: usize,
692}
693
694impl DiffChangeCounts {
695    /// Total changed lines.
696    #[must_use]
697    pub const fn total(self) -> usize {
698        self.additions + self.deletions
699    }
700}
701
702/// Counts additions and deletions in structured hunks.
703#[must_use]
704pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
705    let mut counts = DiffChangeCounts::default();
706    for line in hunks.iter().flat_map(|hunk| &hunk.lines) {
707        match line.kind {
708            DiffLineKind::Addition => counts.additions += 1,
709            DiffLineKind::Deletion => counts.deletions += 1,
710            DiffLineKind::Context => {}
711        }
712    }
713    counts
714}
715
716/// Semantic role used by legacy preview consumers.
717#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
718pub enum DiffDisplayKind {
719    /// File or parser metadata.
720    Metadata,
721    /// Hunk range header.
722    HunkHeader,
723    /// Unchanged context.
724    Context,
725    /// Added content.
726    Addition,
727    /// Deleted content.
728    Deletion,
729}
730
731impl DiffDisplayKind {
732    /// Whether the kind represents source content.
733    #[must_use]
734    pub const fn is_diff(self) -> bool {
735        matches!(self, Self::Context | Self::Addition | Self::Deletion)
736    }
737}
738
739/// A semantic legacy display line.
740#[derive(Clone, Debug, Eq, PartialEq)]
741pub struct DiffDisplayLine {
742    /// Semantic role.
743    pub kind: DiffDisplayKind,
744    /// Old-side number.
745    pub old_line: Option<u32>,
746    /// New-side number.
747    pub new_line: Option<u32>,
748    /// Content without the diff marker or line ending.
749    pub text: String,
750    /// Intraline changed byte ranges.
751    pub changed: WordChangedRanges,
752}
753
754impl DiffDisplayLine {
755    /// Constructs a source body line.
756    #[must_use]
757    pub fn body(kind: DiffDisplayKind, old_line: Option<u32>, new_line: Option<u32>, text: String) -> Self {
758        Self {
759            kind,
760            old_line,
761            new_line,
762            text,
763            changed: Vec::new(),
764        }
765    }
766
767    /// Whether this line represents source content.
768    #[must_use]
769    pub const fn is_diff(&self) -> bool {
770        self.kind.is_diff()
771    }
772
773    /// Formats a single numbered gutter.
774    #[must_use]
775    pub fn numbered_text(&self, line_number_width: usize) -> String {
776        match self.kind {
777            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
778            DiffDisplayKind::Deletion => {
779                format!("-{:>width$} │ {}", self.old_line.unwrap_or_default(), self.text, width = line_number_width)
780            }
781            DiffDisplayKind::Addition => {
782                format!("+{:>width$} │ {}", self.new_line.unwrap_or_default(), self.text, width = line_number_width)
783            }
784            DiffDisplayKind::Context => format!(
785                " {:>width$} │ {}",
786                self.new_line.or(self.old_line).unwrap_or_default(),
787                self.text,
788                width = line_number_width
789            ),
790        }
791    }
792}
793
794/// Converts hunks to semantic display lines and bounded intraline ranges.
795#[must_use]
796pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
797    display_lines_from_hunks_with_timeout(hunks, default_inline_timeout())
798}
799
800fn display_lines_from_hunks_with_timeout(hunks: &[DiffHunk], inline_timeout: Duration) -> Vec<DiffDisplayLine> {
801    let mut output = Vec::new();
802    for hunk in hunks {
803        output.push(DiffDisplayLine::body(
804            DiffDisplayKind::HunkHeader,
805            None,
806            None,
807            format_hunk_header(hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines),
808        ));
809        output.extend(hunk.lines.iter().map(|line| {
810            let kind = match line.kind {
811                DiffLineKind::Context => DiffDisplayKind::Context,
812                DiffLineKind::Addition => DiffDisplayKind::Addition,
813                DiffLineKind::Deletion => DiffDisplayKind::Deletion,
814            };
815            DiffDisplayLine::body(kind, line.old_line, line.new_line, trim_line_ending(&line.text).to_owned())
816        }));
817    }
818    annotate_word_level_diffs_with_timeout(&mut output, inline_timeout);
819    output
820}
821
822/// Parses unified text into display lines, retaining metadata lines.
823#[must_use]
824pub fn display_lines_from_unified_diff(input: &str) -> Vec<DiffDisplayLine> {
825    let mut output = Vec::new();
826    let mut old_line = 0u32;
827    let mut new_line = 0u32;
828    let mut in_hunk = false;
829    let mut remaining_old = 0usize;
830    let mut remaining_new = 0usize;
831    let mut omission_in_hunk = false;
832    let mut hunk_old_start = 0u32;
833    let mut hunk_new_start = 0u32;
834    let mut hunk_old_count = 0usize;
835    let mut hunk_new_count = 0usize;
836    let mut omitted_tail = Vec::new();
837    for raw in input.lines() {
838        if let Some((old_start, new_start)) = parse_hunk_starts(raw) {
839            assign_omitted_tail_line_numbers(
840                &mut output,
841                &omitted_tail,
842                hunk_old_start,
843                hunk_old_count,
844                hunk_new_start,
845                hunk_new_count,
846            );
847            omitted_tail.clear();
848            old_line = old_start;
849            new_line = new_start;
850            if let Some((_, old_count, _, new_count)) = parse_hunk_range(raw) {
851                remaining_old = old_count;
852                remaining_new = new_count;
853                hunk_old_count = old_count;
854                hunk_new_count = new_count;
855            }
856            hunk_old_start = old_start;
857            hunk_new_start = new_start;
858            in_hunk = true;
859            omission_in_hunk = false;
860            // Preserve the authored header verbatim so range counts survive.
861            // Collapsing to `@@ -65 +65 @@` made a pure deletion read as a
862            // one-line change.
863            output.push(DiffDisplayLine::body(DiffDisplayKind::HunkHeader, None, None, raw.to_owned()));
864        } else if in_hunk && (omission_in_hunk || remaining_new > 0) && raw.starts_with('+') {
865            let output_index = output.len();
866            output.push(DiffDisplayLine::body(
867                DiffDisplayKind::Addition,
868                None,
869                (!omission_in_hunk).then_some(new_line),
870                raw[1..].to_owned(),
871            ));
872            if omission_in_hunk {
873                omitted_tail.push(output_index);
874            }
875            new_line = new_line.saturating_add(1);
876            remaining_new = remaining_new.saturating_sub(1);
877        } else if in_hunk && (omission_in_hunk || remaining_old > 0) && raw.starts_with('-') {
878            let output_index = output.len();
879            output.push(DiffDisplayLine::body(
880                DiffDisplayKind::Deletion,
881                (!omission_in_hunk).then_some(old_line),
882                None,
883                raw[1..].to_owned(),
884            ));
885            if omission_in_hunk {
886                omitted_tail.push(output_index);
887            }
888            old_line = old_line.saturating_add(1);
889            remaining_old = remaining_old.saturating_sub(1);
890        } else if in_hunk && (omission_in_hunk || (remaining_old > 0 && remaining_new > 0)) && raw.starts_with(' ') {
891            let output_index = output.len();
892            output.push(DiffDisplayLine::body(
893                DiffDisplayKind::Context,
894                (!omission_in_hunk).then_some(old_line),
895                (!omission_in_hunk).then_some(new_line),
896                raw[1..].to_owned(),
897            ));
898            if omission_in_hunk {
899                omitted_tail.push(output_index);
900            }
901            old_line = old_line.saturating_add(1);
902            new_line = new_line.saturating_add(1);
903            remaining_old = remaining_old.saturating_sub(1);
904            remaining_new = remaining_new.saturating_sub(1);
905        } else if in_hunk && let Some(omitted) = parse_omitted_line_count(raw) {
906            output.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, raw.to_owned()));
907            omission_in_hunk = true;
908            let omitted = omitted.min(remaining_old).min(remaining_new);
909            old_line = old_line.saturating_add(u32::try_from(omitted).unwrap_or(u32::MAX));
910            new_line = new_line.saturating_add(u32::try_from(omitted).unwrap_or(u32::MAX));
911            remaining_old = remaining_old.saturating_sub(omitted);
912            remaining_new = remaining_new.saturating_sub(omitted);
913        } else {
914            output.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, raw.to_owned()));
915        }
916    }
917    assign_omitted_tail_line_numbers(
918        &mut output,
919        &omitted_tail,
920        hunk_old_start,
921        hunk_old_count,
922        hunk_new_start,
923        hunk_new_count,
924    );
925    annotate_word_level_diffs(&mut output);
926    output
927}
928
929fn assign_omitted_tail_line_numbers(
930    lines: &mut [DiffDisplayLine],
931    tail: &[usize],
932    old_start: u32,
933    old_count: usize,
934    new_start: u32,
935    new_count: usize,
936) {
937    if tail.is_empty() {
938        return;
939    }
940    let old_tail_count = tail
941        .iter()
942        .filter(|&&index| lines[index].kind != DiffDisplayKind::Addition)
943        .count();
944    let new_tail_count = tail
945        .iter()
946        .filter(|&&index| lines[index].kind != DiffDisplayKind::Deletion)
947        .count();
948    let mut old_line = old_start
949        .saturating_add(u32::try_from(old_count).unwrap_or(u32::MAX))
950        .saturating_sub(u32::try_from(old_tail_count).unwrap_or(u32::MAX));
951    let mut new_line = new_start
952        .saturating_add(u32::try_from(new_count).unwrap_or(u32::MAX))
953        .saturating_sub(u32::try_from(new_tail_count).unwrap_or(u32::MAX));
954    for &index in tail {
955        match lines[index].kind {
956            DiffDisplayKind::Addition => {
957                lines[index].new_line = Some(new_line);
958                new_line = new_line.saturating_add(1);
959            }
960            DiffDisplayKind::Deletion => {
961                lines[index].old_line = Some(old_line);
962                old_line = old_line.saturating_add(1);
963            }
964            DiffDisplayKind::Context => {
965                lines[index].old_line = Some(old_line);
966                lines[index].new_line = Some(new_line);
967                old_line = old_line.saturating_add(1);
968                new_line = new_line.saturating_add(1);
969            }
970            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => {}
971        }
972    }
973}
974
975/// Formats a numbered unified diff without ANSI color.
976#[must_use]
977pub fn format_numbered_unified_diff(input: &str) -> Vec<String> {
978    let lines = display_lines_from_unified_diff(input);
979    let width = diff_display_line_number_width(&lines);
980    lines.iter().map(|line| line.numbered_text(width)).collect()
981}
982
983/// Computes the clamped line-number gutter width.
984#[must_use]
985pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
986    let maximum = lines
987        .iter()
988        .flat_map(|line| [line.old_line, line.new_line])
989        .flatten()
990        .max()
991        .unwrap_or_default();
992    decimal_digits(maximum).clamp(5, 6)
993}
994
995/// Minimum content width for the paired old/new preview.
996///
997/// Below this width, two independently numbered panes leave too little room
998/// for source text. Renderers should fall back to the unified presentation.
999pub const DIFF_MIN_SIDE_BY_SIDE_WIDTH: usize = 60;
1000
1001/// Whether a measured width can support the paired old/new preview.
1002///
1003/// An unknown width preserves the existing caller behavior; redirected
1004/// output and test sinks may not expose terminal sizing at all.
1005#[must_use]
1006pub const fn diff_side_by_side_fits(available_width: Option<usize>) -> bool {
1007    match available_width {
1008        Some(width) => width >= DIFF_MIN_SIDE_BY_SIDE_WIDTH,
1009        None => true,
1010    }
1011}
1012
1013/// Minimum source width retained when the unified line gutter is visible.
1014///
1015/// This keeps the marker, line number, separator, and a useful amount of
1016/// source text together. Narrower layouts should hide the gutter and give its
1017/// columns back to the source body.
1018pub const DIFF_MIN_BODY_WIDTH_WITH_GUTTER: usize = 20;
1019
1020/// Width consumed by a unified diff gutter after the line-number field.
1021///
1022/// The rendered shape is `+123 │ `: one marker plus the three-cell separator
1023/// around `│`. The line-number field is supplied by the caller because it is
1024/// derived from the visible diff excerpt.
1025#[must_use]
1026pub const fn diff_gutter_width(line_number_width: usize) -> usize {
1027    line_number_width.saturating_add(4)
1028}
1029
1030/// Whether a unified diff can keep its marker, line number, and separator
1031/// without starving the source body.
1032#[must_use]
1033pub const fn diff_gutter_fits(available_width: usize, line_number_width: usize) -> bool {
1034    available_width >= diff_gutter_width(line_number_width).saturating_add(DIFF_MIN_BODY_WIDTH_WITH_GUTTER)
1035}
1036
1037/// Width to pass to semantic unified layout when the renderer hides its
1038/// visible gutter.
1039///
1040/// `layout_display_lines` subtracts the normal gutter before wrapping source
1041/// text. Giving that width back keeps wrapping aligned with compact rendering
1042/// without adding another public layout option.
1043#[must_use]
1044pub const fn diff_layout_width(available_width: usize, line_number_width: usize, show_gutter: bool) -> usize {
1045    if show_gutter {
1046        available_width
1047    } else {
1048        available_width.saturating_add(diff_gutter_width(line_number_width))
1049    }
1050}
1051
1052fn decimal_digits(mut number: u32) -> usize {
1053    let mut digits = 1usize;
1054    while number >= 10 {
1055        number /= 10;
1056        digits += 1;
1057    }
1058    digits
1059}
1060
1061/// One paired row in the compatibility side-by-side model.
1062#[derive(Clone, Debug, Eq, PartialEq)]
1063pub struct SideBySideRow {
1064    /// Old-side cell.
1065    pub left: Option<DiffDisplayLine>,
1066    /// New-side cell.
1067    pub right: Option<DiffDisplayLine>,
1068}
1069
1070impl SideBySideRow {
1071    /// Whether the left cell is a header spanning both panes.
1072    #[must_use]
1073    pub fn is_full_width(&self) -> bool {
1074        self.right.is_none()
1075            && self
1076                .left
1077                .as_ref()
1078                .is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata))
1079    }
1080}
1081
1082/// Pairs deletions and additions for side-by-side presentation.
1083#[must_use]
1084pub fn side_by_side_rows(lines: &[DiffDisplayLine]) -> Vec<SideBySideRow> {
1085    let mut rows = Vec::new();
1086    for_each_side_by_side_pair(lines, |left, right| {
1087        rows.push(SideBySideRow { left: left.cloned(), right: right.cloned() });
1088    });
1089    rows
1090}
1091
1092fn for_each_side_by_side_pair<'a, F>(lines: &'a [DiffDisplayLine], mut visit: F)
1093where
1094    F: FnMut(Option<&'a DiffDisplayLine>, Option<&'a DiffDisplayLine>),
1095{
1096    let mut index = 0usize;
1097    while index < lines.len() {
1098        match lines[index].kind {
1099            DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata => {
1100                visit(Some(&lines[index]), None);
1101                index += 1;
1102            }
1103            DiffDisplayKind::Context => {
1104                let line = &lines[index];
1105                visit(Some(line), Some(line));
1106                index += 1;
1107            }
1108            DiffDisplayKind::Deletion => {
1109                let delete_start = index;
1110                while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
1111                    index += 1;
1112                }
1113                let insert_start = index;
1114                while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
1115                    index += 1;
1116                }
1117                let delete_count = insert_start - delete_start;
1118                let insert_count = index - insert_start;
1119                for offset in 0..delete_count.max(insert_count) {
1120                    visit(
1121                        (offset < delete_count).then(|| &lines[delete_start + offset]),
1122                        (offset < insert_count).then(|| &lines[insert_start + offset]),
1123                    );
1124                }
1125            }
1126            DiffDisplayKind::Addition => {
1127                visit(None, Some(&lines[index]));
1128                index += 1;
1129            }
1130        }
1131    }
1132}
1133
1134/// Adds byte-safe intraline ranges to consecutive deletion/addition groups.
1135pub fn annotate_word_level_diffs(lines: &mut [DiffDisplayLine]) {
1136    annotate_word_level_diffs_with_timeout(lines, default_inline_timeout());
1137}
1138
1139fn annotate_word_level_diffs_with_timeout(lines: &mut [DiffDisplayLine], timeout: Duration) {
1140    // Binary content (NUL bytes) makes word-level refinement meaningless and
1141    // expensive; skip it entirely so intraline work stays within budget.
1142    if lines.iter().any(|line| line.text.as_bytes().contains(&0)) {
1143        return;
1144    }
1145    let deadline = Instant::now().checked_add(timeout);
1146    let mut index = 0usize;
1147    while index < lines.len() {
1148        if lines[index].kind != DiffDisplayKind::Deletion {
1149            index += 1;
1150            continue;
1151        }
1152        let delete_start = index;
1153        while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
1154            index += 1;
1155        }
1156        let insert_start = index;
1157        while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
1158            index += 1;
1159        }
1160        let pair_count = (insert_start - delete_start).min(index - insert_start);
1161        for offset in 0..pair_count {
1162            if deadline.is_some_and(|limit| Instant::now() >= limit) {
1163                return;
1164            }
1165            let (old_ranges, new_ranges) =
1166                word_level_changed_ranges(&lines[delete_start + offset].text, &lines[insert_start + offset].text);
1167            lines[delete_start + offset].changed = old_ranges;
1168            lines[insert_start + offset].changed = new_ranges;
1169        }
1170    }
1171}
1172
1173/// Computes byte-safe word-level changed ranges for a line pair.
1174#[must_use]
1175pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
1176    if old.is_empty() || new.is_empty() || old.len().saturating_add(new.len()) > 16_384 {
1177        return (Vec::new(), Vec::new());
1178    }
1179    let diff = TextDiff::configure()
1180        .algorithm(similar::Algorithm::Myers)
1181        .timeout(Duration::from_millis(10))
1182        .diff_unicode_words(old, new);
1183    if diff.ratio() < 0.35 {
1184        return (Vec::new(), Vec::new());
1185    }
1186    let mut old_offset = 0usize;
1187    let mut new_offset = 0usize;
1188    let mut old_ranges = Vec::new();
1189    let mut new_ranges = Vec::new();
1190    for change in diff.iter_all_changes() {
1191        let length = change.value().len();
1192        match change.tag() {
1193            ChangeTag::Equal => {
1194                old_offset += length;
1195                new_offset += length;
1196            }
1197            ChangeTag::Delete => {
1198                push_range(&mut old_ranges, old_offset, old_offset + length);
1199                old_offset += length;
1200            }
1201            ChangeTag::Insert => {
1202                push_range(&mut new_ranges, new_offset, new_offset + length);
1203                new_offset += length;
1204            }
1205        }
1206    }
1207    (old_ranges, new_ranges)
1208}
1209
1210fn push_range(ranges: &mut WordChangedRanges, start: usize, end: usize) {
1211    match ranges.last_mut() {
1212        Some((_, prior_end)) if *prior_end == start => *prior_end = end,
1213        _ => ranges.push((start, end)),
1214    }
1215}
1216
1217/// Requested preview layout.
1218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1219#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1220#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
1221pub enum DiffLayout {
1222    /// One stacked old/new column.
1223    #[default]
1224    Unified,
1225    /// Paired old/new panes.
1226    SideBySide,
1227}
1228
1229/// Width and bounded-excerpt options for semantic layout.
1230#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1231pub struct LayoutOptions {
1232    /// Requested layout.
1233    pub layout: DiffLayout,
1234    /// Available terminal columns.
1235    pub width: usize,
1236    /// Maximum rows, including an omission marker.
1237    pub max_rows: usize,
1238    /// Whether source bodies hard-wrap at display width.
1239    pub wrap: bool,
1240    /// Side-by-side fallback threshold.
1241    pub min_side_by_side_width: usize,
1242}
1243
1244impl Default for LayoutOptions {
1245    fn default() -> Self {
1246        Self {
1247            layout: DiffLayout::Unified,
1248            width: 80,
1249            max_rows: 2_000,
1250            wrap: true,
1251            min_side_by_side_width: DIFF_MIN_SIDE_BY_SIDE_WIDTH,
1252        }
1253    }
1254}
1255
1256/// Semantic role for a renderer-neutral row.
1257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1258pub enum DiffRowKind {
1259    /// File metadata outside a hunk.
1260    Metadata,
1261    /// Hunk header.
1262    HunkHeader,
1263    /// Unchanged content.
1264    Context,
1265    /// Added content.
1266    Addition,
1267    /// Deleted content.
1268    Deletion,
1269    /// A bounded middle omission.
1270    Omission,
1271}
1272
1273/// One independently styled content segment.
1274#[derive(Debug, Clone, PartialEq, Eq)]
1275pub struct DiffSegment {
1276    /// Segment text.
1277    pub text: String,
1278    /// Whether the segment receives intraline emphasis.
1279    pub emphasized: bool,
1280}
1281
1282/// One side of a semantic preview row.
1283#[derive(Debug, Clone, PartialEq, Eq)]
1284pub struct DiffCell {
1285    /// Old-side line number.
1286    pub old_line: Option<u32>,
1287    /// New-side line number.
1288    pub new_line: Option<u32>,
1289    /// Diff marker.
1290    pub marker: char,
1291    /// Styled content segments.
1292    pub segments: Vec<DiffSegment>,
1293}
1294
1295/// A renderer-neutral row, optionally containing paired side-by-side cells.
1296#[derive(Debug, Clone, PartialEq, Eq)]
1297pub struct DiffRow {
1298    /// Semantic role.
1299    pub kind: DiffRowKind,
1300    /// Marker for unified renderers and simple inspection.
1301    pub marker: char,
1302    /// Stable zero-based hunk identity.
1303    pub hunk_index: Option<usize>,
1304    /// Whether this row continues a hard-wrapped source line.
1305    pub continuation: bool,
1306    /// Unified or old-side cell.
1307    pub left: Option<DiffCell>,
1308    /// New-side cell in side-by-side mode.
1309    pub right: Option<DiffCell>,
1310}
1311
1312fn layout_document(document: &DiffDocument, options: LayoutOptions) -> Vec<DiffRow> {
1313    let display = display_lines_from_hunks_with_timeout(&document.hunks, document.inline_timeout);
1314    layout_display_lines(&display, options)
1315}
1316
1317/// Lays out precomputed display lines without repeating intraline analysis.
1318///
1319/// Interactive applications can cache semantic lines when an overlay opens
1320/// and call this inexpensive step again after a resize.
1321#[must_use]
1322pub fn layout_display_lines(display: &[DiffDisplayLine], options: LayoutOptions) -> Vec<DiffRow> {
1323    let use_side_by_side = options.layout == DiffLayout::SideBySide && options.width >= options.min_side_by_side_width;
1324    let mut rows = RowCollector::new(options.max_rows);
1325    if use_side_by_side {
1326        layout_side_by_side(display, options, &mut rows);
1327    } else {
1328        layout_unified(display, options, &mut rows);
1329    }
1330    rows.finish()
1331}
1332
1333/// Returns a bounded head/tail excerpt of semantic display lines.
1334///
1335/// The returned vector contains at most `max_rows` entries. When rows are
1336/// omitted, one metadata entry (`... N lines omitted ...`) is inserted between
1337/// the retained head and tail. Source line numbers and intraline ranges on
1338/// retained entries are preserved, so callers can render the excerpt without
1339/// reparsing or inventing positions.
1340#[must_use]
1341pub fn bounded_display_lines(lines: &[DiffDisplayLine], max_rows: usize) -> Vec<DiffDisplayLine> {
1342    if max_rows == 0 {
1343        return Vec::new();
1344    }
1345    if lines.len() <= max_rows {
1346        return lines.to_vec();
1347    }
1348
1349    let retained = max_rows.saturating_sub(1);
1350    let head_count = retained.saturating_add(1) / 2;
1351    let tail_count = retained / 2;
1352    let omitted = lines.len().saturating_sub(head_count + tail_count);
1353
1354    let mut bounded = Vec::with_capacity(max_rows);
1355    bounded.extend_from_slice(&lines[..head_count]);
1356    bounded.push(DiffDisplayLine::body(
1357        DiffDisplayKind::Metadata,
1358        None,
1359        None,
1360        format!("... {omitted} lines omitted ..."),
1361    ));
1362    if tail_count > 0 {
1363        bounded.extend_from_slice(&lines[lines.len() - tail_count..]);
1364    }
1365    bounded
1366}
1367
1368fn layout_unified(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1369    let gutter_width = diff_display_line_number_width(lines).saturating_add(4);
1370    let content_width = options.width.saturating_sub(gutter_width).max(1);
1371    let mut hunk_index = None;
1372    for line in lines {
1373        if line.kind == DiffDisplayKind::Metadata {
1374            rows.push(metadata_row(&line.text, hunk_index));
1375            continue;
1376        }
1377        if line.kind == DiffDisplayKind::HunkHeader {
1378            hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1379            rows.push(header_row(&line.text, hunk_index));
1380            continue;
1381        }
1382        let marker = marker_for_kind(line.kind);
1383        for (continuation, segments) in wrap_segments(&line.text, &line.changed, content_width, options.wrap)
1384            .into_iter()
1385            .enumerate()
1386        {
1387            rows.push(DiffRow {
1388                kind: row_kind(line.kind),
1389                marker,
1390                hunk_index,
1391                continuation: continuation > 0,
1392                left: Some(DiffCell {
1393                    old_line: (continuation == 0).then_some(line.old_line).flatten(),
1394                    new_line: (continuation == 0).then_some(line.new_line).flatten(),
1395                    marker,
1396                    segments,
1397                }),
1398                right: None,
1399            });
1400        }
1401    }
1402}
1403
1404fn layout_side_by_side(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1405    let pane_width = options.width.saturating_sub(1) / 2;
1406    let content_width = pane_width.saturating_sub(6).max(1);
1407    let mut hunk_index = None;
1408    for_each_side_by_side_pair(lines, |left, right| {
1409        let is_full_width = right.is_none()
1410            && left.is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata));
1411        if is_full_width {
1412            let text = left.map_or("", |line| line.text.as_str());
1413            if left.is_some_and(|line| line.kind == DiffDisplayKind::HunkHeader) {
1414                hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1415                rows.push(header_row(text, hunk_index));
1416            } else {
1417                rows.push(metadata_row(text, hunk_index));
1418            }
1419            return;
1420        }
1421        let left_parts = left.map_or_else(
1422            || vec![Vec::new()],
1423            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1424        );
1425        let right_parts = right.map_or_else(
1426            || vec![Vec::new()],
1427            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1428        );
1429        let count = left_parts.len().max(right_parts.len());
1430        for offset in 0..count {
1431            let left_cell = left.and_then(|line| {
1432                left_parts.get(offset).map(|segments| DiffCell {
1433                    old_line: (offset == 0).then_some(line.old_line).flatten(),
1434                    new_line: (offset == 0).then_some(line.new_line).flatten(),
1435                    marker: marker_for_kind(line.kind),
1436                    segments: segments.clone(),
1437                })
1438            });
1439            let right_cell = right.and_then(|line| {
1440                right_parts.get(offset).map(|segments| DiffCell {
1441                    old_line: (offset == 0).then_some(line.old_line).flatten(),
1442                    new_line: (offset == 0).then_some(line.new_line).flatten(),
1443                    marker: marker_for_kind(line.kind),
1444                    segments: segments.clone(),
1445                })
1446            });
1447            let kind = right.or(left).map_or(DiffRowKind::Context, |line| row_kind(line.kind));
1448            rows.push(DiffRow {
1449                kind,
1450                marker: right_cell.as_ref().or(left_cell.as_ref()).map_or(' ', |cell| cell.marker),
1451                hunk_index,
1452                continuation: offset > 0,
1453                left: left_cell,
1454                right: right_cell,
1455            });
1456        }
1457    });
1458}
1459
1460fn header_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1461    DiffRow {
1462        kind: DiffRowKind::HunkHeader,
1463        marker: '@',
1464        hunk_index,
1465        continuation: false,
1466        left: Some(DiffCell {
1467            old_line: None,
1468            new_line: None,
1469            marker: '@',
1470            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1471        }),
1472        right: None,
1473    }
1474}
1475
1476fn metadata_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1477    DiffRow {
1478        kind: DiffRowKind::Metadata,
1479        marker: ' ',
1480        hunk_index,
1481        continuation: false,
1482        left: Some(DiffCell {
1483            old_line: None,
1484            new_line: None,
1485            marker: ' ',
1486            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1487        }),
1488        right: None,
1489    }
1490}
1491
1492struct RowCollector {
1493    head: Vec<DiffRow>,
1494    tail: VecDeque<DiffRow>,
1495    max_rows: usize,
1496    total_rows: usize,
1497    overflowed: bool,
1498}
1499
1500impl RowCollector {
1501    fn new(max_rows: usize) -> Self {
1502        Self {
1503            head: Vec::new(),
1504            tail: VecDeque::new(),
1505            max_rows,
1506            total_rows: 0,
1507            overflowed: false,
1508        }
1509    }
1510
1511    fn push(&mut self, row: DiffRow) {
1512        self.total_rows = self.total_rows.saturating_add(1);
1513        if self.max_rows == 0 {
1514            return;
1515        }
1516        if !self.overflowed {
1517            self.head.push(row);
1518            if self.head.len() <= self.max_rows {
1519                return;
1520            }
1521
1522            let retained = self.max_rows.saturating_sub(1);
1523            let head_count = retained.saturating_add(1) / 2;
1524            let tail_count = retained / 2;
1525            if tail_count > 0 {
1526                let tail_start = self.head.len() - tail_count;
1527                self.tail = self.head.split_off(tail_start).into();
1528            }
1529            self.head.truncate(head_count);
1530            self.overflowed = true;
1531            return;
1532        }
1533
1534        let tail_count = self.max_rows.saturating_sub(1) / 2;
1535        if tail_count > 0 {
1536            if self.tail.len() == tail_count {
1537                let _ = self.tail.pop_front();
1538            }
1539            self.tail.push_back(row);
1540        }
1541    }
1542
1543    fn finish(mut self) -> Vec<DiffRow> {
1544        if !self.overflowed {
1545            return self.head;
1546        }
1547        let omitted = self.total_rows.saturating_sub(self.head.len()).saturating_sub(self.tail.len());
1548        self.head.push(omission_row(omitted));
1549        self.head.extend(self.tail);
1550        self.head
1551    }
1552}
1553
1554fn omission_row(omitted: usize) -> DiffRow {
1555    DiffRow {
1556        kind: DiffRowKind::Omission,
1557        marker: '…',
1558        hunk_index: None,
1559        continuation: false,
1560        left: Some(DiffCell {
1561            old_line: None,
1562            new_line: None,
1563            marker: '…',
1564            segments: vec![DiffSegment {
1565                text: format!("{omitted} rows omitted"),
1566                emphasized: false,
1567            }],
1568        }),
1569        right: None,
1570    }
1571}
1572
1573fn wrap_segments(text: &str, changed: &[(usize, usize)], width: usize, wrap: bool) -> Vec<Vec<DiffSegment>> {
1574    if !wrap || UnicodeWidthStr::width(text) <= width {
1575        return vec![segment_slice(text, changed, 0, text.len())];
1576    }
1577    let mut rows = Vec::new();
1578    let mut byte_start = 0usize;
1579    let mut display_width = 0usize;
1580    for (byte, character) in text.char_indices() {
1581        let char_width = UnicodeWidthChar::width(character).unwrap_or_default();
1582        if display_width > 0 && display_width.saturating_add(char_width) > width {
1583            rows.push(segment_slice(text, changed, byte_start, byte));
1584            byte_start = byte;
1585            display_width = 0;
1586        }
1587        display_width = display_width.saturating_add(char_width);
1588    }
1589    rows.push(segment_slice(text, changed, byte_start, text.len()));
1590    rows
1591}
1592
1593fn segment_slice(text: &str, changed: &[(usize, usize)], start: usize, end: usize) -> Vec<DiffSegment> {
1594    if start == end {
1595        return vec![DiffSegment { text: String::new(), emphasized: false }];
1596    }
1597    let mut boundaries = vec![start, end];
1598    for &(range_start, range_end) in changed {
1599        if range_start < end && range_end > start {
1600            let bounded_start = range_start.max(start).min(end);
1601            let bounded_end = range_end.max(start).min(end);
1602            if text.is_char_boundary(bounded_start) && text.is_char_boundary(bounded_end) {
1603                boundaries.push(bounded_start);
1604                boundaries.push(bounded_end);
1605            }
1606        }
1607    }
1608    boundaries.sort_unstable();
1609    boundaries.dedup();
1610    boundaries
1611        .windows(2)
1612        .filter_map(|pair| {
1613            let segment_start = pair[0];
1614            let segment_end = pair[1];
1615            (segment_start < segment_end).then(|| DiffSegment {
1616                text: text[segment_start..segment_end].to_owned(),
1617                emphasized: changed
1618                    .iter()
1619                    .any(|&(range_start, range_end)| segment_start >= range_start && segment_end <= range_end),
1620            })
1621        })
1622        .collect()
1623}
1624
1625fn marker_for_kind(kind: DiffDisplayKind) -> char {
1626    match kind {
1627        DiffDisplayKind::Addition => '+',
1628        DiffDisplayKind::Deletion => '-',
1629        DiffDisplayKind::HunkHeader => '@',
1630        DiffDisplayKind::Metadata | DiffDisplayKind::Context => ' ',
1631    }
1632}
1633
1634fn row_kind(kind: DiffDisplayKind) -> DiffRowKind {
1635    match kind {
1636        DiffDisplayKind::Addition => DiffRowKind::Addition,
1637        DiffDisplayKind::Deletion => DiffRowKind::Deletion,
1638        DiffDisplayKind::HunkHeader => DiffRowKind::HunkHeader,
1639        DiffDisplayKind::Metadata => DiffRowKind::Metadata,
1640        DiffDisplayKind::Context => DiffRowKind::Context,
1641    }
1642}
1643
1644fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> {
1645    let (old, _, new, _) = parse_hunk_range(line)?;
1646    Some((old, new))
1647}
1648
1649fn parse_hunk_range(line: &str) -> Option<(u32, usize, u32, usize)> {
1650    let body = line.strip_prefix("@@ ")?.split(" @@").next()?;
1651    let mut parts = body.split_whitespace();
1652    let (old_start, old_count) = parse_range(parts.next()?, '-')?;
1653    let (new_start, new_count) = parse_range(parts.next()?, '+')?;
1654    Some((old_start, old_count, new_start, new_count))
1655}
1656
1657fn parse_range(value: &str, marker: char) -> Option<(u32, usize)> {
1658    let range = value.strip_prefix(marker)?;
1659    let mut parts = range.splitn(2, ',');
1660    let start = parts.next()?.parse().ok()?;
1661    let count = parts.next().map_or(Some(1), |count| count.parse().ok())?;
1662    Some((start, count))
1663}
1664
1665fn parse_omitted_line_count(line: &str) -> Option<usize> {
1666    let line = line.trim();
1667    line.strip_prefix("... ")?.strip_suffix(" lines omitted ...")?.parse().ok()
1668}
1669
1670fn is_unified_metadata_line(line: &str) -> bool {
1671    line.starts_with("--- ")
1672        || line.starts_with("+++ ")
1673        || line.starts_with("new file mode ")
1674        || line.starts_with("deleted file mode ")
1675        || line.starts_with("rename from ")
1676        || line.starts_with("rename to ")
1677        || line.starts_with("copy from ")
1678        || line.starts_with("copy to ")
1679        || line.starts_with("similarity index ")
1680        || line.starts_with("dissimilarity index ")
1681        || line.starts_with("old mode ")
1682        || line.starts_with("new mode ")
1683        || line.starts_with("Binary files ")
1684        || line == "GIT binary patch"
1685        || line.starts_with("literal ")
1686        || line.starts_with("delta ")
1687}
1688
1689fn trim_line_ending(text: &str) -> &str {
1690    text.strip_suffix("\r\n")
1691        .or_else(|| text.strip_suffix('\n'))
1692        .or_else(|| text.strip_suffix('\r'))
1693        .unwrap_or(text)
1694}
1695
1696#[cfg(feature = "ansi")]
1697mod ansi_adapter {
1698    use super::DiffRow;
1699    use anstyle::{Reset, Style};
1700
1701    /// Caller-supplied foreground styles for ANSI output.
1702    #[derive(Debug, Clone, Copy, Default)]
1703    pub struct AnsiDiffPalette {
1704        /// Hunk and omission style.
1705        pub header: Style,
1706        /// Context style.
1707        pub context: Style,
1708        /// Addition style.
1709        pub addition: Style,
1710        /// Deletion style.
1711        pub deletion: Style,
1712        /// Extra intraline emphasis.
1713        pub emphasis: Style,
1714    }
1715
1716    /// Renders semantic rows as ANSI lines.
1717    #[must_use]
1718    pub fn render_ansi_rows(rows: &[DiffRow], palette: AnsiDiffPalette, color: bool) -> Vec<String> {
1719        rows.iter()
1720            .map(|row| {
1721                let mut output = String::new();
1722                output.push(row.marker);
1723                output.push(' ');
1724                if let Some(cell) = &row.left {
1725                    render_cell(&mut output, cell, palette, color);
1726                }
1727                if let Some(cell) = &row.right {
1728                    output.push_str(" │ ");
1729                    render_cell(&mut output, cell, palette, color);
1730                }
1731                output
1732            })
1733            .collect()
1734    }
1735
1736    fn render_cell(output: &mut String, cell: &super::DiffCell, palette: AnsiDiffPalette, color: bool) {
1737        let style = match cell.marker {
1738            '+' => palette.addition,
1739            '-' => palette.deletion,
1740            '@' | '…' => palette.header,
1741            _ => palette.context,
1742        };
1743        for segment in &cell.segments {
1744            if color {
1745                let selected = if segment.emphasized { palette.emphasis } else { style };
1746                output.push_str(&selected.render().to_string());
1747                output.push_str(&segment.text);
1748                output.push_str(&Reset.render().to_string());
1749            } else {
1750                output.push_str(&segment.text);
1751            }
1752        }
1753    }
1754}
1755
1756#[cfg(feature = "ansi")]
1757pub use ansi_adapter::{AnsiDiffPalette, render_ansi_rows};
1758
1759#[cfg(feature = "ratatui")]
1760mod ratatui_adapter {
1761    use super::DiffRow;
1762    use ratatui::text::{Line, Span};
1763
1764    /// Converts semantic rows to unstyled Ratatui lines for caller styling.
1765    #[must_use]
1766    pub fn to_ratatui_lines(rows: &[DiffRow]) -> Vec<Line<'static>> {
1767        rows.iter()
1768            .map(|row| {
1769                let mut spans = vec![Span::raw(format!("{} ", row.marker))];
1770                if let Some(cell) = &row.left {
1771                    spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1772                }
1773                if let Some(cell) = &row.right {
1774                    spans.push(Span::raw(" │ "));
1775                    spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1776                }
1777                Line::from(spans)
1778            })
1779            .collect()
1780    }
1781}
1782
1783#[cfg(feature = "ratatui")]
1784pub use ratatui_adapter::to_ratatui_lines;
1785
1786#[cfg(test)]
1787mod tests {
1788    use super::*;
1789
1790    #[test]
1791    fn asymmetric_replacement_has_correct_line_numbers() {
1792        let document = DiffDocument::between("a\nb\nc\n", "a\nx\ny\nc\n", DiffOptions::default());
1793        let lines: Vec<_> = document.hunks.iter().flat_map(|hunk| &hunk.lines).collect();
1794        assert!(lines.iter().any(|line| {
1795            line.kind == DiffLineKind::Deletion && line.old_line == Some(2) && line.new_line.is_none()
1796        }));
1797        assert!(lines.iter().any(|line| {
1798            line.kind == DiffLineKind::Addition && line.old_line.is_none() && line.new_line == Some(3)
1799        }));
1800        assert_eq!(document.stats.additions, 2);
1801        assert_eq!(document.stats.deletions, 1);
1802    }
1803
1804    #[test]
1805    fn repeated_lines_keep_the_changed_anchor() {
1806        let document = DiffDocument::between("same\nold\nsame\n", "same\nnew\nsame\n", DiffOptions::default());
1807        assert_eq!(document.stats.additions, 1);
1808        assert_eq!(document.stats.deletions, 1);
1809        assert_eq!(document.hunks[0].old_start, 1);
1810    }
1811
1812    #[test]
1813    fn zero_context_hunks_preserve_empty_side_anchors() {
1814        let options = DiffOptions { context_lines: 0, ..DiffOptions::default() };
1815        let insertion = DiffDocument::between("a\nc\n", "a\nb\nc\n", options.clone());
1816        assert_eq!(insertion.hunks[0].old_start, 2);
1817        assert_eq!(insertion.hunks[0].new_start, 2);
1818
1819        let deletion = DiffDocument::between("a\nb\nc\n", "a\nc\n", options);
1820        assert_eq!(deletion.hunks[0].old_start, 2);
1821        assert_eq!(deletion.hunks[0].new_start, 2);
1822    }
1823
1824    #[test]
1825    fn preserves_crlf_cr_and_missing_final_newline() {
1826        let crlf = DiffDocument::between("a\r\nb\r\n", "a\r\nx\r\n", DiffOptions::default());
1827        assert!(crlf.hunks[0].lines.iter().any(|line| line.text == "a\r\n"));
1828        let cr = DiffDocument::between("a\rb\r", "a\rx\r", DiffOptions::default());
1829        assert!(cr.hunks[0].lines.iter().any(|line| line.text == "a\r"));
1830        let eof = DiffDocument::between("a\n", "a", DiffOptions::default());
1831        assert_eq!(eof.stats.additions, 1);
1832        assert_eq!(eof.stats.deletions, 1);
1833    }
1834
1835    #[test]
1836    fn parser_rejects_body_before_hunk() {
1837        let error = DiffDocument::from_unified("-old\n+new\n").expect_err("body must need a hunk");
1838        assert_eq!(error.to_string(), "diff body appears before a hunk header");
1839    }
1840
1841    #[test]
1842    fn parser_accepts_standard_git_metadata_before_hunk() {
1843        let document = DiffDocument::from_unified(
1844            "diff --git a/file.txt b/file.txt\nindex 1111111..2222222 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n",
1845        )
1846        .expect("standard git metadata is not a body");
1847        assert_eq!(document.stats.deletions, 1);
1848        assert_eq!(document.stats.additions, 1);
1849    }
1850
1851    #[test]
1852    fn parser_accepts_metadata_between_multiple_file_hunks() {
1853        let document = DiffDocument::from_unified(
1854            "diff --git a/one.txt b/one.txt\nnew file mode 100644\n--- /dev/null\n+++ b/one.txt\n@@ -0,0 +1 @@\n+one\ndiff --git a/two.txt b/two.txt\nnew file mode 100644\n--- /dev/null\n+++ b/two.txt\n@@ -0,0 +1 @@\n+two\n",
1855        )
1856        .expect("metadata between files is not a hunk body");
1857        assert_eq!(document.hunks.len(), 2);
1858        assert_eq!(document.stats.additions, 2);
1859    }
1860
1861    #[test]
1862    fn parsed_body_lines_preserve_original_terminators() {
1863        let document = DiffDocument::from_unified("@@ -1 +1 @@\r\n-old\r\n+new\r\n").expect("valid CRLF diff");
1864        assert_eq!(document.hunks[0].lines[0].text, "old\r\n");
1865        assert_eq!(document.hunks[0].lines[1].text, "new\r\n");
1866
1867        let document = DiffDocument::from_unified("@@ -1 +1 @@\r-old\r+new\r").expect("valid CR diff");
1868        assert_eq!(document.hunks[0].lines[0].text, "old\r");
1869        assert_eq!(document.hunks[0].lines[1].text, "new\r");
1870    }
1871
1872    #[test]
1873    fn parser_rejects_incomplete_hunk_body() {
1874        let error = DiffDocument::from_unified("@@ -1,2 +1,2 @@\n-old\n+new\n")
1875            .expect_err("hunk body must satisfy the declared ranges");
1876        assert_eq!(error.to_string(), "hunk line counts do not match header");
1877    }
1878
1879    #[test]
1880    fn parser_rejects_unknown_backslash_metadata_inside_hunk() {
1881        let error = DiffDocument::from_unified("@@ -1 +1 @@\n-old\n\\ unexpected marker\n+new\n")
1882            .expect_err("only Git's no-newline marker is valid inside a hunk");
1883        assert_eq!(error.to_string(), "invalid unified diff body line");
1884    }
1885
1886    #[test]
1887    fn parser_tracks_asymmetric_hunk_numbers() {
1888        let document = DiffDocument::from_unified("@@ -4,1 +8,2 @@\n-old\n+new\n+extra\n").expect("valid diff");
1889        assert_eq!(document.hunks[0].old_start, 4);
1890        assert_eq!(document.hunks[0].new_start, 8);
1891        assert_eq!(document.hunks[0].lines[2].new_line, Some(9));
1892    }
1893
1894    #[test]
1895    fn parser_keeps_context_lines_that_look_like_omission_markers() {
1896        let document =
1897            DiffDocument::from_unified("@@ -1 +1 @@\n ... 4 lines omitted ...\n").expect("valid context line");
1898        assert_eq!(document.hunks[0].lines[0].kind, DiffLineKind::Context);
1899        assert_eq!(document.stats.omitted_rows, 0);
1900    }
1901
1902    #[test]
1903    fn parsed_omission_advances_both_line_counters() {
1904        let lines = display_lines_from_unified_diff("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n");
1905        let deletion = lines
1906            .iter()
1907            .find(|line| line.kind == DiffDisplayKind::Deletion)
1908            .expect("deletion after omission");
1909        let addition = lines
1910            .iter()
1911            .find(|line| line.kind == DiffDisplayKind::Addition)
1912            .expect("addition after omission");
1913        assert_eq!(deletion.old_line, Some(15));
1914        assert_eq!(addition.new_line, Some(25));
1915    }
1916
1917    #[test]
1918    fn truncated_hunk_keeps_tail_additions_as_diff_lines() {
1919        let mut input = String::from("@@ -1,201 +1,201 @@\n");
1920        for index in 0..95 {
1921            input.push_str(&format!("-old-{index}\n"));
1922        }
1923        input.push_str("... 243 lines omitted ...\n");
1924        for index in 137..201 {
1925            input.push_str(&format!("+new-{index}\n"));
1926        }
1927
1928        let lines = display_lines_from_unified_diff(&input);
1929        let additions: Vec<_> = lines.iter().filter(|line| line.kind == DiffDisplayKind::Addition).collect();
1930        assert_eq!(additions.len(), 64);
1931        assert_eq!(additions.last().map(|line| line.text.as_str()), Some("new-200"));
1932    }
1933
1934    #[test]
1935    fn truncated_hunk_numbers_tail_from_declared_end() {
1936        let mut input = String::from("@@ -1,201 +1,201 @@\n");
1937        for index in 0..93 {
1938            input.push_str(&format!("-old-{index}\n"));
1939        }
1940        input.push_str("... 277 lines omitted ...\n");
1941        for index in 169..201 {
1942            input.push_str(&format!("+new-{index}\n"));
1943        }
1944
1945        let lines = display_lines_from_unified_diff(&input);
1946        let additions: Vec<_> = lines.iter().filter(|line| line.kind == DiffDisplayKind::Addition).collect();
1947        assert_eq!(additions.first().and_then(|line| line.new_line), Some(170));
1948        assert_eq!(additions.last().and_then(|line| line.new_line), Some(201));
1949    }
1950
1951    #[test]
1952    fn parsed_metadata_stays_outside_hunk_semantics() {
1953        let display = display_lines_from_unified_diff("--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n");
1954        let rows = layout_display_lines(&display, LayoutOptions::default());
1955        assert_eq!(rows[0].kind, DiffRowKind::Metadata);
1956        assert_eq!(rows[1].kind, DiffRowKind::Metadata);
1957        assert_eq!(rows[2].kind, DiffRowKind::HunkHeader);
1958        assert_eq!(rows[2].hunk_index, Some(0));
1959    }
1960
1961    #[test]
1962    fn unified_parser_accepts_omission_and_advances_numbers() {
1963        let document = DiffDocument::from_unified("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n")
1964            .expect("omission marker is valid bounded preview metadata");
1965        assert_eq!(document.hunks[0].lines[1].old_line, Some(15));
1966        assert_eq!(document.hunks[0].lines[2].new_line, Some(25));
1967        assert_eq!(document.hunks[0].old_lines, 6);
1968        assert_eq!(document.hunks[0].new_lines, 6);
1969        assert_eq!(document.stats.omitted_rows, 4);
1970    }
1971
1972    #[test]
1973    fn unified_parser_accepts_asymmetric_bounded_omission() {
1974        let document =
1975            DiffDocument::from_unified("@@ -1,4 +1,8 @@\n-old-1\n... 4 lines omitted ...\n+new-6\n+new-7\n+new-8\n")
1976                .expect("bounded omission may hide different old/new line counts");
1977        assert_eq!(document.stats.omitted_rows, 4);
1978        assert_eq!(document.hunks[0].old_lines, 4);
1979        assert_eq!(document.hunks[0].new_lines, 6);
1980    }
1981
1982    #[test]
1983    fn unified_parser_numbers_one_sided_omitted_tails_from_hunk_end() {
1984        let added = DiffDocument::from_unified("@@ -0,0 +1,5 @@\n+one\n... 3 lines omitted ...\n+five\n")
1985            .expect("bounded addition is valid");
1986        assert_eq!(added.hunks[0].lines[1].new_line, Some(5));
1987
1988        let deleted = DiffDocument::from_unified("@@ -1,5 +0,0 @@\n-one\n... 3 lines omitted ...\n-five\n")
1989            .expect("bounded deletion is valid");
1990        assert_eq!(deleted.hunks[0].lines[1].old_line, Some(5));
1991    }
1992
1993    #[test]
1994    fn intraline_ranges_are_utf8_boundaries() {
1995        let (old, new) = word_level_changed_ranges("café rouge", "café bleu");
1996        for (start, end) in old {
1997            assert!("café rouge".is_char_boundary(start));
1998            assert!("café rouge".is_char_boundary(end));
1999        }
2000        for (start, end) in new {
2001            assert!("café bleu".is_char_boundary(start));
2002            assert!("café bleu".is_char_boundary(end));
2003        }
2004    }
2005
2006    #[test]
2007    fn unicode_width_wrap_keeps_numbers_only_on_first_row() {
2008        let document = DiffDocument::between("", "界界界a\n", DiffOptions::default());
2009        let rows = document.layout(LayoutOptions { width: 12, ..LayoutOptions::default() });
2010        let additions: Vec<_> = rows.iter().filter(|row| row.kind == DiffRowKind::Addition).collect();
2011        assert!(additions.len() >= 2);
2012        assert_eq!(additions[0].left.as_ref().and_then(|cell| cell.new_line), Some(1));
2013        assert!(additions[1].continuation);
2014        assert_eq!(additions[1].left.as_ref().and_then(|cell| cell.new_line), None);
2015        assert_eq!(additions[1].marker, '+');
2016    }
2017
2018    #[test]
2019    fn layout_ignores_invalid_intraline_boundaries() {
2020        let display = [DiffDisplayLine {
2021            kind: DiffDisplayKind::Addition,
2022            old_line: None,
2023            new_line: Some(1),
2024            text: "café".to_owned(),
2025            changed: vec![(2, 4)],
2026        }];
2027        let rows = layout_display_lines(&display, LayoutOptions { width: 20, ..LayoutOptions::default() });
2028        assert_eq!(rows.len(), 1);
2029        assert_eq!(rows[0].left.as_ref().expect("cell").segments[0].text, "café");
2030    }
2031
2032    #[test]
2033    fn narrow_side_by_side_falls_back_to_unified() {
2034        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2035        let rows = document.layout(LayoutOptions {
2036            layout: DiffLayout::SideBySide,
2037            width: 40,
2038            ..LayoutOptions::default()
2039        });
2040        assert!(
2041            rows.iter()
2042                .filter(|row| row.kind != DiffRowKind::HunkHeader)
2043                .all(|row| row.right.is_none())
2044        );
2045    }
2046
2047    #[test]
2048    fn responsive_gutter_policy_preserves_source_room_at_the_boundary() {
2049        assert_eq!(diff_gutter_width(5), 9);
2050        assert!(diff_gutter_fits(29, 5));
2051        assert!(!diff_gutter_fits(28, 5));
2052        assert_eq!(diff_layout_width(29, 5, true), 29);
2053        assert_eq!(diff_layout_width(28, 5, false), 37);
2054    }
2055
2056    #[test]
2057    fn side_by_side_policy_has_a_stable_resize_boundary() {
2058        assert!(!diff_side_by_side_fits(Some(59)));
2059        assert!(diff_side_by_side_fits(Some(DIFF_MIN_SIDE_BY_SIDE_WIDTH)));
2060        assert!(diff_side_by_side_fits(None));
2061    }
2062
2063    #[test]
2064    fn side_by_side_wrapping_leaves_shorter_side_empty() {
2065        let document =
2066            DiffDocument::between("short\n", "this is a much longer replacement line\n", DiffOptions::default());
2067        let rows = document.layout(LayoutOptions {
2068            layout: DiffLayout::SideBySide,
2069            width: 80,
2070            ..LayoutOptions::default()
2071        });
2072        let continuation = rows.iter().find(|row| row.continuation).expect("long replacement should wrap");
2073        assert!(continuation.left.is_none());
2074        assert!(continuation.right.is_some());
2075    }
2076
2077    #[test]
2078    fn bounded_rows_keep_head_tail_and_exact_omission() {
2079        let old = (0..20).map(|index| format!("old-{index}\n")).collect::<String>();
2080        let new = (0..20).map(|index| format!("new-{index}\n")).collect::<String>();
2081        let rows = DiffDocument::between(&old, &new, DiffOptions::default())
2082            .layout(LayoutOptions { max_rows: 5, ..LayoutOptions::default() });
2083        assert_eq!(rows.len(), 5);
2084        let omission = rows.iter().find(|row| row.kind == DiffRowKind::Omission).expect("omission row");
2085        let text = &omission.left.as_ref().expect("cell").segments[0].text;
2086        assert!(text.ends_with(" rows omitted"));
2087        assert!(rows.last().and_then(|row| row.left.as_ref()).is_some());
2088    }
2089
2090    #[test]
2091    fn bounded_display_lines_keep_asymmetric_head_and_tail() {
2092        let lines =
2093            display_lines_from_unified_diff("@@ -1,4 +1,4 @@\n-old-head\n+new-head\n context\n-old-tail\n+new-tail\n");
2094        let bounded = bounded_display_lines(&lines, 4);
2095
2096        assert_eq!(bounded.len(), 4);
2097        assert_eq!(bounded[0].text, "@@ -1,4 +1,4 @@");
2098        assert_eq!(bounded[1].text, "old-head");
2099        assert_eq!(bounded[2].kind, DiffDisplayKind::Metadata);
2100        assert!(bounded[2].text.contains("lines omitted"));
2101        assert_eq!(bounded[3].text, "new-tail");
2102    }
2103
2104    #[test]
2105    fn display_lines_preserve_hunk_range_counts() {
2106        // Regression: `@@ -65,19 +64,0 @@` must not collapse to
2107        // `@@ -65 +64 @@`, which reads as a one-line change.
2108        let lines = display_lines_from_unified_diff("@@ -65,19 +64,0 @@\n-old\n");
2109        assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
2110        assert_eq!(lines[0].text, "@@ -65,19 +64,0 @@");
2111    }
2112
2113    #[test]
2114    fn hunk_header_formatter_elides_single_counts_and_keeps_ranges() {
2115        assert_eq!(format_hunk_header(1, 1, 1, 1), "@@ -1 +1 @@");
2116        assert_eq!(format_hunk_header(65, 19, 65, 0), "@@ -65,19 +64,0 @@");
2117        assert_eq!(format_hunk_header(0, 0, 1, 5), "@@ -0,0 +1,5 @@");
2118    }
2119
2120    #[test]
2121    fn display_lines_from_hunks_keep_range_counts() {
2122        let document = DiffDocument::between("a\nb\nc\n", "a\nx\ny\nc\n", DiffOptions::default());
2123        let lines = display_lines_from_hunks(&document.hunks);
2124        let hunk = &document.hunks[0];
2125        assert_eq!(lines[0].text, format_hunk_header(hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines));
2126        assert!(lines[0].text.contains(','), "hunk header must keep range counts: {:?}", lines[0].text);
2127    }
2128
2129    #[test]
2130    fn plain_unified_formatter_preserves_labels_and_newline_hints() {
2131        let output = format_unified_diff(
2132            "old\n",
2133            "new",
2134            DiffOptions {
2135                old_label: Some("a/file.txt"),
2136                new_label: Some("b/file.txt"),
2137                ..DiffOptions::default()
2138            },
2139        );
2140
2141        assert!(output.starts_with("--- a/file.txt\n+++ b/file.txt\n@@"));
2142        assert!(output.contains("@@ -1 +1 @@"));
2143        assert!(output.contains("-old\n"));
2144        assert!(output.contains("+new\n\\ No newline at end of file\n"));
2145        assert!(!output.contains('\r'));
2146    }
2147
2148    #[test]
2149    fn character_chunks_coalesce_and_reconstruct_both_sides() {
2150        let chunks = compute_diff_chunks("abc", "axc");
2151        assert_eq!(chunks.len(), 4);
2152        let old = chunks
2153            .iter()
2154            .filter_map(|chunk| match chunk {
2155                Chunk::Equal(text) | Chunk::Delete(text) => Some(*text),
2156                Chunk::Insert(_) => None,
2157            })
2158            .collect::<String>();
2159        let new = chunks
2160            .iter()
2161            .filter_map(|chunk| match chunk {
2162                Chunk::Equal(text) | Chunk::Insert(text) => Some(*text),
2163                Chunk::Delete(_) => None,
2164            })
2165            .collect::<String>();
2166        assert_eq!(old, "abc");
2167        assert_eq!(new, "axc");
2168    }
2169
2170    #[test]
2171    fn disjoint_large_input_respects_small_timeout_and_remains_readable() {
2172        let old = (0..10_000).map(|index| format!("old-{index}\n")).collect::<String>();
2173        let new = (0..10_000).rev().map(|index| format!("new-{index}\n")).collect::<String>();
2174        let started = Instant::now();
2175        let document = DiffDocument::between(
2176            &old,
2177            &new,
2178            DiffOptions {
2179                timeout: Duration::from_millis(5),
2180                ..DiffOptions::default()
2181            },
2182        );
2183        assert!(started.elapsed() < Duration::from_secs(2));
2184        assert!(document.stats.additions > 0);
2185        assert!(document.stats.deletions > 0);
2186    }
2187
2188    #[test]
2189    fn crlf_line_endings_are_preserved() {
2190        let document = DiffDocument::between("a\r\nb\r\n", "a\r\nc\r\n", DiffOptions::default());
2191        assert_eq!(document.hunks.len(), 1);
2192        let texts: Vec<&str> = document.hunks[0].lines.iter().map(|line| line.text.as_str()).collect();
2193        assert!(texts.contains(&"a\r\n"));
2194        assert!(texts.contains(&"b\r\n"));
2195        assert!(texts.contains(&"c\r\n"));
2196    }
2197
2198    #[test]
2199    fn missing_final_newline_emits_hint() {
2200        let document = DiffDocument::between("a\nb", "a\nb\n", DiffOptions::default());
2201        let formatted = format_unified_hunks(&document.hunks, &DiffOptions::default());
2202        assert!(formatted.contains("\\ No newline at end of file"));
2203    }
2204
2205    #[test]
2206    fn zero_context_insert_hunk_header_is_git_compatible() {
2207        let options = DiffOptions { context_lines: 0, ..DiffOptions::default() };
2208        let document = DiffDocument::between("", "x\ny\n", options.clone());
2209        let formatted = format_unified_hunks(&document.hunks, &options);
2210        assert!(formatted.contains("@@ -0,0 +1,2 @@"));
2211    }
2212
2213    #[test]
2214    fn binary_content_skips_intraline_annotation() {
2215        let binary_old = "data\u{0}one\nshared\n";
2216        let binary_new = "data\u{0}two\nshared\n";
2217        let lines =
2218            display_lines_from_hunks(&DiffDocument::between(binary_old, binary_new, DiffOptions::default()).hunks);
2219        let annotated: Vec<_> = lines.iter().filter(|line| !line.changed.is_empty()).collect();
2220        assert!(annotated.is_empty(), "binary lines must not receive intraline ranges");
2221
2222        // Sanity: text content still gets intraline ranges.
2223        let text_lines = display_lines_from_hunks(
2224            &DiffDocument::between("alpha beta\n", "alpha gamma\n", DiffOptions::default()).hunks,
2225        );
2226        assert!(text_lines.iter().any(|line| !line.changed.is_empty()));
2227    }
2228
2229    #[cfg(feature = "ansi")]
2230    #[test]
2231    fn ansi_adapter_can_render_without_color() {
2232        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2233        let rows = document.layout(LayoutOptions::default());
2234        let rendered = render_ansi_rows(&rows, AnsiDiffPalette::default(), false);
2235        assert!(rendered.iter().any(|line| line.starts_with("- old")));
2236        assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2237        assert!(rendered.iter().all(|line| !line.contains('\u{1b}')));
2238    }
2239
2240    #[cfg(feature = "ratatui")]
2241    #[test]
2242    fn ratatui_adapter_preserves_semantic_markers() {
2243        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2244        let rows = document.layout(LayoutOptions::default());
2245        let lines = to_ratatui_lines(&rows);
2246        let rendered = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
2247        assert!(rendered.iter().any(|line| line.starts_with("- old")));
2248        assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2249    }
2250
2251    #[test]
2252    fn generative_small_docs_round_trip_across_algorithms_and_unified() {
2253        // Matklad-style oracle fuzzing in the small: a humble deterministic
2254        // PRNG is enough to shake out tricky interactions. Generate tiny docs
2255        // from a swarmed line alphabet (small, overlapping inputs beat huge
2256        // uniform ones) and cross-check Myers vs Patience vs Histogram plus a
2257        // unified format/parse round-trip.
2258        const LINE_ALPHABET: [&str; 6] = ["a\n", "b\n", "c\n", "alpha\n", "beta\n", "x\n"];
2259        let mut rng = SwarmRng::new(0x9E37_79B9_7F4A_7C15);
2260        // Re-use buffers across iterations (static allocation in the small).
2261        let mut alphabet: Vec<&str> = Vec::with_capacity(LINE_ALPHABET.len());
2262        let mut old_text = String::with_capacity(64);
2263        let mut new_text = String::with_capacity(64);
2264
2265        // Hand-picked asymmetric boundaries first: order swaps and empty sides
2266        // catch what symmetric fixtures miss.
2267        let seeds: [(&str, &str); 5] = [
2268            ("a\n", "b\n"),
2269            ("a\nb\n", "b\na\n"),
2270            ("", "x\n"),
2271            ("x\n", ""),
2272            ("a\na\n", "a\n"),
2273        ];
2274        for (old, new) in seeds {
2275            assert_generative_oracles(old, new);
2276        }
2277
2278        for _ in 0..1024 {
2279            alphabet.clear();
2280            alphabet.extend(LINE_ALPHABET);
2281            rng.shuffle_str(&mut alphabet);
2282            let alphabet_len = rng.range(1, alphabet.len() + 1);
2283            alphabet.truncate(alphabet_len);
2284
2285            gen_doc(&mut rng, &alphabet, &mut old_text);
2286            gen_doc(&mut rng, &alphabet, &mut new_text);
2287            assert_generative_oracles(&old_text, &new_text);
2288        }
2289    }
2290
2291    fn assert_generative_oracles(old: &str, new: &str) {
2292        let options_for = |algorithm: DiffAlgorithm| DiffOptions {
2293            context_lines: 100,
2294            algorithm,
2295            ..DiffOptions::default()
2296        };
2297        let myers = DiffDocument::between(old, new, options_for(DiffAlgorithm::Myers));
2298        let patience = DiffDocument::between(old, new, options_for(DiffAlgorithm::Patience));
2299        let histogram = DiffDocument::between(old, new, options_for(DiffAlgorithm::Histogram));
2300
2301        if old == new {
2302            assert!(myers.hunks.is_empty(), "identical docs must yield no hunks: {old:?}");
2303            assert!(patience.hunks.is_empty(), "identical docs must yield no hunks: {old:?}");
2304            assert!(histogram.hunks.is_empty(), "identical docs must yield no hunks: {old:?}");
2305            return;
2306        }
2307
2308        // Oracle 1: applying hunks to the old side must reconstruct both sides.
2309        let (myers_old, myers_new) = apply_hunks(&myers.hunks);
2310        assert_eq!(myers_old, old, "Myers hunks do not reconstruct old side for {old:?} -> {new:?}");
2311        assert_eq!(myers_new, new, "Myers hunks do not reconstruct new side for {old:?} -> {new:?}");
2312
2313        // Oracle 2 (`regex` vs `regex_lite`): all algorithms must agree on the
2314        // applied result even when hunk splitting differs.
2315        let (patience_old, patience_new) = apply_hunks(&patience.hunks);
2316        let (histogram_old, histogram_new) = apply_hunks(&histogram.hunks);
2317        assert_eq!(
2318            (patience_old.as_str(), patience_new.as_str()),
2319            (old, new),
2320            "Patience mis-reconstructs {old:?} -> {new:?}"
2321        );
2322        assert_eq!(
2323            (histogram_old.as_str(), histogram_new.as_str()),
2324            (old, new),
2325            "Histogram mis-reconstructs {old:?} -> {new:?}"
2326        );
2327
2328        // Oracle 3: unified format/parse round-trip preserves the applied result.
2329        let formatted = format_unified_hunks(&myers.hunks, &options_for(DiffAlgorithm::Myers));
2330        let reparsed = DiffDocument::from_unified(&formatted).expect("formatted hunks must parse");
2331        let (re_old, re_new) = apply_hunks(&reparsed.hunks);
2332        assert_eq!(
2333            (re_old.as_str(), re_new.as_str()),
2334            (old, new),
2335            "unified round-trip diverges for {old:?} -> {new:?}"
2336        );
2337        assert_eq!(
2338            (reparsed.stats.additions, reparsed.stats.deletions),
2339            (myers.stats.additions, myers.stats.deletions),
2340            "unified round-trip changed change counts for {old:?} -> {new:?}"
2341        );
2342    }
2343
2344    fn apply_hunks(hunks: &[DiffHunk]) -> (String, String) {
2345        let mut old = String::new();
2346        let mut new = String::new();
2347        for line in hunks.iter().flat_map(|hunk| &hunk.lines) {
2348            match line.kind {
2349                DiffLineKind::Context => {
2350                    old.push_str(&line.text);
2351                    new.push_str(&line.text);
2352                }
2353                DiffLineKind::Deletion => old.push_str(&line.text),
2354                DiffLineKind::Addition => new.push_str(&line.text),
2355            }
2356        }
2357        (old, new)
2358    }
2359
2360    fn gen_doc(rng: &mut SwarmRng, alphabet: &[&str], result: &mut String) {
2361        result.clear();
2362        let count = rng.range(0, 8);
2363        for _ in 0..count {
2364            let line = alphabet[rng.range(0, alphabet.len())];
2365            result.push_str(line);
2366        }
2367    }
2368
2369    /// Preserves the full-width reference sequence for ranges shared by 32- and 64-bit targets.
2370    #[test]
2371    fn swarm_rng_range_uses_full_width_samples() {
2372        let mut rng = SwarmRng::new(0);
2373        // Keep the 64-bit sequence on narrower targets too: truncating the
2374        // first sample to 32 bits before taking the remainder would yield 3.
2375        for expected in [5, 4, 5, 7] {
2376            assert_eq!(rng.range(3, 10), expected);
2377        }
2378    }
2379
2380    /// Keeps samples inside half-open ranges, including one-element and maximum-width bounds.
2381    #[test]
2382    fn swarm_rng_range_handles_usize_boundaries() {
2383        let mut rng = SwarmRng::new(0);
2384        for (low, high) in [
2385            (0, 1),
2386            (usize::MAX - 1, usize::MAX),
2387            (0, usize::MAX),
2388            (usize::MAX - 7, usize::MAX),
2389        ] {
2390            for _ in 0..128 {
2391                assert!((low..high).contains(&rng.range(low, high)));
2392            }
2393        }
2394    }
2395
2396    /// Minimal deterministic PRNG (splitmix64): no new dependencies, stable CI.
2397    struct SwarmRng {
2398        state: u64,
2399    }
2400
2401    impl SwarmRng {
2402        /// Initializes the deterministic generator from an explicit test seed.
2403        const fn new(seed: u64) -> Self {
2404            Self { state: seed }
2405        }
2406
2407        /// Advances SplitMix64 and returns the next full-width sample.
2408        fn next_u64(&mut self) -> u64 {
2409            self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
2410            let mut value = self.state;
2411            value = (value ^ (value >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2412            value = (value ^ (value >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2413            value ^ (value >> 31)
2414        }
2415
2416        /// Samples `[low, high)` using the full 64-bit sample before narrowing,
2417        /// keeping the sequence identical for ranges shared by 32- and 64-bit targets.
2418        ///
2419        /// # Panics
2420        /// Panics if `low >= high`.
2421        fn range(&mut self, low: usize, high: usize) -> usize {
2422            assert!(low < high, "empty range");
2423            let span = u64::try_from(high - low).expect("usize range width fits in u64");
2424            // Reduce before narrowing so the remainder fits in usize on every target.
2425            let offset = usize::try_from(self.next_u64() % span).expect("remainder is smaller than the usize range");
2426            low + offset
2427        }
2428
2429        /// Shuffles the slice in place using deterministic Fisher-Yates swaps.
2430        fn shuffle_str(&mut self, items: &mut [&str]) {
2431            for index in (1..items.len()).rev() {
2432                let other = self.range(0, index + 1);
2433                items.swap(index, other);
2434            }
2435        }
2436    }
2437}