use std::collections::HashSet;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Hunk {
pub path: String,
pub removed: Vec<(u32, String)>,
pub added: Vec<String>,
pub added_at: u32,
}
#[must_use]
pub fn generated(path: &str) -> bool {
let name = path.rsplit('/').next().unwrap_or(path);
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or_default();
let spec = name.starts_with("openapi") || name.starts_with("swagger");
["lock", "snap"].contains(&ext)
|| ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "go.sum"].contains(&name)
|| [".min.", ".pb.", "_pb2.", "_generated.", ".generated."].iter().any(|m| name.contains(m))
|| (spec && ["json", "yaml", "yml"].contains(&ext))
|| path.starts_with("dist/")
|| path.contains("/dist/")
}
#[must_use]
pub fn generated_by_attr(out: &str) -> HashSet<String> {
let f: Vec<&str> = out.split('\0').collect();
let (triples, _) = f.as_chunks::<3>();
triples
.iter()
.filter(|[_, attr, value]| matches!((*attr, *value), ("linguist-generated", "set" | "true") | ("diff", "unset")))
.map(|[p, ..]| (*p).to_string())
.collect()
}
#[must_use]
pub fn size(numstat: &str) -> (usize, usize) {
let (mut files, mut lines) = (0, 0);
let mut it = numstat.split('\0');
while let Some(entry) = it.next() {
let mut f = entry.splitn(3, '\t');
let (Some(add), Some(del), Some(path)) = (f.next(), f.next(), f.next()) else { continue };
let path = if path.is_empty() { it.nth(1).unwrap_or_default() } else { path };
let (Ok(a), Ok(d)) = (add.trim().parse::<usize>(), del.parse::<usize>()) else { continue };
if !generated(path) {
files += 1;
lines += a + d;
}
}
(files, lines)
}
fn starts(header: &str) -> Option<(u32, u32)> {
let mut f = header.strip_prefix("@@ -")?.split(' ');
let start = |s: &str| s.split(',').next()?.parse().ok();
Some((start(f.next()?)?, start(f.next()?.strip_prefix('+')?)?))
}
const BACKSLASH: u8 = 0x5c;
const QUOTE: char = '\u{22}';
fn unescape(e: u8) -> u8 {
match e {
b'n' => 10,
b't' => 9,
b'r' => 13,
b'a' => 7,
b'b' => 8,
b'f' => 12,
b'v' => 11,
other => other,
}
}
fn unquote(q: &str) -> Option<String> {
let inner = q.strip_prefix(QUOTE)?.strip_suffix(QUOTE)?.as_bytes();
let (mut out, mut i) = (Vec::new(), 0);
while let Some(&b) = inner.get(i) {
let octal = inner.get(i + 1..i + 4).filter(|o| b == BACKSLASH && o.iter().all(|d| (b'0'..=b'7').contains(d)));
let (byte, used) = match (b, octal) {
(_, Some(o)) => (u8::from_str_radix(std::str::from_utf8(o).ok()?, 8).ok()?, 4),
(BACKSLASH, None) => (unescape(*inner.get(i + 1)?), 2),
_ => (b, 1),
};
out.push(byte);
i += used;
}
String::from_utf8(out).ok()
}
fn old_path(l: &str) -> Option<String> {
let p = l.strip_prefix("--- ")?.trim_end_matches(char::is_control);
let p = if p.starts_with(QUOTE) { unquote(p)? } else { p.to_string() };
p.strip_prefix("a/").map(str::to_string)
}
#[must_use]
pub fn hunks(diff: &str) -> Vec<Hunk> {
let (mut out, mut path, mut header, mut at) = (Vec::<Hunk>::new(), String::new(), false, 0u32);
for l in diff.lines() {
if l.starts_with("diff --git ") {
(header, path) = (true, String::new());
} else if let Some((a, b)) = l.starts_with("@@ ").then(|| starts(l)).flatten() {
(header, at) = (false, a);
out.push(Hunk { path: path.clone(), added_at: b, ..Hunk::default() });
} else if header {
path = old_path(l).unwrap_or(path);
} else if let Some(h) = out.last_mut() {
body_line(h, l, &mut at);
}
}
out
}
fn body_line(h: &mut Hunk, l: &str, at: &mut u32) {
if let Some(t) = l.strip_prefix('-') {
h.removed.push((*at, t.to_string()));
*at += 1;
} else if let Some(t) = l.strip_prefix('+') {
h.added.push(t.to_string());
}
}
#[must_use]
pub fn blame_lines(porcelain: &str) -> Vec<(String, u32)> {
let (mut out, mut boundary, mut current) = (Vec::new(), HashSet::<String>::new(), None::<String>);
for l in porcelain.lines().filter(|l| !l.starts_with('\t')) {
if l == "boundary" {
boundary.extend(current.clone());
continue;
}
let mut f = l.split(' ');
let (Some(sha), Some(_), Some(line)) = (f.next(), f.next(), f.next()) else { continue };
let Ok(line) = line.parse::<u32>() else { continue };
if sha.len() >= 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
current = Some(sha.to_string());
out.push((sha.to_string(), line));
}
}
out.retain(|(sha, _)| !boundary.contains(sha) && !sha.bytes().all(|b| b == b'0'));
out
}
#[must_use]
pub fn ranges(lines: &[u32]) -> Vec<(u32, u32)> {
let mut v = lines.to_vec();
v.sort_unstable();
v.dedup();
let mut out: Vec<(u32, u32)> = Vec::new();
for n in v {
match out.last_mut() {
Some((_, b)) if *b + 1 == n => *b = n,
_ => out.push((n, n)),
}
}
out
}
#[must_use]
pub fn removed_by_path(hunks: &[Hunk]) -> Vec<(String, Vec<u32>)> {
let mut out: Vec<(String, Vec<u32>)> = Vec::new();
for h in hunks {
let lines = h.removed.iter().map(|(n, _)| *n);
match out.iter_mut().find(|(p, _)| *p == h.path) {
Some((_, v)) => v.extend(lines),
None => out.push((h.path.clone(), lines.collect())),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const DIFF: &str = "\
diff --git a/src/old.rs b/src/new.rs
similarity index 90%
rename from src/old.rs
rename to src/new.rs
--- a/src/old.rs
+++ b/src/new.rs
@@ -2 +2 @@ fn a() {}
-fn b() { flag(); }
+fn b() { lock(); flag(); }
@@ -10,0 +11,2 @@
+fn d() {}
+fn e() {}
@@ -20,2 +22 @@
-let x =
- 1;
+let x = 1;
\\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3 +3 @@
-version = 1
+version = 2
diff --git a/src/added.rs b/src/added.rs
--- /dev/null
+++ b/src/added.rs
@@ -0,0 +1 @@
+fn z() {}
diff --git a/q.sql b/q.sql
--- a/q.sql
+++ b/q.sql
@@ -4 +4 @@
--- the old comment
+-- the new comment
";
#[test]
fn hunks_keep_old_paths_and_line_numbers() {
let h = hunks(DIFF);
assert_eq!(h.len(), 6, "{h:?}");
assert_eq!(h[3].path, "Cargo.lock");
let h: Vec<Hunk> = h.into_iter().filter(|h| !generated(&h.path)).collect();
assert_eq!(h[4].removed, [(4, "-- the old comment".to_string())], "a removed line starting with -- is not a header");
assert_eq!((h[0].path.as_str(), h[0].removed.clone()), ("src/old.rs", vec![(2, "fn b() { flag(); }".to_string())]));
assert!(h[1].removed.is_empty() && h[1].added.len() == 2, "a pure addition");
assert_eq!(h[2].removed.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [20, 21]);
assert_eq!(h[3].path, "", "a new file has no old path");
let reflow = crate::notes::regress_cosmetic::formatting_only;
assert!(reflow(&h[2]) && !reflow(&h[0]) && !reflow(&h[1]));
assert_eq!(removed_by_path(&h[..4]), [("src/old.rs".to_string(), vec![2, 20, 21]), (String::new(), vec![])]);
}
#[test]
fn paths_with_spaces_and_quotes() {
let d = "diff --git a/src/my dir/lib.rs b/src/my dir/lib.rs\n--- a/src/my dir/lib.rs\t\n+++ b/src/my dir/lib.rs\t\n@@ -2 +2 @@\n-x\n+y\n\
diff --git \"a/src/l\\303\\257b \\\"q\\\".rs\" \"b/src/l\\303\\257b \\\"q\\\".rs\"\n--- \"a/src/l\\303\\257b \\\"q\\\".rs\"\n+++ \"b/x\"\n@@ -5 +5 @@\n-x\n+y\n";
let h = hunks(d);
assert_eq!(h.iter().map(|h| h.path.as_str()).collect::<Vec<_>>(), ["src/my dir/lib.rs", "src/l\u{ef}b \"q\".rs"], "{d}");
assert_eq!(unquote("\"a\\tb\\\\c\""), Some("a\tb\\c".to_string()));
}
#[test]
fn numstat_sizes_skip_binary_and_generated() {
let z = "3\t1\tsrc/a.rs\0-\t-\tlogo.png\x002\t2\t\0src/old.rs\0src/new.rs\x00100\t50\tCargo.lock\0";
assert_eq!(size(z), (2, 8));
assert!(generated("web/dist/app.js") && generated("a/b.min.css") && generated("yarn.lock") && !generated("src/lock.rs"));
assert!(generated("api/openapi.json") && generated("pb/user.pb.go") && generated("gen/user_pb2.py") && generated("src/schema_generated.rs"));
assert!(!generated("src/openapi.rs") && !generated("docs/api.json"));
let attrs = "api/spec.json\0linguist-generated\0set\0api/spec.json\0diff\0unspecified\0src/a.rs\0linguist-generated\0unspecified\0src/a.rs\0diff\0unset\0b.rs\0diff\0set\0";
assert_eq!(generated_by_attr(attrs), HashSet::from(["api/spec.json".to_string(), "src/a.rs".to_string()]));
}
#[test]
fn blame_lines_per_line_without_boundary_and_uncommitted() {
let a = "a".repeat(40);
let b = "b".repeat(40);
let z = "0".repeat(40);
let p = format!(
"{a} 1 5 2\nauthor A\nsummary x\n\tline5\n{a} 2 6\n\tline6\n{b} 7 7 1\nauthor B\nboundary\nsummary y\n\tline7\n{z} 8 8 1\n\tline8\n{a} 3 9 1\n\tline9\n"
);
assert_eq!(blame_lines(&p), [(a.clone(), 5), (a.clone(), 6), (a, 9)]);
assert_eq!(ranges(&[9, 5, 6, 6, 12]), [(5, 6), (9, 9), (12, 12)]);
}
}