//! A patch read into files and rows, so a page can show each line with its //! old and new numbers rather than as the text git prints. /// One file's part of a patch. #[derive(Debug, Default, PartialEq)] pub struct File { /// Its path before, and after; the same unless it moved. pub old: String, pub new: String, pub status: Status, pub binary: bool, pub rows: Vec, pub added: usize, pub removed: usize, } #[derive(Debug, Default, Clone, Copy, PartialEq)] pub enum Status { Added, Deleted, Renamed, #[default] Modified, } #[derive(Debug, PartialEq)] pub struct Row { pub kind: Kind, pub old: Option, pub new: Option, /// The line without its `+`, `-` or space; a hunk's or note's whole line. pub text: String, } #[derive(Debug, Clone, Copy, PartialEq)] pub enum Kind { Hunk, Context, Add, Del, /// `\ No newline at end of file`. Note, } impl Kind { /// Whether the row is a line of the file, rather than about it. pub fn is_line(self) -> bool { matches!(self, Kind::Context | Kind::Add | Kind::Del) } } /// `-12,3` or `+12`: where a hunk's side starts, and how many lines it has. fn range(s: &str) -> Option<(u32, u32)> { let (start, len) = s[1..].split_once(',').unwrap_or((&s[1..], "1")); Some((start.parse().ok()?, len.parse().ok()?)) } fn path(s: &str) -> String { s.split_once('/') .map_or(s, |(_, p)| p) .trim_end_matches('\t') .to_string() } /// Reads `git show --patch` or `git diff` output. A hunk's lines are counted /// from its header, so a removed line that reads `-- x` is not taken for a /// file header. pub fn parse(patch: &str) -> Vec { let mut files: Vec = Vec::new(); // Lines left in the current hunk, old and new. let (mut old_left, mut new_left) = (0u32, 0u32); let (mut old_at, mut new_at) = (0u32, 0u32); for line in patch.lines() { if old_left > 0 || new_left > 0 { let Some(file) = files.last_mut() else { break }; let (kind, text) = match line.split_at_checked(1).unwrap_or((" ", "")) { ("+", text) => (Kind::Add, text), ("-", text) => (Kind::Del, text), ("\\", _) => (Kind::Note, line), (_, text) => (Kind::Context, text), }; let (old, new) = match kind { Kind::Add => (None, Some(new_at)), Kind::Del => (Some(old_at), None), Kind::Context => (Some(old_at), Some(new_at)), _ => (None, None), }; if old.is_some() { (old_at, old_left) = (old_at + 1, old_left.saturating_sub(1)); } if new.is_some() { (new_at, new_left) = (new_at + 1, new_left.saturating_sub(1)); } file.added += (kind == Kind::Add) as usize; file.removed += (kind == Kind::Del) as usize; file.rows.push(Row { kind, old, new, text: text.to_string(), }); continue; } if let Some(names) = line.strip_prefix("diff --git ") { let (a, b) = names.split_once(" b/").unwrap_or((names, names)); files.push(File { old: path(a), new: b.to_string(), ..File::default() }); continue; } let Some(file) = files.last_mut() else { continue; }; if line.starts_with("new file mode") { file.status = Status::Added; } else if line.starts_with("deleted file mode") { file.status = Status::Deleted; } else if let Some(p) = line.strip_prefix("rename from ") { (file.old, file.status) = (p.to_string(), Status::Renamed); } else if let Some(p) = line.strip_prefix("rename to ") { file.new = p.to_string(); } else if line.starts_with("Binary files ") { file.binary = true; } else if let Some(p) = line.strip_prefix("--- ").filter(|p| *p != "/dev/null") { file.old = path(p); } else if let Some(p) = line.strip_prefix("+++ ").filter(|p| *p != "/dev/null") { file.new = path(p); } else if line.starts_with("@@ ") { let mut sides = line.split(' ').skip(1); let (Some((o, ol)), Some((n, nl))) = (sides.next().and_then(range), sides.next().and_then(range)) else { continue; }; (old_at, old_left, new_at, new_left) = (o, ol, n, nl); file.rows.push(Row { kind: Kind::Hunk, old: None, new: None, text: line.to_string(), }); } else if line.starts_with('\\') { file.rows.push(Row { kind: Kind::Note, old: None, new: None, text: line.to_string(), }); } } files } #[cfg(test)] mod tests { use super::*; fn shown(f: &File) -> Vec { f.rows .iter() .map(|r| { let n = |x: Option| x.map(|x| x.to_string()).unwrap_or_default(); format!("{:?} {} {} {}", r.kind, n(r.old), n(r.new), r.text) }) .collect() } #[test] fn reads_a_patch() { let patch = "\ diff --git a/src/a.rs b/src/a.rs index 1..2 100644 --- a/src/a.rs +++ b/src/a.rs @@ -1,3 +1,3 @@ fn a() keep --- not a header +++ nor this end @@ -9 +9,2 @@ -x \\ No newline at end of file +x +y diff --git a/new file.txt b/new file.txt new file mode 100644 --- /dev/null +++ b/new file.txt @@ -0,0 +1 @@ +hi diff --git a/gone b/gone deleted file mode 100644 --- a/gone +++ /dev/null @@ -1 +0,0 @@ -bye diff --git a/old.png b/new.png similarity index 90% rename from old.png rename to new.png Binary files a/old.png and b/new.png differ diff --git a/run b/run old mode 100644 new mode 100755 @@ broken @@ \\ stray "; let files = parse(patch); assert_eq!(files.len(), 5); let a = &files[0]; assert_eq!( (a.old.as_str(), a.new.as_str(), a.status), ("src/a.rs", "src/a.rs", Status::Modified) ); assert_eq!((a.added, a.removed), (3, 2)); assert_eq!( shown(a), [ "Hunk @@ -1,3 +1,3 @@ fn a()", "Context 1 1 keep", "Del 2 -- not a header", "Add 2 ++ nor this", "Context 3 3 end", "Hunk @@ -9 +9,2 @@", "Del 9 x", "Note \\ No newline at end of file", "Add 9 x", "Add 10 y", ] ); assert_eq!( (files[1].new.as_str(), files[1].status, files[1].added), ("new file.txt", Status::Added, 1) ); assert_eq!( (files[2].old.as_str(), files[2].status, files[2].removed), ("gone", Status::Deleted, 1) ); let moved = &files[3]; assert_eq!( ( moved.old.as_str(), moved.new.as_str(), moved.status, moved.binary ), ("old.png", "new.png", Status::Renamed, true) ); assert_eq!(shown(&files[4]), ["Note \\ stray"]); assert!(Kind::Add.is_line() && !Kind::Hunk.is_line()); assert!(parse("index 1..2\n@@ -1 +1 @@\n x\n").is_empty()); assert_eq!(range("-3"), Some((3, 1))); assert_eq!(range("+a,1"), None); } }