1use std::borrow::Cow;
8
9use similar::{ChangeTag, TextDiff};
10
11use crate::diff::options::DiffOptions;
12use crate::objects::FileMode;
13
14#[path = "diff/line_engine.rs"]
15mod line_engine;
16
17#[cfg(test)]
18pub(crate) use line_engine::structured_hunk_for_id_from_unified_slice;
19pub(crate) use line_engine::{
20 hash_hunk_body_from_unified_slice, hunk_body_digest_from_unified_slice,
21};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct SideBytes {
26 pub data: Vec<u8>,
28 pub encoding_lossy: bool,
30}
31
32impl SideBytes {
33 #[must_use]
35 pub fn from_bytes(data: Vec<u8>) -> Self {
36 let encoding_lossy = !data.is_empty() && std::str::from_utf8(&data).is_err();
37 Self {
38 data,
39 encoding_lossy,
40 }
41 }
42
43 #[must_use]
45 pub fn text(&self) -> String {
46 String::from_utf8_lossy(&self.data).into_owned()
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct StructuredSegment {
53 pub text: String,
55 pub emphasis: bool,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum StructuredLineKind {
62 Context,
64 Add,
66 Del,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct StructuredLine {
73 pub kind: StructuredLineKind,
75 pub old: Option<usize>,
77 pub new: Option<usize>,
79 pub segments: Vec<StructuredSegment>,
81 pub no_newline_at_eof: bool,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct StructuredHunk {
88 pub old_start: usize,
90 pub new_start: usize,
92 pub old_lines: usize,
94 pub new_lines: usize,
96 pub context: Option<String>,
98 pub lines: Vec<StructuredLine>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct StructuredTextDiff {
105 pub hunks: Vec<StructuredHunk>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct StructuredFileDiff {
112 pub old_mode: Option<String>,
114 pub new_mode: Option<String>,
116 pub old_encoding_lossy: bool,
118 pub new_encoding_lossy: bool,
120 pub hunks: Vec<StructuredHunk>,
122}
123
124#[derive(Debug, Clone)]
126struct TextLines {
127 lines: Vec<String>,
128}
129
130impl TextLines {
131 fn from_text(text: &str) -> Self {
132 let (parts, _) = lines_with_trailing_newline(text);
133 Self {
134 lines: parts
135 .iter()
136 .map(|s| strip_diff_newline_only(s).to_owned())
137 .collect(),
138 }
139 }
140}
141
142fn side_text_for_diff(side: &SideBytes) -> Cow<'_, str> {
144 if side.data.is_empty() {
145 return Cow::Borrowed("");
146 }
147 match std::str::from_utf8(&side.data) {
148 Ok(text) => Cow::Borrowed(text),
149 Err(_) => Cow::Owned(String::from_utf8_lossy(&side.data).into_owned()),
150 }
151}
152
153fn structured_text_diff_cow(
155 old_text: Cow<'_, str>,
156 new_text: Cow<'_, str>,
157 options: DiffOptions,
158) -> StructuredTextDiff {
159 structured_text_diff(old_text.as_ref(), new_text.as_ref(), options)
160}
161
162#[must_use]
164pub fn structured_text_diff(
165 old_text: &str,
166 new_text: &str,
167 options: DiffOptions,
168) -> StructuredTextDiff {
169 let mut hunks = line_engine::line_hunks(old_text, new_text, &options, options.function_context);
170 if options.word_level_inline {
171 apply_word_level_inline(&mut hunks, old_text, new_text);
172 }
173 StructuredTextDiff { hunks }
174}
175
176fn apply_word_level_inline(hunks: &mut [StructuredHunk], old_text: &str, new_text: &str) {
177 let old = TextLines::from_text(old_text);
178 let new = TextLines::from_text(new_text);
179 let old_snapshot = old.lines.clone();
180 let new_snapshot = new.lines.clone();
181
182 for hunk in &mut *hunks {
183 let mut i = 0usize;
184 while i < hunk.lines.len() {
185 if hunk.lines[i].kind != StructuredLineKind::Del {
186 i += 1;
187 continue;
188 }
189 let del_start = i;
190 while i < hunk.lines.len() && hunk.lines[i].kind == StructuredLineKind::Del {
191 i += 1;
192 }
193 let add_start = i;
194 while i < hunk.lines.len() && hunk.lines[i].kind == StructuredLineKind::Add {
195 i += 1;
196 }
197 let del_end = i.min(add_start);
198 let add_end = i;
199 if del_end > del_start && add_end > add_start {
200 let del_count = del_end - del_start;
201 let add_count = add_end - add_start;
202 let pairs = del_count.min(add_count);
203 for p in 0..pairs {
204 let del_idx = del_start + p;
205 let add_idx = add_start + p;
206 let (left, right) = hunk.lines.split_at_mut(add_idx);
207 let del_line = &mut left[del_idx];
208 let add_line = &mut right[0];
209 emphasize_replace_line_pair(del_line, add_line);
210 }
211 }
212 }
213 }
214
215 pin_canonical_line_text(hunks, &old_snapshot, &new_snapshot);
216}
217
218fn emphasize_replace_line_pair(del: &mut StructuredLine, add: &mut StructuredLine) {
219 let old_line = del.segments[0].text.clone();
220 let new_line = add.segments[0].text.clone();
221 let diff = TextDiff::from_words(&old_line, &new_line);
222 del.segments = word_segments_from_diff(&diff, &old_line, true);
223 add.segments = word_segments_from_diff(&diff, &new_line, false);
224}
225
226fn word_segments_from_diff(
227 diff: &TextDiff<'_, '_, str>,
228 full_line: &str,
229 old_side: bool,
230) -> Vec<StructuredSegment> {
231 let mut segments: Vec<StructuredSegment> = Vec::new();
232 for change in diff.iter_all_changes() {
233 let is_this_side = match change.tag() {
234 ChangeTag::Delete => old_side,
235 ChangeTag::Insert => !old_side,
236 ChangeTag::Equal => true,
237 };
238 if !is_this_side {
239 continue;
240 }
241 let emphasis = change.tag() != ChangeTag::Equal;
242 let text = change.value().to_owned();
243 if text.is_empty() {
244 continue;
245 }
246 if let Some(last) = segments.last_mut() {
247 if last.emphasis == emphasis {
248 last.text.push_str(&text);
249 continue;
250 }
251 }
252 segments.push(StructuredSegment { text, emphasis });
253 }
254 if segments.is_empty() && !full_line.is_empty() {
255 segments.push(StructuredSegment {
256 text: full_line.to_owned(),
257 emphasis: false,
258 });
259 }
260 reconcile_segments_with_line(&mut segments, full_line);
261 segments
262}
263
264fn pin_canonical_line_text(
266 hunks: &mut [StructuredHunk],
267 old_lines: &[String],
268 new_lines: &[String],
269) {
270 for hunk in hunks {
271 for line in &mut hunk.lines {
272 let canonical = match line.kind {
273 StructuredLineKind::Del => line
274 .old
275 .and_then(|n| old_lines.get(n.saturating_sub(1)).cloned()),
276 StructuredLineKind::Add => line
277 .new
278 .and_then(|n| new_lines.get(n.saturating_sub(1)).cloned()),
279 StructuredLineKind::Context => line
280 .old
281 .and_then(|n| old_lines.get(n.saturating_sub(1)).cloned()),
282 };
283 let Some(canonical) = canonical else {
284 continue;
285 };
286 if line.kind == StructuredLineKind::Del {
287 let emphasis = line.segments.iter().any(|s| s.emphasis);
288 line.segments = vec![StructuredSegment {
289 text: canonical,
290 emphasis,
291 }];
292 continue;
293 }
294 let rendered: String = line.segments.iter().map(|s| s.text.as_str()).collect();
295 if rendered != canonical {
296 let emphasis = line.segments.iter().any(|s| s.emphasis);
297 line.segments = vec![StructuredSegment {
298 text: canonical,
299 emphasis,
300 }];
301 }
302 }
303 }
304}
305
306#[must_use]
308pub fn structured_file_diff(
309 old_mode: FileMode,
310 new_mode: FileMode,
311 old: &SideBytes,
312 new: &SideBytes,
313 options: DiffOptions,
314) -> StructuredFileDiff {
315 let old_mode = old_mode.octal_if_present();
316 let new_mode = new_mode.octal_if_present();
317 let old_text = side_text_for_diff(old);
318 let new_text = side_text_for_diff(new);
319 let hunks = structured_text_diff_cow(old_text, new_text, options).hunks;
320 StructuredFileDiff {
321 old_mode,
322 new_mode,
323 old_encoding_lossy: old.encoding_lossy,
324 new_encoding_lossy: new.encoding_lossy,
325 hunks,
326 }
327}
328
329fn lines_with_trailing_newline(text: &str) -> (Vec<&str>, bool) {
331 if text.is_empty() {
332 return (Vec::new(), false);
333 }
334 let ends_nl = text.ends_with('\n');
335 let mut parts: Vec<&str> = text.split('\n').collect();
336 if ends_nl && parts.last() == Some(&"") {
337 parts.pop();
338 }
339 (parts, ends_nl)
340}
341
342fn strip_diff_newline_only(s: &str) -> &str {
343 s.strip_suffix('\n').unwrap_or(s)
344}
345
346pub(crate) fn old_line_bodies_for_context(text: &str) -> Vec<String> {
348 TextLines::from_text(text).lines
349}
350
351pub(crate) fn enclosing_function_context(
353 lines: &[String],
354 old_start_1based: usize,
355) -> Option<String> {
356 if old_start_1based == 0 {
357 return None;
358 }
359 function_context(lines, old_start_1based.saturating_sub(1))
360}
361
362fn function_context(lines: &[String], start_0based: usize) -> Option<String> {
363 lines[..start_0based.min(lines.len())]
364 .iter()
365 .rev()
366 .find_map(|line| {
367 let first = line.chars().next()?;
368 (first.is_alphabetic() || first == '_').then(|| line.trim_end().to_owned())
369 })
370}
371
372fn reconcile_segments_with_line(segments: &mut Vec<StructuredSegment>, full_line: &str) {
373 if full_line.is_empty() {
374 return;
375 }
376 let rendered: String = segments.iter().map(|s| s.text.as_str()).collect();
377 if rendered != full_line {
378 let emphasis = segments.iter().any(|s| s.emphasis);
379 segments.clear();
380 segments.push(StructuredSegment {
381 text: full_line.to_owned(),
382 emphasis,
383 });
384 }
385}
386
387#[must_use]
389pub fn format_hunk_range(start: usize, count: usize) -> String {
390 if count == 0 {
391 "0,0".to_owned()
392 } else if count == 1 {
393 start.to_string()
394 } else {
395 format!("{start},{count}")
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use std::process::Command;
403
404 fn git_diff(old: &str, new: &str) -> String {
405 let tmp = tempfile::tempdir().expect("tempdir");
406 let path = tmp.path().join("f.txt");
407 std::fs::write(&path, old).expect("write old");
408 Command::new("git")
409 .args(["init", "-q", "-b", "main"])
410 .current_dir(tmp.path())
411 .status()
412 .expect("git init");
413 Command::new("git")
414 .args(["config", "user.email", "t@e.com"])
415 .current_dir(tmp.path())
416 .status()
417 .unwrap();
418 Command::new("git")
419 .args(["config", "user.name", "T"])
420 .current_dir(tmp.path())
421 .status()
422 .unwrap();
423 Command::new("git")
424 .args(["add", "f.txt"])
425 .current_dir(tmp.path())
426 .status()
427 .unwrap();
428 Command::new("git")
429 .args(["commit", "-qm", "init"])
430 .current_dir(tmp.path())
431 .status()
432 .unwrap();
433 std::fs::write(&path, new).expect("write new");
434 let out = Command::new("git")
435 .args(["diff", "f.txt"])
436 .current_dir(tmp.path())
437 .output()
438 .expect("git diff");
439 String::from_utf8(out.stdout).expect("utf8")
440 }
441
442 fn parse_hunk_header(patch: &str) -> (usize, usize, usize, usize) {
443 let line = patch.lines().find(|l| l.starts_with("@@")).expect("hunk");
444 let rest = line.strip_prefix("@@ ").expect("prefix");
445 let (old_part, new_part) = rest.split_once(" +").expect("plus");
446 let old_part = old_part.strip_prefix('-').expect("minus");
447 let (os, ol) = parse_range(old_part.split(' ').next().expect("old"));
448 let new_part = new_part.split(" @@").next().expect("close");
449 let (ns, nl) = parse_range(new_part);
450 (os, ol, ns, nl)
451 }
452
453 fn parse_range(s: &str) -> (usize, usize) {
454 if let Some((a, b)) = s.split_once(',') {
455 (a.parse().expect("start"), b.parse().expect("count"))
456 } else {
457 (s.parse().expect("start"), 1)
458 }
459 }
460
461 #[test]
462 fn lines_with_trailing_newline_splits_crlf() {
463 let (parts, ends) = lines_with_trailing_newline("a\r\nb\r\n");
464 assert!(ends);
465 assert_eq!(parts, ["a\r", "b\r"]);
466 }
467
468 #[test]
469 fn text_lines_from_text_preserves_cr() {
470 let t = TextLines::from_text("a\r\nb\r\n");
471 assert_eq!(t.lines[0], "a\r");
472 }
473
474 #[test]
475 fn crlf_to_lf_shows_carriage_return_on_removed_lines() {
476 let old = "a\r\nb\r\n";
477 let new = "a\nb\n";
478 let d = structured_text_diff(old, new, DiffOptions::default());
479 let del: Vec<_> = d.hunks[0]
480 .lines
481 .iter()
482 .filter(|l| l.kind == StructuredLineKind::Del)
483 .collect();
484 assert_eq!(del[0].segments[0].text, "a\r");
485 }
486
487 #[test]
488 fn trailing_newline_only_change_sets_no_newline_flag() {
489 let old = "line1\nline2";
490 let new = "line1\nline2\n";
491 let d = structured_text_diff(old, new, DiffOptions::default());
492 let del = d.hunks[0]
493 .lines
494 .iter()
495 .find(|l| l.kind == StructuredLineKind::Del)
496 .expect("deleted line2");
497 assert!(del.no_newline_at_eof);
498 }
499
500 #[test]
501 fn context_last_line_without_eof_newline_is_marked() {
502 let old = "old\nlast";
503 let new = "new\nlast";
504 let d = structured_text_diff(old, new, DiffOptions::default());
505 let ctx = d.hunks[0]
506 .lines
507 .iter()
508 .find(|l| l.kind == StructuredLineKind::Context && l.segments[0].text == "last")
509 .expect("context last");
510 assert!(ctx.no_newline_at_eof);
511 }
512
513 fn git_diff_u0(old: &str, new: &str) -> String {
514 let tmp = tempfile::tempdir().expect("tempdir");
515 let path = tmp.path().join("f.txt");
516 std::fs::write(&path, old).expect("write old");
517 Command::new("git")
518 .args(["init", "-q", "-b", "main"])
519 .current_dir(tmp.path())
520 .status()
521 .expect("git init");
522 for (k, v) in [("user.email", "t@e.com"), ("user.name", "T")] {
523 Command::new("git")
524 .args(["config", k, v])
525 .current_dir(tmp.path())
526 .status()
527 .unwrap();
528 }
529 Command::new("git")
530 .args(["add", "f.txt"])
531 .current_dir(tmp.path())
532 .status()
533 .unwrap();
534 Command::new("git")
535 .args(["commit", "-qm", "init"])
536 .current_dir(tmp.path())
537 .status()
538 .unwrap();
539 std::fs::write(&path, new).expect("write new");
540 let out = Command::new("git")
541 .args(["diff", "-U0", "f.txt"])
542 .current_dir(tmp.path())
543 .output()
544 .expect("git diff");
545 String::from_utf8(out.stdout).expect("utf8")
546 }
547
548 #[cfg_attr(
549 not(feature = "full-tests"),
550 ignore = "starts system git or is heavy; run with --features full-tests"
551 )]
552 #[test]
553 fn zero_context_insertion_anchor_matches_git() {
554 let old = "a\nb\nc\n";
555 let new = "a\nb\nX\nc\n";
556 let patch = git_diff_u0(old, new);
557 let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
558 let d = structured_text_diff(
559 old,
560 new,
561 DiffOptions {
562 context_lines: 0,
563 ..DiffOptions::default()
564 },
565 );
566 let h = &d.hunks[0];
567 assert_eq!(
568 (h.old_start, h.old_lines, h.new_start, h.new_lines),
569 (g_os, g_ol, g_ns, g_nl)
570 );
571 assert_eq!((h.old_start, h.old_lines), (2, 0));
572 }
573
574 #[cfg_attr(
575 not(feature = "full-tests"),
576 ignore = "starts system git or is heavy; run with --features full-tests"
577 )]
578 #[test]
579 fn zero_context_deletion_anchor_matches_git() {
580 let old = "a\nb\nc\n";
581 let new = "a\nc\n";
582 let patch = git_diff_u0(old, new);
583 let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
584 let d = structured_text_diff(
585 old,
586 new,
587 DiffOptions {
588 context_lines: 0,
589 ..DiffOptions::default()
590 },
591 );
592 let h = &d.hunks[0];
593 assert_eq!(
594 (h.old_start, h.old_lines, h.new_start, h.new_lines),
595 (g_os, g_ol, g_ns, g_nl)
596 );
597 assert_eq!((h.new_start, h.new_lines), (1, 0));
598 }
599
600 #[cfg_attr(
601 not(feature = "full-tests"),
602 ignore = "starts system git or is heavy; run with --features full-tests"
603 )]
604 #[test]
605 fn structured_hunk_retains_enclosing_function_context() {
606 let old = "fn foo() {\n let x = 1;\n}\n";
607 let new = "fn foo() {\n let x = 2;\n}\n";
608 let mut opts = DiffOptions::default();
609 opts.function_context = true;
610 let d = structured_text_diff(old, new, opts);
611 let h = &d.hunks[0];
612 assert_eq!(h.context.as_deref(), Some("fn foo() {"));
613 }
614
615 #[cfg_attr(
616 not(feature = "full-tests"),
617 ignore = "starts system git or is heavy; run with --features full-tests"
618 )]
619 #[test]
620 fn insert_into_empty_file_matches_git_hunk_header() {
621 let patch = git_diff("", "x\n");
622 let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
623 let d = structured_text_diff("", "x\n", DiffOptions::default());
624 let h = &d.hunks[0];
625 assert_eq!(
626 (h.old_start, h.old_lines, h.new_start, h.new_lines),
627 (g_os, g_ol, g_ns, g_nl)
628 );
629 assert_eq!((h.old_start, h.old_lines), (0, 0));
630 }
631
632 #[cfg_attr(
633 not(feature = "full-tests"),
634 ignore = "starts system git or is heavy; run with --features full-tests"
635 )]
636 #[test]
637 fn delete_to_empty_matches_git_hunk_header() {
638 let patch = git_diff("x\n", "");
639 let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&patch);
640 let d = structured_text_diff("x\n", "", DiffOptions::default());
641 let h = &d.hunks[0];
642 assert_eq!(
643 (h.old_start, h.old_lines, h.new_start, h.new_lines),
644 (g_os, g_ol, g_ns, g_nl)
645 );
646 assert_eq!((h.new_start, h.new_lines), (0, 0));
647 }
648
649 #[cfg_attr(
650 not(feature = "full-tests"),
651 ignore = "starts system git or is heavy; run with --features full-tests"
652 )]
653 #[test]
654 fn line_level_multiple_hunks_match_git() {
655 let old = "keep\nchange-a\nmid\nchange-b\nend\n";
656 let new = "keep\nCHANGE-A\nmid\nCHANGE-B\nend\n";
657 let patch = git_diff_u0(old, new);
658 let git_hunks: Vec<_> = patch.lines().filter(|l| l.starts_with("@@")).collect();
659 assert_eq!(git_hunks.len(), 2);
660 let d = structured_text_diff(
661 old,
662 new,
663 DiffOptions {
664 context_lines: 0,
665 word_level_inline: false,
666 ..DiffOptions::default()
667 },
668 );
669 assert_eq!(d.hunks.len(), 2);
670 for (h, header) in d.hunks.iter().zip(git_hunks) {
671 let (g_os, g_ol, g_ns, g_nl) = parse_hunk_header(&format!("{header}\n"));
672 assert_eq!(
673 (h.old_start, h.old_lines, h.new_start, h.new_lines),
674 (g_os, g_ol, g_ns, g_nl)
675 );
676 }
677 }
678
679 #[test]
680 fn line_level_blank_line_in_hunk() {
681 let old = "a\n\nb\n";
682 let new = "a\nX\n\nb\n";
683 let d = structured_text_diff(
684 old,
685 new,
686 DiffOptions {
687 context_lines: 1,
688 word_level_inline: false,
689 ..DiffOptions::default()
690 },
691 );
692 assert!(!d.hunks.is_empty());
693 assert!(
694 d.hunks[0]
695 .lines
696 .iter()
697 .any(|l| l.segments[0].text.is_empty() && l.kind == StructuredLineKind::Context)
698 || d.hunks[0]
699 .lines
700 .iter()
701 .any(|l| l.segments[0].text.is_empty())
702 );
703 }
704
705 #[test]
706 fn line_level_missing_eof_newline_flag() {
707 let old = "only";
708 let new = "only\n";
709 let d = structured_text_diff(
710 old,
711 new,
712 DiffOptions {
713 context_lines: 0,
714 word_level_inline: false,
715 ..DiffOptions::default()
716 },
717 );
718 let h = &d.hunks[0];
719 let add = h
720 .lines
721 .iter()
722 .find(|l| l.kind == StructuredLineKind::Add)
723 .expect("add line");
724 assert!(add.no_newline_at_eof || h.lines.iter().any(|l| l.no_newline_at_eof));
725 }
726
727 #[test]
728 fn hunk_header_counts_match_line_totals() {
729 let old = "x\n";
730 let new = "x\nmore\n";
731 let d = structured_text_diff(
732 old,
733 new,
734 DiffOptions {
735 context_lines: 3,
736 ..DiffOptions::default()
737 },
738 );
739 let h = &d.hunks[0];
740 assert_eq!(h.old_lines, 1);
741 assert_eq!(h.new_lines, 2);
742 }
743
744 #[test]
745 fn mode_only_change_has_empty_hunks() {
746 let old = SideBytes::from_bytes(b"x\n".to_vec());
747 let new = SideBytes::from_bytes(b"x\n".to_vec());
748 let f = structured_file_diff(
749 FileMode::REGULAR,
750 FileMode::EXECUTABLE,
751 &old,
752 &new,
753 DiffOptions::default(),
754 );
755 assert!(f.hunks.is_empty());
756 }
757}