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