#[derive(Debug, Eq, PartialEq)] pub struct Line { pub old_number: String, pub new_number: String, pub text: String, pub kind: &'static str, } pub struct Parsed { pub lines: Vec, pub columns: usize, } pub fn parse_file(diff: &str, path: &str) -> Parsed { let section = sections(diff) .into_iter() .find(|section| section_path(section).as_deref() == Some(path)) .unwrap_or(""); if section.is_empty() { let text = format!("No diff data is available for {path}."); return Parsed { columns: text.chars().count(), lines: vec![Line { old_number: String::new(), new_number: String::new(), text, kind: "header", }], }; } let mut old = 0; let mut new = 0; let mut columns = 0; let lines = section .lines() .map(|text| { columns = columns.max(display_columns(text)); let (old_number, new_number, kind) = if text.starts_with("@@") { if let Some((old_start, new_start)) = hunk_starts(text) { old = old_start; new = new_start; } (String::new(), String::new(), "hunk") } else if text.starts_with('+') && !text.starts_with("+++") { let number = new.to_string(); new += 1; (String::new(), number, "addition") } else if text.starts_with('-') && !text.starts_with("---") { let number = old.to_string(); old += 1; (number, String::new(), "removal") } else if text.starts_with(' ') { let numbers = (old.to_string(), new.to_string()); old += 1; new += 1; (numbers.0, numbers.1, "context") } else { (String::new(), String::new(), "header") }; Line { old_number, new_number, text: text.into(), kind, } }) .collect(); Parsed { lines, columns } } fn sections(diff: &str) -> Vec<&str> { let mut starts: Vec<_> = diff .match_indices("diff --git ") .map(|(index, _)| index) .collect(); starts.push(diff.len()); starts .windows(2) .map(move |bounds| &diff[bounds[0]..bounds[1]]) .collect() } fn section_path(section: &str) -> Option { section .lines() .find_map(|line| line.strip_prefix("+++ ")) .filter(|path| *path != "/dev/null") .or_else(|| { section .lines() .find_map(|line| line.strip_prefix("--- ")) .filter(|path| *path != "/dev/null") }) .map(|path| { decode_path(path) .trim_start_matches("a/") .trim_start_matches("b/") .into() }) } fn decode_path(path: &str) -> String { let path = path.trim_matches('"'); let mut bytes = Vec::with_capacity(path.len()); let mut chars = path.bytes(); while let Some(byte) = chars.next() { if byte != b'\\' { bytes.push(byte); continue; } let Some(escaped) = chars.next() else { break }; match escaped { b'n' => bytes.push(b'\n'), b'r' => bytes.push(b'\r'), b't' => bytes.push(b'\t'), b'\\' | b'"' => bytes.push(escaped), b'0'..=b'7' => { let mut value = escaped - b'0'; for _ in 0..2 { let Some(next) = chars.next() else { break }; if !(b'0'..=b'7').contains(&next) { bytes.push(value); bytes.push(next); value = 0; break; } value = value * 8 + next - b'0'; } if value != 0 { bytes.push(value); } } other => bytes.push(other), } } String::from_utf8_lossy(&bytes).into_owned() } fn hunk_starts(line: &str) -> Option<(i32, i32)> { let mut parts = line.split_whitespace(); (parts.next()? == "@@").then_some(())?; Some(( range_start(parts.next()?, '-'), range_start(parts.next()?, '+'), )) } fn range_start(value: &str, prefix: char) -> i32 { value .strip_prefix(prefix) .and_then(|value| value.split(',').next()) .and_then(|value| value.parse().ok()) .unwrap_or_default() } fn display_columns(line: &str) -> usize { line.chars().fold(0, |column, character| { if character == '\t' { (column / 8 + 1) * 8 } else { column + 1 } }) } #[cfg(test)] mod tests { use super::*; #[test] fn selects_and_numbers_a_file_diff() { let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\ diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n"; let parsed = parse_file(diff, "two"); assert_eq!(parsed.lines[4].old_number, "4"); assert_eq!(parsed.lines[4].new_number, "8"); assert_eq!(parsed.lines[5].kind, "removal"); assert_eq!(parsed.lines[6].kind, "addition"); assert!(parsed.lines.iter().all(|line| !line.text.contains("old"))); } #[test] fn decodes_git_quoted_paths() { let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n"; assert_eq!( parse_file(diff, "café").lines.last().unwrap().kind, "addition" ); } }