//! 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. use std::cmp::Ordering; /// 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, /// The blobs it was, and is, as its `index` line names them: none for /// a side it does not have. pub old_blob: Option, pub new_blob: Option, pub status: Status, pub binary: bool, pub rows: Vec, pub added: usize, pub removed: usize, } impl File { /// The path it goes by: where it is after the change, or was, if deleted. pub fn path(&self) -> &str { if self.status == Status::Deleted { &self.old } else { &self.new } } } #[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() } /// A blob an `index` line names, or none for the zeros of a missing side. fn blob(s: &str) -> Option { s.bytes().any(|b| b != b'0').then(|| s.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; }; let blobs = line .strip_prefix("index ") .and_then(|l| l.split(' ').next().unwrap_or(l).split_once("..")); if let Some((a, b)) = blobs { (file.old_blob, file.new_blob) = (blob(a), blob(b)); } else 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 } /// Puts `files` in the order an editor's file tree lists them, so a diff /// read down is its tree read down: in each directory, its directories /// before its files, each by name ([`by_name`]). pub fn sort(files: &mut [File]) { files.sort_by(|a, b| in_tree(a.path(), b.path())); } fn in_tree(a: &str, b: &str) -> Ordering { let (mut a, mut b) = (a.split('/'), b.split('/')); loop { match (a.next(), b.next()) { (Some(x), Some(y)) if x == y => {} (Some(x), Some(y)) => { // Whether each is a directory, with more of its path to come. let (x_dir, y_dir) = (a.clone().next().is_some(), b.clone().next().is_some()); return y_dir.cmp(&x_dir).then_with(|| by_name(x, y)); } // One path goes on where the other ends: a directory, before a file of its name. (x, y) => return y.is_some().cmp(&x.is_some()), } } } /// Names as an editor sorts them: case aside, and a run of digits by its /// number (`v2` before `v10`); as written where those leave them alike. fn by_name(a: &str, b: &str) -> Ordering { let (mut x, mut y) = (a, b); loop { let (Some(c), Some(d)) = (x.chars().next(), y.chars().next()) else { return (!x.is_empty()).cmp(&!y.is_empty()).then_with(|| a.cmp(b)); }; let order = if c.is_ascii_digit() && d.is_ascii_digit() { let (n, m) = (digits(x), digits(y)); (x, y) = (&x[n.len()..], &y[m.len()..]); let (n, m) = (n.trim_start_matches('0'), m.trim_start_matches('0')); n.len().cmp(&m.len()).then_with(|| n.cmp(m)) } else { (x, y) = (&x[c.len_utf8()..], &y[d.len_utf8()..]); c.to_lowercase().cmp(d.to_lowercase()) }; if order.is_ne() { return order; } } } /// The run of digits `s` starts with. fn digits(s: &str) -> &str { &s[..s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())] } #[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 index 0000000..e69de29 --- /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.old_blob.as_deref(), a.new_blob.as_deref()), (Some("1"), Some("2")) ); 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[1].old_blob.as_deref(), files[1].new_blob.as_deref()), (None, Some("e69de29")) ); assert_eq!( (files[2].old.as_str(), files[2].status, files[2].removed), ("gone", Status::Deleted, 1) ); assert_eq!(files[2].old_blob, None); 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); } #[test] fn sorts_as_a_file_tree() { let at = |path: &str| File { new: path.to_string(), ..File::default() }; let gone = File { old: "a".to_string(), status: Status::Deleted, ..File::default() }; let mut files = vec![ at("Cargo.lock"), at("crates/a/lib.rs"), at("README.md"), at("justfile"), at("web/README.md"), at("web/src/app.css"), at("web/src/lib/a.ts"), at("docs/v10.md"), at("docs/v2.md"), at("docs/v02.md"), at("docs/v2.mdx"), at("a/b"), gone, ]; sort(&mut files); let paths: Vec<&str> = files.iter().map(File::path).collect(); assert_eq!( paths, [ "a/b", "crates/a/lib.rs", "docs/v02.md", "docs/v2.md", "docs/v2.mdx", "docs/v10.md", "web/src/lib/a.ts", "web/src/app.css", "web/README.md", "a", "Cargo.lock", "justfile", "README.md", ] ); // A directory goes before a file of its name, and a path is level with itself. assert_eq!(in_tree("a", "a/b"), Ordering::Greater); assert_eq!(in_tree("a/b", "a/b"), Ordering::Equal); } }