1use std::collections::HashSet;
8
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct Hunk {
12 pub path: String,
14 pub removed: Vec<(u32, String)>,
15 pub added: Vec<String>,
16 pub added_at: u32,
18}
19
20#[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#[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#[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 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
66fn 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;
75const QUOTE: char = '\u{22}';
76
77fn unescape(e: u8) -> u8 {
79 match e {
80 b'n' => 10,
81 b't' => 9,
82 b'r' => 13,
83 b'a' => 7,
84 b'b' => 8,
85 b'f' => 12,
86 b'v' => 11,
87 other => other,
88 }
89}
90
91fn unquote(q: &str) -> Option<String> {
93 let inner = q.strip_prefix(QUOTE)?.strip_suffix(QUOTE)?.as_bytes();
94 let (mut out, mut i) = (Vec::new(), 0);
95 while let Some(&b) = inner.get(i) {
96 let octal = inner.get(i + 1..i + 4).filter(|o| b == BACKSLASH && o.iter().all(|d| (b'0'..=b'7').contains(d)));
97 let (byte, used) = match (b, octal) {
98 (_, Some(o)) => (u8::from_str_radix(std::str::from_utf8(o).ok()?, 8).ok()?, 4),
99 (BACKSLASH, None) => (unescape(*inner.get(i + 1)?), 2),
100 _ => (b, 1),
101 };
102 out.push(byte);
103 i += used;
104 }
105 String::from_utf8(out).ok()
106}
107
108fn old_path(l: &str) -> Option<String> {
111 let p = l.strip_prefix("--- ")?.trim_end_matches(char::is_control);
112 let p = if p.starts_with(QUOTE) { unquote(p)? } else { p.to_string() };
113 p.strip_prefix("a/").map(str::to_string)
114}
115
116#[must_use]
119pub fn hunks(diff: &str) -> Vec<Hunk> {
120 let (mut out, mut path, mut header, mut at) = (Vec::<Hunk>::new(), String::new(), false, 0u32);
121 for l in diff.lines() {
122 if l.starts_with("diff --git ") {
123 (header, path) = (true, String::new());
124 } else if let Some((a, b)) = l.starts_with("@@ ").then(|| starts(l)).flatten() {
125 (header, at) = (false, a);
126 out.push(Hunk { path: path.clone(), added_at: b, ..Hunk::default() });
127 } else if header {
128 path = old_path(l).unwrap_or(path);
129 } else if let Some(h) = out.last_mut() {
130 body_line(h, l, &mut at);
131 }
132 }
133 out
134}
135
136fn body_line(h: &mut Hunk, l: &str, at: &mut u32) {
137 if let Some(t) = l.strip_prefix('-') {
138 h.removed.push((*at, t.to_string()));
139 *at += 1;
140 } else if let Some(t) = l.strip_prefix('+') {
141 h.added.push(t.to_string());
142 }
143}
144
145#[must_use]
149pub fn blame_lines(porcelain: &str) -> Vec<(String, u32)> {
150 let (mut out, mut boundary, mut current) = (Vec::new(), HashSet::<String>::new(), None::<String>);
151 for l in porcelain.lines().filter(|l| !l.starts_with('\t')) {
152 if l == "boundary" {
153 boundary.extend(current.clone());
154 continue;
155 }
156 let mut f = l.split(' ');
157 let (Some(sha), Some(_), Some(line)) = (f.next(), f.next(), f.next()) else { continue };
158 let Ok(line) = line.parse::<u32>() else { continue };
159 if sha.len() >= 40 && sha.bytes().all(|b| b.is_ascii_hexdigit()) {
160 current = Some(sha.to_string());
161 out.push((sha.to_string(), line));
162 }
163 }
164 out.retain(|(sha, _)| !boundary.contains(sha) && !sha.bytes().all(|b| b == b'0'));
165 out
166}
167
168#[must_use]
170pub fn ranges(lines: &[u32]) -> Vec<(u32, u32)> {
171 let mut v = lines.to_vec();
172 v.sort_unstable();
173 v.dedup();
174 let mut out: Vec<(u32, u32)> = Vec::new();
175 for n in v {
176 match out.last_mut() {
177 Some((_, b)) if *b + 1 == n => *b = n,
178 _ => out.push((n, n)),
179 }
180 }
181 out
182}
183
184#[must_use]
186pub fn removed_by_path(hunks: &[Hunk]) -> Vec<(String, Vec<u32>)> {
187 let mut out: Vec<(String, Vec<u32>)> = Vec::new();
188 for h in hunks {
189 let lines = h.removed.iter().map(|(n, _)| *n);
190 match out.iter_mut().find(|(p, _)| *p == h.path) {
191 Some((_, v)) => v.extend(lines),
192 None => out.push((h.path.clone(), lines.collect())),
193 }
194 }
195 out
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 const DIFF: &str = "\
203diff --git a/src/old.rs b/src/new.rs
204similarity index 90%
205rename from src/old.rs
206rename to src/new.rs
207--- a/src/old.rs
208+++ b/src/new.rs
209@@ -2 +2 @@ fn a() {}
210-fn b() { flag(); }
211+fn b() { lock(); flag(); }
212@@ -10,0 +11,2 @@
213+fn d() {}
214+fn e() {}
215@@ -20,2 +22 @@
216-let x =
217- 1;
218+let x = 1;
219\\ No newline at end of file
220diff --git a/Cargo.lock b/Cargo.lock
221--- a/Cargo.lock
222+++ b/Cargo.lock
223@@ -3 +3 @@
224-version = 1
225+version = 2
226diff --git a/src/added.rs b/src/added.rs
227--- /dev/null
228+++ b/src/added.rs
229@@ -0,0 +1 @@
230+fn z() {}
231diff --git a/q.sql b/q.sql
232--- a/q.sql
233+++ b/q.sql
234@@ -4 +4 @@
235--- the old comment
236+-- the new comment
237";
238
239 #[test]
240 fn hunks_keep_old_paths_and_line_numbers() {
241 let h = hunks(DIFF);
242 assert_eq!(h.len(), 6, "{h:?}");
243 assert_eq!(h[3].path, "Cargo.lock");
244 let h: Vec<Hunk> = h.into_iter().filter(|h| !generated(&h.path)).collect();
245 assert_eq!(h[4].removed, [(4, "-- the old comment".to_string())], "a removed line starting with -- is not a header");
246 assert_eq!((h[0].path.as_str(), h[0].removed.clone()), ("src/old.rs", vec![(2, "fn b() { flag(); }".to_string())]));
247 assert!(h[1].removed.is_empty() && h[1].added.len() == 2, "a pure addition");
248 assert_eq!(h[2].removed.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [20, 21]);
249 assert_eq!(h[3].path, "", "a new file has no old path");
250 let reflow = crate::notes::regress_cosmetic::formatting_only;
251 assert!(reflow(&h[2]) && !reflow(&h[0]) && !reflow(&h[1]));
252 assert_eq!(removed_by_path(&h[..4]), [("src/old.rs".to_string(), vec![2, 20, 21]), (String::new(), vec![])]);
253 }
254
255 #[test]
257 fn paths_with_spaces_and_quotes() {
258 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\
259diff --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";
260 let h = hunks(d);
261 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}");
262 assert_eq!(unquote("\"a\\tb\\\\c\""), Some("a\tb\\c".to_string()));
263 }
264
265 #[test]
266 fn numstat_sizes_skip_binary_and_generated() {
267 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";
268 assert_eq!(size(z), (2, 8));
269 assert!(generated("web/dist/app.js") && generated("a/b.min.css") && generated("yarn.lock") && !generated("src/lock.rs"));
270 assert!(generated("api/openapi.json") && generated("pb/user.pb.go") && generated("gen/user_pb2.py") && generated("src/schema_generated.rs"));
271 assert!(!generated("src/openapi.rs") && !generated("docs/api.json"));
272 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";
273 assert_eq!(generated_by_attr(attrs), HashSet::from(["api/spec.json".to_string(), "src/a.rs".to_string()]));
274 }
275
276 #[test]
277 fn blame_lines_per_line_without_boundary_and_uncommitted() {
278 let a = "a".repeat(40);
279 let b = "b".repeat(40);
280 let z = "0".repeat(40);
281 let p = format!(
282 "{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"
283 );
284 assert_eq!(blame_lines(&p), [(a.clone(), 5), (a.clone(), 6), (a, 9)]);
285 assert_eq!(ranges(&[9, 5, 6, 6, 12]), [(5, 6), (9, 9), (12, 12)]);
286 }
287}