Skip to main content

ashlar_diff/
patch.rs

1//! Unified-patch parsing and writing (`git diff`, `diff -u`).
2//!
3//! Text before the first file (a commit message, an mbox header) and lines between files that no
4//! rule claims are skipped. Inside a hunk, the header's line counts must match the body exactly;
5//! anything else is a [`PatchError`], never a panic. Nothing is allocated from a number the
6//! patch states: memory follows the input's length.
7
8use std::fmt;
9
10use crate::{FileDiff, FileStatus, Hunk, Line, LineKind, emphasize};
11
12/// Why a patch did not parse.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum PatchErrorKind {
15    /// An `@@` line that is not `@@ -a[,b] +c[,d] @@`.
16    BadHunkHeader,
17    /// A hunk before any file header.
18    HunkWithoutFile,
19    /// The patch ends before a hunk has all the lines its header announces.
20    UnexpectedEnd,
21    /// A hunk line that does not start with ` `, `-`, `+` or `\`, or more lines of one side than
22    /// the header announces.
23    BadHunkLine,
24    /// A line number past `u32::MAX`.
25    TooLarge,
26}
27
28/// A patch that did not parse, and where.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct PatchError {
31    /// 1-based line of the patch.
32    pub line: usize,
33    pub kind: PatchErrorKind,
34}
35
36impl fmt::Display for PatchError {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        let what = match self.kind {
39            PatchErrorKind::BadHunkHeader => "malformed hunk header",
40            PatchErrorKind::HunkWithoutFile => "hunk without a file header",
41            PatchErrorKind::UnexpectedEnd => "patch ends inside a hunk",
42            PatchErrorKind::BadHunkLine => "malformed hunk line",
43            PatchErrorKind::TooLarge => "line number too large",
44        };
45        write!(f, "line {}: {what}", self.line)
46    }
47}
48
49impl std::error::Error for PatchError {}
50
51/// Extended header lines `git diff` writes between `diff --git` and `---`.
52const GIT_HEADERS: &[&str] = &[
53    "old mode ",
54    "new mode ",
55    "deleted file mode ",
56    "new file mode ",
57    "similarity index ",
58    "dissimilarity index ",
59    "rename from ",
60    "rename to ",
61    "copy from ",
62    "copy to ",
63    "index ",
64    "Binary files ",
65];
66
67/// Parses a unified patch into one [`FileDiff`] per file, with word-level emphasis.
68pub fn parse_patch(text: &str) -> Result<Vec<FileDiff>, PatchError> {
69    let lines: Vec<&str> = text.split_inclusive('\n').map(|l| l.strip_suffix('\n').unwrap_or(l)).collect();
70    let mut files: Vec<FileDiff> = Vec::new();
71    let mut cur: Option<FileDiff> = None;
72    let mut i = 0usize;
73    while let Some(&line) = lines.get(i) {
74        if let Some(rest) = line.strip_prefix("diff --git ") {
75            files.extend(cur.take());
76            let mut f = FileDiff { header: vec![line.to_owned()], ..FileDiff::default() };
77            if let Some((a, b)) = git_paths(rest) {
78                f.old_path = Some(a);
79                f.new_path = Some(b);
80            }
81            i += 1;
82            i = git_header(&lines, i, &mut f);
83            cur = Some(f);
84        } else if line.starts_with("--- ") && lines.get(i + 1).is_some_and(|l| l.starts_with("+++ ")) {
85            files.extend(cur.take());
86            let mut f = FileDiff::default();
87            i = file_lines(&lines, i, &mut f);
88            if f.old_path.is_none() {
89                f.status = FileStatus::Added;
90            } else if f.new_path.is_none() {
91                f.status = FileStatus::Deleted;
92            }
93            cur = Some(f);
94        } else if line.starts_with("@@ ") {
95            let Some(f) = cur.as_mut() else { return Err(PatchError { line: i + 1, kind: PatchErrorKind::HunkWithoutFile }) };
96            let (hunk, next) = hunk(&lines, i)?;
97            f.hunks.push(hunk);
98            i = next;
99        } else {
100            i += 1;
101        }
102    }
103    files.extend(cur);
104    for f in &mut files {
105        for h in &mut f.hunks {
106            emphasize(h);
107        }
108    }
109    Ok(files)
110}
111
112/// Reads the extended header after `diff --git`, up to and including `+++` (or the binary data).
113/// Returns the index of the first line after it.
114fn git_header(lines: &[&str], mut i: usize, f: &mut FileDiff) -> usize {
115    while let Some(&line) = lines.get(i) {
116        if line.starts_with("--- ") && lines.get(i + 1).is_some_and(|l| l.starts_with("+++ ")) {
117            return file_lines(lines, i, f);
118        }
119        if line == "GIT binary patch" {
120            f.binary = true;
121            // the data runs to the next file
122            while let Some(&l) = lines.get(i) {
123                if l.starts_with("diff --git ") {
124                    break;
125                }
126                f.header.push(l.to_owned());
127                i += 1;
128            }
129            return i;
130        }
131        let Some(prefix) = GIT_HEADERS.iter().find(|p| line.starts_with(**p)) else { return i };
132        let value = line.get(prefix.len()..).unwrap_or("");
133        match *prefix {
134            "old mode " => f.old_mode = Some(value.to_owned()),
135            "new mode " => f.new_mode = Some(value.to_owned()),
136            "deleted file mode " => {
137                f.status = FileStatus::Deleted;
138                f.old_mode = Some(value.to_owned());
139                f.new_path = None;
140            }
141            "new file mode " => {
142                f.status = FileStatus::Added;
143                f.new_mode = Some(value.to_owned());
144                f.old_path = None;
145            }
146            "similarity index " => f.similarity = value.strip_suffix('%').and_then(|v| v.parse().ok()),
147            "rename from " => {
148                f.status = FileStatus::Renamed;
149                f.old_path = Some(unquote(value));
150            }
151            "rename to " => f.new_path = Some(unquote(value)),
152            "copy from " => {
153                f.status = FileStatus::Copied;
154                f.old_path = Some(unquote(value));
155            }
156            "copy to " => f.new_path = Some(unquote(value)),
157            "Binary files " => f.binary = true,
158            _ => {}
159        }
160        f.header.push(line.to_owned());
161        i += 1;
162    }
163    i
164}
165
166/// Reads a `---` / `+++` pair at `i` into the header and paths; returns the index after it.
167fn file_lines(lines: &[&str], i: usize, f: &mut FileDiff) -> usize {
168    let old = lines.get(i).copied().unwrap_or("");
169    let new = lines.get(i + 1).copied().unwrap_or("");
170    f.header.push(old.to_owned());
171    f.header.push(new.to_owned());
172    f.old_path = header_path(old.get(4..).unwrap_or(""), "a/");
173    f.new_path = header_path(new.get(4..).unwrap_or(""), "b/");
174    i + 2
175}
176
177/// The path of a `---`/`+++` line: no timestamp, unquoted, `/dev/null` is `None`, prefix stripped.
178fn header_path(raw: &str, prefix: &str) -> Option<String> {
179    let raw = if raw.starts_with('"') { raw } else { raw.split('\t').next().unwrap_or(raw) };
180    let p = unquote(raw.trim_end_matches(['\r']));
181    if p == "/dev/null" {
182        return None;
183    }
184    Some(p.strip_prefix(prefix).map(str::to_owned).unwrap_or(p))
185}
186
187/// The paths of `diff --git a/x b/y`, prefixes stripped. With spaces in unquoted paths it is
188/// ambiguous; the `---`/`+++` and rename lines that follow override it.
189fn git_paths(rest: &str) -> Option<(String, String)> {
190    if rest.starts_with('"') {
191        let end = quoted_end(rest)?;
192        let a = unquote(rest.get(..end)?);
193        let b = unquote(rest.get(end..)?.trim_start());
194        return Some((strip(&a, "a/"), strip(&b, "b/")));
195    }
196    // "a/P b/P": the same path twice splits in the middle
197    let half = rest.len() / 2;
198    if rest.len() % 2 == 1
199        && let (Some(a), Some(b)) = (rest.get(..half), rest.get(half + 1..))
200        && a.strip_prefix("a/").is_some_and(|x| Some(x) == b.strip_prefix("b/"))
201    {
202        return Some((strip(a, "a/"), strip(b, "b/")));
203    }
204    let at = rest.find(" b/")?;
205    let a = rest.get(..at)?;
206    let b = rest.get(at + 1..)?;
207    Some((strip(a, "a/"), strip(&unquote(b), "b/")))
208}
209
210fn strip(p: &str, prefix: &str) -> String {
211    p.strip_prefix(prefix).unwrap_or(p).to_owned()
212}
213
214/// Byte index just past the closing quote of a C-quoted string at the start of `s`.
215fn quoted_end(s: &str) -> Option<usize> {
216    let mut escaped = false;
217    for (i, c) in s.char_indices().skip(1) {
218        match c {
219            _ if escaped => escaped = false,
220            '\\' => escaped = true,
221            '"' => return Some(i + 1),
222            _ => {}
223        }
224    }
225    None
226}
227
228/// Undoes git's C-style path quoting (`"a/sp\303\244ce \"x\""`); other strings pass through.
229fn unquote(s: &str) -> String {
230    let Some(inner) = s.strip_prefix('"').and_then(|x| x.strip_suffix('"')) else { return s.to_owned() };
231    let mut bytes: Vec<u8> = Vec::with_capacity(inner.len());
232    let mut it = inner.bytes().peekable();
233    while let Some(c) = it.next() {
234        if c != b'\\' {
235            bytes.push(c);
236            continue;
237        }
238        match it.next() {
239            Some(b'n') => bytes.push(b'\n'),
240            Some(b't') => bytes.push(b'\t'),
241            Some(b'r') => bytes.push(b'\r'),
242            Some(b'a') => bytes.push(7),
243            Some(b'b') => bytes.push(8),
244            Some(b'f') => bytes.push(12),
245            Some(b'v') => bytes.push(11),
246            Some(d @ b'0'..=b'7') => {
247                let mut v = u32::from(d - b'0');
248                for _ in 0..2 {
249                    match it.peek() {
250                        Some(&n @ b'0'..=b'7') => {
251                            v = v * 8 + u32::from(n - b'0');
252                            it.next();
253                        }
254                        _ => break,
255                    }
256                }
257                bytes.push(u8::try_from(v).unwrap_or(b'?'));
258            }
259            Some(other) => bytes.push(other),
260            None => bytes.push(b'\\'),
261        }
262    }
263    String::from_utf8_lossy(&bytes).into_owned()
264}
265
266/// `a` or `a,b` (the count defaults to 1).
267fn range(s: &str) -> Option<(u32, u32)> {
268    let (start, len) = match s.split_once(',') {
269        Some((a, b)) => (a, Some(b)),
270        None => (s, None),
271    };
272    let digits = |x: &str| !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit());
273    if !digits(start) || len.is_some_and(|l| !digits(l)) {
274        return None;
275    }
276    Some((start.parse().ok()?, len.map_or(Some(1), |l| l.parse().ok())?))
277}
278
279/// `@@ -a,b +c,d @@ section`.
280fn hunk_header(line: &str) -> Option<Hunk> {
281    let rest = line.strip_prefix("@@ -")?;
282    let (old, rest) = rest.split_once(" +")?;
283    let (new, rest) = rest.split_once(" @@")?;
284    let (old_start, old_len) = range(old)?;
285    let (new_start, new_len) = range(new)?;
286    let section = rest.strip_prefix(' ').unwrap_or(rest).to_owned();
287    Some(Hunk { old_start, old_len, new_start, new_len, section, lines: Vec::new() })
288}
289
290/// Reads the hunk whose header is at `i`; returns it and the index after it.
291fn hunk(lines: &[&str], i: usize) -> Result<(Hunk, usize), PatchError> {
292    let err = |at: usize, kind| PatchError { line: at + 1, kind };
293    let mut h = hunk_header(lines.get(i).copied().unwrap_or("")).ok_or(err(i, PatchErrorKind::BadHunkHeader))?;
294    let (mut old_left, mut new_left) = (h.old_len, h.new_len);
295    let (mut old_no, mut new_no) = (h.old_range().start, h.new_range().start);
296    let mut j = i + 1;
297    while old_left > 0 || new_left > 0 {
298        let Some(&line) = lines.get(j) else { return Err(err(j, PatchErrorKind::UnexpectedEnd)) };
299        let (kind, text) = match line.as_bytes().first() {
300            // an empty line is a context line whose trailing space an editor stripped
301            None => (LineKind::Context, ""),
302            Some(b' ') => (LineKind::Context, line.get(1..).unwrap_or("")),
303            Some(b'-') => (LineKind::Removed, line.get(1..).unwrap_or("")),
304            Some(b'+') => (LineKind::Added, line.get(1..).unwrap_or("")),
305            Some(b'\\') => {
306                if let Some(last) = h.lines.last_mut() {
307                    last.no_newline = true;
308                }
309                j += 1;
310                continue;
311            }
312            Some(_) => return Err(err(j, PatchErrorKind::BadHunkLine)),
313        };
314        let take_old = kind != LineKind::Added;
315        let take_new = kind != LineKind::Removed;
316        if (take_old && old_left == 0) || (take_new && new_left == 0) {
317            return Err(err(j, PatchErrorKind::BadHunkLine));
318        }
319        let (mut o, mut n) = (None, None);
320        if take_old {
321            o = Some(old_no);
322            old_no = old_no.checked_add(1).ok_or(err(j, PatchErrorKind::TooLarge))?;
323            old_left -= 1;
324        }
325        if take_new {
326            n = Some(new_no);
327            new_no = new_no.checked_add(1).ok_or(err(j, PatchErrorKind::TooLarge))?;
328            new_left -= 1;
329        }
330        h.lines.push(Line { kind, old_no: o, new_no: n, text: text.to_owned(), no_newline: false, emphasis: Vec::new() });
331        j += 1;
332    }
333    // a marker after the hunk's last line
334    if lines.get(j).is_some_and(|l| l.starts_with('\\')) {
335        if let Some(last) = h.lines.last_mut() {
336            last.no_newline = true;
337        }
338        j += 1;
339    }
340    Ok((h, j))
341}
342
343/// Writes `file` as a unified patch into `out`.
344pub(crate) fn write_file(file: &FileDiff, out: &mut String) {
345    for l in &file.header {
346        out.push_str(l);
347        out.push('\n');
348    }
349    for h in &file.hunks {
350        out.push_str(&h.header());
351        out.push('\n');
352        for l in &h.lines {
353            out.push(match l.kind {
354                LineKind::Context => ' ',
355                LineKind::Removed => '-',
356                LineKind::Added => '+',
357            });
358            out.push_str(&l.text);
359            out.push('\n');
360            if l.no_newline {
361                out.push_str("\\ No newline at end of file\n");
362            }
363        }
364    }
365}
366
367/// Writes files as one unified patch (the inverse of [`parse_patch`] for `git diff` output).
368pub fn write_patch(files: &[FileDiff]) -> String {
369    let mut out = String::new();
370    for f in files {
371        write_file(f, &mut out);
372    }
373    out
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn hunk_headers() {
382        let h = hunk_header("@@ -1,3 +4 @@ fn main() {").unwrap();
383        assert_eq!((h.old_start, h.old_len, h.new_start, h.new_len), (1, 3, 4, 1));
384        assert_eq!(h.section, "fn main() {");
385        assert!(hunk_header("@@ -1,x +4 @@").is_none());
386        assert!(hunk_header("@@ -99999999999 +1 @@").is_none());
387        assert!(hunk_header("@@ -+1 +1 @@").is_none());
388    }
389
390    #[test]
391    fn paths() {
392        assert_eq!(git_paths("a/x y b/x y"), Some(("x y".into(), "x y".into())));
393        assert_eq!(git_paths("a/old b/new"), Some(("old".into(), "new".into())));
394        assert_eq!(git_paths("\"a/t\\303\\244b\" \"b/t\\303\\244b\""), Some(("täb".into(), "täb".into())));
395        assert_eq!(header_path("a/f.rs\t2024-01-01", "a/"), Some("f.rs".into()));
396        assert_eq!(header_path("/dev/null", "a/"), None);
397        assert_eq!(unquote("\"\\\"q\\\\\\n\""), "\"q\\\n");
398    }
399
400    #[test]
401    fn malformed() {
402        let e = |p: &str| parse_patch(p).unwrap_err().kind;
403        assert_eq!(e("@@ -1 +1 @@\n-a\n+b\n"), PatchErrorKind::HunkWithoutFile);
404        assert_eq!(e("--- a\n+++ b\n@@ -1,2 +1 @@\n-a\n"), PatchErrorKind::UnexpectedEnd);
405        assert_eq!(e("--- a\n+++ b\n@@ -1 +1 @@\n*a\n"), PatchErrorKind::BadHunkLine);
406        assert_eq!(e("--- a\n+++ b\n@@ -1 +1 @@\n-a\n-b\n"), PatchErrorKind::BadHunkLine);
407        assert_eq!(e("--- a\n+++ b\n@@ -1 +1\n"), PatchErrorKind::BadHunkHeader);
408        assert_eq!(e("--- a\n+++ b\n@@ -4294967295,2 +1,2 @@\n a\n b\n"), PatchErrorKind::TooLarge);
409    }
410}