1use std::fmt;
9
10use crate::{FileDiff, FileStatus, Hunk, Line, LineKind, emphasize};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum PatchErrorKind {
15 BadHunkHeader,
17 HunkWithoutFile,
19 UnexpectedEnd,
21 BadHunkLine,
24 TooLarge,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub struct PatchError {
31 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
51const 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
67pub 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
112fn 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 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
166fn 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
177fn 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
187fn 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 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
214fn 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
228fn 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
266fn 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
279fn 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
290fn 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 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 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
343pub(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
367pub 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}