1#![expect(
2 clippy::string_slice,
3 clippy::cast_possible_truncation,
4 clippy::indexing_slicing,
5 reason = "Word-level LCS walks atom indices by design; offsets are bounded to the source line."
6)]
7
8use crate::diff::{DiffHunk, DiffLineKind};
15use crate::diff_paths::{
16 format_start_only_hunk_header, is_diff_addition_line, is_diff_deletion_line, parse_hunk_starts,
17};
18
19pub type WordChangedRanges = Vec<(usize, usize)>;
21
22const MAX_WORD_ATOMS: usize = 512;
24
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub struct DiffChangeCounts {
27 pub additions: usize,
28 pub deletions: usize,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum DiffDisplayKind {
33 Metadata,
34 HunkHeader,
35 Context,
36 Addition,
37 Deletion,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct DiffDisplayLine {
42 pub kind: DiffDisplayKind,
43 pub old_line: Option<u32>,
45 pub new_line: Option<u32>,
47 pub text: String,
48 pub changed: WordChangedRanges,
51}
52
53impl DiffDisplayLine {
54 pub fn body(kind: DiffDisplayKind, old_line: Option<u32>, new_line: Option<u32>, text: String) -> Self {
55 Self {
56 kind,
57 old_line,
58 new_line,
59 text,
60 changed: Vec::new(),
61 }
62 }
63
64 pub fn is_diff(&self) -> bool {
66 self.kind.is_diff()
67 }
68
69 pub fn numbered_text(&self, line_number_width: usize) -> String {
75 let w = line_number_width;
76 match self.kind {
77 DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => self.text.clone(),
78 DiffDisplayKind::Deletion => {
79 format!("-{:>w$} │ {}", self.old_line.unwrap_or_default(), self.text)
80 }
81 DiffDisplayKind::Addition => {
82 format!("+{:>w$} │ {}", self.new_line.unwrap_or_default(), self.text)
83 }
84 DiffDisplayKind::Context => {
85 let no = self.new_line.or(self.old_line).unwrap_or_default();
86 format!(" {:>w$} │ {}", no, self.text)
87 }
88 }
89 }
90}
91
92impl DiffDisplayKind {
93 pub fn is_diff(self) -> bool {
96 matches!(self, Self::Context | Self::Addition | Self::Deletion)
97 }
98}
99
100impl DiffChangeCounts {
101 pub fn total(self) -> usize {
102 self.additions + self.deletions
103 }
104}
105
106pub fn count_diff_changes(hunks: &[DiffHunk]) -> DiffChangeCounts {
107 let mut counts = DiffChangeCounts::default();
108
109 for hunk in hunks {
110 for line in &hunk.lines {
111 match line.kind {
112 DiffLineKind::Addition => counts.additions += 1,
113 DiffLineKind::Deletion => counts.deletions += 1,
114 DiffLineKind::Context => {}
115 }
116 }
117 }
118
119 counts
120}
121
122pub fn display_lines_from_hunks(hunks: &[DiffHunk]) -> Vec<DiffDisplayLine> {
123 let total = hunks.iter().map(|h| 1 + h.lines.len()).sum();
126 let mut lines = Vec::with_capacity(total);
127
128 for hunk in hunks {
129 lines.push(DiffDisplayLine::body(
130 DiffDisplayKind::HunkHeader,
131 None,
132 None,
133 format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
134 ));
135
136 for line in &hunk.lines {
137 lines.push(display_line_from_diff_line(line));
138 }
139 }
140
141 annotate_word_level_diffs(&mut lines);
142 lines
143}
144
145#[derive(Clone, Debug, Eq, PartialEq)]
147pub struct SideBySideRow {
148 pub left: Option<DiffDisplayLine>,
151 pub right: Option<DiffDisplayLine>,
153}
154
155impl SideBySideRow {
156 pub fn is_full_width(&self) -> bool {
158 self.left
159 .as_ref()
160 .is_some_and(|l| matches!(l.kind, DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata))
161 && self.right.is_none()
162 }
163}
164
165pub fn side_by_side_rows(lines: &[DiffDisplayLine]) -> Vec<SideBySideRow> {
171 let mut rows = Vec::with_capacity(lines.len());
172 let mut i = 0usize;
173
174 while i < lines.len() {
175 match lines[i].kind {
176 DiffDisplayKind::HunkHeader | DiffDisplayKind::Metadata => {
177 rows.push(SideBySideRow { left: Some(lines[i].clone()), right: None });
178 i += 1;
179 }
180 DiffDisplayKind::Context => {
181 rows.push(SideBySideRow {
182 left: Some(lines[i].clone()),
183 right: Some(lines[i].clone()),
184 });
185 i += 1;
186 }
187 DiffDisplayKind::Deletion => {
188 let del_start = i;
189 while i < lines.len() && lines[i].kind == DiffDisplayKind::Deletion {
190 i += 1;
191 }
192 let dels = &lines[del_start..i];
193 let add_start = i;
194 while i < lines.len() && lines[i].kind == DiffDisplayKind::Addition {
195 i += 1;
196 }
197 let adds = &lines[add_start..i];
198 let pairs = dels.len().max(adds.len());
199 for pair in 0..pairs {
200 rows.push(SideBySideRow {
201 left: dels.get(pair).cloned(),
202 right: adds.get(pair).cloned(),
203 });
204 }
205 }
206 DiffDisplayKind::Addition => {
207 let add_start = i;
208 while i < lines.len() && lines[i].kind == DiffDisplayKind::Addition {
209 i += 1;
210 }
211 for line in &lines[add_start..i] {
212 rows.push(SideBySideRow { left: None, right: Some(line.clone()) });
213 }
214 }
215 }
216 }
217
218 rows
219}
220
221pub fn display_lines_from_unified_diff(diff_content: &str) -> Vec<DiffDisplayLine> {
222 let mut lines = Vec::with_capacity(diff_content.lines().count());
225 let mut old_line_no = 0u32;
226 let mut new_line_no = 0u32;
227 let mut in_hunk = false;
228
229 for line in diff_content.lines() {
230 if let Some((old_start, new_start)) = parse_hunk_starts(line) {
231 old_line_no = old_start as u32;
232 new_line_no = new_start as u32;
233 in_hunk = true;
234 lines.push(DiffDisplayLine::body(
235 DiffDisplayKind::HunkHeader,
236 None,
237 None,
238 format_start_only_hunk_header(line).unwrap_or_else(|| format!("@@ -{old_start} +{new_start} @@")),
239 ));
240 continue;
241 }
242
243 if !in_hunk {
244 lines.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, line.to_string()));
245 continue;
246 }
247
248 if is_diff_addition_line(line) {
249 lines.push(DiffDisplayLine::body(
250 DiffDisplayKind::Addition,
251 None,
252 Some(new_line_no),
253 line[1..].to_string(),
254 ));
255 new_line_no = new_line_no.saturating_add(1);
256 continue;
257 }
258
259 if is_diff_deletion_line(line) {
260 lines.push(DiffDisplayLine::body(
261 DiffDisplayKind::Deletion,
262 Some(old_line_no),
263 None,
264 line[1..].to_string(),
265 ));
266 old_line_no = old_line_no.saturating_add(1);
267 continue;
268 }
269
270 if let Some(context_line) = line.strip_prefix(' ') {
271 lines.push(DiffDisplayLine::body(
272 DiffDisplayKind::Context,
273 Some(old_line_no),
274 Some(new_line_no),
275 context_line.to_string(),
276 ));
277 old_line_no = old_line_no.saturating_add(1);
278 new_line_no = new_line_no.saturating_add(1);
279 continue;
280 }
281
282 if let Some(omitted) = parse_omitted_line_count(line) {
283 old_line_no = old_line_no.saturating_add(omitted);
284 new_line_no = new_line_no.saturating_add(omitted);
285 lines.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, line.to_string()));
286 continue;
287 }
288
289 lines.push(DiffDisplayLine::body(DiffDisplayKind::Metadata, None, None, line.to_string()));
290 }
291
292 annotate_word_level_diffs(&mut lines);
293 lines
294}
295
296pub fn diff_display_line_number_width(lines: &[DiffDisplayLine]) -> usize {
297 let max_digits = lines
298 .iter()
299 .flat_map(|line| [line.old_line, line.new_line])
300 .flatten()
301 .map(digit_count)
302 .max()
303 .unwrap_or(4);
304 max_digits.clamp(5, 6)
305}
306
307fn digit_count(mut value: u32) -> usize {
308 let mut digits = 1;
309 while value >= 10 {
310 value /= 10;
311 digits += 1;
312 }
313 digits
314}
315
316pub fn format_numbered_unified_diff(diff_content: &str) -> Vec<String> {
317 let display_lines = display_lines_from_unified_diff(diff_content);
318 let width = diff_display_line_number_width(&display_lines);
319 display_lines.into_iter().map(|line| line.numbered_text(width)).collect()
320}
321
322fn parse_omitted_line_count(line: &str) -> Option<u32> {
325 let trimmed = line.trim();
326 let after = trimmed.strip_prefix("...")?;
327 let after = after.trim_start();
328 let digits_end = after.find(|ch: char| !ch.is_ascii_digit())?;
329 if digits_end == 0 {
330 return None;
331 }
332 after[..digits_end].parse().ok()
333}
334
335fn display_line_from_diff_line(line: &crate::diff::DiffLine) -> DiffDisplayLine {
336 let text = line.text.trim_end_matches('\n').to_string();
337 match line.kind {
338 DiffLineKind::Context => DiffDisplayLine::body(DiffDisplayKind::Context, line.old_line, line.new_line, text),
339 DiffLineKind::Addition => DiffDisplayLine::body(DiffDisplayKind::Addition, line.old_line, line.new_line, text),
340 DiffLineKind::Deletion => DiffDisplayLine::body(DiffDisplayKind::Deletion, line.old_line, line.new_line, text),
341 }
342}
343
344pub fn annotate_word_level_diffs(lines: &mut [DiffDisplayLine]) {
350 let mut i = 0;
351 while i < lines.len() {
352 if lines[i].kind != DiffDisplayKind::Deletion {
353 i += 1;
354 continue;
355 }
356 let del_start = i;
357 while i < lines.len() && lines[i].kind == DiffDisplayKind::Deletion {
358 i += 1;
359 }
360 let del_end = i;
361 let add_start = i;
362 while i < lines.len() && lines[i].kind == DiffDisplayKind::Addition {
363 i += 1;
364 }
365 let add_end = i;
366 let (before, rest) = lines.split_at_mut(add_start);
367 let dels = &mut before[del_start..del_end];
368 let adds = &mut rest[..(add_end - add_start)];
369 pair_word_level_ranges(dels, adds);
370 }
371}
372
373fn pair_word_level_ranges(dels: &mut [DiffDisplayLine], adds: &mut [DiffDisplayLine]) {
374 let pairs = dels.len().min(adds.len());
375 let mut computed = Vec::with_capacity(pairs);
376 for idx in 0..pairs {
377 computed.push(word_level_changed_ranges(&dels[idx].text, &adds[idx].text));
378 }
379 for (idx, (old_ranges, new_ranges)) in computed.into_iter().enumerate() {
380 dels[idx].changed = old_ranges;
381 adds[idx].changed = new_ranges;
382 }
383}
384
385const MIN_WORD_SIMILARITY: f64 = 0.35;
390
391fn tokenize_atoms(text: &str) -> Vec<(usize, usize)> {
393 let bytes = text.as_bytes();
394 let mut spans = Vec::with_capacity(text.len() / 2 + 1);
395 let mut i = 0;
396 while i < bytes.len() {
397 let b = bytes[i];
398 let start = i;
399 if b.is_ascii_whitespace() {
400 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
401 i += 1;
402 }
403 } else if b.is_ascii_alphanumeric() || b == b'_' {
404 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
405 i += 1;
406 }
407 } else {
408 let ch_len = text[i..].chars().next().map(char::len_utf8).unwrap_or(1);
409 i += ch_len;
410 }
411 if i > start {
412 spans.push((start, i));
413 }
414 }
415 spans
416}
417
418pub fn word_level_changed_ranges(old: &str, new: &str) -> (WordChangedRanges, WordChangedRanges) {
423 let old_atoms = tokenize_atoms(old);
424 let new_atoms = tokenize_atoms(new);
425 if old_atoms.is_empty() || new_atoms.is_empty() {
426 return (WordChangedRanges::new(), WordChangedRanges::new());
427 }
428 if old_atoms.len() > MAX_WORD_ATOMS || new_atoms.len() > MAX_WORD_ATOMS {
430 return (WordChangedRanges::new(), WordChangedRanges::new());
431 }
432
433 let n = old_atoms.len();
434 let m = new_atoms.len();
435 let mut dp = vec![vec![0usize; m + 1]; n + 1];
436 for i in (0..n).rev() {
437 for j in (0..m).rev() {
438 let old_tok = &old[old_atoms[i].0..old_atoms[i].1];
439 let new_tok = &new[new_atoms[j].0..new_atoms[j].1];
440 dp[i][j] = if old_tok == new_tok {
441 dp[i + 1][j + 1] + 1
442 } else {
443 dp[i + 1][j].max(dp[i][j + 1])
444 };
445 }
446 }
447
448 let lcs = dp[0][0];
449 let max_len = n.max(m);
450 let similarity = if max_len == 0 { 1.0 } else { lcs as f64 / max_len as f64 };
451 if similarity < MIN_WORD_SIMILARITY {
452 return (WordChangedRanges::new(), WordChangedRanges::new());
453 }
454
455 let mut old_changed = vec![false; n];
456 let mut new_changed = vec![false; m];
457 let (mut i, mut j) = (0usize, 0usize);
458 while i < n && j < m {
459 let old_tok = &old[old_atoms[i].0..old_atoms[i].1];
460 let new_tok = &new[new_atoms[j].0..new_atoms[j].1];
461 if old_tok == new_tok {
462 i += 1;
463 j += 1;
464 } else if dp[i + 1][j] >= dp[i][j + 1] {
465 old_changed[i] = true;
466 i += 1;
467 } else {
468 new_changed[j] = true;
469 j += 1;
470 }
471 }
472 while i < n {
473 old_changed[i] = true;
474 i += 1;
475 }
476 while j < m {
477 new_changed[j] = true;
478 j += 1;
479 }
480
481 let old_changed_count = old_changed.iter().filter(|&&f| f).count();
482 let new_changed_count = new_changed.iter().filter(|&&f| f).count();
483 if mostly_changed(old_changed_count, n) || mostly_changed(new_changed_count, m) {
484 return (WordChangedRanges::new(), WordChangedRanges::new());
485 }
486
487 (
488 collapse_atom_flags(&old_atoms, &old_changed, old),
489 collapse_atom_flags(&new_atoms, &new_changed, new),
490 )
491}
492
493fn mostly_changed(changed_atoms: usize, atom_count: usize) -> bool {
495 atom_count > 0 && changed_atoms * 2 > atom_count
496}
497
498fn collapse_atom_flags(atoms: &[(usize, usize)], flags: &[bool], text: &str) -> Vec<(usize, usize)> {
499 let mut ranges: Vec<(usize, usize)> = Vec::new();
500 for (idx, (start, end)) in atoms.iter().enumerate() {
501 if !flags[idx] {
502 continue;
503 }
504 match ranges.last_mut() {
505 Some(last) if last.1 == *start => last.1 = *end,
506 _ => ranges.push((*start, *end)),
507 }
508 }
509 ranges.retain(|(start, end)| text[*start..*end].chars().any(|c| !c.is_ascii_whitespace()));
510 ranges
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::diff::{DiffLine, DiffLineKind};
517
518 #[test]
519 fn counts_diff_changes_from_hunks() {
520 let hunks = vec![DiffHunk {
521 old_start: 1,
522 old_lines: 2,
523 new_start: 1,
524 new_lines: 2,
525 lines: vec![
526 DiffLine {
527 kind: DiffLineKind::Context,
528 old_line: Some(1),
529 new_line: Some(1),
530 text: "same\n".to_string(),
531 },
532 DiffLine {
533 kind: DiffLineKind::Deletion,
534 old_line: Some(2),
535 new_line: None,
536 text: "old\n".to_string(),
537 },
538 DiffLine {
539 kind: DiffLineKind::Addition,
540 old_line: None,
541 new_line: Some(2),
542 text: "new\n".to_string(),
543 },
544 ],
545 }];
546
547 let counts = count_diff_changes(&hunks);
548 assert_eq!(counts.additions, 1);
549 assert_eq!(counts.deletions, 1);
550 assert_eq!(counts.total(), 2);
551 }
552
553 #[test]
554 fn is_diff_discriminates_content_lines() {
555 assert!(DiffDisplayKind::Context.is_diff());
556 assert!(DiffDisplayKind::Addition.is_diff());
557 assert!(DiffDisplayKind::Deletion.is_diff());
558 assert!(!DiffDisplayKind::Metadata.is_diff());
559 assert!(!DiffDisplayKind::HunkHeader.is_diff());
560 }
561
562 #[test]
563 fn formats_numbered_unified_diff_with_single_gutter() {
564 let diff = "\
565@@ -10,2 +10,2 @@
566 old
567-old
568+new
569";
570 let lines = format_numbered_unified_diff(diff);
571 assert!(lines.iter().any(|line| line == "@@ -10 +10 @@"));
572 assert!(lines.iter().any(|line| line.contains(" 10 │ old")));
574 assert!(lines.iter().any(|line| line.contains("- 11 │ old")));
576 assert!(lines.iter().any(|line| line.contains("+ 11 │ new")));
578 }
579
580 #[test]
581 fn numbered_text_uses_pipe_separator_for_markdown_bullets() {
582 let line = DiffDisplayLine::body(
583 DiffDisplayKind::Addition,
584 None,
585 Some(53),
586 "- **Agent-first by design*: prose".to_string(),
587 );
588 let text = line.numbered_text(5);
589 assert_eq!(text, "+ 53 │ - **Agent-first by design*: prose");
590 }
591
592 #[test]
593 fn display_lines_from_hunks_preserves_semantics() {
594 let hunks = vec![DiffHunk {
595 old_start: 10,
596 old_lines: 2,
597 new_start: 10,
598 new_lines: 2,
599 lines: vec![
600 DiffLine {
601 kind: DiffLineKind::Deletion,
602 old_line: Some(10),
603 new_line: None,
604 text: "old\n".to_string(),
605 },
606 DiffLine {
607 kind: DiffLineKind::Addition,
608 old_line: None,
609 new_line: Some(10),
610 text: "new\n".to_string(),
611 },
612 DiffLine {
613 kind: DiffLineKind::Context,
614 old_line: Some(11),
615 new_line: Some(11),
616 text: "same\n".to_string(),
617 },
618 ],
619 }];
620
621 let lines = display_lines_from_hunks(&hunks);
622 assert_eq!(lines[0].kind, DiffDisplayKind::HunkHeader);
623 assert_eq!(lines[0].text, "@@ -10 +10 @@");
624 assert_eq!(lines[1].kind, DiffDisplayKind::Deletion);
625 assert_eq!(lines[1].old_line, Some(10));
626 assert_eq!(lines[1].new_line, None);
627 assert_eq!(lines[2].kind, DiffDisplayKind::Addition);
628 assert_eq!(lines[2].old_line, None);
629 assert_eq!(lines[2].new_line, Some(10));
630 assert_eq!(lines[3].kind, DiffDisplayKind::Context);
631 assert_eq!(lines[3].old_line, Some(11));
632 assert_eq!(lines[3].new_line, Some(11));
633 }
634
635 #[test]
636 fn metadata_lines_stay_metadata_after_hunk() {
637 let diff = "\
638@@ -1 +1 @@
639-old
640\\ No newline at end of file
641+new
642";
643
644 let lines = display_lines_from_unified_diff(diff);
645 assert_eq!(lines[2].kind, DiffDisplayKind::Metadata);
646 assert_eq!(lines[3].kind, DiffDisplayKind::Addition);
647 assert_eq!(lines[3].new_line, Some(1));
648 }
649
650 #[test]
651 fn diff_display_line_number_width_tracks_max_digits() {
652 let lines = vec![
653 DiffDisplayLine::body(DiffDisplayKind::Addition, None, Some(99), "let a = 1;".to_string()),
654 DiffDisplayLine::body(DiffDisplayKind::Context, Some(1), Some(10_420), "let b = 2;".to_string()),
655 ];
656
657 assert_eq!(diff_display_line_number_width(&lines), 5);
658 }
659
660 #[test]
661 fn diff_display_line_number_width_clamps_to_bounds() {
662 let small = vec![DiffDisplayLine::body(
663 DiffDisplayKind::Context,
664 Some(1),
665 Some(1),
666 "text".to_string(),
667 )];
668 assert_eq!(diff_display_line_number_width(&small), 5);
669
670 let large = vec![DiffDisplayLine::body(
671 DiffDisplayKind::Context,
672 Some(100_000),
673 Some(100_000),
674 "text".to_string(),
675 )];
676 assert_eq!(diff_display_line_number_width(&large), 6);
677 }
678
679 #[test]
680 fn word_level_diff_highlights_only_changed_tokens() {
681 let old = "let bright_red = anstyle::Color::Ansi(anstyle::AnsiColor::BrightRed);";
682 let new = "let bright_red = anstyle::Color::Rgb(anstyle::RgbColor(255, 90, 90));";
683 let (old_ranges, new_ranges) = word_level_changed_ranges(old, new);
684 assert!(!old_ranges.is_empty());
685 assert!(!new_ranges.is_empty());
686 let old_changed: String = old_ranges.iter().map(|&(s, e)| &old[s..e]).collect();
687 let new_changed: String = new_ranges.iter().map(|&(s, e)| &new[s..e]).collect();
688 assert!(old_changed.contains("Ansi"));
689 assert!(new_changed.contains("Rgb"));
690 assert!(!old_changed.contains("bright_red"));
691 assert!(!new_changed.contains("bright_red"));
692 }
693
694 #[test]
695 fn word_level_diff_skips_dissimilar_pairs() {
696 let old = "| Pillar | What it means |\n| --- | --- |\n| **Harness** | The model reasons; the harness composes tools. |";
697 let new = "- **The loop is the product.** Tool composition, context management, and\n verification are engineered — not improvised around a chat completion.";
698 let (old_ranges, new_ranges) = word_level_changed_ranges(old, new);
699 assert!(old_ranges.is_empty(), "dissimilar del pair should stay line-only");
700 assert!(new_ranges.is_empty(), "dissimilar add pair should stay line-only");
701 }
702
703 #[test]
704 fn word_level_diff_skips_blank_sides() {
705 let (old_ranges, new_ranges) = word_level_changed_ranges("moved block", "");
706 assert!(old_ranges.is_empty());
707 assert!(new_ranges.is_empty());
708 }
709
710 #[test]
711 fn word_level_diff_skips_oversized_atom_lists() {
712 let long_a = "a ".repeat(MAX_WORD_ATOMS + 10);
713 let long_b = "b ".repeat(MAX_WORD_ATOMS + 10);
714 let (old_ranges, new_ranges) = word_level_changed_ranges(&long_a, &long_b);
715 assert!(old_ranges.is_empty());
716 assert!(new_ranges.is_empty());
717 }
718
719 #[test]
720 fn side_by_side_pairs_context_on_both_columns() {
721 let lines = display_lines_from_hunks(&[DiffHunk {
722 old_start: 1,
723 old_lines: 1,
724 new_start: 1,
725 new_lines: 1,
726 lines: vec![DiffLine {
727 kind: DiffLineKind::Context,
728 old_line: Some(1),
729 new_line: Some(1),
730 text: "same\n".to_string(),
731 }],
732 }]);
733 let rows = side_by_side_rows(&lines);
734 assert_eq!(rows.len(), 2);
735 let context = &rows[1];
736 assert_eq!(context.left.as_ref().map(|l| l.text.as_str()), Some("same"));
737 assert_eq!(context.right.as_ref().map(|l| l.text.as_str()), Some("same"));
738 }
739
740 #[test]
741 fn side_by_side_zips_del_add_runs() {
742 let lines = display_lines_from_unified_diff(
743 "\
744@@ -1,2 +1,2 @@
745-old a
746-old b
747+new a
748+new b
749",
750 );
751 let rows = side_by_side_rows(&lines);
752 assert!(rows[0].is_full_width());
753 assert_eq!(rows[1].left.as_ref().map(|l| l.text.as_str()), Some("old a"));
754 assert_eq!(rows[1].right.as_ref().map(|l| l.text.as_str()), Some("new a"));
755 assert_eq!(rows[2].left.as_ref().map(|l| l.text.as_str()), Some("old b"));
756 assert_eq!(rows[2].right.as_ref().map(|l| l.text.as_str()), Some("new b"));
757 }
758
759 #[test]
760 fn side_by_side_keeps_unpaired_additions_on_right_only() {
761 let lines = display_lines_from_unified_diff(
762 "\
763@@ -1 +1,2 @@
764 keep
765+added
766",
767 );
768 let rows = side_by_side_rows(&lines);
769 assert!(
770 rows.iter()
771 .any(|row| row.left.is_none() && row.right.as_ref().is_some_and(|r| r.text == "added"))
772 );
773 }
774
775 #[test]
776 fn side_by_side_keeps_unpaired_deletions_on_left_only() {
777 let lines = display_lines_from_unified_diff(
778 "\
779@@ -2,1 +1 @@
780 keep
781-removed
782",
783 );
784 let rows = side_by_side_rows(&lines);
785 assert!(
786 rows.iter()
787 .any(|row| row.right.is_none() && row.left.as_ref().is_some_and(|l| l.text == "removed"))
788 );
789 }
790
791 #[test]
792 fn annotate_pairs_consecutive_del_add_runs() {
793 let diff = "\
794@@ -1 +1 @@
795-let a = 1;
796+let a = 2;
797";
798 let lines = display_lines_from_unified_diff(diff);
799 let del = lines.iter().find(|l| l.kind == DiffDisplayKind::Deletion).unwrap();
800 let add = lines.iter().find(|l| l.kind == DiffDisplayKind::Addition).unwrap();
801 assert!(!del.changed.is_empty());
802 assert!(!add.changed.is_empty());
803 assert_eq!(&del.text[del.changed[0].0..del.changed[0].1], "1");
804 assert_eq!(&add.text[add.changed[0].0..add.changed[0].1], "2");
805 }
806
807 #[test]
808 fn word_chip_user_example_pair() {
809 let old = "wrong, these are the defaults you were missing.";
810 let new = "wrong, these are the defaults you were missing — built in, not bolted on.";
811 let (old_ranges, new_ranges) = word_level_changed_ranges(old, new);
812 assert!(old_ranges.is_empty(), "shared prefix should stay line-only");
813 assert!(!new_ranges.is_empty(), "addition suffix should be a chip");
814 let new_changed: String = new_ranges.iter().map(|&(s, e)| &new[s..e]).collect();
815 assert!(new_changed.contains("built"), "got {new_changed:?}");
816 }
817}