use anyhow::{bail, Context, Result};
use std::process::Command;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
File,
Hunk,
Ctx,
Add,
Del,
Thread,
Comment,
}
#[derive(Clone, Debug)]
pub struct Line {
pub kind: Kind,
pub old: Option<u32>,
pub new: Option<u32>,
pub text: String,
}
#[derive(Debug)]
pub struct FileDiff {
pub path: String,
pub old_path: String,
pub meta: String,
pub lines: Vec<Line>,
pub full: Vec<Line>,
pub headers: Vec<(u32, String)>,
}
impl FileDiff {
pub fn prepare(&mut self) {
self.headers = self.lines.iter().filter(|l| l.kind == Kind::Hunk).filter_map(|l| header(&l.text)).collect();
self.full = self.lines.iter().filter(|l| l.kind != Kind::Hunk).cloned().collect();
}
}
pub fn hunk_label(h: &str) -> Option<(u32, u32, String)> {
let new = h.split_whitespace().nth(2)?.get(1..)?;
let (start, count) = new.split_once(',').unwrap_or((new, "1"));
Some((start.parse().ok()?, count.parse().ok()?, h.splitn(3, "@@").nth(2).unwrap_or("").trim().to_owned()))
}
pub type Spans = Vec<(usize, usize)>;
pub fn word_diff(a: &str, b: &str) -> Option<(Spans, Spans)> {
let (ac, bc): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let (at, bt) = (tokens(&ac), tokens(&bc));
if at.len() * bt.len() > 250_000 {
return None; }
let (n, m) = (at.len(), bt.len());
let mut dp = vec![vec![0u16; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
dp[i][j] = if tok(&ac, at[i]) == tok(&bc, bt[j]) { dp[i + 1][j + 1] + 1 } else { dp[i + 1][j].max(dp[i][j + 1]) };
}
}
let (mut ka, mut kb) = (vec![false; n], vec![false; m]);
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if tok(&ac, at[i]) == tok(&bc, bt[j]) {
(ka[i], kb[j]) = (true, true);
(i, j) = (i + 1, j + 1);
} else if dp[i + 1][j] >= dp[i][j + 1] {
i += 1;
} else {
j += 1;
}
}
let solid = |c: &[char], t: &[(usize, usize)], keep: &[bool], kept: bool| -> usize {
t.iter().zip(keep).filter(|(_, k)| **k == kept).map(|(t, _)| c[t.0..t.1].iter().filter(|c| !c.is_whitespace()).count()).sum()
};
let same = solid(&ac, &at, &ka, true) + solid(&bc, &bt, &kb, true);
let diff = solid(&ac, &at, &ka, false) + solid(&bc, &bt, &kb, false);
if same == 0 || diff == 0 || same < diff {
return None;
}
Some((spans(&ac, &at, &ka), spans(&bc, &bt, &kb)))
}
fn tok(c: &[char], t: (usize, usize)) -> &[char] {
&c[t.0..t.1]
}
fn tokens(c: &[char]) -> Vec<(usize, usize)> {
let class = |c: char| if c.is_alphanumeric() || c == '_' { 0 } else if c.is_whitespace() { 1 } else { 2 };
let mut out = vec![];
let mut i = 0;
while i < c.len() {
let k = class(c[i]);
let mut j = i + 1;
while k != 2 && j < c.len() && class(c[j]) == k {
j += 1;
}
out.push((i, j));
i = j;
}
out
}
fn spans(c: &[char], t: &[(usize, usize)], keep: &[bool]) -> Vec<(usize, usize)> {
let mut out: Vec<(usize, usize)> = vec![];
for (k, &(s, e)) in t.iter().enumerate() {
let ws = c[s..e].iter().all(|c| c.is_whitespace());
let changed = !keep[k] || (ws && k > 0 && !keep[k - 1] && t.get(k + 1).is_some() && !keep[k + 1]);
if !changed {
continue;
}
match out.last_mut() {
Some(last) if last.1 == s => last.1 = e,
_ => out.push((s, e)),
}
}
out
}
fn header(h: &str) -> Option<(u32, String)> {
let new = h.split_whitespace().nth(2)?.get(1..)?.split(',').next()?.parse().ok()?;
Some((new, h.splitn(3, "@@").nth(2).unwrap_or("").to_owned()))
}
pub fn load(base: &str, ctx: u32) -> Result<Vec<FileDiff>> {
let mut files = diff(base, 1_000_000, &[])?;
let mut normal = diff(base, ctx, &[])?;
for f in &mut files {
f.prepare();
if let Some(g) = normal.iter_mut().find(|g| g.path == f.path) {
g.prepare();
f.headers = std::mem::take(&mut g.headers);
}
}
Ok(files)
}
pub fn hunks(full: &[Line], ctx: usize) -> Vec<(usize, usize)> {
let mut out: Vec<(usize, usize)> = vec![];
for i in (0..full.len()).filter(|&i| matches!(full[i].kind, Kind::Add | Kind::Del)) {
match out.last_mut() {
Some((_, last)) if i - *last - 1 <= 2 * ctx => *last = i,
_ => out.push((i, i)),
}
}
out
}
pub fn windows(full: &[Line], ctx: usize, extra: impl Fn(usize) -> i64, whole: bool) -> Vec<(usize, usize, Vec<usize>)> {
let hs = hunks(full, ctx);
if full.is_empty() {
return vec![];
}
if whole {
return vec![(0, full.len() - 1, hs.iter().map(|h| h.0).collect())];
}
let mut out: Vec<(usize, usize, Vec<usize>)> = vec![];
for (a, b) in hs {
let x = (ctx as i64 + extra(a)).max(0) as usize;
let (lo, hi) = (a.saturating_sub(x), (b + x).min(full.len() - 1));
match out.last_mut() {
Some(w) if lo <= w.1 + 1 => {
w.1 = w.1.max(hi);
w.2.push(a);
}
_ => out.push((lo, hi, vec![a])),
}
}
out
}
pub fn shown(full: &[Line], windows: &[(usize, usize, Vec<usize>)], headers: &[(u32, String)]) -> Vec<Line> {
let mut out = vec![];
for (lo, hi, _) in windows {
let part = &full[*lo..=*hi];
let start = |f: fn(&Line) -> Option<u32>| part.iter().find_map(f).unwrap_or(0);
let count = |f: fn(&Line) -> Option<u32>| part.iter().filter(|l| f(l).is_some()).count() as u32;
let (os, oc, ns, nc) = (start(|l| l.old), count(|l| l.old), start(|l| l.new), count(|l| l.new));
let suffix = headers.iter().find(|(n, _)| (ns..=ns + nc).contains(n)).map_or("", |h| h.1.as_str());
let text = format!("@@ -{os},{oc} +{ns},{nc} @@{suffix}");
out.push(Line { kind: Kind::Hunk, old: None, new: None, text });
out.extend(part.iter().cloned());
}
out
}
pub fn git(args: &[&str]) -> Result<String> {
let out = Command::new("git").args(args).output().context("running git")?;
if !out.status.success() {
bail!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn merge_base(base: Option<&str>) -> Result<String> {
let base = match base {
Some(b) => b.to_owned(),
None => git(&["rev-parse", "--abbrev-ref", "origin/HEAD"])
.map(|s| s.trim().to_owned())
.unwrap_or_else(|_| "main".to_owned()),
};
Ok(git(&["merge-base", &base, "HEAD"])
.with_context(|| format!("no merge-base with {base}"))?
.trim()
.to_owned())
}
pub fn diff(base: &str, context: u32, paths: &[&str]) -> Result<Vec<FileDiff>> {
let u = format!("-U{context}");
let specs: Vec<String> = paths.iter().map(|p| format!(":(literal){p}")).collect();
let mut args = vec![
"-c", "core.quotepath=off", "diff", "--no-color", "--no-ext-diff", "-M",
"--src-prefix=a/", "--dst-prefix=b/", &u, base, "--",
];
args.extend(specs.iter().map(String::as_str));
Ok(parse(&git(&args)?))
}
pub fn parse(s: &str) -> Vec<FileDiff> {
let mut files: Vec<FileDiff> = vec![];
let (mut old, mut new, mut in_hunk) = (0u32, 0u32, false);
for l in s.lines() {
if let Some(rest) = l.strip_prefix("diff --git ") {
let p = rest.rsplit_once(" b/").map_or(rest, |(_, b)| b).to_owned();
files.push(FileDiff { path: p.clone(), old_path: p, meta: String::new(), lines: vec![], full: vec![], headers: vec![] });
in_hunk = false;
continue;
}
let Some(f) = files.last_mut() else { continue };
if l.starts_with("@@ ") {
let mut it = l.split_whitespace().skip(1);
let mut num = || -> u32 {
it.next().and_then(|s| s.get(1..)?.split(',').next()?.parse().ok()).unwrap_or(0)
};
(old, new, in_hunk) = (num(), num(), true);
f.lines.push(Line { kind: Kind::Hunk, old: None, new: None, text: l.to_owned() });
continue;
}
if !in_hunk {
if let Some(p) = l.strip_prefix("--- a/") {
f.old_path = p.to_owned();
} else if let Some(p) = l.strip_prefix("+++ b/") {
f.path = p.to_owned();
} else if let Some(p) = l.strip_prefix("rename from ") {
f.old_path = p.to_owned();
f.meta = format!("renamed from {p}");
} else if let Some(p) = l.strip_prefix("rename to ") {
f.path = p.to_owned();
} else if l.starts_with("new file") {
f.meta = "new".into();
} else if l.starts_with("deleted file") {
f.meta = "deleted".into();
} else if l.starts_with("Binary files") {
f.meta = "binary".into();
}
continue;
}
let kind = match l.as_bytes().first() {
Some(b'+') => Kind::Add,
Some(b'-') => Kind::Del,
Some(b'\\') => continue, _ => Kind::Ctx,
};
let (o, n) = match kind {
Kind::Add => (None, Some(new)),
Kind::Del => (Some(old), None),
_ => (Some(old), Some(new)),
};
old += o.is_some() as u32;
new += n.is_some() as u32;
f.lines.push(Line { kind, old: o, new: n, text: l.get(1..).unwrap_or("").to_owned() });
}
files
}
#[cfg(test)]
pub const SAMPLE: &str = "\
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 main
fn main() {
- old();
+ new_call(x);
}
diff --git a/old.rs b/new.rs
similarity index 100%
rename from old.rs
rename to new.rs
diff --git a/img.png b/img.png
new file mode 100644
Binary files /dev/null and b/img.png differ
";
#[test]
fn parses() {
let f = parse(SAMPLE);
assert_eq!(f.len(), 3);
let k: Vec<_> = f[0].lines.iter().map(|l| (l.kind, l.old, l.new)).collect();
assert_eq!(k, [
(Kind::Hunk, None, None),
(Kind::Ctx, Some(1), Some(1)),
(Kind::Del, Some(2), None),
(Kind::Add, None, Some(2)),
(Kind::Ctx, Some(3), Some(3)),
]);
assert_eq!(f[0].lines[3].text, " new_call(x);");
assert_eq!((f[1].old_path.as_str(), f[1].path.as_str()), ("old.rs", "new.rs"));
assert_eq!(f[2].meta, "binary");
}
#[test]
fn words() {
let (a, b) = word_diff(" let a = 1;", " let ab = 3;").unwrap();
assert_eq!((a, b), (vec![(8, 9), (12, 13)], vec![(8, 10), (13, 14)]));
assert!(word_diff("fn alpha() {}", "struct Beta;").is_none()); assert_eq!(hunk_label("@@ -1,4 +12,7 @@ fn main"), Some((12, 7, "fn main".into())));
}
#[test]
fn hunk_windows() {
let full: Vec<Line> = (0..30u32)
.map(|i| {
let kind = if i == 10 || i == 25 { Kind::Add } else { Kind::Ctx };
Line { kind, old: (kind == Kind::Ctx).then_some(i + 1), new: Some(i + 1), text: String::new() }
})
.collect();
assert_eq!(hunks(&full, 3), [(10, 10), (25, 25)]);
assert_eq!(windows(&full, 3, |_| 0, false), [(7, 13, vec![10]), (22, 28, vec![25])]);
assert_eq!(windows(&full, 3, |a| if a == 10 { 10 } else { 0 }, false), [(0, 28, vec![10, 25])]);
let lines = shown(&full, &windows(&full, 3, |_| 0, false), &[(8, " fn x".into())]);
assert_eq!(lines[0].text, "@@ -8,6 +8,7 @@ fn x");
assert_eq!(lines.len(), 2 + 7 + 7);
}