Skip to main content

differential_engine/
parse.rs

1//! Parser for `git diff-tree -r -U0 --no-renames` patch output.
2//!
3//! Count-driven: the `@@ -a,b +c,d @@` header states exactly how many removed and
4//! added lines follow, so hunk bodies are consumed by count, never by prefix
5//! sniffing. (Prefix sniffing silently drops deleted lines that start with `--`:
6//! deleting a line `--help` emits `---help`, which a `startswith("---")` header
7//! guard eats. That was a real latent bug in the validated prototype.)
8//!
9//! Unknown lines are hard errors, not skips — a parser that guesses is how hunks
10//! get lost silently.
11
12use std::collections::HashMap;
13
14use crate::EngineError;
15use crate::model::{DiffView, Disposition, FileChange, Hunk};
16use crate::paths::parse_diff_git_path;
17
18/// Parse the canonical `-U0 --no-renames` patch into a [`DiffView`].
19///
20/// `dispositions` comes from a separate `--name-status -z` call; header hints
21/// (`new file mode` / `deleted file mode`) are only a fallback.
22pub fn parse_canonical(
23    raw: &[u8],
24    dispositions: &HashMap<Vec<u8>, Disposition>,
25) -> Result<DiffView, EngineError> {
26    let lines: Vec<&[u8]> = split_lines(raw);
27    let mut files: Vec<FileChange> = Vec::new();
28    let mut hunks: Vec<Hunk> = Vec::new();
29    // Extra per-file parse state, parallel to `files`.
30    let mut header_disp: Vec<Option<Disposition>> = Vec::new();
31
32    let mut i = 0usize;
33    while i < lines.len() {
34        let line = lines[i];
35
36        if let Some(rest) = line.strip_prefix(b"diff --git ".as_slice()) {
37            let path = parse_diff_git_path(rest).ok_or_else(|| EngineError::Parse {
38                line: i + 1,
39                msg: format!("unparseable diff --git header: {}", lossy(line)),
40            })?;
41            files.push(FileChange {
42                path,
43                disposition: Disposition::Modified, // resolved in finalise
44                new_mode: None,
45                old_mode: None,
46                binary: false,
47                submodule: None,
48                hunks: Vec::new(),
49                rename_similarity: None,
50                rename_from: None,
51                rename_to: None,
52                generated: None,
53                old_oid: None,
54                new_oid: None,
55            });
56            header_disp.push(None);
57            i += 1;
58            continue;
59        }
60
61        let Some(f) = files.last_mut() else {
62            // diff-tree between two trees may emit nothing at all; anything else
63            // before the first file header is unexpected.
64            if line.is_empty() {
65                i += 1;
66                continue;
67            }
68            return Err(EngineError::Parse {
69                line: i + 1,
70                msg: format!("content before first diff header: {}", lossy(line)),
71            });
72        };
73
74        if let Some(rest) = line.strip_prefix(b"old mode ".as_slice()) {
75            f.old_mode = Some(lossy(rest));
76        } else if let Some(rest) = line.strip_prefix(b"new mode ".as_slice()) {
77            f.new_mode = Some(lossy(rest));
78        } else if let Some(rest) = line.strip_prefix(b"new file mode ".as_slice()) {
79            f.new_mode = Some(lossy(rest));
80            *header_disp.last_mut().unwrap() = Some(Disposition::Added);
81        } else if let Some(rest) = line.strip_prefix(b"deleted file mode ".as_slice()) {
82            f.old_mode = Some(lossy(rest));
83            *header_disp.last_mut().unwrap() = Some(Disposition::Deleted);
84        } else if let Some(rest) = line.strip_prefix(b"index ".as_slice()) {
85            // `index <old>..<new>[ <mode>]` — mode present when unchanged.
86            if let Some(pos) = rest.iter().rposition(|&b| b == b' ') {
87                let mode = &rest[pos + 1..];
88                if mode.len() == 6 && mode.iter().all(u8::is_ascii_digit) && f.new_mode.is_none() {
89                    f.new_mode = Some(lossy(mode));
90                }
91            }
92        } else if line.starts_with(b"Binary files ") || line == b"GIT binary patch" {
93            f.binary = true;
94        } else if line.starts_with(b"--- ") || line.starts_with(b"+++ ") {
95            // Paths already known from the diff --git header.
96        } else if line.starts_with(b"@@ ") {
97            let (old_start, old_count, new_start, new_count) =
98                parse_hunk_header(line).ok_or_else(|| EngineError::Parse {
99                    line: i + 1,
100                    msg: format!("unparseable hunk header: {}", lossy(line)),
101                })?;
102
103            let mut removed = Vec::with_capacity(old_count as usize);
104            let mut added = Vec::with_capacity(new_count as usize);
105            let mut nonl_old = false;
106            let mut nonl_new = false;
107            i += 1;
108
109            for _ in 0..old_count {
110                let body = *lines.get(i).ok_or_else(|| truncated(i))?;
111                let content =
112                    body.strip_prefix(b"-".as_slice())
113                        .ok_or_else(|| EngineError::Parse {
114                            line: i + 1,
115                            msg: format!("expected removed line, got: {}", lossy(body)),
116                        })?;
117                removed.push(content.to_vec());
118                i += 1;
119            }
120            if old_count > 0 && lines.get(i).is_some_and(|l| l.starts_with(b"\\")) {
121                nonl_old = true;
122                i += 1;
123            }
124            for _ in 0..new_count {
125                let body = *lines.get(i).ok_or_else(|| truncated(i))?;
126                let content =
127                    body.strip_prefix(b"+".as_slice())
128                        .ok_or_else(|| EngineError::Parse {
129                            line: i + 1,
130                            msg: format!("expected added line, got: {}", lossy(body)),
131                        })?;
132                added.push(content.to_vec());
133                i += 1;
134            }
135            if new_count > 0 && lines.get(i).is_some_and(|l| l.starts_with(b"\\")) {
136                nonl_new = true;
137                i += 1;
138            }
139
140            let file_idx = files.len() - 1;
141            files[file_idx].hunks.push(hunks.len());
142            hunks.push(Hunk {
143                file: file_idx,
144                old_start,
145                old_count,
146                new_start,
147                new_count,
148                removed,
149                added,
150                nonl_old,
151                nonl_new,
152            });
153            continue; // i already advanced past the body
154        } else if line.is_empty() {
155            // Blank separator lines do not occur in -U0 patch output; tolerate
156            // them only as trailing end-of-input noise.
157            if lines[i + 1..].iter().any(|l| !l.is_empty()) {
158                return Err(EngineError::Parse {
159                    line: i + 1,
160                    msg: "unexpected blank line inside patch".into(),
161                });
162            }
163        } else {
164            return Err(EngineError::Parse {
165                line: i + 1,
166                msg: format!("unrecognised patch line: {}", lossy(line)),
167            });
168        }
169        i += 1;
170    }
171
172    finalise(files, hunks, header_disp, dispositions)
173}
174
175/// Resolve dispositions, extract submodule ids, and merge duplicate path entries
176/// (a typechange emits two file headers for the same path).
177fn finalise(
178    mut files: Vec<FileChange>,
179    mut hunks: Vec<Hunk>,
180    header_disp: Vec<Option<Disposition>>,
181    dispositions: &HashMap<Vec<u8>, Disposition>,
182) -> Result<DiffView, EngineError> {
183    for (f, hd) in files.iter_mut().zip(&header_disp) {
184        f.disposition = dispositions
185            .get(&f.path)
186            .copied()
187            .or(*hd)
188            .unwrap_or(Disposition::Modified);
189    }
190
191    // Merge consecutive duplicate-path entries (typechange): hunks concatenate,
192    // old side from the first, new side from the second.
193    let mut merged: Vec<FileChange> = Vec::with_capacity(files.len());
194    for f in files.into_iter() {
195        let continues_previous = merged.last().is_some_and(|prev| prev.path == f.path);
196        if continues_previous {
197            let idx = merged.len() - 1;
198            let prev = &mut merged[idx];
199            if f.new_mode.is_some() {
200                prev.new_mode = f.new_mode;
201            }
202            if prev.old_mode.is_none() {
203                prev.old_mode = f.old_mode;
204            }
205            prev.binary |= f.binary;
206            prev.disposition = dispositions
207                .get(&prev.path)
208                .copied()
209                .unwrap_or(Disposition::Modified);
210            for h in &f.hunks {
211                hunks[*h].file = idx;
212                prev.hunks.push(*h);
213            }
214        } else {
215            let idx = merged.len();
216            for h in &f.hunks {
217                hunks[*h].file = idx;
218            }
219            merged.push(f);
220        }
221    }
222
223    // Submodules: gitlink mode; commit ids live in the pseudo-hunk body.
224    for f in merged.iter_mut() {
225        let is_gitlink =
226            f.new_mode.as_deref() == Some("160000") || f.old_mode.as_deref() == Some("160000");
227        if !is_gitlink {
228            continue;
229        }
230        let mut old = None;
231        let mut new = None;
232        for &hi in &f.hunks {
233            for l in &hunks[hi].removed {
234                if let Some(rest) = l.strip_prefix(b"Subproject commit ".as_slice()) {
235                    old = Some(lossy(rest));
236                }
237            }
238            for l in &hunks[hi].added {
239                if let Some(rest) = l.strip_prefix(b"Subproject commit ".as_slice()) {
240                    new = Some(lossy(rest));
241                }
242            }
243        }
244        f.submodule = Some((old, new));
245    }
246
247    Ok(DiffView {
248        files: merged,
249        hunks,
250    })
251}
252
253/// `@@ -a[,b] +c[,d] @@…` — counts default to 1 when elided.
254fn parse_hunk_header(line: &[u8]) -> Option<(u32, u32, u32, u32)> {
255    let rest = line.strip_prefix(b"@@ -".as_slice())?;
256    let (old_start, rest) = take_num(rest)?;
257    let (old_count, rest) = take_opt_count(rest)?;
258    let rest = rest.strip_prefix(b" +".as_slice())?;
259    let (new_start, rest) = take_num(rest)?;
260    let (new_count, rest) = take_opt_count(rest)?;
261    rest.starts_with(b" @@").then_some(())?;
262    Some((old_start, old_count, new_start, new_count))
263}
264
265fn take_num(s: &[u8]) -> Option<(u32, &[u8])> {
266    let end = s
267        .iter()
268        .position(|b| !b.is_ascii_digit())
269        .unwrap_or(s.len());
270    if end == 0 {
271        return None;
272    }
273    let n: u32 = std::str::from_utf8(&s[..end]).ok()?.parse().ok()?;
274    Some((n, &s[end..]))
275}
276
277fn take_opt_count(s: &[u8]) -> Option<(u32, &[u8])> {
278    match s.strip_prefix(b",".as_slice()) {
279        Some(rest) => take_num(rest),
280        None => Some((1, s)),
281    }
282}
283
284fn split_lines(raw: &[u8]) -> Vec<&[u8]> {
285    let mut v: Vec<&[u8]> = raw.split(|&b| b == b'\n').collect();
286    if v.last().is_some_and(|l| l.is_empty()) {
287        v.pop();
288    }
289    v
290}
291
292fn truncated(i: usize) -> EngineError {
293    EngineError::Parse {
294        line: i + 1,
295        msg: "patch truncated inside hunk body".into(),
296    }
297}
298
299fn lossy(b: &[u8]) -> String {
300    String::from_utf8_lossy(&b[..b.len().min(160)]).into_owned()
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn disp_map(entries: &[(&[u8], Disposition)]) -> HashMap<Vec<u8>, Disposition> {
308        entries.iter().map(|(p, d)| (p.to_vec(), *d)).collect()
309    }
310
311    #[test]
312    fn simple_modification() {
313        let raw = b"diff --git a/f.txt b/f.txt\n\
314index 000..111 100644\n\
315--- a/f.txt\n\
316+++ b/f.txt\n\
317@@ -2,1 +2,2 @@\n\
318-old line\n\
319+new line\n\
320+extra\n";
321        let v = parse_canonical(raw, &disp_map(&[(b"f.txt", Disposition::Modified)])).unwrap();
322        assert_eq!(v.files.len(), 1);
323        assert_eq!(v.hunks.len(), 1);
324        let h = &v.hunks[0];
325        assert_eq!(
326            (h.old_start, h.old_count, h.new_start, h.new_count),
327            (2, 1, 2, 2)
328        );
329        assert_eq!(h.removed, vec![b"old line".to_vec()]);
330        assert_eq!(h.added, vec![b"new line".to_vec(), b"extra".to_vec()]);
331        assert_eq!(v.files[0].new_mode.as_deref(), Some("100644"));
332    }
333
334    #[test]
335    fn deleted_line_starting_with_dashes_is_kept() {
336        // Deleting the line `--help` emits `---help`. Prefix sniffing eats it;
337        // count-driven parsing must not.
338        let raw = b"diff --git a/cli.txt b/cli.txt\n\
339--- a/cli.txt\n\
340+++ b/cli.txt\n\
341@@ -1,2 +1,1 @@\n\
342---help\n\
343-+++weird\n\
344+usage\n";
345        let v = parse_canonical(raw, &Default::default()).unwrap();
346        let h = &v.hunks[0];
347        assert_eq!(h.removed, vec![b"--help".to_vec(), b"+++weird".to_vec()]);
348        assert_eq!(h.added, vec![b"usage".to_vec()]);
349    }
350
351    #[test]
352    fn count_elision_defaults_to_one() {
353        let raw = b"diff --git a/f b/f\n\
354--- a/f\n\
355+++ b/f\n\
356@@ -5 +5,2 @@ fn context()\n\
357-x\n\
358+y\n\
359+z\n";
360        let v = parse_canonical(raw, &Default::default()).unwrap();
361        let h = &v.hunks[0];
362        assert_eq!(
363            (h.old_start, h.old_count, h.new_start, h.new_count),
364            (5, 1, 5, 2)
365        );
366    }
367
368    #[test]
369    fn no_newline_markers_per_side() {
370        let raw = b"diff --git a/f b/f\n\
371--- a/f\n\
372+++ b/f\n\
373@@ -1,1 +1,1 @@\n\
374-old\n\
375\\ No newline at end of file\n\
376+new\n\
377\\ No newline at end of file\n";
378        let v = parse_canonical(raw, &Default::default()).unwrap();
379        let h = &v.hunks[0];
380        assert!(h.nonl_old);
381        assert!(h.nonl_new);
382    }
383
384    #[test]
385    fn nonl_old_only() {
386        // Newline added to the final line: old side lacks it, new side has it.
387        let raw = b"diff --git a/f b/f\n\
388--- a/f\n\
389+++ b/f\n\
390@@ -1,1 +1,1 @@\n\
391-old\n\
392\\ No newline at end of file\n\
393+old\n";
394        let v = parse_canonical(raw, &Default::default()).unwrap();
395        assert!(v.hunks[0].nonl_old);
396        assert!(!v.hunks[0].nonl_new);
397    }
398
399    #[test]
400    fn binary_file_has_no_hunks() {
401        let raw = b"diff --git a/img.png b/img.png\n\
402new file mode 100644\n\
403index 000..111\n\
404Binary files /dev/null and b/img.png differ\n";
405        let v = parse_canonical(raw, &disp_map(&[(b"img.png", Disposition::Added)])).unwrap();
406        assert!(v.files[0].binary);
407        assert!(v.files[0].hunks.is_empty());
408        assert_eq!(v.files[0].disposition, Disposition::Added);
409    }
410
411    #[test]
412    fn mode_only_change_has_no_hunks() {
413        let raw = b"diff --git a/run.sh b/run.sh\n\
414old mode 100644\n\
415new mode 100755\n";
416        let v = parse_canonical(raw, &Default::default()).unwrap();
417        assert_eq!(v.files[0].old_mode.as_deref(), Some("100644"));
418        assert_eq!(v.files[0].new_mode.as_deref(), Some("100755"));
419        assert!(v.files[0].hunks.is_empty());
420    }
421
422    #[test]
423    fn submodule_pseudo_hunk_is_kept_and_ids_extracted() {
424        let raw = b"diff --git a/dep b/dep\n\
425index aaa..bbb 160000\n\
426--- a/dep\n\
427+++ b/dep\n\
428@@ -1 +1 @@\n\
429-Subproject commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
430+Subproject commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n";
431        let v = parse_canonical(raw, &Default::default()).unwrap();
432        assert_eq!(v.hunks.len(), 1, "pseudo-hunk stays in the canonical count");
433        let (old, new) = v.files[0].submodule.clone().unwrap();
434        assert_eq!(old.unwrap(), "a".repeat(40));
435        assert_eq!(new.unwrap(), "b".repeat(40));
436    }
437
438    #[test]
439    fn unrecognised_line_is_a_hard_error() {
440        let raw = b"diff --git a/f b/f\n\
441some garbage git will never emit\n";
442        assert!(parse_canonical(raw, &Default::default()).is_err());
443    }
444
445    #[test]
446    fn typechange_double_entry_merges() {
447        let raw = b"diff --git a/link b/link\n\
448deleted file mode 100644\n\
449--- a/link\n\
450+++ /dev/null\n\
451@@ -1,1 +0,0 @@\n\
452-real content\n\
453diff --git a/link b/link\n\
454new file mode 120000\n\
455--- /dev/null\n\
456+++ b/link\n\
457@@ -0,0 +1,1 @@\n\
458+target\n\
459\\ No newline at end of file\n";
460        let v = parse_canonical(raw, &disp_map(&[(b"link", Disposition::Modified)])).unwrap();
461        assert_eq!(v.files.len(), 1);
462        assert_eq!(v.files[0].hunks.len(), 2);
463        assert_eq!(v.files[0].old_mode.as_deref(), Some("100644"));
464        assert_eq!(v.files[0].new_mode.as_deref(), Some("120000"));
465        assert_eq!(v.hunks[1].file, 0);
466    }
467}