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