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/// Computes a character-level diff with adjacent chunks coalesced.
569#[must_use]
570pub fn compute_diff_chunks<'a>(old: &'a str, new: &'a str) -> Vec<Chunk<'a>> {
571    if old == new {
572        return (!old.is_empty()).then_some(Chunk::Equal(old)).into_iter().collect();
573    }
574    let diff = TextDiff::configure()
575        .algorithm(similar::Algorithm::Myers)
576        .timeout(Duration::from_millis(200))
577        .diff_chars(old, new);
578    let mut chunks = Vec::new();
579    let mut old_offset = 0usize;
580    let mut new_offset = 0usize;
581    let mut run: Option<(ChangeTag, usize, usize)> = None;
582
583    for change in diff.iter_all_changes() {
584        let value = change.value();
585        let byte_len = value.len();
586        let (start, end) = match change.tag() {
587            ChangeTag::Equal | ChangeTag::Delete => (old_offset, old_offset.saturating_add(byte_len)),
588            ChangeTag::Insert => (new_offset, new_offset.saturating_add(byte_len)),
589        };
590        if let Some((tag, run_start, run_end)) = run {
591            if tag == change.tag() && run_end == start {
592                run = Some((tag, run_start, end));
593            } else {
594                push_chunk(&mut chunks, tag, run_start, run_end, old, new);
595                run = Some((change.tag(), start, end));
596            }
597        } else {
598            run = Some((change.tag(), start, end));
599        }
600        match change.tag() {
601            ChangeTag::Equal => {
602                old_offset = old_offset.saturating_add(byte_len);
603                new_offset = new_offset.saturating_add(byte_len);
604            }
605            ChangeTag::Delete => old_offset = old_offset.saturating_add(byte_len),
606            ChangeTag::Insert => new_offset = new_offset.saturating_add(byte_len),
607        }
608    }
609    if let Some((tag, start, end)) = run {
610        push_chunk(&mut chunks, tag, start, end, old, new);
611    }
612    chunks
613}
614
615fn push_chunk<'a>(chunks: &mut Vec<Chunk<'a>>, tag: ChangeTag, start: usize, end: usize, old: &'a str, new: &'a str) {
616    let chunk = match tag {
617        ChangeTag::Equal => Chunk::Equal(&old[start..end]),
618        ChangeTag::Delete => Chunk::Delete(&old[start..end]),
619        ChangeTag::Insert => Chunk::Insert(&new[start..end]),
620    };
621    chunks.push(chunk);
622}
623
624fn split_lines_with_terminator(text: &str) -> Vec<String> {
625    split_line_slices(text).into_iter().map(str::to_owned).collect()
626}
627
628fn split_line_slices(text: &str) -> Vec<&str> {
629    let mut lines = Vec::new();
630    let mut start = 0usize;
631    let bytes = text.as_bytes();
632    let mut index = 0usize;
633    while index < bytes.len() {
634        if bytes[index] != b'\n' && bytes[index] != b'\r' {
635            index += 1;
636            continue;
637        }
638        let end = if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
639            index + 2
640        } else {
641            index + 1
642        };
643        lines.push(&text[start..end]);
644        start = end;
645        index = end;
646    }
647    if start < text.len() {
648        lines.push(&text[start..]);
649    }
650    lines
651}
652
653/// Intraline byte range, always aligned to UTF-8 boundaries.
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
656pub struct IntralineRange {
657    /// Inclusive byte start.
658    pub start: usize,
659    /// Exclusive byte end.
660    pub end: usize,
661}
662
663/// Intra-line highlight ranges retained for compatibility.
664pub type WordChangedRanges = Vec<(usize, usize)>;
665
666/// Aggregate addition/deletion counts retained for compatibility.
667#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
668pub struct DiffChangeCounts {
669    /// Added lines.
670    pub additions: usize,
671    /// Deleted lines.
672    pub deletions: usize,
673}
674
675impl DiffChangeCounts {
676    /// Total changed lines.
677    #[must_use]
678    pub const fn total(self) -> usize {
679        self.additions + self.deletions
680    }
681}
682
683/// Counts additions and deletions in structured hunks.
684#[must_use]
685pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
686    let mut counts = DiffChangeCounts::default();
687    for line in hunks.iter().flat_map(|hunk| &hunk.lines) {
688        match line.kind {
689            DiffLineKind::Addition => counts.additions += 1,
690            DiffLineKind::Deletion => counts.deletions += 1,
691            DiffLineKind::Context => {}
692        }
693    }
694    counts
695}
696
697/// Semantic role used by legacy preview consumers.
698#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
699pub enum DiffDisplayKind {
700    /// File or parser metadata.
701    Metadata,
702    /// Hunk range header.
703    HunkHeader,
704    /// Unchanged context.
705    Context,
706    /// Added content.
707    Addition,
708    /// Deleted content.
709    Deletion,
710}
711
712impl DiffDisplayKind {
713    /// Whether the kind represents source content.
714    #[must_use]
715    pub const fn is_diff(self) -> bool {
716        matches!(self, Self::Context | Self::Addition | Self::Deletion)
717    }
718}
719
720/// A semantic legacy display line.
721#[derive(Clone, Debug, Eq, PartialEq)]
722pub struct DiffDisplayLine {
723    /// Semantic role.
724    pub kind: DiffDisplayKind,
725    /// Old-side number.
726    pub old_line: Option<u32>,
727    /// New-side number.
728    pub new_line: Option<u32>,
729    /// Content without the diff marker or line ending.
730    pub text: String,
731    /// Intraline changed byte ranges.
732    pub changed: WordChangedRanges,
733}
734
735impl DiffDisplayLine {
736    /// Constructs a source body line.
737    #[must_use]
738    pub fn body(kind: DiffDisplayKind, old_line: Option<u32>, new_line: Option<u32>, text: String) -> Self {
739        Self {
740            kind,
741            old_line,
742            new_line,
743            text,
744            changed: Vec::new(),
745        }
746    }
747
748    /// Whether this line represents source content.
749    #[must_use]
750    pub const fn is_diff(&self) -> bool {
751        self.kind.is_diff()
752    }
753
754    /// Formats a single numbered gutter.
755    #[must_use]
756    pub fn numbered_text(&self, line_number_width: usize) -> String {
757        match self.kind {
758            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
759            DiffDisplayKind::Deletion => {
760                format!("-{:>width$} │ {}", self.old_line.unwrap_or_default(), self.text, width = line_number_width)
761            }
762            DiffDisplayKind::Addition => {
763                format!("+{:>width$} │ {}", self.new_line.unwrap_or_default(), self.text, width = line_number_width)
764            }
765            DiffDisplayKind::Context => format!(
766                " {:>width$} │ {}",
767                self.new_line.or(self.old_line).unwrap_or_default(),
768                self.text,
769                width = line_number_width
770            ),
771        }
772    }
773}
774
775/// Converts hunks to semantic display lines and bounded intraline ranges.
776#[must_use]
777pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
778    display_lines_from_hunks_with_timeout(hunks, default_inline_timeout())
779}
780
781fn display_lines_from_hunks_with_timeout(hunks: &[DiffHunk], inline_timeout: Duration) -> Vec<DiffDisplayLine> {
782    let mut output = Vec::new();
783    for hunk in hunks {
784        output.push(DiffDisplayLine::body(
785            DiffDisplayKind::HunkHeader,
786            None,
787            None,
788            format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
789        ));
790        output.extend(hunk.lines.iter().map(|line| {
791            let kind = match line.kind {
792                DiffLineKind::Context => DiffDisplayKind::Context,
793                DiffLineKind::Addition => DiffDisplayKind::Addition,
794                DiffLineKind::Deletion => DiffDisplayKind::Deletion,
795            };
796            DiffDisplayLine::body(kind, line.old_line, line.new_line, trim_line_ending(&line.text).to_owned())
797        }));
798    }
799    annotate_word_level_diffs_with_timeout(&mut output, inline_timeout);
800    output
801}
802
803/// Parses unified text into display lines, retaining metadata lines.
804#[must_use]
805pub fn display_lines_from_unified_diff(input: &str) -> Vec<DiffDisplayLine> {
806    let mut output = Vec::new();
807    let mut old_line = 0u32;
808    let mut new_line = 0u32;
809    let mut in_hunk = false;
810    let mut remaining_old = 0usize;
811    let mut remaining_new = 0usize;
812    let mut omission_in_hunk = false;
813    let mut hunk_old_start = 0u32;
814    let mut hunk_new_start = 0u32;
815    let mut hunk_old_count = 0usize;
816    let mut hunk_new_count = 0usize;
817    let mut omitted_tail = Vec::new();
818    for raw in input.lines() {
819        if let Some((old_start, new_start)) = parse_hunk_starts(raw) {
820            assign_omitted_tail_line_numbers(
821                &mut output,
822                &omitted_tail,
823                hunk_old_start,
824                hunk_old_count,
825                hunk_new_start,
826                hunk_new_count,
827            );
828            omitted_tail.clear();
829            old_line = old_start;
830            new_line = new_start;
831            if let Some((_, old_count, _, new_count)) = parse_hunk_range(raw) {
832                remaining_old = old_count;
833                remaining_new = new_count;
834                hunk_old_count = old_count;
835                hunk_new_count = new_count;
836            }
837            hunk_old_start = old_start;
838            hunk_new_start = new_start;
839            in_hunk = true;
840            omission_in_hunk = false;
841            output.push(DiffDisplayLine::body(
842                DiffDisplayKind::HunkHeader,
843                None,
844                None,
845                format!("@@ -{old_start} +{new_start} @@"),
846            ));
847        } else if in_hunk && (omission_in_hunk || remaining_new > 0) && raw.starts_with('+') {
848            let output_index = output.len();
849            output.push(DiffDisplayLine::body(
850                DiffDisplayKind::Addition,
851                None,
852                (!omission_in_hunk).then_some(new_line),
853                raw[1..].to_owned(),
854            ));
855            if omission_in_hunk {
856                omitted_tail.push(output_index);
857            }
858            new_line = new_line.saturating_add(1);
859            remaining_new = remaining_new.saturating_sub(1);
860        } else if in_hunk && (omission_in_hunk || remaining_old > 0) && raw.starts_with('-') {
861            let output_index = output.len();
862            output.push(DiffDisplayLine::body(
863                DiffDisplayKind::Deletion,
864                (!omission_in_hunk).then_some(old_line),
865                None,
866                raw[1..].to_owned(),
867            ));
868            if omission_in_hunk {
869                omitted_tail.push(output_index);
870            }
871            old_line = old_line.saturating_add(1);
872            remaining_old = remaining_old.saturating_sub(1);
873        } else if in_hunk && (omission_in_hunk || (remaining_old > 0 && remaining_new > 0)) && raw.starts_with(' ') {
874            let output_index = output.len();
875            output.push(DiffDisplayLine::body(
876                DiffDisplayKind::Context,
877                (!omission_in_hunk).then_some(old_line),
878                (!omission_in_hunk).then_some(new_line),
879                raw[1..].to_owned(),
880            ));
881            if omission_in_hunk {
882                omitted_tail.push(output_index);
883            }
884            old_line = old_line.saturating_add(1);
885            new_line = new_line.saturating_add(1);
886            remaining_old = remaining_old.saturating_sub(1);
887            remaining_new = remaining_new.saturating_sub(1);
888        } else if in_hunk && let Some(omitted) = parse_omitted_line_count(raw) {
889            output.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, raw.to_owned()));
890            omission_in_hunk = true;
891            let omitted = omitted.min(remaining_old).min(remaining_new);
892            old_line = old_line.saturating_add(u32::try_from(omitted).unwrap_or(u32::MAX));
893            new_line = new_line.saturating_add(u32::try_from(omitted).unwrap_or(u32::MAX));
894            remaining_old = remaining_old.saturating_sub(omitted);
895            remaining_new = remaining_new.saturating_sub(omitted);
896        } else {
897            output.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, raw.to_owned()));
898        }
899    }
900    assign_omitted_tail_line_numbers(
901        &mut output,
902        &omitted_tail,
903        hunk_old_start,
904        hunk_old_count,
905        hunk_new_start,
906        hunk_new_count,
907    );
908    annotate_word_level_diffs(&mut output);
909    output
910}
911
912fn assign_omitted_tail_line_numbers(
913    lines: &mut [DiffDisplayLine],
914    tail: &[usize],
915    old_start: u32,
916    old_count: usize,
917    new_start: u32,
918    new_count: usize,
919) {
920    if tail.is_empty() {
921        return;
922    }
923    let old_tail_count = tail
924        .iter()
925        .filter(|&&index| lines[index].kind != DiffDisplayKind::Addition)
926        .count();
927    let new_tail_count = tail
928        .iter()
929        .filter(|&&index| lines[index].kind != DiffDisplayKind::Deletion)
930        .count();
931    let mut old_line = old_start
932        .saturating_add(u32::try_from(old_count).unwrap_or(u32::MAX))
933        .saturating_sub(u32::try_from(old_tail_count).unwrap_or(u32::MAX));
934    let mut new_line = new_start
935        .saturating_add(u32::try_from(new_count).unwrap_or(u32::MAX))
936        .saturating_sub(u32::try_from(new_tail_count).unwrap_or(u32::MAX));
937    for &index in tail {
938        match lines[index].kind {
939            DiffDisplayKind::Addition => {
940                lines[index].new_line = Some(new_line);
941                new_line = new_line.saturating_add(1);
942            }
943            DiffDisplayKind::Deletion => {
944                lines[index].old_line = Some(old_line);
945                old_line = old_line.saturating_add(1);
946            }
947            DiffDisplayKind::Context => {
948                lines[index].old_line = Some(old_line);
949                lines[index].new_line = Some(new_line);
950                old_line = old_line.saturating_add(1);
951                new_line = new_line.saturating_add(1);
952            }
953            DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => {}
954        }
955    }
956}
957
958/// Formats a numbered unified diff without ANSI color.
959#[must_use]
960pub fn format_numbered_unified_diff(input: &str) -> Vec<String> {
961    let lines = display_lines_from_unified_diff(input);
962    let width = diff_display_line_number_width(&lines);
963    lines.iter().map(|line| line.numbered_text(width)).collect()
964}
965
966/// Computes the clamped line-number gutter width.
967#[must_use]
968pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
969    let maximum = lines
970        .iter()
971        .flat_map(|line| [line.old_line, line.new_line])
972        .flatten()
973        .max()
974        .unwrap_or_default();
975    decimal_digits(maximum).clamp(5, 6)
976}
977
978/// Minimum content width for the paired old/new preview.
979///
980/// Below this width, two independently numbered panes leave too little room
981/// for source text. Renderers should fall back to the unified presentation.
982pub const DIFF_MIN_SIDE_BY_SIDE_WIDTH: usize = 60;
983
984/// Whether a measured width can support the paired old/new preview.
985///
986/// An unknown width preserves the existing caller behavior; redirected
987/// output and test sinks may not expose terminal sizing at all.
988#[must_use]
989pub const fn diff_side_by_side_fits(available_width: Option<usize>) -> bool {
990    match available_width {
991        Some(width) => width >= DIFF_MIN_SIDE_BY_SIDE_WIDTH,
992        None => true,
993    }
994}
995
996/// Minimum source width retained when the unified line gutter is visible.
997///
998/// This keeps the marker, line number, separator, and a useful amount of
999/// source text together. Narrower layouts should hide the gutter and give its
1000/// columns back to the source body.
1001pub const DIFF_MIN_BODY_WIDTH_WITH_GUTTER: usize = 20;
1002
1003/// Width consumed by a unified diff gutter after the line-number field.
1004///
1005/// The rendered shape is `+123 │ `: one marker plus the three-cell separator
1006/// around `│`. The line-number field is supplied by the caller because it is
1007/// derived from the visible diff excerpt.
1008#[must_use]
1009pub const fn diff_gutter_width(line_number_width: usize) -> usize {
1010    line_number_width.saturating_add(4)
1011}
1012
1013/// Whether a unified diff can keep its marker, line number, and separator
1014/// without starving the source body.
1015#[must_use]
1016pub const fn diff_gutter_fits(available_width: usize, line_number_width: usize) -> bool {
1017    available_width >= diff_gutter_width(line_number_width).saturating_add(DIFF_MIN_BODY_WIDTH_WITH_GUTTER)
1018}
1019
1020/// Width to pass to semantic unified layout when the renderer hides its
1021/// visible gutter.
1022///
1023/// `layout_display_lines` subtracts the normal gutter before wrapping source
1024/// text. Giving that width back keeps wrapping aligned with compact rendering
1025/// without adding another public layout option.
1026#[must_use]
1027pub const fn diff_layout_width(available_width: usize, line_number_width: usize, show_gutter: bool) -> usize {
1028    if show_gutter {
1029        available_width
1030    } else {
1031        available_width.saturating_add(diff_gutter_width(line_number_width))
1032    }
1033}
1034
1035fn decimal_digits(mut number: u32) -> usize {
1036    let mut digits = 1usize;
1037    while number >= 10 {
1038        number /= 10;
1039        digits += 1;
1040    }
1041    digits
1042}
1043
1044/// One paired row in the compatibility side-by-side model.
1045#[derive(Clone, Debug, Eq, PartialEq)]
1046pub struct SideBySideRow {
1047    /// Old-side cell.
1048    pub left: Option<DiffDisplayLine>,
1049    /// New-side cell.
1050    pub right: Option<DiffDisplayLine>,
1051}
1052
1053impl SideBySideRow {
1054    /// Whether the left cell is a header spanning both panes.
1055    #[must_use]
1056    pub fn is_full_width(&self) -> bool {
1057        self.right.is_none()
1058            && self
1059                .left
1060                .as_ref()
1061                .is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata))
1062    }
1063}
1064
1065/// Pairs deletions and additions for side-by-side presentation.
1066#[must_use]
1067pub fn side_by_side_rows(lines: &[DiffDisplayLine]) -> Vec<SideBySideRow> {
1068    let mut rows = Vec::new();
1069    for_each_side_by_side_pair(lines, |left, right| {
1070        rows.push(SideBySideRow { left: left.cloned(), right: right.cloned() });
1071    });
1072    rows
1073}
1074
1075fn for_each_side_by_side_pair<'a, F>(lines: &'a [DiffDisplayLine], mut visit: F)
1076where
1077    F: FnMut(Option<&'a DiffDisplayLine>, Option<&'a DiffDisplayLine>),
1078{
1079    let mut index = 0usize;
1080    while index < lines.len() {
1081        match lines[index].kind {
1082            DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata => {
1083                visit(Some(&lines[index]), None);
1084                index += 1;
1085            }
1086            DiffDisplayKind::Context => {
1087                let line = &lines[index];
1088                visit(Some(line), Some(line));
1089                index += 1;
1090            }
1091            DiffDisplayKind::Deletion => {
1092                let delete_start = index;
1093                while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
1094                    index += 1;
1095                }
1096                let insert_start = index;
1097                while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
1098                    index += 1;
1099                }
1100                let delete_count = insert_start - delete_start;
1101                let insert_count = index - insert_start;
1102                for offset in 0..delete_count.max(insert_count) {
1103                    visit(
1104                        (offset < delete_count).then(|| &lines[delete_start + offset]),
1105                        (offset < insert_count).then(|| &lines[insert_start + offset]),
1106                    );
1107                }
1108            }
1109            DiffDisplayKind::Addition => {
1110                visit(None, Some(&lines[index]));
1111                index += 1;
1112            }
1113        }
1114    }
1115}
1116
1117/// Adds byte-safe intraline ranges to consecutive deletion/addition groups.
1118pub fn annotate_word_level_diffs(lines: &mut [DiffDisplayLine]) {
1119    annotate_word_level_diffs_with_timeout(lines, default_inline_timeout());
1120}
1121
1122fn annotate_word_level_diffs_with_timeout(lines: &mut [DiffDisplayLine], timeout: Duration) {
1123    let deadline = Instant::now().checked_add(timeout);
1124    let mut index = 0usize;
1125    while index < lines.len() {
1126        if lines[index].kind != DiffDisplayKind::Deletion {
1127            index += 1;
1128            continue;
1129        }
1130        let delete_start = index;
1131        while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
1132            index += 1;
1133        }
1134        let insert_start = index;
1135        while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
1136            index += 1;
1137        }
1138        let pair_count = (insert_start - delete_start).min(index - insert_start);
1139        for offset in 0..pair_count {
1140            if deadline.is_some_and(|limit| Instant::now() >= limit) {
1141                return;
1142            }
1143            let (old_ranges, new_ranges) =
1144                word_level_changed_ranges(&lines[delete_start + offset].text, &lines[insert_start + offset].text);
1145            lines[delete_start + offset].changed = old_ranges;
1146            lines[insert_start + offset].changed = new_ranges;
1147        }
1148    }
1149}
1150
1151/// Computes byte-safe word-level changed ranges for a line pair.
1152#[must_use]
1153pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
1154    if old.is_empty() || new.is_empty() || old.len().saturating_add(new.len()) > 16_384 {
1155        return (Vec::new(), Vec::new());
1156    }
1157    let diff = TextDiff::configure()
1158        .algorithm(similar::Algorithm::Myers)
1159        .timeout(Duration::from_millis(10))
1160        .diff_unicode_words(old, new);
1161    if diff.ratio() < 0.35 {
1162        return (Vec::new(), Vec::new());
1163    }
1164    let mut old_offset = 0usize;
1165    let mut new_offset = 0usize;
1166    let mut old_ranges = Vec::new();
1167    let mut new_ranges = Vec::new();
1168    for change in diff.iter_all_changes() {
1169        let length = change.value().len();
1170        match change.tag() {
1171            ChangeTag::Equal => {
1172                old_offset += length;
1173                new_offset += length;
1174            }
1175            ChangeTag::Delete => {
1176                push_range(&mut old_ranges, old_offset, old_offset + length);
1177                old_offset += length;
1178            }
1179            ChangeTag::Insert => {
1180                push_range(&mut new_ranges, new_offset, new_offset + length);
1181                new_offset += length;
1182            }
1183        }
1184    }
1185    (old_ranges, new_ranges)
1186}
1187
1188fn push_range(ranges: &mut WordChangedRanges, start: usize, end: usize) {
1189    match ranges.last_mut() {
1190        Some((_, prior_end)) if *prior_end == start => *prior_end = end,
1191        _ => ranges.push((start, end)),
1192    }
1193}
1194
1195/// Requested preview layout.
1196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1198#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
1199pub enum DiffLayout {
1200    /// One stacked old/new column.
1201    #[default]
1202    Unified,
1203    /// Paired old/new panes.
1204    SideBySide,
1205}
1206
1207/// Width and bounded-excerpt options for semantic layout.
1208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1209pub struct LayoutOptions {
1210    /// Requested layout.
1211    pub layout: DiffLayout,
1212    /// Available terminal columns.
1213    pub width: usize,
1214    /// Maximum rows, including an omission marker.
1215    pub max_rows: usize,
1216    /// Whether source bodies hard-wrap at display width.
1217    pub wrap: bool,
1218    /// Side-by-side fallback threshold.
1219    pub min_side_by_side_width: usize,
1220}
1221
1222impl Default for LayoutOptions {
1223    fn default() -> Self {
1224        Self {
1225            layout: DiffLayout::Unified,
1226            width: 80,
1227            max_rows: 2_000,
1228            wrap: true,
1229            min_side_by_side_width: DIFF_MIN_SIDE_BY_SIDE_WIDTH,
1230        }
1231    }
1232}
1233
1234/// Semantic role for a renderer-neutral row.
1235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1236pub enum DiffRowKind {
1237    /// File metadata outside a hunk.
1238    Metadata,
1239    /// Hunk header.
1240    HunkHeader,
1241    /// Unchanged content.
1242    Context,
1243    /// Added content.
1244    Addition,
1245    /// Deleted content.
1246    Deletion,
1247    /// A bounded middle omission.
1248    Omission,
1249}
1250
1251/// One independently styled content segment.
1252#[derive(Debug, Clone, PartialEq, Eq)]
1253pub struct DiffSegment {
1254    /// Segment text.
1255    pub text: String,
1256    /// Whether the segment receives intraline emphasis.
1257    pub emphasized: bool,
1258}
1259
1260/// One side of a semantic preview row.
1261#[derive(Debug, Clone, PartialEq, Eq)]
1262pub struct DiffCell {
1263    /// Old-side line number.
1264    pub old_line: Option<u32>,
1265    /// New-side line number.
1266    pub new_line: Option<u32>,
1267    /// Diff marker.
1268    pub marker: char,
1269    /// Styled content segments.
1270    pub segments: Vec<DiffSegment>,
1271}
1272
1273/// A renderer-neutral row, optionally containing paired side-by-side cells.
1274#[derive(Debug, Clone, PartialEq, Eq)]
1275pub struct DiffRow {
1276    /// Semantic role.
1277    pub kind: DiffRowKind,
1278    /// Marker for unified renderers and simple inspection.
1279    pub marker: char,
1280    /// Stable zero-based hunk identity.
1281    pub hunk_index: Option<usize>,
1282    /// Whether this row continues a hard-wrapped source line.
1283    pub continuation: bool,
1284    /// Unified or old-side cell.
1285    pub left: Option<DiffCell>,
1286    /// New-side cell in side-by-side mode.
1287    pub right: Option<DiffCell>,
1288}
1289
1290fn layout_document(document: &DiffDocument, options: LayoutOptions) -> Vec<DiffRow> {
1291    let display = display_lines_from_hunks_with_timeout(&document.hunks, document.inline_timeout);
1292    layout_display_lines(&display, options)
1293}
1294
1295/// Lays out precomputed display lines without repeating intraline analysis.
1296///
1297/// Interactive applications can cache semantic lines when an overlay opens
1298/// and call this inexpensive step again after a resize.
1299#[must_use]
1300pub fn layout_display_lines(display: &[DiffDisplayLine], options: LayoutOptions) -> Vec<DiffRow> {
1301    let use_side_by_side = options.layout == DiffLayout::SideBySide && options.width >= options.min_side_by_side_width;
1302    let mut rows = RowCollector::new(options.max_rows);
1303    if use_side_by_side {
1304        layout_side_by_side(display, options, &mut rows);
1305    } else {
1306        layout_unified(display, options, &mut rows);
1307    }
1308    rows.finish()
1309}
1310
1311/// Returns a bounded head/tail excerpt of semantic display lines.
1312///
1313/// The returned vector contains at most `max_rows` entries. When rows are
1314/// omitted, one metadata entry (`... N lines omitted ...`) is inserted between
1315/// the retained head and tail. Source line numbers and intraline ranges on
1316/// retained entries are preserved, so callers can render the excerpt without
1317/// reparsing or inventing positions.
1318#[must_use]
1319pub fn bounded_display_lines(lines: &[DiffDisplayLine], max_rows: usize) -> Vec<DiffDisplayLine> {
1320    if max_rows == 0 {
1321        return Vec::new();
1322    }
1323    if lines.len() <= max_rows {
1324        return lines.to_vec();
1325    }
1326
1327    let retained = max_rows.saturating_sub(1);
1328    let head_count = retained.saturating_add(1) / 2;
1329    let tail_count = retained / 2;
1330    let omitted = lines.len().saturating_sub(head_count + tail_count);
1331
1332    let mut bounded = Vec::with_capacity(max_rows);
1333    bounded.extend_from_slice(&lines[..head_count]);
1334    bounded.push(DiffDisplayLine::body(
1335        DiffDisplayKind::Metadata,
1336        None,
1337        None,
1338        format!("... {omitted} lines omitted ..."),
1339    ));
1340    if tail_count > 0 {
1341        bounded.extend_from_slice(&lines[lines.len() - tail_count..]);
1342    }
1343    bounded
1344}
1345
1346fn layout_unified(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1347    let gutter_width = diff_display_line_number_width(lines).saturating_add(4);
1348    let content_width = options.width.saturating_sub(gutter_width).max(1);
1349    let mut hunk_index = None;
1350    for line in lines {
1351        if line.kind == DiffDisplayKind::Metadata {
1352            rows.push(metadata_row(&line.text, hunk_index));
1353            continue;
1354        }
1355        if line.kind == DiffDisplayKind::HunkHeader {
1356            hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1357            rows.push(header_row(&line.text, hunk_index));
1358            continue;
1359        }
1360        let marker = marker_for_kind(line.kind);
1361        for (continuation, segments) in wrap_segments(&line.text, &line.changed, content_width, options.wrap)
1362            .into_iter()
1363            .enumerate()
1364        {
1365            rows.push(DiffRow {
1366                kind: row_kind(line.kind),
1367                marker,
1368                hunk_index,
1369                continuation: continuation > 0,
1370                left: Some(DiffCell {
1371                    old_line: (continuation == 0).then_some(line.old_line).flatten(),
1372                    new_line: (continuation == 0).then_some(line.new_line).flatten(),
1373                    marker,
1374                    segments,
1375                }),
1376                right: None,
1377            });
1378        }
1379    }
1380}
1381
1382fn layout_side_by_side(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1383    let pane_width = options.width.saturating_sub(1) / 2;
1384    let content_width = pane_width.saturating_sub(6).max(1);
1385    let mut hunk_index = None;
1386    for_each_side_by_side_pair(lines, |left, right| {
1387        let is_full_width = right.is_none()
1388            && left.is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata));
1389        if is_full_width {
1390            let text = left.map_or("", |line| line.text.as_str());
1391            if left.is_some_and(|line| line.kind == DiffDisplayKind::HunkHeader) {
1392                hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1393                rows.push(header_row(text, hunk_index));
1394            } else {
1395                rows.push(metadata_row(text, hunk_index));
1396            }
1397            return;
1398        }
1399        let left_parts = left.map_or_else(
1400            || vec![Vec::new()],
1401            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1402        );
1403        let right_parts = right.map_or_else(
1404            || vec![Vec::new()],
1405            |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1406        );
1407        let count = left_parts.len().max(right_parts.len());
1408        for offset in 0..count {
1409            let left_cell = left.and_then(|line| {
1410                left_parts.get(offset).map(|segments| DiffCell {
1411                    old_line: (offset == 0).then_some(line.old_line).flatten(),
1412                    new_line: (offset == 0).then_some(line.new_line).flatten(),
1413                    marker: marker_for_kind(line.kind),
1414                    segments: segments.clone(),
1415                })
1416            });
1417            let right_cell = right.and_then(|line| {
1418                right_parts.get(offset).map(|segments| DiffCell {
1419                    old_line: (offset == 0).then_some(line.old_line).flatten(),
1420                    new_line: (offset == 0).then_some(line.new_line).flatten(),
1421                    marker: marker_for_kind(line.kind),
1422                    segments: segments.clone(),
1423                })
1424            });
1425            let kind = right.or(left).map_or(DiffRowKind::Context, |line| row_kind(line.kind));
1426            rows.push(DiffRow {
1427                kind,
1428                marker: right_cell.as_ref().or(left_cell.as_ref()).map_or(' ', |cell| cell.marker),
1429                hunk_index,
1430                continuation: offset > 0,
1431                left: left_cell,
1432                right: right_cell,
1433            });
1434        }
1435    });
1436}
1437
1438fn header_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1439    DiffRow {
1440        kind: DiffRowKind::HunkHeader,
1441        marker: '@',
1442        hunk_index,
1443        continuation: false,
1444        left: Some(DiffCell {
1445            old_line: None,
1446            new_line: None,
1447            marker: '@',
1448            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1449        }),
1450        right: None,
1451    }
1452}
1453
1454fn metadata_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1455    DiffRow {
1456        kind: DiffRowKind::Metadata,
1457        marker: ' ',
1458        hunk_index,
1459        continuation: false,
1460        left: Some(DiffCell {
1461            old_line: None,
1462            new_line: None,
1463            marker: ' ',
1464            segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1465        }),
1466        right: None,
1467    }
1468}
1469
1470struct RowCollector {
1471    head: Vec<DiffRow>,
1472    tail: VecDeque<DiffRow>,
1473    max_rows: usize,
1474    total_rows: usize,
1475    overflowed: bool,
1476}
1477
1478impl RowCollector {
1479    fn new(max_rows: usize) -> Self {
1480        Self {
1481            head: Vec::new(),
1482            tail: VecDeque::new(),
1483            max_rows,
1484            total_rows: 0,
1485            overflowed: false,
1486        }
1487    }
1488
1489    fn push(&mut self, row: DiffRow) {
1490        self.total_rows = self.total_rows.saturating_add(1);
1491        if self.max_rows == 0 {
1492            return;
1493        }
1494        if !self.overflowed {
1495            self.head.push(row);
1496            if self.head.len() <= self.max_rows {
1497                return;
1498            }
1499
1500            let retained = self.max_rows.saturating_sub(1);
1501            let head_count = retained.saturating_add(1) / 2;
1502            let tail_count = retained / 2;
1503            if tail_count > 0 {
1504                let tail_start = self.head.len() - tail_count;
1505                self.tail = self.head.split_off(tail_start).into();
1506            }
1507            self.head.truncate(head_count);
1508            self.overflowed = true;
1509            return;
1510        }
1511
1512        let tail_count = self.max_rows.saturating_sub(1) / 2;
1513        if tail_count > 0 {
1514            if self.tail.len() == tail_count {
1515                let _ = self.tail.pop_front();
1516            }
1517            self.tail.push_back(row);
1518        }
1519    }
1520
1521    fn finish(mut self) -> Vec<DiffRow> {
1522        if !self.overflowed {
1523            return self.head;
1524        }
1525        let omitted = self.total_rows.saturating_sub(self.head.len()).saturating_sub(self.tail.len());
1526        self.head.push(omission_row(omitted));
1527        self.head.extend(self.tail);
1528        self.head
1529    }
1530}
1531
1532fn omission_row(omitted: usize) -> DiffRow {
1533    DiffRow {
1534        kind: DiffRowKind::Omission,
1535        marker: '…',
1536        hunk_index: None,
1537        continuation: false,
1538        left: Some(DiffCell {
1539            old_line: None,
1540            new_line: None,
1541            marker: '…',
1542            segments: vec![DiffSegment {
1543                text: format!("{omitted} rows omitted"),
1544                emphasized: false,
1545            }],
1546        }),
1547        right: None,
1548    }
1549}
1550
1551fn wrap_segments(text: &str, changed: &[(usize, usize)], width: usize, wrap: bool) -> Vec<Vec<DiffSegment>> {
1552    if !wrap || UnicodeWidthStr::width(text) <= width {
1553        return vec![segment_slice(text, changed, 0, text.len())];
1554    }
1555    let mut rows = Vec::new();
1556    let mut byte_start = 0usize;
1557    let mut display_width = 0usize;
1558    for (byte, character) in text.char_indices() {
1559        let char_width = UnicodeWidthChar::width(character).unwrap_or_default();
1560        if display_width > 0 && display_width.saturating_add(char_width) > width {
1561            rows.push(segment_slice(text, changed, byte_start, byte));
1562            byte_start = byte;
1563            display_width = 0;
1564        }
1565        display_width = display_width.saturating_add(char_width);
1566    }
1567    rows.push(segment_slice(text, changed, byte_start, text.len()));
1568    rows
1569}
1570
1571fn segment_slice(text: &str, changed: &[(usize, usize)], start: usize, end: usize) -> Vec<DiffSegment> {
1572    if start == end {
1573        return vec![DiffSegment { text: String::new(), emphasized: false }];
1574    }
1575    let mut boundaries = vec![start, end];
1576    for &(range_start, range_end) in changed {
1577        if range_start < end && range_end > start {
1578            let bounded_start = range_start.max(start).min(end);
1579            let bounded_end = range_end.max(start).min(end);
1580            if text.is_char_boundary(bounded_start) && text.is_char_boundary(bounded_end) {
1581                boundaries.push(bounded_start);
1582                boundaries.push(bounded_end);
1583            }
1584        }
1585    }
1586    boundaries.sort_unstable();
1587    boundaries.dedup();
1588    boundaries
1589        .windows(2)
1590        .filter_map(|pair| {
1591            let segment_start = pair[0];
1592            let segment_end = pair[1];
1593            (segment_start < segment_end).then(|| DiffSegment {
1594                text: text[segment_start..segment_end].to_owned(),
1595                emphasized: changed
1596                    .iter()
1597                    .any(|&(range_start, range_end)| segment_start >= range_start && segment_end <= range_end),
1598            })
1599        })
1600        .collect()
1601}
1602
1603fn marker_for_kind(kind: DiffDisplayKind) -> char {
1604    match kind {
1605        DiffDisplayKind::Addition => '+',
1606        DiffDisplayKind::Deletion => '-',
1607        DiffDisplayKind::HunkHeader => '@',
1608        DiffDisplayKind::Metadata | DiffDisplayKind::Context => ' ',
1609    }
1610}
1611
1612fn row_kind(kind: DiffDisplayKind) -> DiffRowKind {
1613    match kind {
1614        DiffDisplayKind::Addition => DiffRowKind::Addition,
1615        DiffDisplayKind::Deletion => DiffRowKind::Deletion,
1616        DiffDisplayKind::HunkHeader => DiffRowKind::HunkHeader,
1617        DiffDisplayKind::Metadata => DiffRowKind::Metadata,
1618        DiffDisplayKind::Context => DiffRowKind::Context,
1619    }
1620}
1621
1622fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> {
1623    let (old, _, new, _) = parse_hunk_range(line)?;
1624    Some((old, new))
1625}
1626
1627fn parse_hunk_range(line: &str) -> Option<(u32, usize, u32, usize)> {
1628    let body = line.strip_prefix("@@ ")?.split(" @@").next()?;
1629    let mut parts = body.split_whitespace();
1630    let (old_start, old_count) = parse_range(parts.next()?, '-')?;
1631    let (new_start, new_count) = parse_range(parts.next()?, '+')?;
1632    Some((old_start, old_count, new_start, new_count))
1633}
1634
1635fn parse_range(value: &str, marker: char) -> Option<(u32, usize)> {
1636    let range = value.strip_prefix(marker)?;
1637    let mut parts = range.splitn(2, ',');
1638    let start = parts.next()?.parse().ok()?;
1639    let count = parts.next().map_or(Some(1), |count| count.parse().ok())?;
1640    Some((start, count))
1641}
1642
1643fn parse_omitted_line_count(line: &str) -> Option<usize> {
1644    let line = line.trim();
1645    line.strip_prefix("... ")?.strip_suffix(" lines omitted ...")?.parse().ok()
1646}
1647
1648fn is_unified_metadata_line(line: &str) -> bool {
1649    line.starts_with("--- ")
1650        || line.starts_with("+++ ")
1651        || line.starts_with("new file mode ")
1652        || line.starts_with("deleted file mode ")
1653        || line.starts_with("rename from ")
1654        || line.starts_with("rename to ")
1655        || line.starts_with("copy from ")
1656        || line.starts_with("copy to ")
1657        || line.starts_with("similarity index ")
1658        || line.starts_with("dissimilarity index ")
1659        || line.starts_with("old mode ")
1660        || line.starts_with("new mode ")
1661        || line.starts_with("Binary files ")
1662        || line == "GIT binary patch"
1663        || line.starts_with("literal ")
1664        || line.starts_with("delta ")
1665}
1666
1667fn trim_line_ending(text: &str) -> &str {
1668    text.strip_suffix("\r\n")
1669        .or_else(|| text.strip_suffix('\n'))
1670        .or_else(|| text.strip_suffix('\r'))
1671        .unwrap_or(text)
1672}
1673
1674#[cfg(feature = "ansi")]
1675mod ansi_adapter {
1676    use super::DiffRow;
1677    use anstyle::{Reset, Style};
1678
1679    /// Caller-supplied foreground styles for ANSI output.
1680    #[derive(Debug, Clone, Copy, Default)]
1681    pub struct AnsiDiffPalette {
1682        /// Hunk and omission style.
1683        pub header: Style,
1684        /// Context style.
1685        pub context: Style,
1686        /// Addition style.
1687        pub addition: Style,
1688        /// Deletion style.
1689        pub deletion: Style,
1690        /// Extra intraline emphasis.
1691        pub emphasis: Style,
1692    }
1693
1694    /// Renders semantic rows as ANSI lines.
1695    #[must_use]
1696    pub fn render_ansi_rows(rows: &[DiffRow], palette: AnsiDiffPalette, color: bool) -> Vec<String> {
1697        rows.iter()
1698            .map(|row| {
1699                let mut output = String::new();
1700                output.push(row.marker);
1701                output.push(' ');
1702                if let Some(cell) = &row.left {
1703                    render_cell(&mut output, cell, palette, color);
1704                }
1705                if let Some(cell) = &row.right {
1706                    output.push_str(" │ ");
1707                    render_cell(&mut output, cell, palette, color);
1708                }
1709                output
1710            })
1711            .collect()
1712    }
1713
1714    fn render_cell(output: &mut String, cell: &super::DiffCell, palette: AnsiDiffPalette, color: bool) {
1715        let style = match cell.marker {
1716            '+' => palette.addition,
1717            '-' => palette.deletion,
1718            '@' | '…' => palette.header,
1719            _ => palette.context,
1720        };
1721        for segment in &cell.segments {
1722            if color {
1723                let selected = if segment.emphasized { palette.emphasis } else { style };
1724                output.push_str(&selected.render().to_string());
1725                output.push_str(&segment.text);
1726                output.push_str(&Reset.render().to_string());
1727            } else {
1728                output.push_str(&segment.text);
1729            }
1730        }
1731    }
1732}
1733
1734#[cfg(feature = "ansi")]
1735pub use ansi_adapter::{AnsiDiffPalette, render_ansi_rows};
1736
1737#[cfg(feature = "ratatui")]
1738mod ratatui_adapter {
1739    use super::DiffRow;
1740    use ratatui::text::{Line, Span};
1741
1742    /// Converts semantic rows to unstyled Ratatui lines for caller styling.
1743    #[must_use]
1744    pub fn to_ratatui_lines(rows: &[DiffRow]) -> Vec<Line<'static>> {
1745        rows.iter()
1746            .map(|row| {
1747                let mut spans = vec![Span::raw(format!("{} ", row.marker))];
1748                if let Some(cell) = &row.left {
1749                    spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1750                }
1751                if let Some(cell) = &row.right {
1752                    spans.push(Span::raw(" │ "));
1753                    spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1754                }
1755                Line::from(spans)
1756            })
1757            .collect()
1758    }
1759}
1760
1761#[cfg(feature = "ratatui")]
1762pub use ratatui_adapter::to_ratatui_lines;
1763
1764#[cfg(test)]
1765mod tests {
1766    use super::*;
1767
1768    #[test]
1769    fn asymmetric_replacement_has_correct_line_numbers() {
1770        let document = DiffDocument::between("a\nb\nc\n", "a\nx\ny\nc\n", DiffOptions::default());
1771        let lines: Vec<_> = document.hunks.iter().flat_map(|hunk| &hunk.lines).collect();
1772        assert!(lines.iter().any(|line| {
1773            line.kind == DiffLineKind::Deletion && line.old_line == Some(2) && line.new_line.is_none()
1774        }));
1775        assert!(lines.iter().any(|line| {
1776            line.kind == DiffLineKind::Addition && line.old_line.is_none() && line.new_line == Some(3)
1777        }));
1778        assert_eq!(document.stats.additions, 2);
1779        assert_eq!(document.stats.deletions, 1);
1780    }
1781
1782    #[test]
1783    fn repeated_lines_keep_the_changed_anchor() {
1784        let document = DiffDocument::between("same\nold\nsame\n", "same\nnew\nsame\n", DiffOptions::default());
1785        assert_eq!(document.stats.additions, 1);
1786        assert_eq!(document.stats.deletions, 1);
1787        assert_eq!(document.hunks[0].old_start, 1);
1788    }
1789
1790    #[test]
1791    fn zero_context_hunks_preserve_empty_side_anchors() {
1792        let options = DiffOptions { context_lines: 0, ..DiffOptions::default() };
1793        let insertion = DiffDocument::between("a\nc\n", "a\nb\nc\n", options.clone());
1794        assert_eq!(insertion.hunks[0].old_start, 2);
1795        assert_eq!(insertion.hunks[0].new_start, 2);
1796
1797        let deletion = DiffDocument::between("a\nb\nc\n", "a\nc\n", options);
1798        assert_eq!(deletion.hunks[0].old_start, 2);
1799        assert_eq!(deletion.hunks[0].new_start, 2);
1800    }
1801
1802    #[test]
1803    fn preserves_crlf_cr_and_missing_final_newline() {
1804        let crlf = DiffDocument::between("a\r\nb\r\n", "a\r\nx\r\n", DiffOptions::default());
1805        assert!(crlf.hunks[0].lines.iter().any(|line| line.text == "a\r\n"));
1806        let cr = DiffDocument::between("a\rb\r", "a\rx\r", DiffOptions::default());
1807        assert!(cr.hunks[0].lines.iter().any(|line| line.text == "a\r"));
1808        let eof = DiffDocument::between("a\n", "a", DiffOptions::default());
1809        assert_eq!(eof.stats.additions, 1);
1810        assert_eq!(eof.stats.deletions, 1);
1811    }
1812
1813    #[test]
1814    fn parser_rejects_body_before_hunk() {
1815        let error = DiffDocument::from_unified("-old\n+new\n").expect_err("body must need a hunk");
1816        assert_eq!(error.to_string(), "diff body appears before a hunk header");
1817    }
1818
1819    #[test]
1820    fn parser_accepts_standard_git_metadata_before_hunk() {
1821        let document = DiffDocument::from_unified(
1822            "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",
1823        )
1824        .expect("standard git metadata is not a body");
1825        assert_eq!(document.stats.deletions, 1);
1826        assert_eq!(document.stats.additions, 1);
1827    }
1828
1829    #[test]
1830    fn parser_accepts_metadata_between_multiple_file_hunks() {
1831        let document = DiffDocument::from_unified(
1832            "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",
1833        )
1834        .expect("metadata between files is not a hunk body");
1835        assert_eq!(document.hunks.len(), 2);
1836        assert_eq!(document.stats.additions, 2);
1837    }
1838
1839    #[test]
1840    fn parsed_body_lines_preserve_original_terminators() {
1841        let document = DiffDocument::from_unified("@@ -1 +1 @@\r\n-old\r\n+new\r\n").expect("valid CRLF diff");
1842        assert_eq!(document.hunks[0].lines[0].text, "old\r\n");
1843        assert_eq!(document.hunks[0].lines[1].text, "new\r\n");
1844
1845        let document = DiffDocument::from_unified("@@ -1 +1 @@\r-old\r+new\r").expect("valid CR diff");
1846        assert_eq!(document.hunks[0].lines[0].text, "old\r");
1847        assert_eq!(document.hunks[0].lines[1].text, "new\r");
1848    }
1849
1850    #[test]
1851    fn parser_rejects_incomplete_hunk_body() {
1852        let error = DiffDocument::from_unified("@@ -1,2 +1,2 @@\n-old\n+new\n")
1853            .expect_err("hunk body must satisfy the declared ranges");
1854        assert_eq!(error.to_string(), "hunk line counts do not match header");
1855    }
1856
1857    #[test]
1858    fn parser_rejects_unknown_backslash_metadata_inside_hunk() {
1859        let error = DiffDocument::from_unified("@@ -1 +1 @@\n-old\n\\ unexpected marker\n+new\n")
1860            .expect_err("only Git's no-newline marker is valid inside a hunk");
1861        assert_eq!(error.to_string(), "invalid unified diff body line");
1862    }
1863
1864    #[test]
1865    fn parser_tracks_asymmetric_hunk_numbers() {
1866        let document = DiffDocument::from_unified("@@ -4,1 +8,2 @@\n-old\n+new\n+extra\n").expect("valid diff");
1867        assert_eq!(document.hunks[0].old_start, 4);
1868        assert_eq!(document.hunks[0].new_start, 8);
1869        assert_eq!(document.hunks[0].lines[2].new_line, Some(9));
1870    }
1871
1872    #[test]
1873    fn parser_keeps_context_lines_that_look_like_omission_markers() {
1874        let document =
1875            DiffDocument::from_unified("@@ -1 +1 @@\n ... 4 lines omitted ...\n").expect("valid context line");
1876        assert_eq!(document.hunks[0].lines[0].kind, DiffLineKind::Context);
1877        assert_eq!(document.stats.omitted_rows, 0);
1878    }
1879
1880    #[test]
1881    fn parsed_omission_advances_both_line_counters() {
1882        let lines = display_lines_from_unified_diff("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n");
1883        let deletion = lines
1884            .iter()
1885            .find(|line| line.kind == DiffDisplayKind::Deletion)
1886            .expect("deletion after omission");
1887        let addition = lines
1888            .iter()
1889            .find(|line| line.kind == DiffDisplayKind::Addition)
1890            .expect("addition after omission");
1891        assert_eq!(deletion.old_line, Some(15));
1892        assert_eq!(addition.new_line, Some(25));
1893    }
1894
1895    #[test]
1896    fn truncated_hunk_keeps_tail_additions_as_diff_lines() {
1897        let mut input = String::from("@@ -1,201 +1,201 @@\n");
1898        for index in 0..95 {
1899            input.push_str(&format!("-old-{index}\n"));
1900        }
1901        input.push_str("... 243 lines omitted ...\n");
1902        for index in 137..201 {
1903            input.push_str(&format!("+new-{index}\n"));
1904        }
1905
1906        let lines = display_lines_from_unified_diff(&input);
1907        let additions: Vec<_> = lines.iter().filter(|line| line.kind == DiffDisplayKind::Addition).collect();
1908        assert_eq!(additions.len(), 64);
1909        assert_eq!(additions.last().map(|line| line.text.as_str()), Some("new-200"));
1910    }
1911
1912    #[test]
1913    fn truncated_hunk_numbers_tail_from_declared_end() {
1914        let mut input = String::from("@@ -1,201 +1,201 @@\n");
1915        for index in 0..93 {
1916            input.push_str(&format!("-old-{index}\n"));
1917        }
1918        input.push_str("... 277 lines omitted ...\n");
1919        for index in 169..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.first().and_then(|line| line.new_line), Some(170));
1926        assert_eq!(additions.last().and_then(|line| line.new_line), Some(201));
1927    }
1928
1929    #[test]
1930    fn parsed_metadata_stays_outside_hunk_semantics() {
1931        let display = display_lines_from_unified_diff("--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n");
1932        let rows = layout_display_lines(&display, LayoutOptions::default());
1933        assert_eq!(rows[0].kind, DiffRowKind::Metadata);
1934        assert_eq!(rows[1].kind, DiffRowKind::Metadata);
1935        assert_eq!(rows[2].kind, DiffRowKind::HunkHeader);
1936        assert_eq!(rows[2].hunk_index, Some(0));
1937    }
1938
1939    #[test]
1940    fn unified_parser_accepts_omission_and_advances_numbers() {
1941        let document = DiffDocument::from_unified("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n")
1942            .expect("omission marker is valid bounded preview metadata");
1943        assert_eq!(document.hunks[0].lines[1].old_line, Some(15));
1944        assert_eq!(document.hunks[0].lines[2].new_line, Some(25));
1945        assert_eq!(document.hunks[0].old_lines, 6);
1946        assert_eq!(document.hunks[0].new_lines, 6);
1947        assert_eq!(document.stats.omitted_rows, 4);
1948    }
1949
1950    #[test]
1951    fn unified_parser_accepts_asymmetric_bounded_omission() {
1952        let document =
1953            DiffDocument::from_unified("@@ -1,4 +1,8 @@\n-old-1\n... 4 lines omitted ...\n+new-6\n+new-7\n+new-8\n")
1954                .expect("bounded omission may hide different old/new line counts");
1955        assert_eq!(document.stats.omitted_rows, 4);
1956        assert_eq!(document.hunks[0].old_lines, 4);
1957        assert_eq!(document.hunks[0].new_lines, 6);
1958    }
1959
1960    #[test]
1961    fn unified_parser_numbers_one_sided_omitted_tails_from_hunk_end() {
1962        let added = DiffDocument::from_unified("@@ -0,0 +1,5 @@\n+one\n... 3 lines omitted ...\n+five\n")
1963            .expect("bounded addition is valid");
1964        assert_eq!(added.hunks[0].lines[1].new_line, Some(5));
1965
1966        let deleted = DiffDocument::from_unified("@@ -1,5 +0,0 @@\n-one\n... 3 lines omitted ...\n-five\n")
1967            .expect("bounded deletion is valid");
1968        assert_eq!(deleted.hunks[0].lines[1].old_line, Some(5));
1969    }
1970
1971    #[test]
1972    fn intraline_ranges_are_utf8_boundaries() {
1973        let (old, new) = word_level_changed_ranges("café rouge", "café bleu");
1974        for (start, end) in old {
1975            assert!("café rouge".is_char_boundary(start));
1976            assert!("café rouge".is_char_boundary(end));
1977        }
1978        for (start, end) in new {
1979            assert!("café bleu".is_char_boundary(start));
1980            assert!("café bleu".is_char_boundary(end));
1981        }
1982    }
1983
1984    #[test]
1985    fn unicode_width_wrap_keeps_numbers_only_on_first_row() {
1986        let document = DiffDocument::between("", "界界界a\n", DiffOptions::default());
1987        let rows = document.layout(LayoutOptions { width: 12, ..LayoutOptions::default() });
1988        let additions: Vec<_> = rows.iter().filter(|row| row.kind == DiffRowKind::Addition).collect();
1989        assert!(additions.len() >= 2);
1990        assert_eq!(additions[0].left.as_ref().and_then(|cell| cell.new_line), Some(1));
1991        assert!(additions[1].continuation);
1992        assert_eq!(additions[1].left.as_ref().and_then(|cell| cell.new_line), None);
1993        assert_eq!(additions[1].marker, '+');
1994    }
1995
1996    #[test]
1997    fn layout_ignores_invalid_intraline_boundaries() {
1998        let display = [DiffDisplayLine {
1999            kind: DiffDisplayKind::Addition,
2000            old_line: None,
2001            new_line: Some(1),
2002            text: "café".to_owned(),
2003            changed: vec![(2, 4)],
2004        }];
2005        let rows = layout_display_lines(&display, LayoutOptions { width: 20, ..LayoutOptions::default() });
2006        assert_eq!(rows.len(), 1);
2007        assert_eq!(rows[0].left.as_ref().expect("cell").segments[0].text, "café");
2008    }
2009
2010    #[test]
2011    fn narrow_side_by_side_falls_back_to_unified() {
2012        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2013        let rows = document.layout(LayoutOptions {
2014            layout: DiffLayout::SideBySide,
2015            width: 40,
2016            ..LayoutOptions::default()
2017        });
2018        assert!(
2019            rows.iter()
2020                .filter(|row| row.kind != DiffRowKind::HunkHeader)
2021                .all(|row| row.right.is_none())
2022        );
2023    }
2024
2025    #[test]
2026    fn responsive_gutter_policy_preserves_source_room_at_the_boundary() {
2027        assert_eq!(diff_gutter_width(5), 9);
2028        assert!(diff_gutter_fits(29, 5));
2029        assert!(!diff_gutter_fits(28, 5));
2030        assert_eq!(diff_layout_width(29, 5, true), 29);
2031        assert_eq!(diff_layout_width(28, 5, false), 37);
2032    }
2033
2034    #[test]
2035    fn side_by_side_policy_has_a_stable_resize_boundary() {
2036        assert!(!diff_side_by_side_fits(Some(59)));
2037        assert!(diff_side_by_side_fits(Some(DIFF_MIN_SIDE_BY_SIDE_WIDTH)));
2038        assert!(diff_side_by_side_fits(None));
2039    }
2040
2041    #[test]
2042    fn side_by_side_wrapping_leaves_shorter_side_empty() {
2043        let document =
2044            DiffDocument::between("short\n", "this is a much longer replacement line\n", DiffOptions::default());
2045        let rows = document.layout(LayoutOptions {
2046            layout: DiffLayout::SideBySide,
2047            width: 80,
2048            ..LayoutOptions::default()
2049        });
2050        let continuation = rows.iter().find(|row| row.continuation).expect("long replacement should wrap");
2051        assert!(continuation.left.is_none());
2052        assert!(continuation.right.is_some());
2053    }
2054
2055    #[test]
2056    fn bounded_rows_keep_head_tail_and_exact_omission() {
2057        let old = (0..20).map(|index| format!("old-{index}\n")).collect::<String>();
2058        let new = (0..20).map(|index| format!("new-{index}\n")).collect::<String>();
2059        let rows = DiffDocument::between(&old, &new, DiffOptions::default())
2060            .layout(LayoutOptions { max_rows: 5, ..LayoutOptions::default() });
2061        assert_eq!(rows.len(), 5);
2062        let omission = rows.iter().find(|row| row.kind == DiffRowKind::Omission).expect("omission row");
2063        let text = &omission.left.as_ref().expect("cell").segments[0].text;
2064        assert!(text.ends_with(" rows omitted"));
2065        assert!(rows.last().and_then(|row| row.left.as_ref()).is_some());
2066    }
2067
2068    #[test]
2069    fn bounded_display_lines_keep_asymmetric_head_and_tail() {
2070        let lines =
2071            display_lines_from_unified_diff("@@ -1,4 +1,4 @@\n-old-head\n+new-head\n context\n-old-tail\n+new-tail\n");
2072        let bounded = bounded_display_lines(&lines, 4);
2073
2074        assert_eq!(bounded.len(), 4);
2075        assert_eq!(bounded[0].text, "@@ -1 +1 @@");
2076        assert_eq!(bounded[1].text, "old-head");
2077        assert_eq!(bounded[2].kind, DiffDisplayKind::Metadata);
2078        assert!(bounded[2].text.contains("lines omitted"));
2079        assert_eq!(bounded[3].text, "new-tail");
2080    }
2081
2082    #[test]
2083    fn plain_unified_formatter_preserves_labels_and_newline_hints() {
2084        let output = format_unified_diff(
2085            "old\n",
2086            "new",
2087            DiffOptions {
2088                old_label: Some("a/file.txt"),
2089                new_label: Some("b/file.txt"),
2090                ..DiffOptions::default()
2091            },
2092        );
2093
2094        assert!(output.starts_with("--- a/file.txt\n+++ b/file.txt\n@@"));
2095        assert!(output.contains("@@ -1 +1 @@"));
2096        assert!(output.contains("-old\n"));
2097        assert!(output.contains("+new\n\\ No newline at end of file\n"));
2098        assert!(!output.contains('\r'));
2099    }
2100
2101    #[test]
2102    fn character_chunks_coalesce_and_reconstruct_both_sides() {
2103        let chunks = compute_diff_chunks("abc", "axc");
2104        assert_eq!(chunks.len(), 4);
2105        let old = chunks
2106            .iter()
2107            .filter_map(|chunk| match chunk {
2108                Chunk::Equal(text) | Chunk::Delete(text) => Some(*text),
2109                Chunk::Insert(_) => None,
2110            })
2111            .collect::<String>();
2112        let new = chunks
2113            .iter()
2114            .filter_map(|chunk| match chunk {
2115                Chunk::Equal(text) | Chunk::Insert(text) => Some(*text),
2116                Chunk::Delete(_) => None,
2117            })
2118            .collect::<String>();
2119        assert_eq!(old, "abc");
2120        assert_eq!(new, "axc");
2121    }
2122
2123    #[test]
2124    fn disjoint_large_input_respects_small_timeout_and_remains_readable() {
2125        let old = (0..10_000).map(|index| format!("old-{index}\n")).collect::<String>();
2126        let new = (0..10_000).rev().map(|index| format!("new-{index}\n")).collect::<String>();
2127        let started = Instant::now();
2128        let document = DiffDocument::between(
2129            &old,
2130            &new,
2131            DiffOptions {
2132                timeout: Duration::from_millis(5),
2133                ..DiffOptions::default()
2134            },
2135        );
2136        assert!(started.elapsed() < Duration::from_secs(2));
2137        assert!(document.stats.additions > 0);
2138        assert!(document.stats.deletions > 0);
2139    }
2140
2141    #[cfg(feature = "ansi")]
2142    #[test]
2143    fn ansi_adapter_can_render_without_color() {
2144        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2145        let rows = document.layout(LayoutOptions::default());
2146        let rendered = render_ansi_rows(&rows, AnsiDiffPalette::default(), false);
2147        assert!(rendered.iter().any(|line| line.starts_with("- old")));
2148        assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2149        assert!(rendered.iter().all(|line| !line.contains('\u{1b}')));
2150    }
2151
2152    #[cfg(feature = "ratatui")]
2153    #[test]
2154    fn ratatui_adapter_preserves_semantic_markers() {
2155        let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2156        let rows = document.layout(LayoutOptions::default());
2157        let lines = to_ratatui_lines(&rows);
2158        let rendered = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
2159        assert!(rendered.iter().any(|line| line.starts_with("- old")));
2160        assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2161    }
2162}