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;
74const QUOTE: char = '\u{22}';
75
76fn 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
90fn 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
107fn 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#[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#[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#[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#[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 #[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}