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 looks_like_a_diff(input: &[u8]) -> bool {
input
.split(|&b| b == b'\n')
.any(|line| diff_marker(line).is_some())
}
fn diff_marker(line: &[u8]) -> Option<&'static [u8]> {
longest_prefix(markers(), line)
}
#[doc(hidden)]
pub fn markers() -> impl Iterator<Item = &'static [u8]> {
ORDINARY_MARKERS
.iter()
.chain(COMBINED_MARKERS.iter())
.copied()
}
fn longest_prefix(
prefixes: impl Iterator<Item = &'static [u8]>,
line: &[u8],
) -> Option<&'static [u8]> {
prefixes
.filter(|p| line.starts_with(p))
.max_by_key(|p| p.len())
}
fn says_what_its_marker_promises(line: &[u8]) -> bool {
let Some(marker) = diff_marker(line) else {
return false;
};
if marker == HUNK_HEADER_MARKER {
return parse_hunk_header(line).is_ok();
}
carries_something_past(line, marker)
}
#[doc(hidden)]
pub fn carries_something_past(line: &[u8], prefix: &[u8]) -> bool {
line.strip_prefix(prefix)
.is_some_and(|tail| tail.iter().any(|b| !b.is_ascii_whitespace()))
}
const HEADERS_A_PATH_FOLLOWS: [&[u8]; 4] =
[b"rename from ", b"rename to ", b"copy from ", b"copy to "];
const HEADERS_ASCII_FOLLOWS: [&[u8]; 7] = [
b"index ",
b"similarity index ",
b"dissimilarity index ",
b"new file mode ",
b"deleted file mode ",
b"old mode ",
b"new mode ",
];
pub fn reads_as_a_diff_line(line: &[u8]) -> bool {
says_what_its_marker_promises(line)
|| the_header_a_path_follows_in(line).is_some()
|| says_what_its_header_promises(line)
}
#[doc(hidden)]
pub fn the_header_a_path_follows_in(line: &[u8]) -> Option<&'static [u8]> {
longest_prefix(headers_a_path_follows(), line)
}
#[doc(hidden)]
pub fn headers_a_path_follows() -> impl Iterator<Item = &'static [u8]> {
HEADERS_A_PATH_FOLLOWS.iter().copied()
}
#[doc(hidden)]
pub fn headers_ascii_follows() -> impl Iterator<Item = &'static [u8]> {
HEADERS_ASCII_FOLLOWS.iter().copied()
}
fn says_what_its_header_promises(line: &[u8]) -> bool {
longest_prefix(headers_ascii_follows(), line)
.is_some_and(|header| carries_something_past(line, header))
}
const HUNK_HEADER_MARKER: &[u8] = b"@@ ";
const ORDINARY_MARKERS: [&[u8]; 5] = [
b"diff --git ",
b"--- ",
b"+++ ",
HUNK_HEADER_MARKER,
b"Binary files ",
];
const COMBINED_MARKERS: [&[u8]; 3] = [b"diff --cc ", b"diff --combined ", b"@@@"];
fn is_combined_marker(line: &[u8]) -> bool {
COMBINED_MARKERS.iter().any(|m| line.starts_with(m))
}
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(' ');
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 = old.strip_prefix('-').ok_or_else(bad)?;
let new = new.strip_prefix('+').ok_or_else(bad)?;
let (old_start, old_lines) = parse_range(old)?;
let (new_start, new_lines) = parse_range(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 bad = || ParseError::BadHunkHeader(s.to_string());
let mut parts = s.split(',');
let start = parts.next().and_then(count_from).ok_or_else(bad)?;
let count = match parts.next() {
Some(c) => count_from(c).ok_or_else(bad)?,
None => 1,
};
if parts.next().is_some() {
return Err(bad());
}
Ok((start, count))
}
fn count_from(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::emit::emit;
use crate::model::{FileContent, LineKind};
#[test]
fn the_longest_prefix_that_opens_a_line_is_the_one_reported() {
let prefixes: [&'static [u8]; 2] = [b"@@ ", b"@@ -"];
let line = b"@@ -1 +1 @@";
assert_eq!(
longest_prefix(prefixes.into_iter(), line),
Some(b"@@ -".as_slice())
);
assert_eq!(
longest_prefix(prefixes.into_iter().rev(), line),
Some(b"@@ -".as_slice())
);
}
#[test]
fn a_prefix_that_does_not_open_a_line_carries_nothing() {
assert!(!carries_something_past(b"@@", b"@@ -1 +1 @@"));
assert!(!carries_something_past(b"index 100", b"--- "));
}
#[test]
fn a_prefix_followed_only_by_whitespace_promises_nothing() {
for tail in ["\n", "\r\n", "\x0c", "\t", " "] {
for marker in markers() {
let line = [marker, tail.as_bytes()].concat();
let bare = String::from_utf8_lossy(marker);
assert!(!reads_as_a_diff_line(&line), "`{bare}` and {tail:?}");
}
for header in headers_ascii_follows() {
let line = [header, tail.as_bytes()].concat();
let bare = String::from_utf8_lossy(header);
assert!(!reads_as_a_diff_line(&line), "`{bare}` and {tail:?}");
}
}
}
#[test]
fn a_bare_header_says_something_only_where_a_path_follows_it() {
for header in headers_a_path_follows() {
let bare = String::from_utf8_lossy(header);
assert!(reads_as_a_diff_line(header), "bare `{bare}`");
}
for header in headers_ascii_follows() {
let bare = String::from_utf8_lossy(header);
assert!(!reads_as_a_diff_line(header), "bare `{bare}`");
assert!(
!reads_as_a_diff_line(&[header, b"\r\n".as_slice()].concat()),
"`{bare}` and the line ending a patch written under Windows leaves"
);
assert!(
reads_as_a_diff_line(&[header, b"1".as_slice()].concat()),
"`{bare}1`"
);
}
assert!(reads_as_a_diff_line(b"rename from f.txt"));
assert!(reads_as_a_diff_line(b"similarity index 100%"));
assert!(reads_as_a_diff_line(b"index 111..222 100644"));
assert!(!reads_as_a_diff_line(b"renamed the file"));
}
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 a_non_ascii_space_between_the_ranges_is_rejected() {
for sep in ["\u{a0}", "\u{85}", "\u{2008}", "\u{3000}"] {
let src = format!("--- a/f\n+++ b/f\n@@ -1,3{sep}+1,3 @@\n a\n-b\n+B\n c\n");
assert!(
matches!(parse(src.as_bytes()), Err(ParseError::BadHunkHeader(_))),
"separator {sep:?} must not parse"
);
}
}
#[test]
fn a_tab_or_a_double_space_between_the_ranges_is_rejected() {
for sep in ["\t", " "] {
let src = format!("--- a/f\n+++ b/f\n@@ -1,3{sep}+1,3 @@\n a\n-b\n+B\n c\n");
assert!(
matches!(parse(src.as_bytes()), Err(ParseError::BadHunkHeader(_))),
"separator {sep:?} must not parse"
);
}
}
#[test]
fn a_range_without_its_sign_or_with_a_stray_one_is_rejected() {
for header in [
"@@ +1,3 +1,3 @@",
"@@ -1,3 1,3 @@",
"@@ -+1,3 +1,3 @@",
"@@ -1,+3 +1,3 @@",
] {
let src = format!("--- a/f\n+++ b/f\n{header}\n a\n-b\n+B\n c\n");
assert!(
matches!(parse(src.as_bytes()), Err(ParseError::BadHunkHeader(_))),
"header {header:?} must not parse"
);
}
}
#[test]
fn non_ascii_digits_in_a_range_are_rejected() {
let src = "--- a/f\n+++ b/f\n@@ -\u{661},3 +1,3 @@\n a\n-b\n+B\n c\n";
assert!(matches!(
parse(src.as_bytes()),
Err(ParseError::BadHunkHeader(_))
));
}
#[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());
}
}