use crate::model::*;
use std::fmt;
#[derive(Debug, PartialEq, Eq)]
pub enum ParseError {
BadHunkHeader(String),
Unexpected(String),
Combined(String),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::BadHunkHeader(s) => write!(f, "malformed hunk header: {s}"),
ParseError::Unexpected(s) => write!(f, "unexpected diff content: {s}"),
ParseError::Combined(s) => write!(
f,
"combined (merge) diff is not supported, hunkpick reads two-sided \
unified diffs: {s}"
),
}
}
}
impl std::error::Error for ParseError {}
#[derive(Default)]
struct Remaining {
old: u32,
new: u32,
}
impl Remaining {
fn exhausted(&self) -> bool {
self.old == 0 && self.new == 0
}
}
enum BodyLine {
Consumed,
HunkEnded,
}
#[derive(Default)]
struct FileState {
in_hunk: bool,
saw_hunk: bool,
saw_marker_pair: bool,
remaining: Remaining,
}
pub fn parse(input: &[u8]) -> Result<Patch, ParseError> {
let mut files: Vec<FileDiff> = Vec::new();
let mut preamble: Vec<Vec<u8>> = Vec::new();
let mut cur: Option<FileDiff> = None;
let mut st = FileState::default();
let mut lines = input.split(|&b| b == b'\n').peekable();
while let Some(line) = lines.next() {
let is_last_empty = line.is_empty() && lines.peek().is_none();
if is_last_empty {
break;
}
if !st.in_hunk && is_combined_marker(line) {
return Err(ParseError::Combined(
String::from_utf8_lossy(line).into_owned(),
));
}
if let Some(rest) = line.strip_prefix(b"diff --git ") {
if let Some(f) = cur.take() {
files.push(f);
}
cur = Some(start_git_file(line, rest));
st = FileState::default();
continue;
}
if starts_plain_file(line, cur.is_some(), &st) {
if let Some(f) = cur.take() {
files.push(f);
}
cur = Some(new_file(Vec::new()));
st = FileState::default();
}
let Some(f) = cur.as_mut() else {
preamble.push(line.to_vec());
continue;
};
if line.starts_with(b"@@ ") {
open_hunk(f, line, &mut st)?;
continue;
}
if st.in_hunk {
let FileContent::Text(hunks) = &mut f.content else {
unreachable!("in_hunk is set only after a text hunk header")
};
let h = hunks
.last_mut()
.expect("in_hunk implies a hunk was already pushed");
if let BodyLine::HunkEnded = take_body_line(h, line, &mut st.remaining) {
st.in_hunk = false;
push_header(f, line);
}
continue;
}
if line.starts_with(b"+++ ") {
st.saw_marker_pair = true;
}
push_header(f, line);
}
if let Some(f) = cur.take() {
files.push(f);
}
Ok(Patch {
preamble,
files,
no_trailing_newline: !input.is_empty() && !input.ends_with(b"\n"),
})
}
fn open_hunk(f: &mut FileDiff, line: &[u8], st: &mut FileState) -> Result<(), ParseError> {
let hunk = parse_hunk_header(line)?;
let FileContent::Text(hunks) = &mut f.content else {
return Err(ParseError::Unexpected("hunk in binary file".into()));
};
st.remaining = Remaining {
old: hunk.old_lines,
new: hunk.new_lines,
};
hunks.push(hunk);
st.saw_hunk = true;
st.in_hunk = !st.remaining.exhausted();
Ok(())
}
fn new_file(headers: Vec<Vec<u8>>) -> FileDiff {
FileDiff {
headers,
trailer: Vec::new(),
old_path: None,
new_path: None,
content: FileContent::Text(Vec::new()),
}
}
pub fn is_combined_marker(line: &[u8]) -> bool {
line.starts_with(b"diff --cc ")
|| line.starts_with(b"diff --combined ")
|| line.starts_with(b"@@@")
}
fn start_git_file(line: &[u8], rest: &[u8]) -> FileDiff {
let mut f = new_file(vec![line.to_vec()]);
if let Some((old_path, new_path)) = split_diff_git_paths(rest) {
f.old_path = Some(old_path);
f.new_path = Some(new_path);
}
f
}
fn starts_plain_file(line: &[u8], have_file: bool, st: &FileState) -> bool {
line.starts_with(b"--- ")
&& (!have_file
|| (st.saw_hunk && st.remaining.exhausted())
|| (!st.saw_hunk && st.saw_marker_pair))
}
fn take_body_line(h: &mut Hunk, line: &[u8], rem: &mut Remaining) -> BodyLine {
match line.first() {
Some(b' ') if rem.old > 0 && rem.new > 0 => {
h.lines.push(mk_line(LineKind::Context, &line[1..]));
rem.old -= 1;
rem.new -= 1;
}
None if rem.old > 0 && rem.new > 0 => {
h.lines.push(mk_line(LineKind::Context, b""));
rem.old -= 1;
rem.new -= 1;
}
Some(b'+') if rem.new > 0 => {
h.lines.push(mk_line(LineKind::Add, &line[1..]));
rem.new -= 1;
}
Some(b'-') if rem.old > 0 => {
h.lines.push(mk_line(LineKind::Del, &line[1..]));
rem.old -= 1;
}
_ if line.starts_with(b"\\ ") => {
let Some(last) = h.lines.last_mut() else {
return BodyLine::HunkEnded;
};
last.no_newline = Some(line.to_vec());
}
_ => return BodyLine::HunkEnded,
}
BodyLine::Consumed
}
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
hay.windows(needle.len()).position(|w| w == needle)
}
fn mk_line(kind: LineKind, text: &[u8]) -> Line {
Line {
kind,
text: text.to_vec(),
no_newline: None,
}
}
fn push_header(f: &mut FileDiff, line: &[u8]) {
if let FileContent::Binary(b) = &mut f.content {
b.push(line.to_vec());
return;
}
let hunks_so_far = f.hunk_count();
if is_binary_marker(line) {
match &mut f.content {
FileContent::Text(h) if h.is_empty() => {
f.content = FileContent::Binary(vec![line.to_vec()]);
}
FileContent::Text(_) => f.trailer.push((hunks_so_far, line.to_vec())),
FileContent::Binary(_) => unreachable!("handled above"),
}
return;
}
if hunks_so_far > 0 {
f.trailer.push((hunks_so_far, line.to_vec()));
return;
}
if let Some(rest) = line.strip_prefix(b"--- ") {
f.old_path = Some(strip_ab(rest));
} else if let Some(rest) = line.strip_prefix(b"+++ ") {
f.new_path = Some(strip_ab(rest));
}
f.headers.push(line.to_vec());
}
fn is_binary_marker(line: &[u8]) -> bool {
let line = line.strip_suffix(b"\r").unwrap_or(line);
line.starts_with(b"Binary files ") || line == b"GIT binary patch"
}
fn strip_ab(s: &[u8]) -> Vec<u8> {
let s = s.strip_suffix(b"\r").unwrap_or(s);
if let Some(decoded) = unquote(s) {
return strip_ab_prefix(&decoded).to_vec();
}
let s = match s.iter().position(|&b| b == b'\t') {
Some(i) => &s[..i],
None => s,
};
strip_ab_prefix(s).to_vec()
}
fn strip_ab_prefix(s: &[u8]) -> &[u8] {
s.strip_prefix(b"a/")
.or_else(|| s.strip_prefix(b"b/"))
.unwrap_or(s)
}
fn unquote(s: &[u8]) -> Option<Vec<u8>> {
let body = s.strip_prefix(b"\"")?.strip_suffix(b"\"")?;
let mut out = Vec::with_capacity(body.len());
let mut it = body.iter().copied();
while let Some(b) = it.next() {
if b != b'\\' {
out.push(b);
continue;
}
match it.next()? {
b'a' => out.push(0x07),
b'b' => out.push(0x08),
b'f' => out.push(0x0c),
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'v' => out.push(0x0b),
b'\\' => out.push(b'\\'),
b'"' => out.push(b'"'),
d @ b'0'..=b'7' => {
let mut v = u32::from(d - b'0');
for _ in 0..2 {
let n = it.next()?;
if !n.is_ascii_digit() || n > b'7' {
return None;
}
v = v * 8 + u32::from(n - b'0');
}
out.push(u8::try_from(v).ok()?);
}
_ => return None,
}
}
Some(out)
}
fn split_diff_git_paths(rest: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
let rest = rest.strip_suffix(b"\r").unwrap_or(rest);
if rest.first() == Some(&b'"') {
let mut end = None;
let mut escaped = false;
for (i, &b) in rest.iter().enumerate().skip(1) {
match b {
_ if escaped => escaped = false,
b'\\' => escaped = true,
b'"' => {
end = Some(i);
break;
}
_ => {}
}
}
let end = end?;
let second = rest.get(end + 2..)?;
return Some((strip_ab(&rest[..=end]), strip_ab(second)));
}
let mid = rest.len() / 2;
if rest.len() % 2 == 1 && rest.get(mid) == Some(&b' ') {
return Some((strip_ab(&rest[..mid]), strip_ab(&rest[mid + 1..])));
}
let at = find_last_subslice(rest, b" b/")?;
Some((strip_ab(&rest[..at]), strip_ab(&rest[at + 1..])))
}
fn find_last_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
hay.windows(needle.len()).rposition(|w| w == needle)
}
fn parse_hunk_header(line: &[u8]) -> Result<Hunk, ParseError> {
let bad = || ParseError::BadHunkHeader(String::from_utf8_lossy(line).into_owned());
const SEP: &[u8] = b" @@";
let body = line.strip_prefix(b"@@ ").ok_or_else(bad)?;
let end = find_subslice(body, SEP).ok_or_else(bad)?;
let ranges = &body[..end];
let after = &body[end + SEP.len()..];
let section = after.strip_prefix(b" ").unwrap_or(after).to_vec();
let ranges = std::str::from_utf8(ranges).map_err(|_| bad())?;
let mut it = ranges.split_whitespace();
let old = it.next().ok_or_else(bad)?;
let new = it.next().ok_or_else(bad)?;
if it.next().is_some() {
return Err(bad());
}
let (old_start, old_lines) = parse_range(old.strip_prefix('-').unwrap_or(old))?;
let (new_start, new_lines) = parse_range(new.strip_prefix('+').unwrap_or(new))?;
Ok(Hunk {
old_start,
old_lines,
new_start,
new_lines,
section,
lines: Vec::new(),
})
}
fn parse_range(s: &str) -> Result<(u32, u32), ParseError> {
let mut parts = s.split(',');
let start = parts
.next()
.and_then(|x| x.parse().ok())
.ok_or_else(|| ParseError::BadHunkHeader(s.to_string()))?;
let count = match parts.next() {
Some(c) => c
.parse()
.map_err(|_| ParseError::BadHunkHeader(s.to_string()))?,
None => 1,
};
if parts.next().is_some() {
return Err(ParseError::BadHunkHeader(s.to_string()));
}
Ok((start, count))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::emit::emit;
use crate::model::{FileContent, LineKind};
const ONE: &str = "\
diff --git a/f.txt b/f.txt
index 111..222 100644
--- a/f.txt
+++ b/f.txt
@@ -1,3 +1,3 @@
a
-b
+B
c
";
#[test]
fn parses_single_hunk() {
let p = parse(ONE.as_bytes()).unwrap();
assert_eq!(p.files.len(), 1);
let f = &p.files[0];
assert_eq!(f.old_path.as_deref(), Some(b"f.txt".as_slice()));
assert_eq!(f.new_path.as_deref(), Some(b"f.txt".as_slice()));
let FileContent::Text(hunks) = &f.content else {
panic!("text")
};
assert_eq!(hunks.len(), 1);
let h = &hunks[0];
assert_eq!(
(h.old_start, h.old_lines, h.new_start, h.new_lines),
(1, 3, 1, 3)
);
assert_eq!(h.lines.len(), 4);
assert_eq!(h.lines[1].kind, LineKind::Del);
assert_eq!(h.lines[1].text.as_slice(), b"b");
}
#[test]
fn parses_multi_hunk_with_section() {
let src = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,2 +1,2 @@ fn one()
x
-y
+Y
@@ -10,2 +10,3 @@ fn two()
p
+q
r
";
let p = parse(src.as_bytes()).unwrap();
let FileContent::Text(h) = &p.files[0].content else {
panic!()
};
assert_eq!(h.len(), 2);
assert_eq!(h[0].section.as_slice(), b"fn one()");
assert_eq!(h[1].section.as_slice(), b"fn two()");
assert_eq!((h[1].new_start, h[1].new_lines), (10, 3));
}
#[test]
fn parses_multi_file() {
let src = "\
diff --git a/x b/x
--- a/x
+++ b/x
@@ -1 +1 @@
-1
+2
diff --git a/y b/y
--- a/y
+++ b/y
@@ -1 +1 @@
-3
+4
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files.len(), 2);
assert_eq!(p.files[0].new_path.as_deref(), Some(b"x".as_slice()));
assert_eq!(p.files[1].new_path.as_deref(), Some(b"y".as_slice()));
}
#[test]
fn parses_no_newline_marker() {
let src = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1 +1 @@
-old
\\ No newline at end of file
+new
\\ No newline at end of file
";
let p = parse(src.as_bytes()).unwrap();
let FileContent::Text(h) = &p.files[0].content else {
panic!()
};
assert!(h[0].lines[0].no_newline.is_some());
assert!(h[0].lines[1].no_newline.is_some());
}
#[test]
fn parses_binary_file() {
let src = "\
diff --git a/img.png b/img.png
index 111..222 100644
Binary files a/img.png and b/img.png differ
";
let p = parse(src.as_bytes()).unwrap();
assert!(matches!(p.files[0].content, FileContent::Binary(_)));
}
#[test]
fn deletion_line_dash_dash_not_mistaken_for_file_header() {
let src = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,2 +1,2 @@
xyz
--- old comment
+++ new comment
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files.len(), 1, "no phantom file");
let FileContent::Text(h) = &p.files[0].content else {
panic!()
};
assert_eq!(h.len(), 1);
assert_eq!(h[0].lines.len(), 3);
assert_eq!(h[0].lines[1].kind, LineKind::Del);
assert_eq!(h[0].lines[1].text.as_slice(), b"-- old comment");
assert_eq!(h[0].lines[2].kind, LineKind::Add);
assert_eq!(h[0].lines[2].text.as_slice(), b"++ new comment");
}
#[test]
fn empty_line_in_hunk_body_is_a_context_line() {
let src = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,6 +1,6 @@
a
-b
+B
c
-d
+D
-x
+X
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files.len(), 1, "no phantom file");
let f = &p.files[0];
assert_eq!(f.headers.len(), 3, "body lines must not leak into headers");
let FileContent::Text(h) = &f.content else {
panic!()
};
assert_eq!(h.len(), 1);
assert_eq!(h[0].lines.len(), 9, "whole body belongs to the hunk");
assert_eq!(h[0].lines[6].kind, LineKind::Context);
assert!(h[0].lines[6].text.is_empty());
assert_eq!(h[0].lines[7].kind, LineKind::Del);
assert_eq!(h[0].lines[7].text.as_slice(), b"x");
}
#[test]
fn empty_line_past_the_declared_count_still_ends_the_hunk() {
let src = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1 +1 @@
-a
+A
";
let p = parse(src.as_bytes()).unwrap();
let f = &p.files[0];
let FileContent::Text(h) = &f.content else {
panic!()
};
assert_eq!(h[0].lines.len(), 2, "only the declared body lines");
assert_eq!(
f.headers.len(),
3,
"no body-adjacent line among the headers"
);
assert_eq!(f.trailer, vec![(1usize, b"".to_vec())]);
}
#[test]
fn binary_marker_after_hunks_is_kept_not_dropped() {
let src = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1 +1 @@
-a
+A
Binary files a/f and b/f differ
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(
p.files[0].trailer,
vec![(1usize, b"Binary files a/f and b/f differ".to_vec())]
);
}
#[test]
fn a_format_patch_mail_header_survives_the_round_trip() {
let src = concat!(
"From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n",
"From: Someone <someone@example.invalid>\n",
"Subject: [PATCH] change f\n",
"\n",
" f | 2 +-\n",
" 1 file changed, 1 insertion(+), 1 deletion(-)\n",
"\n",
"diff --git a/f b/f\n",
"--- a/f\n",
"+++ b/f\n",
"@@ -1 +1 @@\n",
"-a\n",
"+A\n",
"-- \n",
"2.53.0\n",
);
let p = parse(src.as_bytes()).unwrap();
assert_eq!(emit(&p), src.as_bytes());
}
#[test]
fn a_no_newline_marker_before_any_body_line_is_kept() {
let src = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1 +1 @@
\\ No newline at end of file
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(emit(&p), src.as_bytes(), "no byte may be dropped");
}
#[test]
fn crlf_binary_marker_still_starts_a_binary_entry() {
let src = "\
diff --git a/f.bin b/f.bin\r
index 34b631e..f0c6ea3 100644\r
GIT binary patch\r
literal 13\r
UcmeAS@N;M2<Y3P)$w(~%02liMsQ>@~\r
";
let p = parse(src.as_bytes()).unwrap();
let FileContent::Binary(b) = &p.files[0].content else {
panic!("a CRLF binary marker must start a binary entry");
};
assert_eq!(b.len(), 3, "marker and payload belong to the entry");
assert_eq!(emit(&p), src.as_bytes(), "and come back unchanged");
}
#[test]
fn signature_after_the_last_hunk_keeps_its_place() {
let src = concat!(
"diff --git a/f b/f\n",
"--- a/f\n",
"+++ b/f\n",
"@@ -1 +1 @@\n",
"-a\n",
"+A\n",
"-- \n",
"2.53.0\n",
);
let p = parse(src.as_bytes()).unwrap();
let f = &p.files[0];
assert_eq!(
f.trailer,
vec![(1usize, b"-- ".to_vec()), (1usize, b"2.53.0".to_vec())]
);
}
#[test]
fn plain_diff_entry_without_hunks_does_not_absorb_the_next_file() {
let src = "\
--- a/x
+++ b/x
--- a/y
+++ b/y
@@ -1 +1 @@
-1
+2
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files.len(), 2, "two separate entries");
assert_eq!(p.files[0].new_path.as_deref(), Some(b"x".as_slice()));
assert_eq!(p.files[1].new_path.as_deref(), Some(b"y".as_slice()));
}
#[test]
fn quoted_path_is_decoded_to_its_bytes() {
let src = "\
diff --git \"a/\\303\\251.txt\" \"b/\\303\\251.txt\"
--- \"a/\\303\\251.txt\"
+++ \"b/\\303\\251.txt\"
@@ -1 +1 @@
-a
+A
";
let p = parse(src.as_bytes()).unwrap();
let f = &p.files[0];
assert_eq!(f.old_path.as_deref(), Some("é.txt".as_bytes()));
assert_eq!(f.new_path.as_deref(), Some("é.txt".as_bytes()));
assert_eq!(f.display_path(), "é.txt");
}
#[test]
fn quoted_path_keeps_escaped_specials() {
let src = "\
--- \"a/we\\\"ird\\tname\"
+++ \"b/we\\\"ird\\tname\"
@@ -1 +1 @@
-a
+A
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(
p.files[0].new_path.as_deref(),
Some(b"we\"ird\tname".as_slice())
);
}
#[test]
fn crlf_diff_path_has_no_carriage_return() {
let src = "diff --git a/f b/f\r\n--- a/f\r\n+++ b/f\r\n@@ -1 +1 @@\r\n-a\r\n+A\r\n";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files[0].new_path.as_deref(), Some(b"f".as_slice()));
assert_eq!(p.files[0].old_path.as_deref(), Some(b"f".as_slice()));
}
#[test]
fn binary_file_path_comes_from_the_diff_git_line() {
let src = "\
diff --git a/img.png b/img.png
index 111..222 100644
Binary files a/img.png and b/img.png differ
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files[0].display_path(), "img.png");
}
#[test]
fn diff_git_paths_do_not_override_the_marker_lines() {
let src = "\
diff --git a/old b/new
similarity index 90%
rename from old
rename to new
--- a/old
+++ b/new
@@ -1 +1 @@
-a
+A
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files[0].old_path.as_deref(), Some(b"old".as_slice()));
assert_eq!(p.files[0].new_path.as_deref(), Some(b"new".as_slice()));
}
#[test]
fn parses_plain_non_git_diff() {
let src = "\
--- old.txt\t2020-01-01
+++ new.txt\t2020-01-02
@@ -1 +1 @@
-a
+b
";
let p = parse(src.as_bytes()).unwrap();
assert_eq!(p.files.len(), 1);
assert_eq!(p.files[0].old_path.as_deref(), Some(b"old.txt".as_slice()));
assert!(!p.files[0].headers.is_empty());
}
}