johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<String>,
pub new_blob: Option<String>,
pub status: Status,
pub binary: bool,
pub rows: Vec<Row>,
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<u32>,
pub new: Option<u32>,
/// 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<String> {
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<File> {
let mut files: Vec<File> = 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<String> {
f.rows
.iter()
.map(|r| {
let n = |x: Option<u32>| 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);
}
}