~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
396 lines · 12.2 KB Raw History
//! 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);
    }
}