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)]
7use similar::{ChangeTag, TextDiff};
10use std::collections::VecDeque;
11use std::fmt;
12use std::time::{Duration, Instant};
13use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Chunk<'a> {
18 Equal(&'a str),
20 Delete(&'a str),
22 Insert(&'a str),
24}
25
26#[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 #[default]
33 Myers,
34 Patience,
36 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#[derive(Debug, Clone)]
52pub struct DiffOptions<'a> {
53 pub context_lines: usize,
55 pub old_label: Option<&'a str>,
57 pub new_label: Option<&'a str>,
59 pub missing_newline_hint: bool,
61 pub algorithm: DiffAlgorithm,
63 pub timeout: Duration,
65 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#[derive(Debug, Clone, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct DiffHunk {
87 pub old_start: usize,
89 pub old_lines: usize,
91 pub new_start: usize,
93 pub new_lines: usize,
95 pub lines: Vec<DiffLine>,
97}
98
99#[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 Context,
106 Addition,
108 Deletion,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
115pub struct DiffLine {
116 pub kind: DiffLineKind,
118 pub old_line: Option<u32>,
120 pub new_line: Option<u32>,
122 pub text: String,
124}
125
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
129pub struct DiffStats {
130 pub additions: usize,
132 pub deletions: usize,
134 pub context: usize,
136 pub hunks: usize,
138 pub omitted_rows: usize,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
145pub struct DiffDocument {
146 pub hunks: Vec<DiffHunk>,
148 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 #[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 #[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 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 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 #[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#[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#[derive(Debug, Clone)]
476#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
477pub struct DiffBundle {
478 pub hunks: Vec<DiffHunk>,
480 pub formatted: String,
482 pub is_empty: bool,
484}
485
486pub 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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
669#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
670pub struct IntralineRange {
671 pub start: usize,
673 pub end: usize,
675}
676
677pub type WordChangedRanges = Vec<(usize, usize)>;
679
680#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
682pub struct DiffChangeCounts {
683 pub additions: usize,
685 pub deletions: usize,
687}
688
689impl DiffChangeCounts {
690 #[must_use]
692 pub const fn total(self) -> usize {
693 self.additions + self.deletions
694 }
695}
696
697#[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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
713pub enum DiffDisplayKind {
714 Metadata,
716 HunkHeader,
718 Context,
720 Addition,
722 Deletion,
724}
725
726impl DiffDisplayKind {
727 #[must_use]
729 pub const fn is_diff(self) -> bool {
730 matches!(self, Self::Context | Self::Addition | Self::Deletion)
731 }
732}
733
734#[derive(Clone, Debug, Eq, PartialEq)]
736pub struct DiffDisplayLine {
737 pub kind: DiffDisplayKind,
739 pub old_line: Option<u32>,
741 pub new_line: Option<u32>,
743 pub text: String,
745 pub changed: WordChangedRanges,
747}
748
749impl DiffDisplayLine {
750 #[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 #[must_use]
764 pub const fn is_diff(&self) -> bool {
765 self.kind.is_diff()
766 }
767
768 #[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#[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#[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 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#[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#[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
990pub const DIFF_MIN_SIDE_BY_SIDE_WIDTH: usize = 60;
995
996#[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
1008pub const DIFF_MIN_BODY_WIDTH_WITH_GUTTER: usize = 20;
1014
1015#[must_use]
1021pub const fn diff_gutter_width(line_number_width: usize) -> usize {
1022 line_number_width.saturating_add(4)
1023}
1024
1025#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
1058pub struct SideBySideRow {
1059 pub left: Option<DiffDisplayLine>,
1061 pub right: Option<DiffDisplayLine>,
1063}
1064
1065impl SideBySideRow {
1066 #[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#[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
1129pub 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 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#[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#[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 #[default]
1219 Unified,
1220 SideBySide,
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1226pub struct LayoutOptions {
1227 pub layout: DiffLayout,
1229 pub width: usize,
1231 pub max_rows: usize,
1233 pub wrap: bool,
1235 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1253pub enum DiffRowKind {
1254 Metadata,
1256 HunkHeader,
1258 Context,
1260 Addition,
1262 Deletion,
1264 Omission,
1266}
1267
1268#[derive(Debug, Clone, PartialEq, Eq)]
1270pub struct DiffSegment {
1271 pub text: String,
1273 pub emphasized: bool,
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq)]
1279pub struct DiffCell {
1280 pub old_line: Option<u32>,
1282 pub new_line: Option<u32>,
1284 pub marker: char,
1286 pub segments: Vec<DiffSegment>,
1288}
1289
1290#[derive(Debug, Clone, PartialEq, Eq)]
1292pub struct DiffRow {
1293 pub kind: DiffRowKind,
1295 pub marker: char,
1297 pub hunk_index: Option<usize>,
1299 pub continuation: bool,
1301 pub left: Option<DiffCell>,
1303 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#[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#[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 #[derive(Debug, Clone, Copy, Default)]
1698 pub struct AnsiDiffPalette {
1699 pub header: Style,
1701 pub context: Style,
1703 pub addition: Style,
1705 pub deletion: Style,
1707 pub emphasis: Style,
1709 }
1710
1711 #[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 #[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 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 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}