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 let deadline = Instant::now().checked_add(timeout);
1124 let mut index = 0usize;
1125 while index < lines.len() {
1126 if lines[index].kind != DiffDisplayKind::Deletion {
1127 index += 1;
1128 continue;
1129 }
1130 let delete_start = index;
1131 while index < lines.len() && lines[index].kind == DiffDisplayKind::Deletion {
1132 index += 1;
1133 }
1134 let insert_start = index;
1135 while index < lines.len() && lines[index].kind == DiffDisplayKind::Addition {
1136 index += 1;
1137 }
1138 let pair_count = (insert_start - delete_start).min(index - insert_start);
1139 for offset in 0..pair_count {
1140 if deadline.is_some_and(|limit| Instant::now() >= limit) {
1141 return;
1142 }
1143 let (old_ranges, new_ranges) =
1144 word_level_changed_ranges(&lines[delete_start + offset].text, &lines[insert_start + offset].text);
1145 lines[delete_start + offset].changed = old_ranges;
1146 lines[insert_start + offset].changed = new_ranges;
1147 }
1148 }
1149}
1150
1151#[must_use]
1153pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
1154 if old.is_empty() || new.is_empty() || old.len().saturating_add(new.len()) > 16_384 {
1155 return (Vec::new(), Vec::new());
1156 }
1157 let diff = TextDiff::configure()
1158 .algorithm(similar::Algorithm::Myers)
1159 .timeout(Duration::from_millis(10))
1160 .diff_unicode_words(old, new);
1161 if diff.ratio() < 0.35 {
1162 return (Vec::new(), Vec::new());
1163 }
1164 let mut old_offset = 0usize;
1165 let mut new_offset = 0usize;
1166 let mut old_ranges = Vec::new();
1167 let mut new_ranges = Vec::new();
1168 for change in diff.iter_all_changes() {
1169 let length = change.value().len();
1170 match change.tag() {
1171 ChangeTag::Equal => {
1172 old_offset += length;
1173 new_offset += length;
1174 }
1175 ChangeTag::Delete => {
1176 push_range(&mut old_ranges, old_offset, old_offset + length);
1177 old_offset += length;
1178 }
1179 ChangeTag::Insert => {
1180 push_range(&mut new_ranges, new_offset, new_offset + length);
1181 new_offset += length;
1182 }
1183 }
1184 }
1185 (old_ranges, new_ranges)
1186}
1187
1188fn push_range(ranges: &mut WordChangedRanges, start: usize, end: usize) {
1189 match ranges.last_mut() {
1190 Some((_, prior_end)) if *prior_end == start => *prior_end = end,
1191 _ => ranges.push((start, end)),
1192 }
1193}
1194
1195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1198#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
1199pub enum DiffLayout {
1200 #[default]
1202 Unified,
1203 SideBySide,
1205}
1206
1207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1209pub struct LayoutOptions {
1210 pub layout: DiffLayout,
1212 pub width: usize,
1214 pub max_rows: usize,
1216 pub wrap: bool,
1218 pub min_side_by_side_width: usize,
1220}
1221
1222impl Default for LayoutOptions {
1223 fn default() -> Self {
1224 Self {
1225 layout: DiffLayout::Unified,
1226 width: 80,
1227 max_rows: 2_000,
1228 wrap: true,
1229 min_side_by_side_width: DIFF_MIN_SIDE_BY_SIDE_WIDTH,
1230 }
1231 }
1232}
1233
1234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1236pub enum DiffRowKind {
1237 Metadata,
1239 HunkHeader,
1241 Context,
1243 Addition,
1245 Deletion,
1247 Omission,
1249}
1250
1251#[derive(Debug, Clone, PartialEq, Eq)]
1253pub struct DiffSegment {
1254 pub text: String,
1256 pub emphasized: bool,
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Eq)]
1262pub struct DiffCell {
1263 pub old_line: Option<u32>,
1265 pub new_line: Option<u32>,
1267 pub marker: char,
1269 pub segments: Vec<DiffSegment>,
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Eq)]
1275pub struct DiffRow {
1276 pub kind: DiffRowKind,
1278 pub marker: char,
1280 pub hunk_index: Option<usize>,
1282 pub continuation: bool,
1284 pub left: Option<DiffCell>,
1286 pub right: Option<DiffCell>,
1288}
1289
1290fn layout_document(document: &DiffDocument, options: LayoutOptions) -> Vec<DiffRow> {
1291 let display = display_lines_from_hunks_with_timeout(&document.hunks, document.inline_timeout);
1292 layout_display_lines(&display, options)
1293}
1294
1295#[must_use]
1300pub fn layout_display_lines(display: &[DiffDisplayLine], options: LayoutOptions) -> Vec<DiffRow> {
1301 let use_side_by_side = options.layout == DiffLayout::SideBySide && options.width >= options.min_side_by_side_width;
1302 let mut rows = RowCollector::new(options.max_rows);
1303 if use_side_by_side {
1304 layout_side_by_side(display, options, &mut rows);
1305 } else {
1306 layout_unified(display, options, &mut rows);
1307 }
1308 rows.finish()
1309}
1310
1311#[must_use]
1319pub fn bounded_display_lines(lines: &[DiffDisplayLine], max_rows: usize) -> Vec<DiffDisplayLine> {
1320 if max_rows == 0 {
1321 return Vec::new();
1322 }
1323 if lines.len() <= max_rows {
1324 return lines.to_vec();
1325 }
1326
1327 let retained = max_rows.saturating_sub(1);
1328 let head_count = retained.saturating_add(1) / 2;
1329 let tail_count = retained / 2;
1330 let omitted = lines.len().saturating_sub(head_count + tail_count);
1331
1332 let mut bounded = Vec::with_capacity(max_rows);
1333 bounded.extend_from_slice(&lines[..head_count]);
1334 bounded.push(DiffDisplayLine::body(
1335 DiffDisplayKind::Metadata,
1336 None,
1337 None,
1338 format!("... {omitted} lines omitted ..."),
1339 ));
1340 if tail_count > 0 {
1341 bounded.extend_from_slice(&lines[lines.len() - tail_count..]);
1342 }
1343 bounded
1344}
1345
1346fn layout_unified(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1347 let gutter_width = diff_display_line_number_width(lines).saturating_add(4);
1348 let content_width = options.width.saturating_sub(gutter_width).max(1);
1349 let mut hunk_index = None;
1350 for line in lines {
1351 if line.kind == DiffDisplayKind::Metadata {
1352 rows.push(metadata_row(&line.text, hunk_index));
1353 continue;
1354 }
1355 if line.kind == DiffDisplayKind::HunkHeader {
1356 hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1357 rows.push(header_row(&line.text, hunk_index));
1358 continue;
1359 }
1360 let marker = marker_for_kind(line.kind);
1361 for (continuation, segments) in wrap_segments(&line.text, &line.changed, content_width, options.wrap)
1362 .into_iter()
1363 .enumerate()
1364 {
1365 rows.push(DiffRow {
1366 kind: row_kind(line.kind),
1367 marker,
1368 hunk_index,
1369 continuation: continuation > 0,
1370 left: Some(DiffCell {
1371 old_line: (continuation == 0).then_some(line.old_line).flatten(),
1372 new_line: (continuation == 0).then_some(line.new_line).flatten(),
1373 marker,
1374 segments,
1375 }),
1376 right: None,
1377 });
1378 }
1379 }
1380}
1381
1382fn layout_side_by_side(lines: &[DiffDisplayLine], options: LayoutOptions, rows: &mut RowCollector) {
1383 let pane_width = options.width.saturating_sub(1) / 2;
1384 let content_width = pane_width.saturating_sub(6).max(1);
1385 let mut hunk_index = None;
1386 for_each_side_by_side_pair(lines, |left, right| {
1387 let is_full_width = right.is_none()
1388 && left.is_some_and(|line| matches!(line.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata));
1389 if is_full_width {
1390 let text = left.map_or("", |line| line.text.as_str());
1391 if left.is_some_and(|line| line.kind == DiffDisplayKind::HunkHeader) {
1392 hunk_index = Some(hunk_index.map_or(0, |index| index + 1));
1393 rows.push(header_row(text, hunk_index));
1394 } else {
1395 rows.push(metadata_row(text, hunk_index));
1396 }
1397 return;
1398 }
1399 let left_parts = left.map_or_else(
1400 || vec![Vec::new()],
1401 |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1402 );
1403 let right_parts = right.map_or_else(
1404 || vec![Vec::new()],
1405 |line| wrap_segments(&line.text, &line.changed, content_width, options.wrap),
1406 );
1407 let count = left_parts.len().max(right_parts.len());
1408 for offset in 0..count {
1409 let left_cell = left.and_then(|line| {
1410 left_parts.get(offset).map(|segments| DiffCell {
1411 old_line: (offset == 0).then_some(line.old_line).flatten(),
1412 new_line: (offset == 0).then_some(line.new_line).flatten(),
1413 marker: marker_for_kind(line.kind),
1414 segments: segments.clone(),
1415 })
1416 });
1417 let right_cell = right.and_then(|line| {
1418 right_parts.get(offset).map(|segments| DiffCell {
1419 old_line: (offset == 0).then_some(line.old_line).flatten(),
1420 new_line: (offset == 0).then_some(line.new_line).flatten(),
1421 marker: marker_for_kind(line.kind),
1422 segments: segments.clone(),
1423 })
1424 });
1425 let kind = right.or(left).map_or(DiffRowKind::Context, |line| row_kind(line.kind));
1426 rows.push(DiffRow {
1427 kind,
1428 marker: right_cell.as_ref().or(left_cell.as_ref()).map_or(' ', |cell| cell.marker),
1429 hunk_index,
1430 continuation: offset > 0,
1431 left: left_cell,
1432 right: right_cell,
1433 });
1434 }
1435 });
1436}
1437
1438fn header_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1439 DiffRow {
1440 kind: DiffRowKind::HunkHeader,
1441 marker: '@',
1442 hunk_index,
1443 continuation: false,
1444 left: Some(DiffCell {
1445 old_line: None,
1446 new_line: None,
1447 marker: '@',
1448 segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1449 }),
1450 right: None,
1451 }
1452}
1453
1454fn metadata_row(text: &str, hunk_index: Option<usize>) -> DiffRow {
1455 DiffRow {
1456 kind: DiffRowKind::Metadata,
1457 marker: ' ',
1458 hunk_index,
1459 continuation: false,
1460 left: Some(DiffCell {
1461 old_line: None,
1462 new_line: None,
1463 marker: ' ',
1464 segments: vec![DiffSegment { text: text.to_owned(), emphasized: false }],
1465 }),
1466 right: None,
1467 }
1468}
1469
1470struct RowCollector {
1471 head: Vec<DiffRow>,
1472 tail: VecDeque<DiffRow>,
1473 max_rows: usize,
1474 total_rows: usize,
1475 overflowed: bool,
1476}
1477
1478impl RowCollector {
1479 fn new(max_rows: usize) -> Self {
1480 Self {
1481 head: Vec::new(),
1482 tail: VecDeque::new(),
1483 max_rows,
1484 total_rows: 0,
1485 overflowed: false,
1486 }
1487 }
1488
1489 fn push(&mut self, row: DiffRow) {
1490 self.total_rows = self.total_rows.saturating_add(1);
1491 if self.max_rows == 0 {
1492 return;
1493 }
1494 if !self.overflowed {
1495 self.head.push(row);
1496 if self.head.len() <= self.max_rows {
1497 return;
1498 }
1499
1500 let retained = self.max_rows.saturating_sub(1);
1501 let head_count = retained.saturating_add(1) / 2;
1502 let tail_count = retained / 2;
1503 if tail_count > 0 {
1504 let tail_start = self.head.len() - tail_count;
1505 self.tail = self.head.split_off(tail_start).into();
1506 }
1507 self.head.truncate(head_count);
1508 self.overflowed = true;
1509 return;
1510 }
1511
1512 let tail_count = self.max_rows.saturating_sub(1) / 2;
1513 if tail_count > 0 {
1514 if self.tail.len() == tail_count {
1515 let _ = self.tail.pop_front();
1516 }
1517 self.tail.push_back(row);
1518 }
1519 }
1520
1521 fn finish(mut self) -> Vec<DiffRow> {
1522 if !self.overflowed {
1523 return self.head;
1524 }
1525 let omitted = self.total_rows.saturating_sub(self.head.len()).saturating_sub(self.tail.len());
1526 self.head.push(omission_row(omitted));
1527 self.head.extend(self.tail);
1528 self.head
1529 }
1530}
1531
1532fn omission_row(omitted: usize) -> DiffRow {
1533 DiffRow {
1534 kind: DiffRowKind::Omission,
1535 marker: '…',
1536 hunk_index: None,
1537 continuation: false,
1538 left: Some(DiffCell {
1539 old_line: None,
1540 new_line: None,
1541 marker: '…',
1542 segments: vec![DiffSegment {
1543 text: format!("{omitted} rows omitted"),
1544 emphasized: false,
1545 }],
1546 }),
1547 right: None,
1548 }
1549}
1550
1551fn wrap_segments(text: &str, changed: &[(usize, usize)], width: usize, wrap: bool) -> Vec<Vec<DiffSegment>> {
1552 if !wrap || UnicodeWidthStr::width(text) <= width {
1553 return vec![segment_slice(text, changed, 0, text.len())];
1554 }
1555 let mut rows = Vec::new();
1556 let mut byte_start = 0usize;
1557 let mut display_width = 0usize;
1558 for (byte, character) in text.char_indices() {
1559 let char_width = UnicodeWidthChar::width(character).unwrap_or_default();
1560 if display_width > 0 && display_width.saturating_add(char_width) > width {
1561 rows.push(segment_slice(text, changed, byte_start, byte));
1562 byte_start = byte;
1563 display_width = 0;
1564 }
1565 display_width = display_width.saturating_add(char_width);
1566 }
1567 rows.push(segment_slice(text, changed, byte_start, text.len()));
1568 rows
1569}
1570
1571fn segment_slice(text: &str, changed: &[(usize, usize)], start: usize, end: usize) -> Vec<DiffSegment> {
1572 if start == end {
1573 return vec![DiffSegment { text: String::new(), emphasized: false }];
1574 }
1575 let mut boundaries = vec![start, end];
1576 for &(range_start, range_end) in changed {
1577 if range_start < end && range_end > start {
1578 let bounded_start = range_start.max(start).min(end);
1579 let bounded_end = range_end.max(start).min(end);
1580 if text.is_char_boundary(bounded_start) && text.is_char_boundary(bounded_end) {
1581 boundaries.push(bounded_start);
1582 boundaries.push(bounded_end);
1583 }
1584 }
1585 }
1586 boundaries.sort_unstable();
1587 boundaries.dedup();
1588 boundaries
1589 .windows(2)
1590 .filter_map(|pair| {
1591 let segment_start = pair[0];
1592 let segment_end = pair[1];
1593 (segment_start < segment_end).then(|| DiffSegment {
1594 text: text[segment_start..segment_end].to_owned(),
1595 emphasized: changed
1596 .iter()
1597 .any(|&(range_start, range_end)| segment_start >= range_start && segment_end <= range_end),
1598 })
1599 })
1600 .collect()
1601}
1602
1603fn marker_for_kind(kind: DiffDisplayKind) -> char {
1604 match kind {
1605 DiffDisplayKind::Addition => '+',
1606 DiffDisplayKind::Deletion => '-',
1607 DiffDisplayKind::HunkHeader => '@',
1608 DiffDisplayKind::Metadata | DiffDisplayKind::Context => ' ',
1609 }
1610}
1611
1612fn row_kind(kind: DiffDisplayKind) -> DiffRowKind {
1613 match kind {
1614 DiffDisplayKind::Addition => DiffRowKind::Addition,
1615 DiffDisplayKind::Deletion => DiffRowKind::Deletion,
1616 DiffDisplayKind::HunkHeader => DiffRowKind::HunkHeader,
1617 DiffDisplayKind::Metadata => DiffRowKind::Metadata,
1618 DiffDisplayKind::Context => DiffRowKind::Context,
1619 }
1620}
1621
1622fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> {
1623 let (old, _, new, _) = parse_hunk_range(line)?;
1624 Some((old, new))
1625}
1626
1627fn parse_hunk_range(line: &str) -> Option<(u32, usize, u32, usize)> {
1628 let body = line.strip_prefix("@@ ")?.split(" @@").next()?;
1629 let mut parts = body.split_whitespace();
1630 let (old_start, old_count) = parse_range(parts.next()?, '-')?;
1631 let (new_start, new_count) = parse_range(parts.next()?, '+')?;
1632 Some((old_start, old_count, new_start, new_count))
1633}
1634
1635fn parse_range(value: &str, marker: char) -> Option<(u32, usize)> {
1636 let range = value.strip_prefix(marker)?;
1637 let mut parts = range.splitn(2, ',');
1638 let start = parts.next()?.parse().ok()?;
1639 let count = parts.next().map_or(Some(1), |count| count.parse().ok())?;
1640 Some((start, count))
1641}
1642
1643fn parse_omitted_line_count(line: &str) -> Option<usize> {
1644 let line = line.trim();
1645 line.strip_prefix("... ")?.strip_suffix(" lines omitted ...")?.parse().ok()
1646}
1647
1648fn is_unified_metadata_line(line: &str) -> bool {
1649 line.starts_with("--- ")
1650 || line.starts_with("+++ ")
1651 || line.starts_with("new file mode ")
1652 || line.starts_with("deleted file mode ")
1653 || line.starts_with("rename from ")
1654 || line.starts_with("rename to ")
1655 || line.starts_with("copy from ")
1656 || line.starts_with("copy to ")
1657 || line.starts_with("similarity index ")
1658 || line.starts_with("dissimilarity index ")
1659 || line.starts_with("old mode ")
1660 || line.starts_with("new mode ")
1661 || line.starts_with("Binary files ")
1662 || line == "GIT binary patch"
1663 || line.starts_with("literal ")
1664 || line.starts_with("delta ")
1665}
1666
1667fn trim_line_ending(text: &str) -> &str {
1668 text.strip_suffix("\r\n")
1669 .or_else(|| text.strip_suffix('\n'))
1670 .or_else(|| text.strip_suffix('\r'))
1671 .unwrap_or(text)
1672}
1673
1674#[cfg(feature = "ansi")]
1675mod ansi_adapter {
1676 use super::DiffRow;
1677 use anstyle::{Reset, Style};
1678
1679 #[derive(Debug, Clone, Copy, Default)]
1681 pub struct AnsiDiffPalette {
1682 pub header: Style,
1684 pub context: Style,
1686 pub addition: Style,
1688 pub deletion: Style,
1690 pub emphasis: Style,
1692 }
1693
1694 #[must_use]
1696 pub fn render_ansi_rows(rows: &[DiffRow], palette: AnsiDiffPalette, color: bool) -> Vec<String> {
1697 rows.iter()
1698 .map(|row| {
1699 let mut output = String::new();
1700 output.push(row.marker);
1701 output.push(' ');
1702 if let Some(cell) = &row.left {
1703 render_cell(&mut output, cell, palette, color);
1704 }
1705 if let Some(cell) = &row.right {
1706 output.push_str(" │ ");
1707 render_cell(&mut output, cell, palette, color);
1708 }
1709 output
1710 })
1711 .collect()
1712 }
1713
1714 fn render_cell(output: &mut String, cell: &super::DiffCell, palette: AnsiDiffPalette, color: bool) {
1715 let style = match cell.marker {
1716 '+' => palette.addition,
1717 '-' => palette.deletion,
1718 '@' | '…' => palette.header,
1719 _ => palette.context,
1720 };
1721 for segment in &cell.segments {
1722 if color {
1723 let selected = if segment.emphasized { palette.emphasis } else { style };
1724 output.push_str(&selected.render().to_string());
1725 output.push_str(&segment.text);
1726 output.push_str(&Reset.render().to_string());
1727 } else {
1728 output.push_str(&segment.text);
1729 }
1730 }
1731 }
1732}
1733
1734#[cfg(feature = "ansi")]
1735pub use ansi_adapter::{AnsiDiffPalette, render_ansi_rows};
1736
1737#[cfg(feature = "ratatui")]
1738mod ratatui_adapter {
1739 use super::DiffRow;
1740 use ratatui::text::{Line, Span};
1741
1742 #[must_use]
1744 pub fn to_ratatui_lines(rows: &[DiffRow]) -> Vec<Line<'static>> {
1745 rows.iter()
1746 .map(|row| {
1747 let mut spans = vec![Span::raw(format!("{} ", row.marker))];
1748 if let Some(cell) = &row.left {
1749 spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1750 }
1751 if let Some(cell) = &row.right {
1752 spans.push(Span::raw(" │ "));
1753 spans.extend(cell.segments.iter().map(|segment| Span::raw(segment.text.clone())));
1754 }
1755 Line::from(spans)
1756 })
1757 .collect()
1758 }
1759}
1760
1761#[cfg(feature = "ratatui")]
1762pub use ratatui_adapter::to_ratatui_lines;
1763
1764#[cfg(test)]
1765mod tests {
1766 use super::*;
1767
1768 #[test]
1769 fn asymmetric_replacement_has_correct_line_numbers() {
1770 let document = DiffDocument::between("a\nb\nc\n", "a\nx\ny\nc\n", DiffOptions::default());
1771 let lines: Vec<_> = document.hunks.iter().flat_map(|hunk| &hunk.lines).collect();
1772 assert!(lines.iter().any(|line| {
1773 line.kind == DiffLineKind::Deletion && line.old_line == Some(2) && line.new_line.is_none()
1774 }));
1775 assert!(lines.iter().any(|line| {
1776 line.kind == DiffLineKind::Addition && line.old_line.is_none() && line.new_line == Some(3)
1777 }));
1778 assert_eq!(document.stats.additions, 2);
1779 assert_eq!(document.stats.deletions, 1);
1780 }
1781
1782 #[test]
1783 fn repeated_lines_keep_the_changed_anchor() {
1784 let document = DiffDocument::between("same\nold\nsame\n", "same\nnew\nsame\n", DiffOptions::default());
1785 assert_eq!(document.stats.additions, 1);
1786 assert_eq!(document.stats.deletions, 1);
1787 assert_eq!(document.hunks[0].old_start, 1);
1788 }
1789
1790 #[test]
1791 fn zero_context_hunks_preserve_empty_side_anchors() {
1792 let options = DiffOptions { context_lines: 0, ..DiffOptions::default() };
1793 let insertion = DiffDocument::between("a\nc\n", "a\nb\nc\n", options.clone());
1794 assert_eq!(insertion.hunks[0].old_start, 2);
1795 assert_eq!(insertion.hunks[0].new_start, 2);
1796
1797 let deletion = DiffDocument::between("a\nb\nc\n", "a\nc\n", options);
1798 assert_eq!(deletion.hunks[0].old_start, 2);
1799 assert_eq!(deletion.hunks[0].new_start, 2);
1800 }
1801
1802 #[test]
1803 fn preserves_crlf_cr_and_missing_final_newline() {
1804 let crlf = DiffDocument::between("a\r\nb\r\n", "a\r\nx\r\n", DiffOptions::default());
1805 assert!(crlf.hunks[0].lines.iter().any(|line| line.text == "a\r\n"));
1806 let cr = DiffDocument::between("a\rb\r", "a\rx\r", DiffOptions::default());
1807 assert!(cr.hunks[0].lines.iter().any(|line| line.text == "a\r"));
1808 let eof = DiffDocument::between("a\n", "a", DiffOptions::default());
1809 assert_eq!(eof.stats.additions, 1);
1810 assert_eq!(eof.stats.deletions, 1);
1811 }
1812
1813 #[test]
1814 fn parser_rejects_body_before_hunk() {
1815 let error = DiffDocument::from_unified("-old\n+new\n").expect_err("body must need a hunk");
1816 assert_eq!(error.to_string(), "diff body appears before a hunk header");
1817 }
1818
1819 #[test]
1820 fn parser_accepts_standard_git_metadata_before_hunk() {
1821 let document = DiffDocument::from_unified(
1822 "diff --git a/file.txt b/file.txt\nindex 1111111..2222222 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n",
1823 )
1824 .expect("standard git metadata is not a body");
1825 assert_eq!(document.stats.deletions, 1);
1826 assert_eq!(document.stats.additions, 1);
1827 }
1828
1829 #[test]
1830 fn parser_accepts_metadata_between_multiple_file_hunks() {
1831 let document = DiffDocument::from_unified(
1832 "diff --git a/one.txt b/one.txt\nnew file mode 100644\n--- /dev/null\n+++ b/one.txt\n@@ -0,0 +1 @@\n+one\ndiff --git a/two.txt b/two.txt\nnew file mode 100644\n--- /dev/null\n+++ b/two.txt\n@@ -0,0 +1 @@\n+two\n",
1833 )
1834 .expect("metadata between files is not a hunk body");
1835 assert_eq!(document.hunks.len(), 2);
1836 assert_eq!(document.stats.additions, 2);
1837 }
1838
1839 #[test]
1840 fn parsed_body_lines_preserve_original_terminators() {
1841 let document = DiffDocument::from_unified("@@ -1 +1 @@\r\n-old\r\n+new\r\n").expect("valid CRLF diff");
1842 assert_eq!(document.hunks[0].lines[0].text, "old\r\n");
1843 assert_eq!(document.hunks[0].lines[1].text, "new\r\n");
1844
1845 let document = DiffDocument::from_unified("@@ -1 +1 @@\r-old\r+new\r").expect("valid CR diff");
1846 assert_eq!(document.hunks[0].lines[0].text, "old\r");
1847 assert_eq!(document.hunks[0].lines[1].text, "new\r");
1848 }
1849
1850 #[test]
1851 fn parser_rejects_incomplete_hunk_body() {
1852 let error = DiffDocument::from_unified("@@ -1,2 +1,2 @@\n-old\n+new\n")
1853 .expect_err("hunk body must satisfy the declared ranges");
1854 assert_eq!(error.to_string(), "hunk line counts do not match header");
1855 }
1856
1857 #[test]
1858 fn parser_rejects_unknown_backslash_metadata_inside_hunk() {
1859 let error = DiffDocument::from_unified("@@ -1 +1 @@\n-old\n\\ unexpected marker\n+new\n")
1860 .expect_err("only Git's no-newline marker is valid inside a hunk");
1861 assert_eq!(error.to_string(), "invalid unified diff body line");
1862 }
1863
1864 #[test]
1865 fn parser_tracks_asymmetric_hunk_numbers() {
1866 let document = DiffDocument::from_unified("@@ -4,1 +8,2 @@\n-old\n+new\n+extra\n").expect("valid diff");
1867 assert_eq!(document.hunks[0].old_start, 4);
1868 assert_eq!(document.hunks[0].new_start, 8);
1869 assert_eq!(document.hunks[0].lines[2].new_line, Some(9));
1870 }
1871
1872 #[test]
1873 fn parser_keeps_context_lines_that_look_like_omission_markers() {
1874 let document =
1875 DiffDocument::from_unified("@@ -1 +1 @@\n ... 4 lines omitted ...\n").expect("valid context line");
1876 assert_eq!(document.hunks[0].lines[0].kind, DiffLineKind::Context);
1877 assert_eq!(document.stats.omitted_rows, 0);
1878 }
1879
1880 #[test]
1881 fn parsed_omission_advances_both_line_counters() {
1882 let lines = display_lines_from_unified_diff("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n");
1883 let deletion = lines
1884 .iter()
1885 .find(|line| line.kind == DiffDisplayKind::Deletion)
1886 .expect("deletion after omission");
1887 let addition = lines
1888 .iter()
1889 .find(|line| line.kind == DiffDisplayKind::Addition)
1890 .expect("addition after omission");
1891 assert_eq!(deletion.old_line, Some(15));
1892 assert_eq!(addition.new_line, Some(25));
1893 }
1894
1895 #[test]
1896 fn truncated_hunk_keeps_tail_additions_as_diff_lines() {
1897 let mut input = String::from("@@ -1,201 +1,201 @@\n");
1898 for index in 0..95 {
1899 input.push_str(&format!("-old-{index}\n"));
1900 }
1901 input.push_str("... 243 lines omitted ...\n");
1902 for index in 137..201 {
1903 input.push_str(&format!("+new-{index}\n"));
1904 }
1905
1906 let lines = display_lines_from_unified_diff(&input);
1907 let additions: Vec<_> = lines.iter().filter(|line| line.kind == DiffDisplayKind::Addition).collect();
1908 assert_eq!(additions.len(), 64);
1909 assert_eq!(additions.last().map(|line| line.text.as_str()), Some("new-200"));
1910 }
1911
1912 #[test]
1913 fn truncated_hunk_numbers_tail_from_declared_end() {
1914 let mut input = String::from("@@ -1,201 +1,201 @@\n");
1915 for index in 0..93 {
1916 input.push_str(&format!("-old-{index}\n"));
1917 }
1918 input.push_str("... 277 lines omitted ...\n");
1919 for index in 169..201 {
1920 input.push_str(&format!("+new-{index}\n"));
1921 }
1922
1923 let lines = display_lines_from_unified_diff(&input);
1924 let additions: Vec<_> = lines.iter().filter(|line| line.kind == DiffDisplayKind::Addition).collect();
1925 assert_eq!(additions.first().and_then(|line| line.new_line), Some(170));
1926 assert_eq!(additions.last().and_then(|line| line.new_line), Some(201));
1927 }
1928
1929 #[test]
1930 fn parsed_metadata_stays_outside_hunk_semantics() {
1931 let display = display_lines_from_unified_diff("--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new\n");
1932 let rows = layout_display_lines(&display, LayoutOptions::default());
1933 assert_eq!(rows[0].kind, DiffRowKind::Metadata);
1934 assert_eq!(rows[1].kind, DiffRowKind::Metadata);
1935 assert_eq!(rows[2].kind, DiffRowKind::HunkHeader);
1936 assert_eq!(rows[2].hunk_index, Some(0));
1937 }
1938
1939 #[test]
1940 fn unified_parser_accepts_omission_and_advances_numbers() {
1941 let document = DiffDocument::from_unified("@@ -10,6 +20,6 @@\n same\n... 4 lines omitted ...\n-old\n+new\n")
1942 .expect("omission marker is valid bounded preview metadata");
1943 assert_eq!(document.hunks[0].lines[1].old_line, Some(15));
1944 assert_eq!(document.hunks[0].lines[2].new_line, Some(25));
1945 assert_eq!(document.hunks[0].old_lines, 6);
1946 assert_eq!(document.hunks[0].new_lines, 6);
1947 assert_eq!(document.stats.omitted_rows, 4);
1948 }
1949
1950 #[test]
1951 fn unified_parser_accepts_asymmetric_bounded_omission() {
1952 let document =
1953 DiffDocument::from_unified("@@ -1,4 +1,8 @@\n-old-1\n... 4 lines omitted ...\n+new-6\n+new-7\n+new-8\n")
1954 .expect("bounded omission may hide different old/new line counts");
1955 assert_eq!(document.stats.omitted_rows, 4);
1956 assert_eq!(document.hunks[0].old_lines, 4);
1957 assert_eq!(document.hunks[0].new_lines, 6);
1958 }
1959
1960 #[test]
1961 fn unified_parser_numbers_one_sided_omitted_tails_from_hunk_end() {
1962 let added = DiffDocument::from_unified("@@ -0,0 +1,5 @@\n+one\n... 3 lines omitted ...\n+five\n")
1963 .expect("bounded addition is valid");
1964 assert_eq!(added.hunks[0].lines[1].new_line, Some(5));
1965
1966 let deleted = DiffDocument::from_unified("@@ -1,5 +0,0 @@\n-one\n... 3 lines omitted ...\n-five\n")
1967 .expect("bounded deletion is valid");
1968 assert_eq!(deleted.hunks[0].lines[1].old_line, Some(5));
1969 }
1970
1971 #[test]
1972 fn intraline_ranges_are_utf8_boundaries() {
1973 let (old, new) = word_level_changed_ranges("café rouge", "café bleu");
1974 for (start, end) in old {
1975 assert!("café rouge".is_char_boundary(start));
1976 assert!("café rouge".is_char_boundary(end));
1977 }
1978 for (start, end) in new {
1979 assert!("café bleu".is_char_boundary(start));
1980 assert!("café bleu".is_char_boundary(end));
1981 }
1982 }
1983
1984 #[test]
1985 fn unicode_width_wrap_keeps_numbers_only_on_first_row() {
1986 let document = DiffDocument::between("", "界界界a\n", DiffOptions::default());
1987 let rows = document.layout(LayoutOptions { width: 12, ..LayoutOptions::default() });
1988 let additions: Vec<_> = rows.iter().filter(|row| row.kind == DiffRowKind::Addition).collect();
1989 assert!(additions.len() >= 2);
1990 assert_eq!(additions[0].left.as_ref().and_then(|cell| cell.new_line), Some(1));
1991 assert!(additions[1].continuation);
1992 assert_eq!(additions[1].left.as_ref().and_then(|cell| cell.new_line), None);
1993 assert_eq!(additions[1].marker, '+');
1994 }
1995
1996 #[test]
1997 fn layout_ignores_invalid_intraline_boundaries() {
1998 let display = [DiffDisplayLine {
1999 kind: DiffDisplayKind::Addition,
2000 old_line: None,
2001 new_line: Some(1),
2002 text: "café".to_owned(),
2003 changed: vec![(2, 4)],
2004 }];
2005 let rows = layout_display_lines(&display, LayoutOptions { width: 20, ..LayoutOptions::default() });
2006 assert_eq!(rows.len(), 1);
2007 assert_eq!(rows[0].left.as_ref().expect("cell").segments[0].text, "café");
2008 }
2009
2010 #[test]
2011 fn narrow_side_by_side_falls_back_to_unified() {
2012 let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2013 let rows = document.layout(LayoutOptions {
2014 layout: DiffLayout::SideBySide,
2015 width: 40,
2016 ..LayoutOptions::default()
2017 });
2018 assert!(
2019 rows.iter()
2020 .filter(|row| row.kind != DiffRowKind::HunkHeader)
2021 .all(|row| row.right.is_none())
2022 );
2023 }
2024
2025 #[test]
2026 fn responsive_gutter_policy_preserves_source_room_at_the_boundary() {
2027 assert_eq!(diff_gutter_width(5), 9);
2028 assert!(diff_gutter_fits(29, 5));
2029 assert!(!diff_gutter_fits(28, 5));
2030 assert_eq!(diff_layout_width(29, 5, true), 29);
2031 assert_eq!(diff_layout_width(28, 5, false), 37);
2032 }
2033
2034 #[test]
2035 fn side_by_side_policy_has_a_stable_resize_boundary() {
2036 assert!(!diff_side_by_side_fits(Some(59)));
2037 assert!(diff_side_by_side_fits(Some(DIFF_MIN_SIDE_BY_SIDE_WIDTH)));
2038 assert!(diff_side_by_side_fits(None));
2039 }
2040
2041 #[test]
2042 fn side_by_side_wrapping_leaves_shorter_side_empty() {
2043 let document =
2044 DiffDocument::between("short\n", "this is a much longer replacement line\n", DiffOptions::default());
2045 let rows = document.layout(LayoutOptions {
2046 layout: DiffLayout::SideBySide,
2047 width: 80,
2048 ..LayoutOptions::default()
2049 });
2050 let continuation = rows.iter().find(|row| row.continuation).expect("long replacement should wrap");
2051 assert!(continuation.left.is_none());
2052 assert!(continuation.right.is_some());
2053 }
2054
2055 #[test]
2056 fn bounded_rows_keep_head_tail_and_exact_omission() {
2057 let old = (0..20).map(|index| format!("old-{index}\n")).collect::<String>();
2058 let new = (0..20).map(|index| format!("new-{index}\n")).collect::<String>();
2059 let rows = DiffDocument::between(&old, &new, DiffOptions::default())
2060 .layout(LayoutOptions { max_rows: 5, ..LayoutOptions::default() });
2061 assert_eq!(rows.len(), 5);
2062 let omission = rows.iter().find(|row| row.kind == DiffRowKind::Omission).expect("omission row");
2063 let text = &omission.left.as_ref().expect("cell").segments[0].text;
2064 assert!(text.ends_with(" rows omitted"));
2065 assert!(rows.last().and_then(|row| row.left.as_ref()).is_some());
2066 }
2067
2068 #[test]
2069 fn bounded_display_lines_keep_asymmetric_head_and_tail() {
2070 let lines =
2071 display_lines_from_unified_diff("@@ -1,4 +1,4 @@\n-old-head\n+new-head\n context\n-old-tail\n+new-tail\n");
2072 let bounded = bounded_display_lines(&lines, 4);
2073
2074 assert_eq!(bounded.len(), 4);
2075 assert_eq!(bounded[0].text, "@@ -1 +1 @@");
2076 assert_eq!(bounded[1].text, "old-head");
2077 assert_eq!(bounded[2].kind, DiffDisplayKind::Metadata);
2078 assert!(bounded[2].text.contains("lines omitted"));
2079 assert_eq!(bounded[3].text, "new-tail");
2080 }
2081
2082 #[test]
2083 fn plain_unified_formatter_preserves_labels_and_newline_hints() {
2084 let output = format_unified_diff(
2085 "old\n",
2086 "new",
2087 DiffOptions {
2088 old_label: Some("a/file.txt"),
2089 new_label: Some("b/file.txt"),
2090 ..DiffOptions::default()
2091 },
2092 );
2093
2094 assert!(output.starts_with("--- a/file.txt\n+++ b/file.txt\n@@"));
2095 assert!(output.contains("@@ -1 +1 @@"));
2096 assert!(output.contains("-old\n"));
2097 assert!(output.contains("+new\n\\ No newline at end of file\n"));
2098 assert!(!output.contains('\r'));
2099 }
2100
2101 #[test]
2102 fn character_chunks_coalesce_and_reconstruct_both_sides() {
2103 let chunks = compute_diff_chunks("abc", "axc");
2104 assert_eq!(chunks.len(), 4);
2105 let old = chunks
2106 .iter()
2107 .filter_map(|chunk| match chunk {
2108 Chunk::Equal(text) | Chunk::Delete(text) => Some(*text),
2109 Chunk::Insert(_) => None,
2110 })
2111 .collect::<String>();
2112 let new = chunks
2113 .iter()
2114 .filter_map(|chunk| match chunk {
2115 Chunk::Equal(text) | Chunk::Insert(text) => Some(*text),
2116 Chunk::Delete(_) => None,
2117 })
2118 .collect::<String>();
2119 assert_eq!(old, "abc");
2120 assert_eq!(new, "axc");
2121 }
2122
2123 #[test]
2124 fn disjoint_large_input_respects_small_timeout_and_remains_readable() {
2125 let old = (0..10_000).map(|index| format!("old-{index}\n")).collect::<String>();
2126 let new = (0..10_000).rev().map(|index| format!("new-{index}\n")).collect::<String>();
2127 let started = Instant::now();
2128 let document = DiffDocument::between(
2129 &old,
2130 &new,
2131 DiffOptions {
2132 timeout: Duration::from_millis(5),
2133 ..DiffOptions::default()
2134 },
2135 );
2136 assert!(started.elapsed() < Duration::from_secs(2));
2137 assert!(document.stats.additions > 0);
2138 assert!(document.stats.deletions > 0);
2139 }
2140
2141 #[cfg(feature = "ansi")]
2142 #[test]
2143 fn ansi_adapter_can_render_without_color() {
2144 let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2145 let rows = document.layout(LayoutOptions::default());
2146 let rendered = render_ansi_rows(&rows, AnsiDiffPalette::default(), false);
2147 assert!(rendered.iter().any(|line| line.starts_with("- old")));
2148 assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2149 assert!(rendered.iter().all(|line| !line.contains('\u{1b}')));
2150 }
2151
2152 #[cfg(feature = "ratatui")]
2153 #[test]
2154 fn ratatui_adapter_preserves_semantic_markers() {
2155 let document = DiffDocument::between("old\n", "new\n", DiffOptions::default());
2156 let rows = document.layout(LayoutOptions::default());
2157 let lines = to_ratatui_lines(&rows);
2158 let rendered = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
2159 assert!(rendered.iter().any(|line| line.starts_with("- old")));
2160 assert!(rendered.iter().any(|line| line.starts_with("+ new")));
2161 }
2162}