use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HunkLine {
Context(String),
Added(String),
Removed(String),
}
impl HunkLine {
pub const fn marker(&self) -> char {
match self {
HunkLine::Context(_) => ' ',
HunkLine::Added(_) => '+',
HunkLine::Removed(_) => '-',
}
}
pub fn content(&self) -> &str {
match self {
HunkLine::Context(s) | HunkLine::Added(s) | HunkLine::Removed(s) => s,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
pub file_path: PathBuf,
pub old_start: u32,
pub old_count: u32,
pub new_start: u32,
pub new_count: u32,
pub lines: Vec<HunkLine>,
}
pub(crate) fn group_by_file(hunks: impl IntoIterator<Item = Hunk>) -> Vec<Vec<Hunk>> {
let mut by_file: BTreeMap<PathBuf, Vec<Hunk>> = BTreeMap::new();
for hunk in hunks {
by_file
.entry(hunk.file_path.clone())
.or_default()
.push(hunk);
}
by_file.into_values().collect()
}
impl Hunk {
pub fn numbered_lines(&self) -> impl Iterator<Item = (Option<u32>, &HunkLine)> {
let mut next = self.new_start;
self.lines.iter().map(move |line| match line {
HunkLine::Removed(_) => (None, line),
HunkLine::Context(_) | HunkLine::Added(_) => {
let number = next;
next = next.saturating_add(1);
(Some(number), line)
}
})
}
pub fn numbered_new_lines(&self) -> impl Iterator<Item = (u32, &str)> {
self.numbered_lines()
.filter_map(|(number, line)| number.map(|n| (n, line.content())))
}
pub fn whole_file(file_path: PathBuf, content: &str) -> Hunk {
let lines: Vec<HunkLine> = content
.lines()
.map(|line| HunkLine::Context(line.to_owned()))
.collect();
let new_count = lines.len() as u32;
Hunk {
file_path,
old_start: 0,
old_count: 0,
new_start: 1,
new_count,
lines,
}
}
}
pub fn parse_unified_diff(diff_text: &str) -> Vec<Hunk> {
if diff_text.trim().is_empty() {
return Vec::new();
}
let mut hunks: Vec<Hunk> = Vec::new();
let mut current_file: Option<PathBuf> = None;
let mut pending: Option<Hunk> = None;
for line in diff_text.lines() {
if line.starts_with("diff --git ") {
hunks.extend(pending.take());
current_file = None;
continue;
}
if let Some(path) = line.strip_prefix("+++ b/") {
current_file = Some(PathBuf::from(path));
continue;
}
if line.starts_with("+++ /dev/null") {
hunks.extend(pending.take());
current_file = None;
continue;
}
if let Some(after_marker) = line.strip_prefix("@@") {
hunks.extend(pending.take());
pending = parse_hunk_header(after_marker).and_then(|(os, oc, ns, nc)| {
current_file.clone().map(|file_path| Hunk {
file_path,
old_start: os,
old_count: oc,
new_start: ns,
new_count: nc,
lines: Vec::new(),
})
});
continue;
}
let Some(hunk) = pending.as_mut() else {
continue;
};
match line.as_bytes().first() {
Some(b'+') => hunk.lines.push(HunkLine::Added(line[1..].to_owned())),
Some(b'-') => hunk.lines.push(HunkLine::Removed(line[1..].to_owned())),
Some(b' ') => hunk.lines.push(HunkLine::Context(line[1..].to_owned())),
_ => {}
}
}
hunks.extend(pending.take());
hunks
}
fn parse_hunk_header(after_marker: &str) -> Option<(u32, u32, u32, u32)> {
let (ranges, _signature) = after_marker.trim_start().split_once("@@")?;
let mut parts = ranges.split_whitespace();
let (old_start, old_count) = parse_range(parts.next()?.strip_prefix('-')?)?;
let (new_start, new_count) = parse_range(parts.next()?.strip_prefix('+')?)?;
if parts.next().is_some() {
return None;
}
Some((old_start, old_count, new_start, new_count))
}
fn parse_range(s: &str) -> Option<(u32, u32)> {
match s.split_once(',') {
Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
None => Some((s.parse().ok()?, 1)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hunk_header_with_full_counts() {
assert_eq!(parse_hunk_header(" -1,3 +1,4 @@"), Some((1, 3, 1, 4)));
}
#[test]
fn hunk_header_with_omitted_counts_defaults_to_one() {
assert_eq!(parse_hunk_header(" -5 +5 @@"), Some((5, 1, 5, 1)));
}
#[test]
fn hunk_header_with_zero_counts_is_legal() {
assert_eq!(parse_hunk_header(" -0,0 +1,3 @@"), Some((0, 0, 1, 3)));
}
#[test]
fn hunk_header_allows_a_trailing_function_signature() {
assert_eq!(
parse_hunk_header(" -1,3 +1,4 @@ fn compute()"),
Some((1, 3, 1, 4))
);
}
#[test]
fn hunk_header_signature_containing_at_at_does_not_extend_the_ranges() {
assert_eq!(
parse_hunk_header(" -1,3 +1,4 @@ fn f() { \"@@\" }"),
Some((1, 3, 1, 4))
);
}
#[test]
fn malformed_hunk_headers_are_rejected() {
assert!(parse_hunk_header("garbage").is_none());
assert!(parse_hunk_header(" -1,3 +1,4").is_none());
assert!(parse_hunk_header(" -a +1 @@").is_none());
assert!(parse_hunk_header(" -1,3 +1,4 +9,9 @@").is_none());
assert!(parse_hunk_header(" -1,3,5 +1,4 @@").is_none());
}
#[test]
fn range_without_a_comma_has_an_implicit_count_of_one() {
assert_eq!(parse_range("42"), Some((42, 1)));
assert_eq!(parse_range("42,7"), Some((42, 7)));
assert!(parse_range("").is_none());
}
}