use super::compute::split_line_slices;
use super::parse::{parse_hunk_range, trim_line_ending};
use super::{DiffDocument, DiffHunk, DiffLineKind, ParseDiffError};
pub const MAX_REVERSE_FILE_BYTES: usize = 8 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnifiedFile {
pub old_path: Option<String>,
pub new_path: Option<String>,
pub hunks: Vec<DiffHunk>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReverseApplyError {
EmptyDiff,
TooLarge,
EmptyNewSide {
hunk: usize,
},
NoMatch {
hunk: usize,
},
Ambiguous {
hunk: usize,
},
OverlappingSpans,
}
impl std::fmt::Display for ReverseApplyError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyDiff => formatter.write_str("no hunks to reverse"),
Self::TooLarge => formatter.write_str("file exceeds the reversal size bound"),
Self::EmptyNewSide { hunk } => write!(formatter, "hunk {hunk} has no new-side lines"),
Self::NoMatch { hunk } => {
write!(formatter, "hunk {hunk} does not match the current content")
}
Self::Ambiguous { hunk } => {
write!(formatter, "hunk {hunk} matches the current content more than once")
}
Self::OverlappingSpans => formatter.write_str("hunk matches overlap or run out of order"),
}
}
}
impl std::error::Error for ReverseApplyError {}
pub fn split_unified_files(input: &str) -> Result<Vec<UnifiedFile>, ParseDiffError> {
for raw in split_line_slices(input) {
let trimmed = trim_line_ending(raw);
if trimmed == "\\ No newline at end of file" {
return Err(ParseDiffError::new("missing-newline marker cannot be reversed exactly"));
}
let body_prefixed = matches!(trimmed.as_bytes().first(), Some(b' ' | b'+' | b'-'));
if !body_prefixed && super::parse::parse_omitted_line_count(trimmed).is_some() {
return Err(ParseDiffError::new("omitted rows cannot be reversed exactly"));
}
}
let mut sections: Vec<Section> = Vec::new();
let mut section_has_hunk = false;
let mut current = Section::default();
let mut expected_old = 0usize;
let mut expected_new = 0usize;
let mut seen_old = 0usize;
let mut seen_new = 0usize;
let mut in_hunk = false;
for raw in split_line_slices(input) {
let hunk_complete = !in_hunk || (seen_old >= expected_old && seen_new >= expected_new);
let trimmed = trim_line_ending(raw);
if trimmed.starts_with("@@") {
let (_, old_count, _, new_count) =
parse_hunk_range(trimmed).ok_or_else(|| ParseDiffError::new("invalid hunk header"))?;
in_hunk = true;
section_has_hunk = true;
expected_old = old_count;
expected_new = new_count;
seen_old = 0;
seen_new = 0;
current.lines.push(raw);
continue;
}
if trimmed.starts_with("diff --git ") {
if !current.lines.is_empty() {
sections.push(std::mem::take(&mut current));
section_has_hunk = false;
}
in_hunk = false;
if current.git.is_none() {
current.git = trimmed.strip_prefix("diff --git ");
}
current.lines.push(raw);
continue;
}
if hunk_complete && trimmed.starts_with("--- ") {
if !current.lines.is_empty() && (current.old.is_some() || section_has_hunk) {
sections.push(std::mem::take(&mut current));
section_has_hunk = false;
}
in_hunk = false;
if current.old.is_none() {
current.old = trimmed.strip_prefix("--- ");
}
current.lines.push(raw);
continue;
}
if hunk_complete && trimmed.starts_with("+++ ") && current.new.is_none() {
current.new = trimmed.strip_prefix("+++ ");
}
if in_hunk && !hunk_complete {
match trimmed.as_bytes().first().copied() {
Some(b'-') => seen_old += 1,
Some(b'+') => seen_new += 1,
Some(b' ') => {
seen_old += 1;
seen_new += 1;
}
_ => {}
}
}
current.lines.push(raw);
}
if !current.lines.is_empty() {
sections.push(current);
}
let mut files = Vec::with_capacity(sections.len());
for section in §ions {
let text: String = section.lines.concat();
let document = DiffDocument::from_unified(&text)?;
if document.stats.omitted_rows > 0 {
return Err(ParseDiffError::new("omitted rows cannot be reversed exactly"));
}
if document.hunks.is_empty() {
return Err(ParseDiffError::new("file section without hunks"));
}
let (old_path, new_path) = section_paths(section)?;
files.push(UnifiedFile { old_path, new_path, hunks: document.hunks });
}
Ok(files)
}
#[derive(Default)]
struct Section<'a> {
lines: Vec<&'a str>,
old: Option<&'a str>,
new: Option<&'a str>,
git: Option<&'a str>,
}
pub fn reverse_apply_hunks(current: &str, hunks: &[DiffHunk]) -> Result<String, ReverseApplyError> {
if hunks.is_empty() {
return Err(ReverseApplyError::EmptyDiff);
}
if current.len() > MAX_REVERSE_FILE_BYTES {
return Err(ReverseApplyError::TooLarge);
}
let segments = split_line_slices(current);
let mut spans: Vec<(usize, usize, Vec<&str>)> = Vec::with_capacity(hunks.len());
for (index, hunk) in hunks.iter().enumerate() {
let new_block: Vec<&str> = hunk
.lines
.iter()
.filter(|line| line.kind != DiffLineKind::Deletion)
.map(|line| line.text.as_str())
.collect();
if new_block.is_empty() {
return Err(ReverseApplyError::EmptyNewSide { hunk: index });
}
let matches: Vec<usize> = segments
.windows(new_block.len())
.enumerate()
.filter_map(|(start, window)| (window == new_block.as_slice()).then_some(start))
.take(2)
.collect();
let [start] = matches.as_slice() else {
if matches.is_empty() {
return Err(ReverseApplyError::NoMatch { hunk: index });
}
return Err(ReverseApplyError::Ambiguous { hunk: index });
};
let old_block: Vec<&str> = hunk
.lines
.iter()
.filter(|line| line.kind != DiffLineKind::Addition)
.map(|line| line.text.as_str())
.collect();
spans.push((*start, start + new_block.len(), old_block));
}
for pair in spans.windows(2) {
let [(.., previous_end, _), (start, ..)] = pair else {
continue;
};
if start < previous_end {
return Err(ReverseApplyError::OverlappingSpans);
}
}
let mut restored = String::with_capacity(current.len());
let mut cursor = 0usize;
for (start, end, old_block) in &spans {
for segment in &segments[cursor..*start] {
restored.push_str(segment);
}
for line in old_block {
restored.push_str(line);
}
cursor = *end;
}
for segment in &segments[cursor..] {
restored.push_str(segment);
}
Ok(restored)
}
fn section_paths(section: &Section<'_>) -> Result<(Option<String>, Option<String>), ParseDiffError> {
if section.old.is_some() || section.new.is_some() {
let old_path = section.old.map(normalize_diff_path).transpose()?.flatten();
let new_path = section.new.map(normalize_diff_path).transpose()?.flatten();
return Ok((old_path, new_path));
}
if let Some(git) = section.git {
let (old, new) = split_git_header(git)?;
return Ok((normalize_diff_path(old)?, normalize_diff_path(new)?));
}
Ok((None, None))
}
fn split_git_header(rest: &str) -> Result<(&str, &str), ParseDiffError> {
let rest = rest.trim_end();
let (old, new) = if let Some(quoted) = rest.strip_prefix('"') {
let end = quoted
.find('"')
.ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
let new_start = quoted[end + 1..]
.find('"')
.ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
let new_quoted = "ed[end + 1 + new_start + 1..];
let new_end = new_quoted
.find('"')
.ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
("ed[..end], &new_quoted[..new_end])
} else {
let mut parts = rest.splitn(2, ' ');
let old = parts.next().ok_or_else(|| ParseDiffError::new("malformed diff header"))?;
let new = parts.next().ok_or_else(|| ParseDiffError::new("malformed diff header"))?;
(old, new)
};
if old.is_empty() || new.is_empty() {
return Err(ParseDiffError::new("malformed diff header"));
}
Ok((old, new))
}
fn normalize_diff_path(path: &str) -> Result<Option<String>, ParseDiffError> {
let path = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")).unwrap_or(path);
if path == "/dev/null" {
return Ok(None);
}
if path.contains('\\') {
return Err(ParseDiffError::new("unsupported escaped diff path"));
}
Ok(Some(path.to_owned()))
}