1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
//! 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<Option<String>, String> {
let lines: Vec<&str> = old.split_inclusive('\n').collect();
let mut out: Vec<String> = 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<String>, Vec<String>) = (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");
}
}