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