//! Applies a unified diff of one file, as `git apply` would: each hunk's context must match //! exactly, at its line or the nearest line after the previous hunk where it does. /// What a patch leaves of `old`: the new text, or None when it deletes the file (`+++ /dev/null`). pub fn apply(old: &str, patch: &str) -> Result, String> { let lines: Vec<&str> = old.split_inclusive('\n').collect(); let mut out: Vec = Vec::new(); let mut pos = 0; let mut offset: isize = 0; let mut deletes = false; let mut hunks = 0; let mut rows = patch.split_inclusive('\n').peekable(); while let Some(row) = rows.next() { if row.starts_with("+++ /dev/null") { deletes = true; } let Some(header) = row.strip_prefix("@@ ") else { continue }; let (old_start, old_count, new_count) = parse_header(header)?; hunks += 1; let (mut from, mut to): (Vec, Vec) = (Vec::new(), Vec::new()); let (mut left_old, mut left_new) = (old_count, new_count); while left_old > 0 || left_new > 0 { let row = rows.next().ok_or("a hunk ends early")?; let text = row.get(1..).unwrap_or("").to_string(); match row.as_bytes().first() { Some(b' ') | Some(b'\n') | Some(b'\r') => { let text = if row.starts_with(' ') { text } else { row.to_string() }; from.push(text.clone()); to.push(text); left_old = left_old.checked_sub(1).ok_or("a hunk is longer than its header says")?; left_new = left_new.checked_sub(1).ok_or("a hunk is longer than its header says")?; } Some(b'-') => { from.push(text); left_old = left_old.checked_sub(1).ok_or("a hunk is longer than its header says")?; } Some(b'+') => { to.push(text); left_new = left_new.checked_sub(1).ok_or("a hunk is longer than its header says")?; } Some(b'\\') => continue, _ => return Err(format!("not a hunk line: {row:?}")), } // "\ No newline at end of file" takes the newline off the line before it. if rows.peek().is_some_and(|r| r.starts_with('\\')) { let side = row.as_bytes()[0]; if side != b'+' { strip(&mut from); } if side != b'-' { strip(&mut to); } } } if rows.peek().is_some_and(|r| r.starts_with('\\')) { rows.next(); } // Where the hunk says it goes, then outward from there, never before the previous hunk. let want = (if old_count == 0 { old_start } else { old_start.saturating_sub(1) } as isize + offset).max(pos as isize) as usize; let fits = |at: usize| at + from.len() <= lines.len() && lines[at..at + from.len()].iter().zip(&from).all(|(a, b)| *a == b); let at = (0..=lines.len()) .flat_map(|d| [want.checked_add(d), want.checked_sub(d).filter(|_| d > 0)]) .flatten() .filter(|&at| at >= pos && at <= lines.len()) .find(|&at| fits(at)) .ok_or_else(|| format!("hunk {hunks} (@@ {}) does not apply", header.trim_end()))?; out.extend(lines[pos..at].iter().map(|s| s.to_string())); out.extend(to); offset = at as isize - old_start.saturating_sub(1) as isize; pos = at + from.len(); } if hunks == 0 && !deletes { return Err("the patch has no hunks".into()); } out.extend(lines[pos..].iter().map(|s| s.to_string())); Ok((!deletes).then(|| out.concat())) } fn strip(side: &mut [String]) { if let Some(last) = side.last_mut() { if last.ends_with('\n') { last.pop(); if last.ends_with('\r') { last.pop(); } } } } // "-a[,b] +c[,d] @@ ..." -> (a, b, d) fn parse_header(header: &str) -> Result<(usize, usize, usize), String> { let bad = || format!("not a hunk header: @@ {}", header.trim_end()); let mut parts = header.split_whitespace(); let range = |part: Option<&str>, sign: char| -> Result<(usize, usize), String> { let part = part.and_then(|p| p.strip_prefix(sign)).ok_or_else(bad)?; let (start, count) = part.split_once(',').unwrap_or((part, "1")); Ok((start.parse().map_err(|_| bad())?, count.parse().map_err(|_| bad())?)) }; let (old_start, old_count) = range(parts.next(), '-')?; let (_, new_count) = range(parts.next(), '+')?; Ok((old_start, old_count, new_count)) } #[cfg(test)] mod tests { use super::apply; #[test] fn hunks_apply_with_offsets_and_newline_markers() { let old = "a\nb\nc\nd\ne\nf\n"; let patch = "--- a/x\n+++ b/x\n@@ -2,2 +2,2 @@\n b\n-c\n+C\n@@ -5,2 +5,3 @@\n e\n-f\n+F\n+g\n\\ No newline at end of file\n"; assert_eq!(apply(old, patch).unwrap().unwrap(), "a\nb\nC\nd\ne\nF\ng"); // The same hunk two lines off still finds its context. assert_eq!(apply("x\ny\na\nb\nc\n", "@@ -2,2 +2,2 @@\n b\n-c\n+C\n").unwrap().unwrap(), "x\ny\na\nb\nC\n"); // A new file, a deleted one, and context that is not there. assert_eq!(apply("", "--- /dev/null\n+++ b/n\n@@ -0,0 +1,2 @@\n+1\n+2\n").unwrap().unwrap(), "1\n2\n"); assert_eq!(apply("1\n", "--- a/n\n+++ /dev/null\n@@ -1 +0,0 @@\n-1\n").unwrap(), None); assert!(apply("a\n", "@@ -1 +1 @@\n-z\n+y\n").unwrap_err().contains("does not apply")); // Adding a final newline. assert_eq!(apply("a", "@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+a\n").unwrap().unwrap(), "a\n"); } }