1use std::fmt::Write as _;
37use std::path::PathBuf;
38
39use crate::highlight::{DiffLineClass, classify_diff_line};
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct HunkPatch {
44 pub header: Vec<String>,
46 pub hunk: Vec<String>,
48 pub header_line: usize,
50 pub end_line: usize,
52}
53
54impl HunkPatch {
55 pub fn display_location(&self) -> String {
63 let path = self.display_path();
64 let start = self
65 .hunk
66 .first()
67 .and_then(|h| parse_hunk_starts(h.trim_end()));
68 match (path, start) {
69 (Some(p), Some(HunkStarts { new, .. })) => format!("{p}:{new}"),
70 (Some(p), None) => p.to_string(),
71 (None, _) => "hunk".to_string(),
72 }
73 }
74
75 pub fn file_path(&self) -> Option<&str> {
86 self.display_path().or_else(|| {
87 let minus = self.header.iter().find(|l| l.starts_with("--- "))?;
88 let rest = minus[4..].trim_end();
89 (rest != "/dev/null").then(|| rest.strip_prefix("a/").unwrap_or(rest))
90 })
91 }
92
93 pub fn display_path(&self) -> Option<&str> {
99 let plus = self.header.iter().find(|l| l.starts_with("+++ "))?;
100 let rest = plus[4..].trim_end();
101 if rest == "/dev/null" {
102 return None;
103 }
104 Some(rest.strip_prefix("b/").unwrap_or(rest))
105 }
106
107 pub fn to_patch(&self) -> String {
112 let mut out = String::new();
113 for line in self.header.iter().chain(self.hunk.iter()) {
114 let _ = writeln!(out, "{line}");
115 }
116 out
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum ApplyDirection {
128 Forward,
130 Reverse,
132}
133
134impl HunkPatch {
135 pub fn restrict_to_rows(
165 &self,
166 selected: std::ops::RangeInclusive<usize>,
167 direction: ApplyDirection,
168 ) -> Option<HunkPatch> {
169 let mut body: Vec<String> = Vec::new();
170 let mut selected_changes = 0usize;
171 let mut kept_previous = false;
176
177 for (k, line) in self.hunk.iter().enumerate().skip(1) {
178 let row = self.header_line + k;
181 if line.starts_with('\\') {
182 if kept_previous {
183 body.push(line.clone());
184 }
185 continue;
186 }
187 let marker = line.chars().next();
188 let is_change = matches!(marker, Some('+') | Some('-'));
189 if !is_change {
190 body.push(line.clone());
191 kept_previous = true;
192 continue;
193 }
194 let is_add = marker == Some('+');
195 if selected.contains(&row) {
196 body.push(line.clone());
197 kept_previous = true;
198 selected_changes += 1;
199 continue;
200 }
201 let dropped = match direction {
202 ApplyDirection::Forward => is_add,
203 ApplyDirection::Reverse => !is_add,
204 };
205 if dropped {
206 kept_previous = false;
207 } else {
208 body.push(format!(" {}", &line[1..]));
211 kept_previous = true;
212 }
213 }
214
215 if selected_changes == 0 {
216 return None;
217 }
218
219 let old = body
220 .iter()
221 .filter(|l| !l.starts_with('\\') && !l.starts_with('+'))
222 .count();
223 let new = body
224 .iter()
225 .filter(|l| !l.starts_with('\\') && !l.starts_with('-'))
226 .count();
227 let header = rewrite_hunk_header(self.hunk.first()?, old, new)?;
228
229 let mut hunk = Vec::with_capacity(body.len() + 1);
230 hunk.push(header);
231 hunk.extend(body);
232 Some(HunkPatch {
233 header: self.header.clone(),
234 hunk,
235 header_line: self.header_line,
236 end_line: self.end_line,
237 })
238 }
239}
240
241fn rewrite_hunk_header(original: &str, old: usize, new: usize) -> Option<String> {
244 let trimmed = original.trim_end();
245 let starts = parse_hunk_starts(trimmed)?;
246 let suffix = trimmed
251 .strip_prefix("@@ ")
252 .and_then(|rest| rest.split_once(" @@"))
253 .map(|(_, after)| after)
254 .unwrap_or("");
255 Some(format!(
256 "@@ -{},{} +{},{} @@{}",
257 starts.old, old, starts.new, new, suffix
258 ))
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263struct HunkCounts {
264 old: usize,
265 new: usize,
266}
267
268fn parse_hunk_counts(header: &str) -> Option<HunkCounts> {
273 let inner = header.strip_prefix("@@ ")?;
274 let inner = inner.split(" @@").next()?;
275 let mut parts = inner.split_whitespace();
276 let old = parse_range_count(parts.next()?.strip_prefix('-')?)?;
277 let new = parse_range_count(parts.next()?.strip_prefix('+')?)?;
278 Some(HunkCounts { old, new })
279}
280
281fn parse_range_count(range: &str) -> Option<usize> {
283 match range.split_once(',') {
284 Some((_, count)) => count.parse().ok(),
285 None => Some(1),
286 }
287}
288
289struct HunkStarts {
292 old: usize,
293 new: usize,
294}
295
296fn parse_hunk_starts(header: &str) -> Option<HunkStarts> {
297 let inner = header.strip_prefix("@@ ")?;
298 let inner = inner.split(" @@").next()?;
299 let mut parts = inner.split_whitespace();
300 let old = parse_range_start(parts.next()?.strip_prefix('-')?)?;
301 let new = parse_range_start(parts.next()?.strip_prefix('+')?)?;
302 Some(HunkStarts { old, new })
303}
304
305fn parse_range_start(range: &str) -> Option<usize> {
307 range
308 .split_once(',')
309 .map(|(start, _)| start)
310 .unwrap_or(range)
311 .parse()
312 .ok()
313}
314
315#[cfg(test)]
320pub(crate) fn hunk_at(lines: &[&str], cursor: usize) -> Option<HunkPatch> {
321 hunk_at_with(|i| lines.get(i).map(|l| (*l).to_string()), cursor)
322}
323
324pub fn path_at_cursor(read: impl Fn(usize) -> Option<String>, cursor: usize) -> Option<PathBuf> {
361 for l in (0..=cursor).rev() {
362 let text = read(l)?;
363 if let Some(rest) = text.strip_prefix("diff --git a/") {
364 return rest.split(" b/").next().map(PathBuf::from);
368 }
369 }
370 parse_stat_line(&read(cursor)?)
373}
374
375fn parse_stat_line(line: &str) -> Option<PathBuf> {
381 let trimmed = line.trim_start();
382 let (path, _rest) = trimmed.split_once(" | ")?;
383 let path = path.trim();
384 (!path.is_empty()).then(|| PathBuf::from(path))
385}
386
387pub fn hunk_at_with(read: impl Fn(usize) -> Option<String>, cursor: usize) -> Option<HunkPatch> {
388 let header_line = enclosing_hunk_header(&read, cursor)?;
389 let header_text = read(header_line)?;
390 let counts = parse_hunk_counts(header_text.trim_end())?;
391
392 let mut old_seen = 0usize;
401 let mut new_seen = 0usize;
402 let mut hunk = vec![header_text];
403 let mut idx = header_line + 1;
404 while old_seen < counts.old || new_seen < counts.new {
405 let Some(line) = read(idx) else { break };
406 if line.starts_with('\\') {
410 hunk.push(line);
411 idx += 1;
412 continue;
413 }
414 match line.chars().next() {
415 Some('+') => new_seen += 1,
416 Some('-') => old_seen += 1,
417 Some(' ') | None => {
421 old_seen += 1;
422 new_seen += 1;
423 }
424 _ => break,
427 }
428 hunk.push(line);
429 idx += 1;
430 }
431
432 if old_seen < counts.old || new_seen < counts.new {
435 return None;
436 }
437
438 while let Some(line) = read(idx) {
444 if !line.starts_with('\\') {
445 break;
446 }
447 hunk.push(line);
448 idx += 1;
449 }
450
451 if cursor >= idx {
458 return None;
459 }
460
461 let header = file_header_above(&read, header_line)?;
462 Some(HunkPatch {
463 header,
464 hunk,
465 header_line,
466 end_line: idx,
467 })
468}
469
470pub fn hunk_ordinal_at(read: impl Fn(usize) -> Option<String>, header_row: usize) -> usize {
479 let mut ordinal = 0usize;
480 for row in (0..header_row).rev() {
481 let Some(line) = read(row) else { break };
482 match classify_diff_line(&line) {
483 DiffLineClass::Hunk => ordinal += 1,
484 DiffLineClass::FileCommand => break,
485 _ => {}
486 }
487 }
488 ordinal
489}
490
491fn enclosing_hunk_header(read: &impl Fn(usize) -> Option<String>, cursor: usize) -> Option<usize> {
494 read(cursor)?;
496 for idx in (0..=cursor).rev() {
497 let line = read(idx)?;
498 match classify_diff_line(&line) {
499 DiffLineClass::Hunk => return Some(idx),
500 DiffLineClass::FileCommand => return None,
504 _ => {}
505 }
506 }
507 None
508}
509
510fn file_header_above(
517 read: &impl Fn(usize) -> Option<String>,
518 header_line: usize,
519) -> Option<Vec<String>> {
520 let start = (0..header_line).rev().find(|&i| {
521 read(i)
522 .map(|l| classify_diff_line(&l) == DiffLineClass::FileCommand)
523 .unwrap_or(false)
524 })?;
525 let mut header = Vec::new();
526 for i in start..header_line {
527 let line = read(i)?;
528 if classify_diff_line(&line) == DiffLineClass::Hunk {
529 break;
530 }
531 header.push(line);
532 }
533 Some(header)
534}
535
536#[cfg(test)]
538mod path_at_cursor_tests {
539 use super::*;
540
541 fn reader(lines: &'static [&'static str]) -> impl Fn(usize) -> Option<String> {
542 move |i: usize| lines.get(i).map(|s| s.to_string())
543 }
544
545 const SHOW: &[&str] = &[
547 "commit a1b2c3d4",
548 "Author: Jane Doe <jane@example.com>",
549 "",
550 " do the thing",
551 "",
552 " src/main.rs | 12 +++++-----",
553 " 1 file changed",
554 "",
555 "diff --git a/src/main.rs b/src/main.rs",
556 "index 111..222 100644",
557 "--- a/src/main.rs",
558 "+++ b/src/main.rs",
559 "@@ -1,3 +1,3 @@",
560 " fn main() {",
561 "- let x = a | b;",
562 "+ let x = a & b;",
563 " }",
564 ];
565
566 #[test]
572 fn a_diff_line_containing_a_pipe_resolves_to_its_file_not_to_itself() {
573 let got = path_at_cursor(reader(SHOW), 14);
574 assert_eq!(
575 got,
576 Some(PathBuf::from("src/main.rs")),
577 "a body line with ` | ` in it must resolve through the \
578 `diff --git` header above it"
579 );
580 }
581
582 #[test]
585 fn a_stat_summary_row_resolves_to_the_file_it_names() {
586 assert_eq!(
587 path_at_cursor(reader(SHOW), 5),
588 Some(PathBuf::from("src/main.rs"))
589 );
590 }
591
592 #[test]
594 fn a_plain_diff_resolves_from_the_header_above_the_cursor() {
595 const DIFF: &[&str] = &[
596 "diff --git a/src/lib.rs b/src/lib.rs",
597 "--- a/src/lib.rs",
598 "+++ b/src/lib.rs",
599 "@@ -1 +1 @@",
600 "-old",
601 "+new",
602 ];
603 assert_eq!(
604 path_at_cursor(reader(DIFF), 5),
605 Some(PathBuf::from("src/lib.rs"))
606 );
607 }
608
609 #[test]
613 fn a_multi_file_diff_resolves_to_the_nearest_header_above() {
614 const TWO: &[&str] = &[
615 "diff --git a/a.txt b/a.txt",
616 "@@ -1 +1 @@",
617 "-a",
618 "diff --git a/b.txt b/b.txt",
619 "@@ -1 +1 @@",
620 "-b",
621 ];
622 assert_eq!(path_at_cursor(reader(TWO), 2), Some(PathBuf::from("a.txt")));
623 assert_eq!(path_at_cursor(reader(TWO), 5), Some(PathBuf::from("b.txt")));
624 }
625
626 #[test]
629 fn prose_with_no_diff_above_it_resolves_to_nothing() {
630 const HEADER_ONLY: &[&str] = &["commit a1b2c3d4", "Author: Jane", "", " subject"];
631 assert_eq!(path_at_cursor(reader(HEADER_ONLY), 3), None);
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 const TWO_HUNKS: &str = "\
640diff --git a/src/main.rs b/src/main.rs
641index 1234567..89abcde 100644
642--- a/src/main.rs
643+++ b/src/main.rs
644@@ -1,3 +1,3 @@
645 fn main() {
646- println!(\"old\");
647+ println!(\"new\");
648 }
649@@ -20,2 +20,3 @@ fn other() {
650 let x = 1;
651+ let y = 2;
652 drop(x);
653";
654
655 fn lines(s: &str) -> Vec<&str> {
656 s.lines().collect()
657 }
658
659 #[test]
660 fn a_cursor_inside_the_first_hunk_finds_exactly_that_hunk() {
661 let l = lines(TWO_HUNKS);
662 let h = hunk_at(&l, 6).expect("cursor is inside hunk 1");
664 assert_eq!(h.header_line, 4);
665 assert!(h.hunk[0].starts_with("@@ -1,3 +1,3 @@"));
666 assert!(
667 h.hunk.iter().any(|s| s.contains("println!(\"new\")")),
668 "hunk 1's body is present: {:?}",
669 h.hunk
670 );
671 assert!(
672 !h.hunk.iter().any(|s| s.contains("let y = 2")),
673 "hunk 2 must NOT bleed in: {:?}",
674 h.hunk
675 );
676 }
677
678 #[test]
679 fn a_cursor_in_the_second_hunk_finds_the_second() {
680 let l = lines(TWO_HUNKS);
681 let h = hunk_at(&l, 11).expect("cursor is inside hunk 2");
682 assert!(h.hunk[0].starts_with("@@ -20,2 +20,3 @@"));
683 assert!(h.hunk.iter().any(|s| s.contains("let y = 2")));
684 assert!(!h.hunk.iter().any(|s| s.contains("println!")));
685 }
686
687 #[test]
688 fn the_hunk_header_line_itself_resolves_to_its_own_hunk() {
689 let l = lines(TWO_HUNKS);
690 let h = hunk_at(&l, 4).expect("cursor on the @@ line");
691 assert_eq!(h.header_line, 4);
692 }
693
694 #[test]
695 fn a_cursor_in_the_file_header_is_not_in_a_hunk() {
696 let l = lines(TWO_HUNKS);
697 assert!(
699 hunk_at(&l, 2).is_none(),
700 "header lines fall back to file-level staging"
701 );
702 assert!(hunk_at(&l, 0).is_none(), "the diff --git line likewise");
703 }
704
705 #[test]
706 fn the_counts_stop_the_body_before_a_following_status_entry() {
707 let text = "\
711diff --git a/a.txt b/a.txt
712--- a/a.txt
713+++ b/a.txt
714@@ -1,2 +1,2 @@
715 keep
716-old
717+new
718 modified src/other.rs
719 modified src/third.rs
720";
721 let l = lines(text);
722 let h = hunk_at(&l, 5).expect("inside the hunk");
723 assert_eq!(
724 h.hunk,
725 vec!["@@ -1,2 +1,2 @@", " keep", "-old", "+new"],
726 "the body stops at the declared counts, not at the next entry"
727 );
728 assert!(
729 !h.to_patch().contains("modified src/other.rs"),
730 "a status entry must never reach the patch:\n{}",
731 h.to_patch()
732 );
733 }
734
735 #[test]
741 fn a_cursor_below_a_hunk_resolves_to_no_hunk() {
742 let text = "\
743diff --git a/a.txt b/a.txt
744--- a/a.txt
745+++ b/a.txt
746@@ -1,2 +1,2 @@
747 keep
748-old
749+new
750 modified src/other.rs
751";
752 let l = lines(text);
753 assert!(
754 hunk_at(&l, 6).is_some(),
755 "the last body line is still inside the hunk"
756 );
757 assert!(
758 hunk_at(&l, 7).is_none(),
759 "the following status entry is not in the hunk — `s` there stages the file"
760 );
761 }
762
763 #[test]
768 fn trailing_whitespace_survives_into_the_patch() {
769 let text = concat!(
773 "diff --git a/a.txt b/a.txt\n",
774 "--- a/a.txt\n",
775 "+++ b/a.txt\n",
776 "@@ -1,2 +1,2 @@\n",
777 " keep \n",
778 "-old\t\n",
779 "+new\n",
780 );
781 let l = lines(text);
782 let h = hunk_at(&l, 5).expect("inside the hunk");
783 assert_eq!(h.hunk[1], " keep ", "context kept verbatim");
784 assert_eq!(h.hunk[2], "-old\t", "removed line kept verbatim");
785 }
786
787 #[test]
791 fn a_trailing_no_newline_marker_after_the_last_body_line_rides_along() {
792 let text = "\
793diff --git a/a.txt b/a.txt
794--- a/a.txt
795+++ b/a.txt
796@@ -1 +1 @@
797-old
798+new
799\\ No newline at end of file
800";
801 let l = lines(text);
802 let h = hunk_at(&l, 4).expect("inside the hunk");
803 assert_eq!(
804 h.hunk.last().map(String::as_str),
805 Some("\\ No newline at end of file"),
806 "the trailing marker must reach the patch: {:?}",
807 h.hunk
808 );
809 assert_eq!(h.end_line, 7, "and be counted as part of the hunk");
810 }
811
812 #[test]
815 fn a_hunks_ordinal_counts_within_its_own_file() {
816 let l = lines(TWO_HUNKS);
817 let read = |i: usize| l.get(i).map(|s| (*s).to_string());
818 assert_eq!(hunk_ordinal_at(read, 4), 0, "the first `@@`");
819 assert_eq!(hunk_ordinal_at(read, 9), 1, "the second");
820 }
821
822 #[test]
823 fn the_ordinal_restarts_at_each_files_header() {
824 let text = "\
825diff --git a/a.txt b/a.txt
826@@ -1,1 +1,1 @@
827-a
828diff --git a/b.txt b/b.txt
829@@ -1,1 +1,1 @@
830-b
831@@ -9,1 +9,1 @@
832-c
833";
834 let l = lines(text);
835 let read = |i: usize| l.get(i).map(|s| (*s).to_string());
836 assert_eq!(
837 hunk_ordinal_at(read, 4),
838 0,
839 "b.txt's first hunk is ordinal 0, not 1 — the count stops at its own `diff --git`"
840 );
841 assert_eq!(hunk_ordinal_at(read, 6), 1);
842 }
843
844 #[test]
847 fn file_path_falls_back_to_the_minus_side_for_a_deletion() {
848 let text = "\
849diff --git a/gone.txt b/gone.txt
850--- a/gone.txt
851+++ /dev/null
852@@ -1 +0,0 @@
853-was here
854";
855 let l = lines(text);
856 let h = hunk_at(&l, 4).expect("inside the hunk");
857 assert_eq!(h.display_path(), None, "prompts omit /dev/null");
858 assert_eq!(
859 h.file_path(),
860 Some("gone.txt"),
861 "but the restore must still name the file"
862 );
863 }
864
865 #[test]
866 fn display_location_names_the_file_line_not_the_buffer_row() {
867 let l = lines(TWO_HUNKS);
868 let h = hunk_at(&l, 11).unwrap();
870 assert_eq!(h.display_location(), "src/main.rs:20");
871 }
872
873 #[test]
874 fn display_location_falls_back_when_the_header_is_unparseable() {
875 let text = "\
876diff --git a/gone.txt b/gone.txt
877--- a/gone.txt
878+++ /dev/null
879@@ -1 +0,0 @@
880-was here
881";
882 let l = lines(text);
883 let h = hunk_at(&l, 4).unwrap();
884 assert_eq!(h.display_location(), "hunk", "a deletion has no b/ path");
885 }
886
887 #[test]
888 fn a_hunk_header_without_counts_means_one_line() {
889 let text = "\
890diff --git a/a.txt b/a.txt
891--- a/a.txt
892+++ b/a.txt
893@@ -5 +5 @@
894-old
895+new
896";
897 let l = lines(text);
898 let h = hunk_at(&l, 4).expect("inside the hunk");
899 assert_eq!(h.hunk, vec!["@@ -5 +5 @@", "-old", "+new"]);
900 }
901
902 #[test]
903 fn a_no_newline_marker_rides_along_with_the_body() {
904 let text = "\
905diff --git a/a.txt b/a.txt
906--- a/a.txt
907+++ b/a.txt
908@@ -1 +1 @@
909-old
910\\ No newline at end of file
911+new
912";
913 let l = lines(text);
914 let h = hunk_at(&l, 4).expect("inside the hunk");
915 assert!(
916 h.hunk.iter().any(|s| s.starts_with('\\')),
917 "dropping the marker would change the applied result's trailing newline: {:?}",
918 h.hunk
919 );
920 }
921
922 #[test]
923 fn a_truncated_hunk_is_refused_rather_than_applied() {
924 let text = "\
927diff --git a/a.txt b/a.txt
928--- a/a.txt
929+++ b/a.txt
930@@ -1,5 +1,5 @@
931 one
932-two
933";
934 let l = lines(text);
935 assert!(
936 hunk_at(&l, 5).is_none(),
937 "an unsatisfied count means truncated — refuse"
938 );
939 }
940
941 #[test]
942 fn a_hunk_with_no_file_header_above_it_is_refused() {
943 let text = "\
946@@ -1,2 +1,2 @@
947 keep
948-old
949+new
950";
951 let l = lines(text);
952 assert!(hunk_at(&l, 2).is_none());
953 }
954
955 #[test]
956 fn the_patch_carries_the_header_verbatim_and_ends_in_a_newline() {
957 let l = lines(TWO_HUNKS);
958 let h = hunk_at(&l, 6).unwrap();
959 let patch = h.to_patch();
960 assert!(patch.starts_with("diff --git a/src/main.rs b/src/main.rs\n"));
961 assert!(
962 patch.contains("index 1234567..89abcde 100644"),
963 "index/mode metadata is preserved, not reconstructed:\n{patch}"
964 );
965 assert!(
966 patch.ends_with('\n'),
967 "git apply rejects an unterminated patch"
968 );
969 }
970
971 #[test]
972 fn display_path_reads_the_plus_header() {
973 let l = lines(TWO_HUNKS);
974 let h = hunk_at(&l, 6).unwrap();
975 assert_eq!(h.display_path(), Some("src/main.rs"));
976 }
977
978 #[test]
979 fn display_path_is_none_for_a_deletion() {
980 let text = "\
981diff --git a/gone.txt b/gone.txt
982--- a/gone.txt
983+++ /dev/null
984@@ -1 +0,0 @@
985-was here
986";
987 let l = lines(text);
988 let h = hunk_at(&l, 4).expect("inside the hunk");
989 assert_eq!(h.display_path(), None);
990 assert!(
991 h.to_patch().contains("+++ /dev/null"),
992 "the patch still carries the real header"
993 );
994 }
995
996 #[test]
997 fn every_body_line_of_a_multi_hunk_diff_resolves_to_its_own_hunk() {
998 let l = lines(TWO_HUNKS);
1001 let first: Vec<usize> = (5..=8).collect();
1002 let second: Vec<usize> = (10..=12).collect();
1003 for c in first {
1004 let h = hunk_at(&l, c).unwrap_or_else(|| panic!("line {c} is in hunk 1"));
1005 assert_eq!(h.header_line, 4, "line {c} belongs to hunk 1");
1006 }
1007 for c in second {
1008 let h = hunk_at(&l, c).unwrap_or_else(|| panic!("line {c} is in hunk 2"));
1009 assert_eq!(h.header_line, 9, "line {c} belongs to hunk 2");
1010 }
1011 }
1012}
1013
1014#[cfg(test)]
1020mod region {
1021 use super::*;
1022
1023 const REGION: &str = "\
1024diff --git a/a.txt b/a.txt
1025index 111..222 100644
1026--- a/a.txt
1027+++ b/a.txt
1028@@ -1,3 +1,3 @@
1029 keep
1030-old-a
1031-old-b
1032+new-a
1033+new-b
1034";
1035 const KEEP: usize = 5;
1037 const OLD_A: usize = 6;
1038 const OLD_B: usize = 7;
1039 const NEW_A: usize = 8;
1040 const NEW_B: usize = 9;
1041
1042 fn whole() -> HunkPatch {
1043 let lines: Vec<&str> = REGION.lines().collect();
1044 hunk_at(&lines, OLD_A).expect("the fixture parses")
1045 }
1046
1047 fn body(p: &HunkPatch) -> Vec<&str> {
1048 p.hunk.iter().map(String::as_str).collect()
1049 }
1050
1051 #[test]
1054 fn selecting_the_whole_body_reproduces_the_whole_hunk_patch() {
1055 let whole = whole();
1056 let restricted = whole
1057 .restrict_to_rows(KEEP..=NEW_B, ApplyDirection::Forward)
1058 .expect("changes are selected");
1059 assert_eq!(
1060 restricted.to_patch(),
1061 whole.to_patch(),
1062 "an all-selected region is not a special case, it IS the hunk"
1063 );
1064 }
1065
1066 #[test]
1069 fn a_selection_with_no_change_in_it_is_refused() {
1070 assert!(
1071 whole()
1072 .restrict_to_rows(KEEP..=KEEP, ApplyDirection::Forward)
1073 .is_none(),
1074 "a context-only selection would be a patch that does nothing"
1075 );
1076 }
1077
1078 #[test]
1082 fn staging_one_addition_drops_the_other_and_contextualises_removals() {
1083 let p = whole()
1084 .restrict_to_rows(NEW_A..=NEW_A, ApplyDirection::Forward)
1085 .expect("one addition selected");
1086 assert_eq!(
1087 body(&p),
1088 vec!["@@ -1,3 +1,4 @@", " keep", " old-a", " old-b", "+new-a"],
1089 "old side unchanged (3 lines), new side gains exactly the one addition"
1090 );
1091 }
1092
1093 #[test]
1096 fn staging_one_removal_keeps_it_and_drops_every_addition() {
1097 let p = whole()
1098 .restrict_to_rows(OLD_B..=OLD_B, ApplyDirection::Forward)
1099 .expect("one removal selected");
1100 assert_eq!(
1101 body(&p),
1102 vec!["@@ -1,3 +1,2 @@", " keep", " old-a", "-old-b"],
1103 );
1104 }
1105
1106 #[test]
1110 fn unstaging_mirrors_the_rules_exactly() {
1111 let p = whole()
1112 .restrict_to_rows(OLD_A..=OLD_A, ApplyDirection::Reverse)
1113 .expect("one removal selected");
1114 assert_eq!(
1115 body(&p),
1116 vec!["@@ -1,4 +1,3 @@", " keep", "-old-a", " new-a", " new-b"],
1117 "new side unchanged at 3 — it is what a reverse apply matches. The old \
1118 side is 4 because un-removing `old-a` puts it back ALONGSIDE the \
1119 additions that stay staged."
1120 );
1121 }
1122
1123 #[test]
1125 fn an_interleaved_selection_applies_both_rules() {
1126 let p = whole()
1127 .restrict_to_rows(OLD_B..=NEW_A, ApplyDirection::Forward)
1128 .expect("one removal and one addition selected");
1129 assert_eq!(
1130 body(&p),
1131 vec!["@@ -1,3 +1,3 @@", " keep", " old-a", "-old-b", "+new-a"],
1132 );
1133 }
1134
1135 #[test]
1139 fn a_selection_wider_than_the_hunk_clamps_to_its_body() {
1140 let p = whole()
1141 .restrict_to_rows(0..=999, ApplyDirection::Forward)
1142 .expect("everything selected");
1143 assert_eq!(p.to_patch(), whole().to_patch());
1144 }
1145
1146 #[test]
1150 fn the_headers_function_context_suffix_survives_the_rewrite() {
1151 let text = "\
1152diff --git a/a.rs b/a.rs
1153--- a/a.rs
1154+++ b/a.rs
1155@@ -10,1 +10,1 @@ fn main() {
1156-old
1157+new
1158";
1159 let lines: Vec<&str> = text.lines().collect();
1160 let p = hunk_at(&lines, 4)
1161 .expect("parses")
1162 .restrict_to_rows(4..=4, ApplyDirection::Forward)
1163 .expect("the removal is selected");
1164 assert_eq!(
1165 p.hunk[0], "@@ -10,1 +10,0 @@ fn main() {",
1166 "starts and suffix kept, counts recomputed"
1167 );
1168 }
1169
1170 #[test]
1174 fn a_marker_whose_line_was_dropped_is_dropped_with_it() {
1175 let text = "\
1176diff --git a/a.txt b/a.txt
1177--- a/a.txt
1178+++ b/a.txt
1179@@ -1,2 +1,2 @@
1180 keep
1181-old
1182+new
1183\\ No newline at end of file
1184";
1185 let lines: Vec<&str> = text.lines().collect();
1186 let whole = hunk_at(&lines, 5).expect("parses");
1187 let p = whole
1190 .restrict_to_rows(5..=5, ApplyDirection::Forward)
1191 .expect("the removal is selected");
1192 assert!(
1193 !p.hunk.iter().any(|l| l.starts_with('\\')),
1194 "the marker belonged to the dropped line: {:?}",
1195 p.hunk
1196 );
1197 let p = whole
1199 .restrict_to_rows(6..=6, ApplyDirection::Forward)
1200 .expect("the addition is selected");
1201 assert!(
1202 p.hunk.last().is_some_and(|l| l.starts_with('\\')),
1203 "kept with the line it annotates: {:?}",
1204 p.hunk
1205 );
1206 }
1207}
1208
1209#[cfg(test)]
1213mod git_round_trip {
1214 use super::*;
1215 use std::process::Command;
1216
1217 fn git(dir: &std::path::Path, args: &[&str]) -> String {
1218 let out = Command::new("git")
1219 .args(args)
1220 .current_dir(dir)
1221 .output()
1222 .expect("git");
1223 String::from_utf8_lossy(&out.stdout).into_owned()
1224 }
1225
1226 fn git_ok(dir: &std::path::Path, args: &[&str]) {
1227 let st = Command::new("git")
1228 .args(args)
1229 .current_dir(dir)
1230 .status()
1231 .expect("git");
1232 assert!(st.success(), "git {args:?} failed");
1233 }
1234
1235 fn two_hunk_repo() -> tempfile::TempDir {
1238 let dir = tempfile::tempdir().expect("tempdir");
1239 let p = dir.path();
1240 git_ok(p, &["init"]);
1241 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
1242 git_ok(p, &["config", "user.name", "lattice-test"]);
1243 let base: String = (1..=20).map(|i| format!("line {i}\n")).collect();
1244 std::fs::write(p.join("a.txt"), &base).unwrap();
1245 git_ok(p, &["add", "a.txt"]);
1246 git_ok(p, &["commit", "-m", "base"]);
1247 let modified: String = (1..=20)
1248 .map(|i| match i {
1249 2 => "line 2 CHANGED\n".to_string(),
1250 19 => "line 19 CHANGED\n".to_string(),
1251 _ => format!("line {i}\n"),
1252 })
1253 .collect();
1254 std::fs::write(p.join("a.txt"), &modified).unwrap();
1255 dir
1256 }
1257
1258 fn nth_hunk_line(text: &str, n: usize) -> usize {
1260 text.lines()
1261 .enumerate()
1262 .filter(|(_, l)| l.starts_with("@@ "))
1263 .map(|(i, _)| i)
1264 .nth(n)
1265 .expect("hunk header")
1266 }
1267
1268 #[test]
1269 fn a_parsed_hunk_applies_to_the_index_without_taking_its_neighbour() {
1270 let dir = two_hunk_repo();
1271 let p = dir.path();
1272 let diff = git(p, &["diff", "--", "a.txt"]);
1273 let lines: Vec<&str> = diff.lines().collect();
1274
1275 let cursor = nth_hunk_line(&diff, 0) + 1;
1277 let h = hunk_at(&lines, cursor).expect("cursor is inside hunk 1");
1278
1279 let repo = lattice_vcs::Repository::discover(p).expect("discover");
1280 lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), true, false)
1281 .expect("the synthesized patch must be one git accepts");
1282
1283 let staged = git(p, &["diff", "--cached", "--", "a.txt"]);
1284 assert!(
1285 staged.contains("line 2 CHANGED"),
1286 "the selected hunk reached the index:\n{staged}"
1287 );
1288 assert!(
1289 !staged.contains("line 19 CHANGED"),
1290 "the neighbouring hunk must stay unstaged:\n{staged}"
1291 );
1292 }
1293
1294 #[test]
1295 fn the_second_hunk_applies_just_as_cleanly() {
1296 let dir = two_hunk_repo();
1300 let p = dir.path();
1301 let diff = git(p, &["diff", "--", "a.txt"]);
1302 let lines: Vec<&str> = diff.lines().collect();
1303
1304 let cursor = nth_hunk_line(&diff, 1) + 1;
1305 let h = hunk_at(&lines, cursor).expect("cursor is inside hunk 2");
1306
1307 let repo = lattice_vcs::Repository::discover(p).expect("discover");
1308 lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), true, false)
1309 .expect("hunk 2's patch applies");
1310
1311 let staged = git(p, &["diff", "--cached", "--", "a.txt"]);
1312 assert!(staged.contains("line 19 CHANGED"), "{staged}");
1313 assert!(!staged.contains("line 2 CHANGED"), "{staged}");
1314 }
1315
1316 #[test]
1317 fn a_parsed_hunk_reverses_out_of_the_index() {
1318 let dir = two_hunk_repo();
1320 let p = dir.path();
1321 git_ok(p, &["add", "a.txt"]);
1322 let staged_diff = git(p, &["diff", "--cached", "--", "a.txt"]);
1323 let lines: Vec<&str> = staged_diff.lines().collect();
1324
1325 let cursor = nth_hunk_line(&staged_diff, 0) + 1;
1326 let h = hunk_at(&lines, cursor).expect("cursor inside staged hunk 1");
1327
1328 let repo = lattice_vcs::Repository::discover(p).expect("discover");
1329 lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), true, true)
1330 .expect("reverse-apply unstages");
1331
1332 let still = git(p, &["diff", "--cached", "--", "a.txt"]);
1333 assert!(!still.contains("line 2 CHANGED"), "{still}");
1334 assert!(still.contains("line 19 CHANGED"), "{still}");
1335 }
1336
1337 #[test]
1345 fn a_committed_hunk_applies_to_the_worktree_and_reverses_back_out() {
1346 let dir = tempfile::tempdir().expect("tempdir");
1347 let p = dir.path();
1348 git_ok(p, &["init"]);
1349 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
1350 git_ok(p, &["config", "user.name", "lattice-test"]);
1351 git_ok(p, &["config", "core.autocrlf", "false"]);
1355 let base: String = (1..=20).map(|i| format!("line {i}\n")).collect();
1356 std::fs::write(p.join("a.txt"), &base).unwrap();
1357 git_ok(p, &["add", "a.txt"]);
1358 git_ok(p, &["commit", "-m", "base"]);
1359
1360 let edited: String = (1..=20)
1363 .map(|i| match i {
1364 2 => "line 2 FROM COMMIT\n".to_string(),
1365 19 => "line 19 FROM COMMIT\n".to_string(),
1366 _ => format!("line {i}\n"),
1367 })
1368 .collect();
1369 std::fs::write(p.join("a.txt"), &edited).unwrap();
1370 git_ok(p, &["add", "a.txt"]);
1371 git_ok(p, &["commit", "-m", "the change"]);
1372 git_ok(p, &["reset", "--hard", "HEAD~1"]);
1376
1377 let show = git(p, &["show", "HEAD@{1}", "--", "a.txt"]);
1378 let lines: Vec<&str> = show.lines().collect();
1379 let cursor = nth_hunk_line(&show, 0) + 1;
1380 let h = hunk_at(&lines, cursor).expect("cursor inside the commit's first hunk");
1381
1382 let repo = lattice_vcs::Repository::discover(p).expect("discover");
1383 lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), false, false)
1384 .expect("`a` applies a committed hunk to the working tree");
1385
1386 let on_disk = std::fs::read_to_string(p.join("a.txt")).unwrap();
1387 assert!(
1388 on_disk.contains("line 2 FROM COMMIT"),
1389 "the hunk must land in the file:\n{on_disk}"
1390 );
1391 assert!(
1392 !on_disk.contains("line 19 FROM COMMIT"),
1393 "and only that hunk — the commit's other change stays out:\n{on_disk}"
1394 );
1395 assert_eq!(
1396 git(p, &["diff", "--cached", "--", "a.txt"]).trim(),
1397 "",
1398 "`a` writes the working tree, never the index — a staged \
1399 hunk here would be a `cached` slip nobody would see"
1400 );
1401
1402 lattice_vcs::Index::apply_patch(&repo, &h.to_patch(), false, true)
1405 .expect("`-` reverses it back out");
1406 assert_eq!(
1407 std::fs::read_to_string(p.join("a.txt")).unwrap(),
1408 base,
1409 "reversing must restore the file exactly"
1410 );
1411 }
1412
1413 fn one_hunk_two_changes_repo() -> tempfile::TempDir {
1417 let dir = tempfile::tempdir().expect("tempdir");
1418 let p = dir.path();
1419 git_ok(p, &["init"]);
1420 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
1421 git_ok(p, &["config", "user.name", "lattice-test"]);
1422 let base: String = (1..=10).map(|i| format!("line {i}\n")).collect();
1423 std::fs::write(p.join("a.txt"), &base).unwrap();
1424 git_ok(p, &["add", "a.txt"]);
1425 git_ok(p, &["commit", "-m", "base"]);
1426 let edited: String = (1..=10)
1427 .map(|i| match i {
1428 4 => "line 4 EDITED\n".to_string(),
1429 5 => "line 5 EDITED\n".to_string(),
1430 _ => format!("line {i}\n"),
1431 })
1432 .collect();
1433 std::fs::write(p.join("a.txt"), &edited).unwrap();
1434 dir
1435 }
1436
1437 fn row_containing(text: &str, needle: &str) -> usize {
1439 text.lines()
1440 .position(|l| l.contains(needle))
1441 .unwrap_or_else(|| panic!("no line containing {needle:?} in:\n{text}"))
1442 }
1443
1444 #[test]
1448 fn a_region_patch_stages_only_the_selected_line() {
1449 let dir = one_hunk_two_changes_repo();
1450 let p = dir.path();
1451 let diff = git(p, &["diff", "--", "a.txt"]);
1452 let lines: Vec<&str> = diff.lines().collect();
1453
1454 let removal = row_containing(&diff, "-line 4");
1455 let whole = hunk_at(&lines, removal).expect("cursor inside the hunk");
1456 let region = whole
1457 .restrict_to_rows(removal..=removal, ApplyDirection::Forward)
1458 .expect("one removal selected");
1459
1460 let repo = lattice_vcs::Repository::discover(p).expect("discover");
1461 lattice_vcs::Index::apply_patch(&repo, ®ion.to_patch(), true, false)
1462 .expect("git must accept the rewritten hunk");
1463
1464 let staged = git(p, &["diff", "--cached", "--", "a.txt"]);
1465 assert!(
1466 staged.contains("-line 4") && !staged.contains("line 4 EDITED"),
1467 "only the removal of line 4 reached the index:\n{staged}"
1468 );
1469 assert!(
1473 !staged.contains("-line 5") && !staged.contains("line 5 EDITED"),
1474 "line 5's change stayed out of the index entirely:\n{staged}"
1475 );
1476 assert!(
1477 std::fs::read_to_string(p.join("a.txt"))
1478 .unwrap()
1479 .contains("line 4 EDITED"),
1480 "the worktree is untouched — staging is an index operation"
1481 );
1482 }
1483
1484 #[test]
1489 fn a_region_patch_unstages_only_the_selected_line() {
1490 let dir = one_hunk_two_changes_repo();
1491 let p = dir.path();
1492 git_ok(p, &["add", "a.txt"]);
1493 let staged_diff = git(p, &["diff", "--cached", "--", "a.txt"]);
1494 let lines: Vec<&str> = staged_diff.lines().collect();
1495
1496 let addition = row_containing(&staged_diff, "+line 4 EDITED");
1497 let whole = hunk_at(&lines, addition).expect("cursor inside the staged hunk");
1498 let region = whole
1499 .restrict_to_rows(addition..=addition, ApplyDirection::Reverse)
1500 .expect("one addition selected");
1501
1502 let repo = lattice_vcs::Repository::discover(p).expect("discover");
1503 lattice_vcs::Index::apply_patch(&repo, ®ion.to_patch(), true, true)
1504 .expect("git must accept the rewritten hunk reversed");
1505
1506 let still = git(p, &["diff", "--cached", "--", "a.txt"]);
1507 assert!(
1508 !still.contains("line 4 EDITED"),
1509 "line 4's change left the index:\n{still}"
1510 );
1511 assert!(
1512 still.contains("line 5 EDITED"),
1513 "line 5's change is still staged:\n{still}"
1514 );
1515 }
1516
1517 #[test]
1518 fn every_cursor_position_in_a_hunk_yields_the_same_patch() {
1519 let dir = two_hunk_repo();
1523 let p = dir.path();
1524 let diff = git(p, &["diff", "--", "a.txt"]);
1525 let lines: Vec<&str> = diff.lines().collect();
1526
1527 let start = nth_hunk_line(&diff, 0);
1528 let end = nth_hunk_line(&diff, 1);
1529 let expected = hunk_at(&lines, start).expect("at the header").to_patch();
1530 for cursor in start..end {
1531 let got = hunk_at(&lines, cursor)
1532 .unwrap_or_else(|| panic!("line {cursor} is inside hunk 1"))
1533 .to_patch();
1534 assert_eq!(
1535 got, expected,
1536 "cursor at line {cursor} produced a different patch"
1537 );
1538 }
1539 }
1540}
1541
1542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1550pub struct SourcePos {
1551 pub line: u32,
1552 pub byte: u32,
1553}
1554
1555pub fn source_position_at(
1589 read: impl Fn(usize) -> Option<String>,
1590 cursor: usize,
1591 cursor_byte: u32,
1592) -> Option<SourcePos> {
1593 let byte = cursor_byte.saturating_sub(1);
1596 let at = |line: u32| Some(SourcePos { line, byte });
1597
1598 let header_line = enclosing_hunk_header(&read, cursor)?;
1599 let header_text = read(header_line)?;
1600 let header = header_text.trim_end();
1601 let start = parse_hunk_starts(header)?.new;
1602 let counts = parse_hunk_counts(header)?;
1603
1604 if cursor == header_line {
1608 return u32::try_from(start.saturating_sub(1))
1609 .ok()
1610 .map(|line| SourcePos { line, byte: 0 });
1611 }
1612
1613 let (mut old_left, mut new_left) = (counts.old, counts.new);
1620 let mut advanced = 0usize;
1621 let mut row = header_line + 1;
1622 while old_left > 0 || new_left > 0 {
1623 if row == cursor {
1629 return u32::try_from(start.saturating_sub(1) + advanced)
1630 .ok()
1631 .and_then(at);
1632 }
1633 let text = read(row)?;
1634 match text.chars().next() {
1635 None | Some(' ') => {
1637 old_left = old_left.checked_sub(1)?;
1638 new_left = new_left.checked_sub(1)?;
1639 advanced += 1;
1640 }
1641 Some('+') => {
1643 new_left = new_left.checked_sub(1)?;
1644 advanced += 1;
1645 }
1646 Some('-') => old_left = old_left.checked_sub(1)?,
1649 Some('\\') => {}
1651 _ => return None,
1652 }
1653 row += 1;
1654 }
1655 None
1658}
1659
1660#[cfg(test)]
1661mod source_position_tests {
1662 use super::{SourcePos, source_position_at};
1663
1664 fn line_at(read: impl Fn(usize) -> Option<String>, cursor: usize) -> Option<u32> {
1668 source_position_at(read, cursor, 0).map(|p| p.line)
1669 }
1670
1671 const DIFF: &[&str] = &[
1672 "diff --git a/src/main.rs b/src/main.rs", "index 111..222 100644", "--- a/src/main.rs", "+++ b/src/main.rs", "@@ -10,3 +20,3 @@ fn main() {", " context one", "-deleted", "+added", " context two", ];
1682
1683 fn read(i: usize) -> Option<String> {
1684 DIFF.get(i).map(|s| s.to_string())
1685 }
1686
1687 #[test]
1689 fn the_first_body_row_is_the_hunks_start() {
1690 assert_eq!(line_at(read, 5), Some(19));
1692 }
1693
1694 #[test]
1696 fn a_deletion_does_not_advance_the_source_line() {
1697 assert_eq!(line_at(read, 6), Some(20));
1702 assert_eq!(line_at(read, 7), Some(20));
1705 }
1706
1707 #[test]
1709 fn context_after_an_addition_keeps_counting() {
1710 assert_eq!(line_at(read, 8), Some(21));
1711 }
1712
1713 #[test]
1715 fn the_header_row_resolves_to_the_hunk_start() {
1716 assert_eq!(line_at(read, 4), Some(19));
1717 }
1718
1719 #[test]
1722 fn a_row_outside_any_hunk_has_no_source_line() {
1723 assert_eq!(line_at(read, 0), None);
1724 assert_eq!(line_at(read, 3), None);
1725 }
1726
1727 #[test]
1736 fn an_entry_row_below_the_hunk_is_not_counted_as_context() {
1737 const STATUS: &[&str] = &[
1738 "diff --git a/a.rs b/a.rs", "--- a/a.rs", "+++ b/a.rs", "@@ -1,1 +5,2 @@", " ctx", "+added", " modified src/other.rs", " modified src/third.rs", ];
1747 let r = |i: usize| STATUS.get(i).map(|s| s.to_string());
1748 assert_eq!(line_at(r, 4), Some(4), "` ctx` is new line 5");
1749 assert_eq!(line_at(r, 5), Some(5), "`+added` is new line 6");
1750 assert_eq!(
1752 line_at(r, 6),
1753 None,
1754 "an entry row below the hunk must not resolve to a line \
1755 inside the hunk's file",
1756 );
1757 assert_eq!(line_at(r, 7), None);
1758 }
1759
1760 #[test]
1763 fn a_single_line_range_parses() {
1764 const ONE: &[&str] = &["@@ -1 +7 @@", " ctx"];
1765 let r = |i: usize| ONE.get(i).map(|s| s.to_string());
1766 assert_eq!(line_at(r, 1), Some(6));
1767 }
1768
1769 #[test]
1778 fn the_diff_marker_is_stripped_from_the_offset() {
1779 let pos = source_position_at(read, 5, 4).expect("inside the hunk");
1784 assert_eq!(pos, SourcePos { line: 19, byte: 3 });
1785 }
1786
1787 #[test]
1791 fn a_cursor_on_the_marker_lands_at_line_start() {
1792 let pos = source_position_at(read, 5, 0).expect("inside the hunk");
1793 assert_eq!(pos, SourcePos { line: 19, byte: 0 });
1794 }
1795
1796 #[test]
1800 fn additions_and_deletions_shift_the_same_way() {
1801 assert_eq!(
1804 source_position_at(read, 7, 3),
1805 Some(SourcePos { line: 20, byte: 2 })
1806 );
1807 assert_eq!(
1809 source_position_at(read, 6, 3),
1810 Some(SourcePos { line: 20, byte: 2 })
1811 );
1812 }
1813
1814 #[test]
1818 fn the_header_row_ignores_the_cursor_offset() {
1819 assert_eq!(
1820 source_position_at(read, 4, 17),
1821 Some(SourcePos { line: 19, byte: 0 })
1822 );
1823 }
1824
1825 #[test]
1829 fn a_multibyte_row_shifts_by_one_byte_not_one_char() {
1830 const MB: &[&str] = &["@@ -1,1 +1,1 @@", " héllo wörld"];
1831 let r = |i: usize| MB.get(i).map(|s| s.to_string());
1832 let pos = source_position_at(r, 1, 4).expect("inside the hunk");
1835 assert_eq!(pos, SourcePos { line: 0, byte: 3 });
1836 assert!(
1837 "héllo wörld".is_char_boundary(pos.byte as usize),
1838 "the resolved offset must be a char boundary"
1839 );
1840 }
1841
1842 #[test]
1845 fn a_row_outside_any_hunk_has_no_position() {
1846 assert_eq!(source_position_at(read, 0, 7), None);
1847 }
1848}