use crate::model::*;
use std::collections::BTreeSet;
use std::fmt;
#[derive(Debug, PartialEq, Eq)]
pub enum SplitError {
NotAContextLine(u32),
OutOfRange(u32),
ChangedLineOutOfRange {
index: usize,
changed: usize,
},
NoChangedLinesSelected,
}
impl fmt::Display for SplitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SplitError::NotAContextLine(n) => {
write!(f, "new-file line {n} is a change line, not a context line")
}
SplitError::OutOfRange(n) => write!(f, "new-file line {n} is out of range"),
SplitError::ChangedLineOutOfRange { index, changed } => write!(
f,
"changed-line index {index} is out of range (sub-hunk has {changed} changed line(s))"
),
SplitError::NoChangedLinesSelected => {
write!(f, "the selection references no changed lines")
}
}
}
}
impl std::error::Error for SplitError {}
pub fn auto_split_hunk(h: &Hunk) -> Vec<Hunk> {
let runs = change_runs(h);
if runs.len() <= 1 {
return vec![h.clone()];
}
let n = h.lines.len();
let mut result = Vec::with_capacity(runs.len());
let mut pre = PrefixCounts::new();
for ri in 0..runs.len() {
let lead_from = if ri == 0 { 0 } else { runs[ri].0 };
let trail_to = if ri + 1 == runs.len() {
n
} else {
runs[ri + 1].0
};
let slice = &h.lines[lead_from..trail_to];
let counts = pre.upto(&h.lines, lead_from);
result.push(rebuild_subhunk(h, slice, lead_from, counts));
}
result
}
pub fn split_hunk_at(h: &Hunk, new_line_cuts: &[u32]) -> Result<Vec<Hunk>, SplitError> {
let mut new_no = h.new_start;
let mut cut_indices: Vec<usize> = Vec::new();
let mut wanted: BTreeSet<u32> = new_line_cuts.iter().copied().collect();
for (i, l) in h.lines.iter().enumerate() {
let here = match l.kind {
LineKind::Context | LineKind::Add => {
let n = new_no;
new_no += 1;
n
}
LineKind::Del => continue,
};
if wanted.remove(&here) {
if !matches!(l.kind, LineKind::Context) {
return Err(SplitError::NotAContextLine(here));
}
cut_indices.push(i);
}
}
if let Some(&missing) = wanted.iter().next() {
return Err(SplitError::OutOfRange(missing));
}
if cut_indices.is_empty() {
return Ok(vec![h.clone()]);
}
let n = h.lines.len();
let mut starts = vec![0usize];
let mut ends: Vec<usize> = cut_indices.iter().map(|&ci| ci + 1).collect();
ends.push(n);
starts.extend(cut_indices.iter().map(|&ci| ci + 1));
Ok(rebuild_pieces(h, &starts, &ends))
}
pub fn split_file_hunk(
f: &mut FileDiff,
hi: usize,
new_line_cuts: &[u32],
) -> Result<usize, SplitError> {
let FileContent::Text(hunks) = &mut f.content else {
unreachable!("split_file_hunk is only called for text files");
};
let pieces = split_hunk_at(&hunks[hi], new_line_cuts)?;
let n = pieces.len();
hunks.splice(hi..=hi, pieces);
for (at, _) in &mut f.trailer {
if *at > hi {
*at = *at + n - 1;
}
}
Ok(n)
}
pub fn slice_changed_lines(h: &Hunk, selected: &BTreeSet<usize>) -> Result<Hunk, SplitError> {
let changed = h.changed_lines().count();
if selected.is_empty() {
return Err(SplitError::NoChangedLinesSelected);
}
if let Some(&max) = selected.iter().next_back() {
if max > changed {
return Err(SplitError::ChangedLineOutOfRange {
index: max,
changed,
});
}
}
let mut lines: Vec<Line> = Vec::with_capacity(h.lines.len());
let mut ci = 0usize; for l in &h.lines {
match l.kind {
LineKind::Context => lines.push(l.clone()),
LineKind::Del => {
ci += 1;
if selected.contains(&ci) {
lines.push(l.clone());
} else {
lines.push(Line {
kind: LineKind::Context,
text: l.text.clone(),
no_newline: l.no_newline.clone(),
});
}
}
LineKind::Add => {
ci += 1;
if selected.contains(&ci) {
lines.push(l.clone());
}
}
}
}
fix_mid_hunk_no_newline(&mut lines);
let (ctx, add, del) = count_kinds(&lines);
Ok(Hunk {
old_start: h.old_start,
old_lines: ctx + del,
new_start: h.new_start,
new_lines: ctx + add,
section: h.section.clone(),
lines,
})
}
fn fix_mid_hunk_no_newline(lines: &mut Vec<Line>) {
let n = lines.len();
let needs_fix = |i: usize, l: &Line| {
i + 1 < n && matches!(l.kind, LineKind::Context) && l.no_newline.is_some()
};
if !lines.iter().enumerate().any(|(i, l)| needs_fix(i, l)) {
return;
}
let mut fixed: Vec<Line> = Vec::with_capacity(n + 1);
for (i, l) in std::mem::take(lines).into_iter().enumerate() {
if needs_fix(i, &l) {
fixed.push(Line {
kind: LineKind::Del,
text: l.text.clone(),
no_newline: l.no_newline.clone(),
});
fixed.push(Line {
kind: LineKind::Add,
text: l.text,
no_newline: None,
});
} else {
fixed.push(l);
}
}
*lines = fixed;
}
fn change_runs(h: &Hunk) -> Vec<(usize, usize)> {
let mut runs = Vec::new();
let mut i = 0;
while i < h.lines.len() {
if matches!(h.lines[i].kind, LineKind::Add | LineKind::Del) {
let start = i;
while i < h.lines.len() && matches!(h.lines[i].kind, LineKind::Add | LineKind::Del) {
i += 1;
}
runs.push((start, i));
} else {
i += 1;
}
}
runs
}
fn rebuild_pieces(h: &Hunk, starts: &[usize], ends: &[usize]) -> Vec<Hunk> {
assert_eq!(starts.len(), ends.len());
let mut result = Vec::new();
let mut pre = PrefixCounts::new();
for (start, end) in starts.iter().zip(ends.iter()) {
if start >= end {
continue;
}
let slice = &h.lines[*start..*end];
let (_, add, del) = count_kinds(slice);
if add + del == 0 {
continue;
}
let counts = pre.upto(&h.lines, *start);
result.push(rebuild_subhunk(h, slice, *start, counts));
}
result
}
struct PrefixCounts {
scanned: usize,
ctx: u32,
add: u32,
del: u32,
}
impl PrefixCounts {
fn new() -> Self {
Self {
scanned: 0,
ctx: 0,
add: 0,
del: 0,
}
}
fn upto(&mut self, lines: &[Line], upto: usize) -> (u32, u32, u32) {
debug_assert!(
upto >= self.scanned,
"sub-hunk starts must be non-decreasing"
);
for l in &lines[self.scanned..upto] {
match l.kind {
LineKind::Context => self.ctx += 1,
LineKind::Add => self.add += 1,
LineKind::Del => self.del += 1,
}
}
self.scanned = upto;
(self.ctx, self.add, self.del)
}
}
fn side_start(parent_start: u32, off: u32, lines: u32) -> u32 {
let start = parent_start.saturating_add(off);
if lines == 0 && off > 0 {
start - 1
} else {
start
}
}
fn rebuild_subhunk(h: &Hunk, slice: &[Line], abs_start: usize, pre: (u32, u32, u32)) -> Hunk {
let (pre_ctx, pre_add, pre_del) = pre;
let old_off = pre_ctx + pre_del;
let new_off = pre_ctx + pre_add;
let (ctx, add, del) = count_kinds(slice);
let old_lines = ctx + del;
let new_lines = ctx + add;
Hunk {
old_start: side_start(h.old_start, old_off, old_lines),
old_lines,
new_start: side_start(h.new_start, new_off, new_lines),
new_lines,
section: if abs_start == 0 {
h.section.clone()
} else {
Vec::new()
},
lines: slice.to_vec(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::emit::emit;
use crate::gittest::applies_to_file;
use crate::model::{FileContent, FileDiff, Patch};
use crate::parser::parse;
fn hunk(src: &str) -> Hunk {
let p = parse(src.as_bytes()).unwrap();
let FileContent::Text(h) = &p.files[0].content else {
panic!()
};
h[0].clone()
}
fn assemble(subs: &[Hunk]) -> Vec<u8> {
emit(&Patch {
preamble: Vec::new(),
no_trailing_newline: false,
files: vec![FileDiff {
headers: vec![b"--- a/f".to_vec(), b"+++ b/f".to_vec()],
trailer: Vec::new(),
old_path: Some(b"f".to_vec()),
new_path: Some(b"f".to_vec()),
content: FileContent::Text(subs.to_vec()),
}],
})
}
const TWO_CHANGES: &str = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,5 +1,5 @@
a
-b
+B
c
-d
+D
e
";
#[test]
fn a_side_with_no_lines_carries_the_preceding_line_number() {
let h = hunk(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,4 @@
a
-b
+B
c
+d
",
);
let subs = auto_split_hunk(&h);
assert_eq!(subs.len(), 2);
assert_eq!((subs[1].old_start, subs[1].old_lines), (3, 0));
let h = hunk(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,5 +1,2 @@
a
-b
c
-d
-e
",
);
let subs = auto_split_hunk(&h);
assert_eq!(subs.len(), 2);
assert_eq!((subs[1].new_start, subs[1].new_lines), (2, 0));
}
#[test]
fn splits_two_changes_separated_by_context() {
let h = hunk(TWO_CHANGES);
let subs = auto_split_hunk(&h);
assert_eq!(subs.len(), 2);
assert_eq!(subs[0].old_start, 1);
assert_eq!(
subs[0]
.lines
.iter()
.filter(|l| l.kind == LineKind::Add)
.count(),
1
);
for s in &subs {
let ctx = s
.lines
.iter()
.filter(|l| l.kind == LineKind::Context)
.count() as u32;
let del = s.lines.iter().filter(|l| l.kind == LineKind::Del).count() as u32;
let add = s.lines.iter().filter(|l| l.kind == LineKind::Add).count() as u32;
assert_eq!(s.old_lines, ctx + del);
assert_eq!(s.new_lines, ctx + add);
}
}
#[test]
fn single_change_returns_one() {
let h = hunk(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
",
);
assert_eq!(auto_split_hunk(&h).len(), 1);
}
#[test]
fn explicit_split_on_context_line() {
let h = hunk(TWO_CHANGES);
let subs = split_hunk_at(&h, &[3]).unwrap();
assert_eq!(subs.len(), 2);
assert_eq!(subs[1].new_start, 4);
for s in &subs {
let ctx = s
.lines
.iter()
.filter(|l| l.kind == LineKind::Context)
.count() as u32;
let del = s.lines.iter().filter(|l| l.kind == LineKind::Del).count() as u32;
let add = s.lines.iter().filter(|l| l.kind == LineKind::Add).count() as u32;
assert_eq!(s.old_lines, ctx + del);
assert_eq!(s.new_lines, ctx + add);
}
}
#[test]
fn explicit_split_on_first_context_line_drops_context_only_piece() {
let h = hunk(TWO_CHANGES);
let subs = split_hunk_at(&h, &[1]).unwrap();
for s in &subs {
let add = s.lines.iter().filter(|l| l.kind == LineKind::Add).count();
let del = s.lines.iter().filter(|l| l.kind == LineKind::Del).count();
assert!(add + del > 0, "no context-only sub-hunk emitted");
assert!(s.old_lines > 0 && s.new_lines > 0);
}
}
#[test]
fn explicit_split_rejects_change_line() {
let h = hunk(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
",
);
assert_eq!(split_hunk_at(&h, &[2]), Err(SplitError::NotAContextLine(2)));
}
#[test]
fn explicit_split_out_of_range() {
let h = hunk(TWO_CHANGES);
assert_eq!(split_hunk_at(&h, &[99]), Err(SplitError::OutOfRange(99)));
}
#[test]
fn explicit_split_on_last_context_line_drops_empty_piece() {
let h = hunk(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
c
",
);
let subs = split_hunk_at(&h, &[3]).unwrap();
for s in &subs {
assert!(!s.lines.is_empty(), "empty sub-hunk piece produced: {s:?}");
assert!(
s.old_lines > 0 || s.new_lines > 0,
"degenerate zero-count sub-hunk produced: {s:?}"
);
}
assert_eq!(subs.len(), 1);
}
#[test]
fn explicit_split_combined_applies_via_git() {
let h = hunk(TWO_CHANGES);
let pieces = split_hunk_at(&h, &[3]).unwrap();
let diff = assemble(&pieces);
assert!(
applies_to_file(&diff, "a\nb\nc\nd\ne\n"),
"git apply --check failed for combined explicit-split patch:\n{}",
String::from_utf8_lossy(&diff)
);
}
#[test]
fn auto_split_subhunks_apply_via_git() {
let h = hunk(TWO_CHANGES);
let subs = auto_split_hunk(&h);
let diff = assemble(&subs);
assert!(
applies_to_file(&diff, "a\nb\nc\nd\ne\n"),
"git apply --check failed for split patch:\n{}",
String::from_utf8_lossy(&diff)
);
}
fn git_apply_ok(subs: &[Hunk], file_content: &str) -> bool {
applies_to_file(&assemble(subs), file_content)
}
fn sel(indices: &[usize]) -> BTreeSet<usize> {
indices.iter().copied().collect()
}
const REPLACEMENT: &str = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,2 +1,2 @@
-a
-b
+A
+B
";
#[test]
fn slice_changed_separates_deletions() {
let h = hunk(REPLACEMENT);
let p = slice_changed_lines(&h, &sel(&[1, 2])).unwrap();
assert_eq!(p.old_lines, 2);
assert_eq!(p.new_lines, 0);
assert!(p.lines.iter().all(|l| l.kind == LineKind::Del));
}
#[test]
fn slice_changed_separates_additions() {
let h = hunk(REPLACEMENT);
let p = slice_changed_lines(&h, &sel(&[3, 4])).unwrap();
assert_eq!(p.old_lines, 2); assert_eq!(p.new_lines, 4); assert_eq!(
p.lines.iter().filter(|l| l.kind == LineKind::Del).count(),
0
);
assert_eq!(
p.lines
.iter()
.filter(|l| l.kind == LineKind::Context)
.count(),
2
);
assert_eq!(
p.lines.iter().filter(|l| l.kind == LineKind::Add).count(),
2
);
}
#[test]
fn slice_changed_del_and_add_pieces_apply_independently_via_git() {
let h = hunk(REPLACEMENT);
let dels = slice_changed_lines(&h, &sel(&[1, 2])).unwrap();
let adds = slice_changed_lines(&h, &sel(&[3, 4])).unwrap();
assert!(git_apply_ok(&[dels], "a\nb\n"), "deletion piece must apply");
assert!(git_apply_ok(&[adds], "a\nb\n"), "addition piece must apply");
}
const ADD_SPLIT_BY_DEL: &str = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,1 +1,2 @@
+x
-y
+z
";
#[test]
fn slice_changed_addresses_deletion_split_by_additions() {
let h = hunk(ADD_SPLIT_BY_DEL);
let p = slice_changed_lines(&h, &sel(&[2])).unwrap();
assert_eq!(p.old_lines, 1);
assert_eq!(p.new_lines, 0);
assert_eq!(p.lines.len(), 1);
assert_eq!(p.lines[0].kind, LineKind::Del);
assert_eq!(p.lines[0].text, b"y");
assert!(git_apply_ok(&[p], "y\n"), "isolated deletion must apply");
}
#[test]
fn slice_changed_selecting_additions_around_deletion_keeps_it_as_context() {
let h = hunk(ADD_SPLIT_BY_DEL);
let p = slice_changed_lines(&h, &sel(&[1, 3])).unwrap();
assert_eq!(p.old_lines, 1); assert_eq!(p.new_lines, 3); assert!(git_apply_ok(&[p], "y\n"), "addition piece must apply");
}
#[test]
fn slice_changed_roundtrip_full_selection_reproduces_body() {
let h = hunk(REPLACEMENT);
let all = slice_changed_lines(&h, &sel(&[1, 2, 3, 4])).unwrap();
assert_eq!(all.lines, h.lines);
}
#[test]
fn slice_changed_readds_no_newline_line_when_additions_follow() {
let h = hunk(
"\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1 +1,2 @@
-a
\\ No newline at end of file
+b
+c
",
);
let p = slice_changed_lines(&h, &sel(&[2, 3])).unwrap();
assert_eq!(p.lines[0].kind, LineKind::Del);
assert_eq!(p.lines[0].text, b"a");
assert!(
p.lines[0].no_newline.is_some(),
"the deleted `a` keeps its no-newline marker"
);
assert_eq!(p.lines[1].kind, LineKind::Add);
assert_eq!(p.lines[1].text, b"a");
assert!(
p.lines[1].no_newline.is_none(),
"the re-added `a` gains a trailing newline"
);
assert_eq!(p.old_lines, 1);
assert_eq!(p.new_lines, 3);
assert!(
git_apply_ok(&[p], "a"),
"addition piece must apply to `a` (no trailing newline)"
);
}
#[test]
fn split_file_hunk_moves_trailing_lines_with_their_hunks() {
let p = parse(
concat!(
"diff --git a/f b/f\n--- a/f\n+++ b/f\n",
"@@ -1,5 +1,5 @@\n a\n-b\n+B\n c\n-d\n+D\n e\n",
"\n",
"@@ -20,3 +20,3 @@\n p\n-q\n+Q\n r\n",
"-- \n2.53.0\n",
)
.as_bytes(),
)
.unwrap();
let mut f = p.files[0].clone();
assert_eq!(
f.trailer,
vec![
(1, b"".to_vec()),
(2, b"-- ".to_vec()),
(2, b"2.53.0".to_vec())
],
"positions before the split"
);
assert_eq!(split_file_hunk(&mut f, 0, &[3]).unwrap(), 2);
assert_eq!(
f.trailer,
vec![
(2, b"".to_vec()),
(3, b"-- ".to_vec()),
(3, b"2.53.0".to_vec())
],
"each line follows the hunk it followed before the split"
);
}
#[test]
fn slice_changed_out_of_range_and_empty_error() {
let h = hunk(REPLACEMENT); assert!(matches!(
slice_changed_lines(&h, &sel(&[5])),
Err(SplitError::ChangedLineOutOfRange {
index: 5,
changed: 4
})
));
assert_eq!(
slice_changed_lines(&h, &sel(&[])),
Err(SplitError::NoChangedLinesSelected)
);
}
}