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