Skip to main content

borg/notes/
regress_diff.rs

1//! What git says about a fix, parsed (regress.rs runs git): `--numstat -z` sizes, `-U0` hunks, and
2//! `blame --porcelain` per line. No git here, so every rule is a unit test.
3//!
4//! Only lines a fix removed or changed count: a pure addition blames nobody, and lock, snapshot and
5//! generated files record no decision. Reflows, renames and moves: `regress_cosmetic.rs`.
6
7use std::collections::HashSet;
8
9/// One `-U0` hunk: lines removed at the parent (with their line numbers there) and lines added.
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct Hunk {
12    /// The path at the parent (the old path of a rename).
13    pub path: String,
14    pub removed: Vec<(u32, String)>,
15    pub added: Vec<String>,
16    /// The line the added lines start at in the fix.
17    pub added_at: u32,
18}
19
20/// Lock files, vendored builds, snapshots and generated code: their lines record no decision.
21#[must_use]
22pub fn generated(path: &str) -> bool {
23    let name = path.rsplit('/').next().unwrap_or(path);
24    let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or_default();
25    let spec = name.starts_with("openapi") || name.starts_with("swagger");
26    ["lock", "snap"].contains(&ext)
27        || ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "go.sum"].contains(&name)
28        || [".min.", ".pb.", "_pb2.", "_generated.", ".generated."].iter().any(|m| name.contains(m))
29        || (spec && ["json", "yaml", "yml"].contains(&ext))
30        || path.starts_with("dist/")
31        || path.contains("/dist/")
32}
33
34/// The paths `git check-attr -z linguist-generated diff` marks generated (`linguist-generated`
35/// set, or `-diff`).
36#[must_use]
37pub fn generated_by_attr(out: &str) -> HashSet<String> {
38    let f: Vec<&str> = out.split('\0').collect();
39    let (triples, _) = f.as_chunks::<3>();
40    triples
41        .iter()
42        .filter(|[_, attr, value]| matches!((*attr, *value), ("linguist-generated", "set" | "true") | ("diff", "unset")))
43        .map(|[p, ..]| (*p).to_string())
44        .collect()
45}
46
47/// Files and changed lines of `git diff --numstat -z -M`, binary and generated files left out.
48#[must_use]
49pub fn size(numstat: &str) -> (usize, usize) {
50    let (mut files, mut lines) = (0, 0);
51    let mut it = numstat.split('\0');
52    while let Some(entry) = it.next() {
53        let mut f = entry.splitn(3, '\t');
54        let (Some(add), Some(del), Some(path)) = (f.next(), f.next(), f.next()) else { continue };
55        // A rename: the old and the new path follow as fields of their own.
56        let path = if path.is_empty() { it.nth(1).unwrap_or_default() } else { path };
57        let (Ok(a), Ok(d)) = (add.trim().parse::<usize>(), del.parse::<usize>()) else { continue };
58        if !generated(path) {
59            files += 1;
60            lines += a + d;
61        }
62    }
63    (files, lines)
64}
65
66/// The old and the new start line of a hunk header (`@@ -a,b +c,d @@`).
67fn starts(header: &str) -> Option<(u32, u32)> {
68    let mut f = header.strip_prefix("@@ -")?.split(' ');
69    let start = |s: &str| s.split(',').next()?.parse().ok();
70    Some((start(f.next()?)?, start(f.next()?.strip_prefix('+')?)?))
71}
72
73const BACKSLASH: u8 = 0x5c;
74const QUOTE: char = '\u{22}';
75
76/// A C escape of a quoted git path (`\n`, `\t`, ...): the byte it stands for.
77fn unescape(e: u8) -> u8 {
78    match e {
79        b'n' => 10,
80        b't' => 9,
81        b'r' => 13,
82        b'a' => 7,
83        b'b' => 8,
84        b'f' => 12,
85        b'v' => 11,
86        other => other,
87    }
88}
89
90/// A path git quoted C style (`"a/l\303\257b.rs"`), unquoted.
91fn unquote(q: &str) -> Option<String> {
92    let inner = q.strip_prefix(QUOTE)?.strip_suffix(QUOTE)?.as_bytes();
93    let (mut out, mut i) = (Vec::new(), 0);
94    while let Some(&b) = inner.get(i) {
95        let octal = inner.get(i + 1..i + 4).filter(|o| b == BACKSLASH && o.iter().all(|d| (b'0'..=b'7').contains(d)));
96        let (byte, used) = match (b, octal) {
97            (_, Some(o)) => (u8::from_str_radix(std::str::from_utf8(o).ok()?, 8).ok()?, 4),
98            (BACKSLASH, None) => (unescape(*inner.get(i + 1)?), 2),
99            _ => (b, 1),
100        };
101        out.push(byte);
102        i += used;
103    }
104    String::from_utf8(out).ok()
105}
106
107/// The old path a `--- ` line names: `a/<path>` (git ends a path with a space in a tab), or quoted;
108/// `None` for `/dev/null` (a new file, nothing to blame).
109fn old_path(l: &str) -> Option<String> {
110    let p = l.strip_prefix("--- ")?.trim_end_matches(char::is_control);
111    let p = if p.starts_with(QUOTE) { unquote(p)? } else { p.to_string() };
112    p.strip_prefix("a/").map(str::to_string)
113}
114
115/// The hunks of `git diff -U0 --src-prefix=a/ --dst-prefix=b/` output. A hunk whose path could not
116/// be read has an empty one.
117#[must_use]
118pub fn hunks(diff: &str) -> Vec<Hunk> {
119    let (mut out, mut path, mut header, mut at) = (Vec::<Hunk>::new(), String::new(), false, 0u32);
120    for l in diff.lines() {
121        if l.starts_with("diff --git ") {
122            (header, path) = (true, String::new());
123        } else if let Some((a, b)) = l.starts_with("@@ ").then(|| starts(l)).flatten() {
124            (header, at) = (false, a);
125            out.push(Hunk { path: path.clone(), added_at: b, ..Hunk::default() });
126        } else if header {
127            path = old_path(l).unwrap_or(path);
128        } else if let Some(h) = out.last_mut() {
129            body_line(h, l, &mut at);
130        }
131    }
132    out
133}
134
135fn body_line(h: &mut Hunk, l: &str, at: &mut u32) {
136    if let Some(t) = l.strip_prefix('-') {
137        h.removed.push((*at, t.to_string()));
138        *at += 1;
139    } else if let Some(t) = l.strip_prefix('+') {
140        h.added.push(t.to_string());
141    }
142}
143
144/// The commit behind each line of `git blame --porcelain` output, with the line's number at the
145/// blamed revision; lines of a `boundary` commit (a shallow graft, a range start) and uncommitted
146/// lines are left out.
147#[must_use]
148pub fn blame_lines(porcelain: &str) -> Vec<(String, u32)> {
149    let (mut out, mut boundary, mut current) = (Vec::new(), HashSet::<String>::new(), None::<String>);
150    for l in porcelain.lines().filter(|l| !l.starts_with('\t')) {
151        if l == "boundary" {
152            boundary.extend(current.clone());
153            continue;
154        }
155        let mut f = l.split(' ');
156        let (Some(sha), Some(_), Some(line)) = (f.next(), f.next(), f.next()) else { continue };
157        let Ok(line) = line.parse::<u32>() else { continue };
158        if sha.len() >= 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
159            current = Some(sha.to_string());
160            out.push((sha.to_string(), line));
161        }
162    }
163    out.retain(|(sha, _)| !boundary.contains(sha) && !sha.bytes().all(|b| b == b'0'));
164    out
165}
166
167/// Sorted line numbers as inclusive ranges.
168#[must_use]
169pub fn ranges(lines: &[u32]) -> Vec<(u32, u32)> {
170    let mut v = lines.to_vec();
171    v.sort_unstable();
172    v.dedup();
173    let mut out: Vec<(u32, u32)> = Vec::new();
174    for n in v {
175        match out.last_mut() {
176            Some((_, b)) if *b + 1 == n => *b = n,
177            _ => out.push((n, n)),
178        }
179    }
180    out
181}
182
183/// The removed lines of `hunks` per path, paths in hunk order.
184#[must_use]
185pub fn removed_by_path(hunks: &[Hunk]) -> Vec<(String, Vec<u32>)> {
186    let mut out: Vec<(String, Vec<u32>)> = Vec::new();
187    for h in hunks {
188        let lines = h.removed.iter().map(|(n, _)| *n);
189        match out.iter_mut().find(|(p, _)| *p == h.path) {
190            Some((_, v)) => v.extend(lines),
191            None => out.push((h.path.clone(), lines.collect())),
192        }
193    }
194    out
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    const DIFF: &str = "\
202diff --git a/src/old.rs b/src/new.rs
203similarity index 90%
204rename from src/old.rs
205rename to src/new.rs
206--- a/src/old.rs
207+++ b/src/new.rs
208@@ -2 +2 @@ fn a() {}
209-fn b() { flag(); }
210+fn b() { lock(); flag(); }
211@@ -10,0 +11,2 @@
212+fn d() {}
213+fn e() {}
214@@ -20,2 +22 @@
215-let x =
216-    1;
217+let x = 1;
218\\ No newline at end of file
219diff --git a/Cargo.lock b/Cargo.lock
220--- a/Cargo.lock
221+++ b/Cargo.lock
222@@ -3 +3 @@
223-version = 1
224+version = 2
225diff --git a/src/added.rs b/src/added.rs
226--- /dev/null
227+++ b/src/added.rs
228@@ -0,0 +1 @@
229+fn z() {}
230diff --git a/q.sql b/q.sql
231--- a/q.sql
232+++ b/q.sql
233@@ -4 +4 @@
234--- the old comment
235+-- the new comment
236";
237
238    #[test]
239    fn hunks_keep_old_paths_and_line_numbers() {
240        let h = hunks(DIFF);
241        assert_eq!(h.len(), 6, "{h:?}");
242        assert_eq!(h[3].path, "Cargo.lock");
243        let h: Vec<Hunk> = h.into_iter().filter(|h| !generated(&h.path)).collect();
244        assert_eq!(h[4].removed, [(4, "-- the old comment".to_string())], "a removed line starting with -- is not a header");
245        assert_eq!((h[0].path.as_str(), h[0].removed.clone()), ("src/old.rs", vec![(2, "fn b() { flag(); }".to_string())]));
246        assert!(h[1].removed.is_empty() && h[1].added.len() == 2, "a pure addition");
247        assert_eq!(h[2].removed.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [20, 21]);
248        assert_eq!(h[3].path, "", "a new file has no old path");
249        let reflow = crate::notes::regress_cosmetic::formatting_only;
250        assert!(reflow(&h[2]) && !reflow(&h[0]) && !reflow(&h[1]));
251        assert_eq!(removed_by_path(&h[..4]), [("src/old.rs".to_string(), vec![2, 20, 21]), (String::new(), vec![])]);
252    }
253
254    /// git ends a path with a space in a tab and quotes one with a quote or a control character.
255    #[test]
256    fn paths_with_spaces_and_quotes() {
257        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\
258diff --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";
259        let h = hunks(d);
260        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}");
261        assert_eq!(unquote("\"a\\tb\\\\c\""), Some("a\tb\\c".to_string()));
262    }
263
264    #[test]
265    fn numstat_sizes_skip_binary_and_generated() {
266        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";
267        assert_eq!(size(z), (2, 8));
268        assert!(generated("web/dist/app.js") && generated("a/b.min.css") && generated("yarn.lock") && !generated("src/lock.rs"));
269        assert!(generated("api/openapi.json") && generated("pb/user.pb.go") && generated("gen/user_pb2.py") && generated("src/schema_generated.rs"));
270        assert!(!generated("src/openapi.rs") && !generated("docs/api.json"));
271        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";
272        assert_eq!(generated_by_attr(attrs), HashSet::from(["api/spec.json".to_string(), "src/a.rs".to_string()]));
273    }
274
275    #[test]
276    fn blame_lines_per_line_without_boundary_and_uncommitted() {
277        let a = "a".repeat(40);
278        let b = "b".repeat(40);
279        let z = "0".repeat(40);
280        let p = format!(
281            "{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"
282        );
283        assert_eq!(blame_lines(&p), [(a.clone(), 5), (a.clone(), 6), (a, 9)]);
284        assert_eq!(ranges(&[9, 5, 6, 6, 12]), [(5, 6), (9, 9), (12, 12)]);
285    }
286}