use std::collections::BTreeMap;
use std::fmt;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PatchLine {
pub op: u8,
pub text: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PatchHunk {
pub old_start: i64,
pub new_start: i64,
pub lines: Vec<PatchLine>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReplayPatchFormatError(pub String);
impl fmt::Display for ReplayPatchFormatError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
return write!(formatter, "{}", self.0);
}
}
const SECTION_HEADER: &[u8] = b"diff --git a/";
const HUNK_HEADER: &[u8] = b"@@ -";
const INCOMPLETE_MARKER: u8 = b'\\';
pub fn split_keeping_newlines(text: &[u8]) -> Vec<Vec<u8>> {
let mut lines: Vec<Vec<u8>> = Vec::new();
let mut start: usize = 0;
while start < text.len() {
let stop: usize = match text[start..].iter().position(|byte| { return *byte == b'\n'; }) {
Some(offset) => start + offset + 1,
None => text.len(),
};
lines.push(text[start..stop].to_vec());
start = stop;
}
return lines;
}
fn parse_number(text: &[u8]) -> Option<i64> {
if text.is_empty() || !text.iter().all(|byte| { return byte.is_ascii_digit(); }) {
return None;
}
let digits: &str = std::str::from_utf8(text).ok()?;
return digits.parse::<i64>().ok();
}
fn parse_range(range: &[u8]) -> Result<(i64, i64), ReplayPatchFormatError> {
let body: &[u8] = &range[1..];
let (start_text, count_text): (&[u8], &[u8]) = match body.iter().position(|byte| { return *byte == b','; }) {
Some(comma) => (&body[..comma], &body[comma + 1..]),
None => (body, b"1"),
};
return match (parse_number(start_text), parse_number(count_text)) {
(Some(start), Some(count)) => Ok((start, count)),
_ => Err(ReplayPatchFormatError(format!(
"malformed hunk range {}",
String::from_utf8_lossy(range)
))),
};
}
fn mark_incomplete(parsed: &mut [PatchLine]) -> Result<(), ReplayPatchFormatError> {
let last: &mut PatchLine = match parsed.last_mut() {
Some(line) => line,
None => {
return Err(ReplayPatchFormatError(String::from(
"an incomplete-line marker precedes every hunk line",
)));
}
};
if last.text.last() == Some(&b'\n') {
last.text.pop();
}
return Ok(());
}
fn parse_hunk(lines: &[Vec<u8>], start: usize) -> Result<(PatchHunk, usize), ReplayPatchFormatError> {
let header: &[u8] = &lines[start];
let end: usize = match find_bytes(header, b" @@", 4) {
Some(found) => found,
None => {
return Err(ReplayPatchFormatError(format!(
"malformed hunk header {}",
String::from_utf8_lossy(header).trim_end()
)));
}
};
let ranges: &[u8] = &header[3..end];
let (old_range, new_range): (&[u8], &[u8]) = match ranges.iter().position(|byte| { return *byte == b' '; }) {
Some(space) => (&ranges[..space], &ranges[space + 1..]),
None => {
return Err(ReplayPatchFormatError(format!(
"malformed hunk header {}",
String::from_utf8_lossy(header).trim_end()
)));
}
};
let (old_start, old_count): (i64, i64) = parse_range(old_range)?;
let (new_start, new_count): (i64, i64) = parse_range(new_range)?;
let mut parsed: Vec<PatchLine> = Vec::new();
let mut old_left: i64 = old_count;
let mut new_left: i64 = new_count;
let mut cursor: usize = start + 1;
while old_left > 0 || new_left > 0 {
let line: &[u8] = match lines.get(cursor) {
Some(found) => found,
None => {
return Err(ReplayPatchFormatError(format!(
"hunk {} is truncated",
String::from_utf8_lossy(header).trim_end()
)));
}
};
cursor += 1;
let marker: u8 = line_marker(line)?;
if marker == INCOMPLETE_MARKER {
mark_incomplete(&mut parsed)?;
continue;
}
if marker != b'+' {
old_left -= 1;
}
if marker != b'-' {
new_left -= 1;
}
let text: Vec<u8> = if line == b"\n" { b"\n".to_vec() } else { line[1..].to_vec() };
parsed.push(PatchLine { op: marker, text });
}
if lines.get(cursor).is_some_and(|line| { return line.starts_with(b"\\"); }) {
mark_incomplete(&mut parsed)?;
cursor += 1;
}
return Ok((
PatchHunk {
old_start,
new_start,
lines: parsed,
},
cursor,
));
}
fn line_marker(line: &[u8]) -> Result<u8, ReplayPatchFormatError> {
let first: u8 = if line == b"\n" { b' ' } else { line.first().copied().unwrap_or(0) };
if matches!(first, b' ' | b'-' | b'+' | b'\\') {
return Ok(first);
}
return Err(ReplayPatchFormatError(format!(
"hunk line {} has no diff marker",
String::from_utf8_lossy(line)
)));
}
fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if haystack.len() < needle.len() {
return None;
}
return (from..=haystack.len() - needle.len()).find(|index| { return &haystack[*index..*index + needle.len()] == needle; });
}
pub fn parse_indexed_patch(
text: &[u8],
) -> Result<BTreeMap<Vec<u8>, Vec<PatchHunk>>, ReplayPatchFormatError> {
let lines: Vec<Vec<u8>> = split_keeping_newlines(text);
let mut files: BTreeMap<Vec<u8>, Vec<PatchHunk>> = BTreeMap::new();
let mut section: Option<Vec<u8>> = None;
let mut hunks: Vec<PatchHunk> = Vec::new();
let mut cursor: usize = 0;
while cursor < lines.len() {
let line: &[u8] = &lines[cursor];
if line.starts_with(HUNK_HEADER) {
let (hunk, next): (PatchHunk, usize) = parse_hunk(&lines, cursor)?;
hunks.push(hunk);
cursor = next;
continue;
}
if line.starts_with(SECTION_HEADER) {
if let Some(name) = section.take() {
files.insert(name, std::mem::take(&mut hunks));
}
hunks.clear();
let rest: &[u8] = &line[SECTION_HEADER.len()..];
let name_end: usize = match rest.iter().position(|byte| { return *byte == b' '; }) {
Some(space) => space,
None => {
return Err(ReplayPatchFormatError(format!(
"section header {} has no destination",
String::from_utf8_lossy(line).trim_end()
)));
}
};
section = Some(rest[..name_end].to_vec());
}
cursor += 1;
}
if let Some(name) = section {
files.insert(name, hunks);
}
return Ok(files);
}
#[cfg(test)]
#[path = "replay_patch_tests.rs"]
mod tests;