Skip to main content

gitu/
gitu_diff.rs

1/// A git diff parser.
2///
3/// The aim of this module is to produce ranges that refer to the original input bytes.
4/// This approach can be preferable where one would want to:
5/// - Use the ranges to highlight changes in a user interface.
6/// - Be sure that the original input is intact.
7///
8use tinyvec::{ArrayVec, array_vec};
9
10use core::ops::Range;
11use std::{
12    borrow::Cow,
13    fmt::{self, Debug},
14};
15
16trait ParsedRange {
17    fn range(&self) -> &Range<usize>;
18}
19
20impl ParsedRange for Range<usize> {
21    fn range(&self) -> &Range<usize> {
22        self
23    }
24}
25
26#[allow(dead_code)]
27#[derive(Debug, Clone)]
28pub struct FileDiff {
29    pub range: Range<usize>,
30    pub header: DiffHeader,
31    pub hunks: Vec<Hunk>,
32}
33
34#[allow(dead_code)]
35#[derive(Debug, PartialEq, Eq, Clone, Copy)]
36pub enum Status {
37    Added,
38    Deleted,
39    Modified,
40    Renamed,
41    Copied,
42    Unmerged,
43}
44
45#[allow(dead_code)]
46#[derive(Debug, Clone)]
47pub struct DiffHeader {
48    pub range: Range<usize>,
49    pub old_file: FilePath,
50    pub new_file: FilePath,
51    pub status: Status,
52}
53
54impl FilePath {
55    pub fn fmt<'a>(&'a self, input: &'a str) -> Cow<'a, str> {
56        if self.is_quoted {
57            Cow::Owned(
58                String::from_utf8(
59                    smashquote::unescape_bytes(input[self.range.clone()].as_bytes()).unwrap(),
60                )
61                .unwrap(),
62            )
63        } else {
64            Cow::Borrowed(&input[self.range.clone()])
65        }
66    }
67}
68
69#[derive(Debug, Clone)]
70pub struct FilePath {
71    pub range: Range<usize>,
72    pub is_quoted: bool,
73}
74
75#[allow(dead_code)]
76#[derive(Debug, Clone)]
77pub struct Hunk {
78    pub range: Range<usize>,
79    pub header: HunkHeader,
80    pub content: HunkContent,
81}
82
83#[allow(dead_code)]
84#[derive(Debug, Clone)]
85pub struct HunkContent {
86    pub range: Range<usize>,
87    pub changes: Vec<Change>,
88}
89
90#[allow(dead_code)]
91#[derive(Debug, Clone)]
92pub struct HunkHeader {
93    pub range: Range<usize>,
94    pub old_line_start: u32,
95    pub old_line_count: u32,
96    pub new_line_start: u32,
97    pub new_line_count: u32,
98    pub fn_ctx: Range<usize>,
99}
100
101#[allow(dead_code)]
102#[derive(Debug, Clone)]
103pub struct Change {
104    pub old: Range<usize>,
105    pub new: Range<usize>,
106}
107
108pub type Result<'a, T> = std::result::Result<T, ParseError<'a>>;
109
110pub struct ParseError<'a> {
111    errors: ArrayVec<[ThinParseError; 4]>,
112    parser: Parser<'a>,
113}
114
115/// Contains all necessary information needed to construct a descriptive error message,
116/// but does not render it out.
117/// This makes conditional `.or_else()` more feasible to use with parsers.
118type ThinResult<T> = std::result::Result<T, ArrayVec<[ThinParseError; 4]>>;
119
120#[derive(Default)]
121pub struct ThinParseError {
122    expected: &'static str,
123}
124
125impl fmt::Display for ParseError<'_> {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.write_str("Error parsing diff\n")?;
128        for (i, error) in self.errors.iter().enumerate() {
129            if i == 0 {
130                f.write_fmt(format_args!("expected {:?}", error.expected))?;
131            } else {
132                f.write_fmt(format_args!("within   {:?}", error.expected))?;
133            }
134
135            f.write_str("\n")?;
136        }
137
138        self.parser.fmt(f)?;
139
140        Ok(())
141    }
142}
143
144impl fmt::Debug for ParseError<'_> {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        f.write_fmt(format_args!("{self}"))
147    }
148}
149
150impl fmt::Display for ThinParseError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "Expected {}", self.expected)
153    }
154}
155
156impl fmt::Debug for ThinParseError {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.write_fmt(format_args!("{self}"))
159    }
160}
161
162impl std::error::Error for ThinParseError {}
163
164#[derive(Clone)]
165pub struct Parser<'a> {
166    input: &'a str,
167    cursor: usize,
168}
169
170type ParseFn<'a, T> = fn(&mut Parser<'a>) -> ThinResult<T>;
171
172impl<'a> fmt::Debug for Parser<'a> {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        let cursor = self.cursor;
175        // TODO Need to handle invalid unicode
176        let line_start = self.input[..cursor].rfind('\n').unwrap_or(0);
177        let line_end = self.input[line_start..]
178            .find('\n')
179            .unwrap_or(self.input.len());
180        let line = &self.input[line_start..line_end];
181        f.write_fmt(format_args!("{}\n", line))?;
182        for _ in line_start..cursor {
183            f.write_str(" ")?;
184        }
185        f.write_str("^")?;
186        Ok(())
187    }
188}
189
190impl<'a> Parser<'a> {
191    pub fn new(input: &'a str) -> Self {
192        Self { input, cursor: 0 }
193    }
194
195    /// Parses a diff file and returns a vector of Diff structures.
196    ///
197    /// The returned ranges refer to the original input bytes.
198    pub fn parse_diff<'b>(&'b mut self) -> Result<'b, Vec<FileDiff>> {
199        self.parse(false)
200    }
201
202    /// Parses a log of patches (`git log -p`): diffs that commits' messages
203    /// separate, and may follow. What is not a diff is skipped.
204    pub fn parse_patches<'b>(&'b mut self) -> Result<'b, Vec<FileDiff>> {
205        self.parse(true)
206    }
207
208    fn parse<'b>(&'b mut self, between_diffs: bool) -> Result<'b, Vec<FileDiff>> {
209        log::trace!("Parser::parse\n{:?}", self);
210        let mut diffs = vec![];
211
212        if self.input.is_empty() {
213            return Ok(vec![]);
214        }
215
216        if !diffs.is_empty() {
217            return Ok(diffs);
218        }
219
220        self.skip_until_diff_header().map_err(|errors| ParseError {
221            errors,
222            parser: self.clone(),
223        })?;
224
225        while self.is_at_diff_header() {
226            diffs.push(self.file_diff().map_err(|errors| ParseError {
227                errors,
228                parser: self.clone(),
229            })?);
230            if between_diffs {
231                self.skip_until_diff_header().map_err(|errors| ParseError {
232                    errors,
233                    parser: self.clone(),
234                })?;
235            }
236        }
237
238        self.eof().map_err(|errors| ParseError {
239            errors,
240            parser: self.clone(),
241        })?;
242
243        Ok(diffs)
244    }
245
246    fn skip_until_diff_header(&mut self) -> ThinResult<()> {
247        log::trace!("Parser::skip_until_diff_header\n{:?}", self);
248        while self.cursor < self.input.len() && !self.is_at_diff_header() {
249            self.consume_until_after(Self::newline_or_eof)?;
250        }
251
252        Ok(())
253    }
254
255    fn is_at_diff_header(&mut self) -> bool {
256        self.peek("diff") || self.peek("*")
257    }
258
259    fn file_diff(&mut self) -> ThinResult<FileDiff> {
260        log::trace!("Parser::file_diff\n{:?}", self);
261        let diff_start = self.cursor;
262        let header = self.diff_header().map_err(|mut err| {
263            err.try_push(ThinParseError {
264                expected: "<diff header>",
265            });
266            err
267        })?;
268
269        let mut hunks = vec![];
270
271        if header.status == Status::Unmerged {
272            self.skip_until_diff_header()?;
273        } else {
274            while self.peek("@@") {
275                hunks.push(self.hunk().map_err(|mut err| {
276                    err.try_push(ThinParseError { expected: "<hunk>" });
277                    err
278                })?);
279            }
280        }
281
282        Ok(FileDiff {
283            range: diff_start..self.cursor,
284            header,
285            hunks,
286        })
287    }
288
289    fn diff_header(&mut self) -> ThinResult<DiffHeader> {
290        log::trace!("Parser::diff_header\n{:?}", self);
291        let diff_header_start = self.cursor;
292        let mut diff_type = Status::Modified;
293
294        if let Ok(unmerged) = self.unmerged_file() {
295            return Ok(unmerged);
296        }
297
298        let (mut old_file, mut new_file, is_conflicted) = self
299            .conflicted_file()
300            .or_else(|_| self.old_new_file_header())?;
301
302        if is_conflicted {
303            diff_type = Status::Unmerged;
304        }
305
306        if self.peek("new file") {
307            diff_type = Status::Added;
308            self.consume_until_after(Self::newline)?;
309        } else if self.peek("deleted file") {
310            diff_type = Status::Deleted;
311            self.consume_until_after(Self::newline)?;
312        }
313
314        if self.consume("similarity index").is_ok() {
315            self.consume_until_after(Self::newline)?;
316        }
317
318        if self.consume("dissimilarity index").is_ok() {
319            self.consume_until_after(Self::newline)?;
320        }
321
322        if self.peek("index") {
323        } else if self.peek("old mode") {
324            self.consume_until_after(Self::newline)?;
325            if self.peek("new mode") {
326                self.consume_until_after(Self::newline)?;
327            }
328        } else if self.peek("new mode") {
329            self.consume_until_after(Self::newline)?;
330        } else if self.peek("deleted file mode") {
331            diff_type = Status::Deleted;
332            self.consume_until_after(Self::newline)?;
333        } else if self.peek("new file mode") {
334            diff_type = Status::Added;
335            self.consume_until_after(Self::newline)?;
336        } else if self.peek("copy from") {
337            diff_type = Status::Copied;
338            self.consume_until_after(Self::newline)?;
339            self.consume("copy to")?;
340            self.consume_until_after(Self::newline)?;
341        } else if self.peek("rename from") {
342            diff_type = Status::Renamed;
343            self.consume_until_after(Self::newline)?;
344            self.consume("rename to")?;
345            self.consume_until_after(Self::newline)?;
346        }
347
348        if self.peek("index") {
349            self.consume("index ")?;
350            self.consume_until_after(Self::newline_or_eof)?;
351        }
352
353        if self.peek("Binary files ") {
354            self.consume_until_after(Self::newline_or_eof)?;
355        }
356
357        if self.consume("--- ").is_ok() {
358            old_file = self.diff_header_path(Self::newline_or_eof)?;
359            self.newline_or_eof()?;
360            self.consume("+++ ")?;
361            new_file = self.diff_header_path(Self::newline_or_eof)?;
362            self.newline_or_eof()?;
363        }
364
365        Ok(DiffHeader {
366            range: diff_header_start..self.cursor,
367            old_file,
368            new_file,
369            status: diff_type,
370        })
371    }
372
373    fn unmerged_file(&mut self) -> ThinResult<DiffHeader> {
374        log::trace!("Parser::unmerged_file\n{:?}", self);
375        let unmerged_path_prefix = self.consume("* Unmerged path ")?;
376        let file = self.diff_header_path(Self::newline_or_eof)?;
377        self.newline_or_eof()?;
378
379        Ok(DiffHeader {
380            range: unmerged_path_prefix.start..self.cursor,
381            old_file: file.clone(),
382            new_file: file,
383            status: Status::Unmerged,
384        })
385    }
386
387    fn old_new_file_header(&mut self) -> ThinResult<(FilePath, FilePath, bool)> {
388        log::trace!("Parser::old_new_file_header\n{:?}", self);
389        self.consume("diff --git ")?;
390
391        // Vary the delimiter of file-paths depending on whether it is quoted, prefixed, or lacks prefix.
392        // e.g.
393        // "a/file-1.txt" "b/file-1.txt" -> whitespace delimiter
394        // a/file-1.txt b/file-1.txt     -> whitespace delimiter, followed by a prefix ("b/")
395        // file-1.txt file-1.txt         -> whitespace delimiter
396        let delim: ParseFn<Range<usize>> = if self.peek("\"") {
397            Self::ascii_whitespace
398        } else if self.peek_fn(Self::diff_header_path_prefix) {
399            |p: &mut Parser<'_>| {
400                let start = p.cursor;
401                p.ascii_whitespace()
402                    .and_then(|_| p.diff_header_path_prefix())?;
403                Ok(start..p.cursor)
404            }
405        } else {
406            Self::ascii_whitespace
407        };
408
409        let old_path = self.diff_header_path(delim)?;
410        self.ascii_whitespace()?;
411        let new_path = self.diff_header_path(Self::newline_or_eof)?;
412        self.newline_or_eof()?;
413
414        Ok((old_path, new_path, false))
415    }
416
417    /// When the header path is not quoted ("..."), then `stop_lookahead` is used.
418    fn diff_header_path(
419        &mut self,
420        stop_lookahead: ParseFn<'a, Range<usize>>,
421    ) -> ThinResult<FilePath> {
422        log::trace!("Parser::diff_header_path\n{:?}", self);
423        if self.consume("\"").ok().is_some() {
424            self.diff_header_path_prefix().ok();
425            let quoted = self.quoted()?;
426            Ok(FilePath {
427                range: quoted,
428                is_quoted: true,
429            })
430        } else {
431            self.diff_header_path_prefix().ok();
432            let consumed = self.consume_until_before(stop_lookahead)?;
433            Ok(FilePath {
434                range: consumed,
435                is_quoted: false,
436            })
437        }
438    }
439
440    fn quoted(&mut self) -> ThinResult<Range<usize>> {
441        log::trace!("Parser::quoted\n{:?}", self);
442        let start = self.cursor;
443
444        while !self.peek("\"") {
445            if self.peek("\\") {
446                self.escaped()?;
447            } else {
448                self.cursor += 1
449            }
450        }
451
452        let range = start..self.cursor;
453        self.consume("\"")?;
454        Ok(range)
455    }
456
457    fn escaped(&mut self) -> ThinResult<Range<usize>> {
458        log::trace!("Parser::escaped\n{:?}", self);
459        let start = self.cursor;
460
461        self.consume("\\")?;
462        match self.input.get(self.cursor..self.cursor + 1) {
463            Some("a" | "b" | "e" | "E" | "f" | "n" | "r" | "t" | "v" | "\'" | "\"" | "\\") => {
464                self.cursor += 1;
465            }
466            Some("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") => {
467                self.cursor += 1;
468                for _ in 0..2 {
469                    if !matches!(
470                        self.input.get(self.cursor..self.cursor + 1),
471                        Some("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
472                    ) {
473                        break;
474                    }
475
476                    self.cursor += 1;
477                }
478            }
479            _ => {
480                self.cursor = start;
481                return Err(array_vec![ThinParseError {
482                    expected: "<escaped char>",
483                }]);
484            }
485        }
486
487        Ok(start..self.cursor)
488    }
489
490    fn diff_header_path_prefix(&mut self) -> ThinResult<Range<usize>> {
491        log::trace!("Parser::diff_header_path_prefix\n{:?}", self);
492        let start = self.cursor;
493        self.ascii_lowercase()
494            .and_then(|_| self.consume("/"))
495            .map_err(|_| {
496                self.cursor = start;
497                array_vec![ThinParseError {
498                    expected: "<diff header path prefix (e.g. 'a/' or 'b/')>",
499                }]
500            })?;
501
502        Ok(start..self.cursor)
503    }
504
505    fn ascii_lowercase(&mut self) -> ThinResult<Range<usize>> {
506        log::trace!("Parser::ascii_lowercase\n{:?}", self);
507        let start = self.cursor;
508        let is_ascii_lowercase = self
509            .input
510            .get(self.cursor..)
511            .and_then(|s| s.chars().next())
512            .is_some_and(|c| c.is_ascii_lowercase());
513
514        if is_ascii_lowercase {
515            self.cursor += 1;
516            Ok(start..self.cursor)
517        } else {
518            Err(array_vec![ThinParseError {
519                expected: "<ascii lowercase char>",
520            }])
521        }
522    }
523
524    fn conflicted_file(&mut self) -> ThinResult<(FilePath, FilePath, bool)> {
525        log::trace!("Parser::conflicted_file\n{:?}", self);
526        self.consume("diff --cc ")?;
527        let file = self.diff_header_path(Self::newline_or_eof)?;
528        self.newline_or_eof()?;
529        Ok((file.clone(), file, true))
530    }
531
532    fn hunk(&mut self) -> ThinResult<Hunk> {
533        log::trace!("Parser::hunk\n{:?}", self);
534        let hunk_start = self.cursor;
535        let header = self.hunk_header().map_err(|mut err| {
536            err.try_push(ThinParseError {
537                expected: "<hunk header>",
538            });
539            err
540        })?;
541        let content = self.hunk_content().map_err(|mut err| {
542            err.try_push(ThinParseError {
543                expected: "<hunk content>",
544            });
545            err
546        })?;
547
548        Ok(Hunk {
549            range: hunk_start..self.cursor,
550            header,
551            content,
552        })
553    }
554
555    fn hunk_content(&mut self) -> ThinResult<HunkContent> {
556        log::trace!("Parser::hunk_content\n{:?}", self);
557        let hunk_content_start = self.cursor;
558        let mut changes = vec![];
559
560        while self.cursor < self.input.len()
561            && [" ", "-", "+", "\\"]
562                .into_iter()
563                .any(|prefix| self.peek(prefix))
564        {
565            self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
566            changes.push(self.change()?);
567            self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
568        }
569
570        Ok(HunkContent {
571            range: hunk_content_start..self.cursor,
572            changes,
573        })
574    }
575
576    fn hunk_header(&mut self) -> ThinResult<HunkHeader> {
577        log::trace!("Parser::hunk_header\n{:?}", self);
578        let hunk_header_start = self.cursor;
579
580        self.consume("@@ -")?;
581        let old_line_start = self.number()?;
582        let old_line_count = if self.consume(",").is_ok() {
583            self.number()?
584        } else {
585            1
586        };
587        self.consume(" +")?;
588        let new_line_start = self.number()?;
589        let new_line_count = if self.consume(",").is_ok() {
590            self.number()?
591        } else {
592            1
593        };
594        self.consume(" @@")?;
595        self.consume(" ").ok();
596
597        let (fn_ctx, newline) = self.consume_until_after(Self::newline_or_eof)?;
598
599        Ok(HunkHeader {
600            range: hunk_header_start..self.cursor,
601            old_line_start,
602            old_line_count,
603            new_line_start,
604            new_line_count,
605            fn_ctx: fn_ctx.start..newline.end,
606        })
607    }
608
609    fn change(&mut self) -> ThinResult<Change> {
610        log::trace!("Parser::change\n{:?}", self);
611        let removed = self.consume_lines_while_prefixed(|parser| parser.peek("-"))?;
612        let removed_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
613        let added = self.consume_lines_while_prefixed(|parser| parser.peek("+"))?;
614        let added_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
615
616        Ok(Change {
617            old: removed.start..removed_meta.end,
618            new: added.start..added_meta.end,
619        })
620    }
621
622    fn consume_lines_while_prefixed(
623        &mut self,
624        pred: fn(&Parser) -> bool,
625    ) -> ThinResult<Range<usize>> {
626        log::trace!("Parser::consume_lines_while_prefixed\n{:?}", self);
627        let start = self.cursor;
628        while self.cursor < self.input.len() && pred(self) {
629            self.consume_until_after(Self::newline_or_eof)?;
630        }
631
632        Ok(start..self.cursor)
633    }
634
635    fn number(&mut self) -> ThinResult<u32> {
636        log::trace!("Parser::number\n{:?}", self);
637        let digit_count = &self
638            .input
639            .get(self.cursor..)
640            .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).count())
641            .unwrap_or(0);
642
643        if digit_count == &0 {
644            return Err(array_vec![ThinParseError {
645                expected: "<number>",
646            }]);
647        }
648
649        self.cursor += digit_count;
650        Ok(self
651            .input
652            .get(self.cursor - digit_count..self.cursor)
653            .ok_or(array_vec![ThinParseError {
654                expected: "<number>",
655            }])?
656            .parse()
657            .unwrap())
658    }
659
660    fn newline_or_eof(&mut self) -> ThinResult<Range<usize>> {
661        log::trace!("Parser::newline_or_eof\n{:?}", self);
662        self.newline().or_else(|_| self.eof()).map_err(|_| {
663            array_vec![ThinParseError {
664                expected: "<newline or eof>",
665            }]
666        })
667    }
668
669    fn newline(&mut self) -> ThinResult<Range<usize>> {
670        log::trace!("Parser::newline\n{:?}", self);
671        self.consume("\r\n")
672            .or_else(|_| self.consume("\n"))
673            .map_err(|_| {
674                array_vec![ThinParseError {
675                    expected: "<newline>",
676                }]
677            })
678    }
679
680    fn ascii_whitespace(&mut self) -> ThinResult<Range<usize>> {
681        log::trace!("Parser::ascii_whitespace\n{:?}", self);
682        self.consume(" ")
683            .or_else(|_| self.consume("\t"))
684            .or_else(|_| self.consume("\n"))
685            .or_else(|_| self.consume("\r\n"))
686            .or_else(|_| self.consume("\r"))
687            .or_else(|_| self.consume("\x0C"))
688            .map_err(|_| {
689                array_vec![ThinParseError {
690                    expected: "<ascii whitespace>",
691                }]
692            })
693    }
694
695    fn eof(&mut self) -> ThinResult<Range<usize>> {
696        log::trace!("Parser::eof\n{:?}", self);
697        if self.cursor == self.input.len() {
698            Ok(self.cursor..self.cursor)
699        } else {
700            Err(array_vec![ThinParseError { expected: "<eof>" }])
701        }
702    }
703
704    /// Scans through the input, moving the cursor byte-by-byte
705    /// until the provided parse_fn will succeed, or the input has been exhausted.
706    /// Returns a tuple of the bytes scanned up until the match, excluding the match itself.
707    /// The resulting cursor will end up before the match.
708    fn consume_until_before<T: ParsedRange>(
709        &mut self,
710        parse_fn: fn(&mut Parser<'a>) -> ThinResult<T>,
711    ) -> ThinResult<Range<usize>> {
712        log::trace!("Parser::consume_until_before\n{:?}", self);
713        let start = self.cursor;
714        let found = self.find(parse_fn).map_err(|mut err| {
715            err.try_push(ThinParseError {
716                expected: "to consume the match",
717            });
718            err
719        })?;
720        self.cursor = found.range().start;
721        Ok(start..found.range().start)
722    }
723
724    /// Scans through the input, moving the cursor byte-by-byte
725    /// until the provided parse_fn will succeed, or the input has been exhausted.
726    /// Returns a tuple of the bytes scanned up until the match, and the match itself.
727    /// The resulting cursor will end up after the match.
728    fn consume_until_after<T: ParsedRange>(
729        &mut self,
730        parse_fn: fn(&mut Parser<'a>) -> ThinResult<T>,
731    ) -> ThinResult<(Range<usize>, T)> {
732        log::trace!("Parser::consume_until_after\n{:?}", self);
733        let start = self.cursor;
734        let found = self.find(parse_fn).map_err(|mut err| {
735            err.try_push(ThinParseError {
736                expected: "to consume the match",
737            });
738            err
739        })?;
740        self.cursor = found.range().end;
741        Ok((start..found.range().start, found))
742    }
743
744    /// Scans through the input byte-by-byte
745    /// until the provided parse_fn will succeed, or the input has been exhausted.
746    /// Returning the match. Does not step the parser.
747    fn find<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> ThinResult<T> {
748        log::trace!("Parser::find\n{:?}", self);
749        let mut sub_parser = self.clone();
750        let mut error = None;
751
752        for pos in self.cursor..=self.input.len() {
753            sub_parser.cursor = pos;
754            match parse_fn(&mut sub_parser) {
755                Ok(result) => return Ok(result),
756                Err(err) => {
757                    if error.is_none() {
758                        error = Some(err);
759                    }
760                    continue;
761                }
762            }
763        }
764
765        let mut errors = error.unwrap();
766        errors.try_push(ThinParseError {
767            expected: "to find a match",
768        });
769
770        Err(errors)
771    }
772
773    fn peek_fn<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> bool {
774        let mut p = Parser {
775            input: self.input,
776            cursor: self.cursor,
777        };
778        parse_fn(&mut p).is_ok()
779    }
780
781    /// Consumes `expected` from the input and moves the cursor past it.
782    /// Returns an error if `expected` was not found at the cursor.
783    fn consume(&mut self, expected: &'static str) -> ThinResult<Range<usize>> {
784        let start = self.cursor;
785
786        if !self.peek(expected) {
787            return Err(array_vec![ThinParseError { expected }]);
788        }
789
790        self.cursor += expected.len();
791        Ok(start..self.cursor)
792    }
793
794    /// Returns true if `expected` is found at the cursor.
795    fn peek(&self, pattern: &str) -> bool {
796        self.input
797            .get(self.cursor..)
798            .is_some_and(|s| s.starts_with(pattern))
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn parse_empty_input() {
808        let mut parser = Parser::new("");
809        let diffs = parser.parse_diff().unwrap();
810        assert!(diffs.is_empty(), "Expected empty vector for empty input");
811    }
812
813    #[test]
814    fn the_file_header_range_ends_where_its_first_hunk_begins() {
815        let input = "diff --git a/file1.txt b/file2.txt\n\
816            index 0000000..1111111 100644\n\
817            --- a/file1.txt\n\
818            +++ b/file2.txt\n\
819            @@ -1,2 +1,2 @@\n\
820            -foo\n\
821            +bar\n";
822        let diff = Parser::new(input).parse_diff().unwrap();
823        assert_eq!(diff[0].header.range, 0..97);
824    }
825
826    #[test]
827    fn parse_valid_diff() {
828        let input = "diff --git a/file1.txt b/file2.txt\n\
829            index 0000000..1111111 100644\n\
830            --- a/file1.txt\n\
831            +++ b/file2.txt\n\
832            @@ -1,2 +1,2 @@ fn main() {\n\
833            -foo\n\
834            +bar\n";
835        let mut parser = Parser::new(input);
836        let diffs = parser.parse_diff().unwrap();
837        assert_eq!(diffs.len(), 1, "Expected one diff block");
838
839        let diff = &diffs[0];
840        let old_file_str = diff.header.old_file.fmt(input);
841        assert_eq!(old_file_str, "file1.txt", "Old file does not match");
842
843        let new_file_str = diff.header.new_file.fmt(input);
844        assert_eq!(new_file_str, "file2.txt", "New file does not match");
845
846        assert_eq!(diff.hunks.len(), 1, "Expected one hunk");
847        let hunk = &diff.hunks[0];
848
849        assert_eq!(hunk.header.old_line_start, 1, "Old line start should be 1");
850        assert_eq!(hunk.header.old_line_count, 2, "Old line count should be 2");
851        assert_eq!(hunk.header.new_line_start, 1, "New line start should be 1");
852        assert_eq!(hunk.header.new_line_count, 2, "New line count should be 2");
853
854        let func_ctx = &input[hunk.header.fn_ctx.clone()];
855        assert_eq!(func_ctx, "fn main() {\n", "Expected function context");
856
857        assert_eq!(
858            hunk.content.changes.len(),
859            1,
860            "Expected one change in the hunk"
861        );
862        let change = &hunk.content.changes[0];
863        let removed_str = &input[change.old.clone()];
864        assert_eq!(removed_str, "-foo\n", "Removed line does not match");
865        let added_str = &input[change.new.clone()];
866        assert_eq!(added_str, "+bar\n", "Added line does not match");
867    }
868
869    #[test]
870    fn parse_multiple_diffs() {
871        let input = "diff --git a/file1.txt b/file1.txt\n\
872            index 0000000..1111111 100644\n\
873            --- a/file1.txt\n\
874            +++ b/file1.txt\n\
875            @@ -1,1 +1,1 @@\n\
876            -foo\n\
877            +bar\n\
878            diff --git a/file2.txt b/file2.txt\n\
879            index 2222222..3333333 100644\n\
880            --- a/file2.txt\n\
881            +++ b/file2.txt\n\
882            @@ -2,2 +2,2 @@\n\
883            -baz\n\
884            +qux\n";
885        let mut parser = Parser::new(input);
886        let diffs = parser.parse_diff().unwrap();
887        assert_eq!(diffs.len(), 2, "Expected two diff blocks");
888
889        let diff1 = &diffs[0];
890        let old_file1 = &input[diff1.header.old_file.range.clone()];
891        assert_eq!(old_file1, "file1.txt", "First diff old file mismatch");
892
893        let diff2 = &diffs[1];
894        let old_file2 = &input[diff2.header.old_file.range.clone()];
895        assert_eq!(old_file2, "file2.txt", "Second diff old file mismatch");
896    }
897
898    #[test]
899    fn parse_crlf_input() {
900        let input = "diff --git a/file.txt b/file.txt\r\n\
901            index 0000000..1111111 100644\r\n\
902            --- a/file.txt\r\n\
903            +++ b/file.txt\r\n\
904            @@ -1,1 +1,1 @@\r\n\
905            -foo\r\n\
906            +bar\r\n";
907        let mut parser = Parser::new(input);
908        let diffs = parser.parse_diff().unwrap();
909        assert_eq!(diffs.len(), 1, "Expected one diff block for CRLF input");
910        let diff = &diffs[0];
911        let old_file = &input[diff.header.old_file.range.clone()];
912        assert_eq!(
913            old_file, "file.txt",
914            "Old file does not match in CRLF input"
915        );
916    }
917
918    #[test]
919    fn parse_malformed_input_missing_diff_header() {
920        let input = "--- a/file.txt\n+++ b/file.txt\n";
921        let mut parser = Parser::new(input);
922        assert_eq!(parser.parse_diff().unwrap().len(), 0);
923    }
924
925    #[test]
926    fn parse_malformed_input_missing_hunk_header() {
927        let input = "diff --git a/file.txt b/file.txt\n\
928            index 0000000..1111111 100644\n\
929            --- a/file.txt\n\
930            +++ b/file.txt\n\
931            foo\n";
932        let mut parser = Parser::new(input);
933        assert!(parser.parse_diff().is_err());
934    }
935
936    #[test]
937    fn parse_malformed_input_invalid_number() {
938        let input = "diff --git a/file.txt b/file.txt\n\
939            index 0000000..1111111 100644\n\
940            --- a/file.txt\n\
941            +++ b/file.txt\n\
942            @@ -a,1 +1,1 @@\n\
943            -foo\n\
944            +bar\n";
945        let mut parser = Parser::new(input);
946        assert!(parser.parse_diff().is_err());
947    }
948
949    #[test]
950    fn parse_malformed_input_extra_characters() {
951        let input = "diff --git a/file.txt b/file.txt\n\
952            index 0000000..1111111 100644\n\
953            --- a/file.txt\n\
954            +++ b/file.txt\n\
955            @@ -1,1 +1,1 @@\n\
956            -foo\n\
957            +bar\n\
958            unexpected\n";
959        let mut parser = Parser::new(input);
960        assert!(parser.parse_diff().is_err());
961    }
962
963    #[test]
964    fn unified_diff_break() {
965        let input = "diff --git a/file.txt b/file.txt\r\n\
966            index 0000000..1111111 100644\r\n\
967            --- a/file.txt\r\n\
968            +++ b/file.txt\r\n\
969            @@ -1,1 +1,1 @@\r\n\
970            -foo\r\n\
971            +bar\r\n";
972        let mut parser = Parser::new(input);
973        let _ = parser.parse_diff();
974    }
975
976    #[test]
977    fn new_file() {
978        let input = "diff --git a/file.txt b/file.txt\r\n\
979            new file mode 100644\r\n\
980            index 0000000..1111111\r\n\
981            --- /dev/null\r\n\
982            +++ b/file.txt\r\n\
983            @@ -0,0 +1,1 @@\r\n\
984            +bar\r\n";
985        let mut parser = Parser::new(input);
986        let diffs = parser.parse_diff().unwrap();
987        assert_eq!(diffs.len(), 1, "Expected one diff block for new file test");
988        let diff = &diffs[0];
989        let old_file_str = diff.header.old_file.fmt(input);
990        assert_eq!(old_file_str, "/dev/null",);
991        let new_file_str = diff.header.new_file.fmt(input);
992        assert_eq!(new_file_str, "file.txt",);
993    }
994
995    #[test]
996    fn omitted_line_count() {
997        let input = "diff --git a/file.txt b/file.txt\r\n\
998            index 0000000..1111111 100644\r\n\
999            --- a/file.txt\r\n\
1000            +++ b/file.txt\r\n\
1001            @@ -1 +1 @@\r\n\
1002            -foo\r\n\
1003            +bar\r\n";
1004        let mut parser = Parser::new(input);
1005        let diffs = parser.parse_diff().unwrap();
1006        assert_eq!(
1007            diffs.len(),
1008            1,
1009            "Expected one diff block for omitted line count test"
1010        );
1011        let diff = &diffs[0];
1012        let hunk = &diff.hunks[0];
1013        assert_eq!(hunk.header.old_line_count, 1, "Old line count should be 1");
1014        assert_eq!(hunk.header.new_line_count, 1, "New line count should be 1");
1015    }
1016
1017    #[test]
1018    fn new_empty_files() {
1019        let input = "diff --git a/file-a b/file-a\n\
1020             new file mode 100644\n\
1021             index 0000000..e69de29\n\
1022             diff --git a/file-b b/file-b\n\
1023             new file mode 100644\n\
1024             index 0000000..e69de29\n";
1025        let mut parser = Parser::new(input);
1026        let diffs = parser.parse_diff().unwrap();
1027        assert_eq!(diffs.len(), 2, "Expected two diff blocks for new files");
1028        assert_eq!(diffs[0].header.status, Status::Added);
1029        assert_eq!(diffs[0].hunks.len(), 0, "Expected no hunks in first diff");
1030        assert_eq!(diffs[1].header.status, Status::Added);
1031        assert_eq!(diffs[1].hunks.len(), 0, "Expected no hunks in second diff");
1032    }
1033
1034    #[test]
1035    fn deleted_file() {
1036        let input = "diff --git a/Cargo.lock b/Cargo.lock\n\
1037            deleted file mode 100644\n\
1038            index 6ae58a0..0000000\n\
1039            --- a/Cargo.lock\n\
1040            +++ /dev/null\n";
1041        let mut parser = Parser::new(input);
1042        let diffs = parser.parse_diff().unwrap();
1043        assert_eq!(diffs.len(), 1, "Expected two diff blocks for new files");
1044        assert_eq!(diffs[0].header.status, Status::Deleted);
1045    }
1046
1047    #[test]
1048    fn mode_change() {
1049        let input = "diff --git a/test-file b/test-file\n\
1050            old mode 100644\n\
1051            new mode 100755\n\
1052            index 1234567..1234567 100644\n\
1053            --- a/test-file\n\
1054            +++ b/test-file\n";
1055        let mut parser = Parser::new(input);
1056        let diffs = parser.parse_diff().unwrap();
1057        assert_eq!(diffs.len(), 1, "Expected one diff block for mode change");
1058        assert_eq!(diffs[0].header.status, Status::Modified);
1059    }
1060
1061    #[test]
1062    fn mode_change_only() {
1063        // Mode change without any content changes (no hunks)
1064        let input = "diff --git a/script.sh b/script.sh\n\
1065            old mode 100644\n\
1066            new mode 100755\n";
1067        let mut parser = Parser::new(input);
1068        let diffs = parser.parse_diff().unwrap();
1069        assert_eq!(
1070            diffs.len(),
1071            1,
1072            "Expected one diff block for mode-only change"
1073        );
1074        assert_eq!(diffs[0].header.status, Status::Modified);
1075        assert_eq!(
1076            diffs[0].hunks.len(),
1077            0,
1078            "Expected no hunks for mode-only change"
1079        );
1080    }
1081
1082    #[test]
1083    fn commit() {
1084        let input = "commit 9318f4040de9e6cf60033f21f6ae91a0f2239d38\n\
1085            Author: altsem <alltidsemester@pm.me>\n\
1086            Date:   Wed Feb 19 19:25:37 2025 +0100\n\
1087            \n\
1088                chore(release): prepare for v0.28.2\n\
1089            \n\
1090            diff --git a/.recent-changelog-entry b/.recent-changelog-entry\n\
1091            index 7c59f63..b3d843c 100644\n\
1092            --- a/.recent-changelog-entry\n\
1093            +++ b/.recent-changelog-entry\n\
1094            @@ -1,7 +1,6 @@\n\
1095            -## [0.28.1] - 2025-02-13\n\
1096            +## [0.28.2] - 2025-02-19\n ### 🐛 Bug Fixes\n \n\
1097            -- Change logging level to reduce inotify spam\n\
1098            -- Don't refresh on `gitu.log` writes (gitu --log)\n\
1099            +- Rebase menu opening after closing Neovim\n";
1100        let mut parser = Parser::new(input);
1101        let diffs = parser.parse_diff().unwrap();
1102        assert_eq!(
1103            &input[diffs[0].header.old_file.range.clone()],
1104            ".recent-changelog-entry"
1105        );
1106        assert_eq!(
1107            &input[diffs[0].header.new_file.range.clone()],
1108            ".recent-changelog-entry"
1109        );
1110        assert_eq!(diffs[0].header.status, Status::Modified);
1111        assert_eq!(
1112            &input[diffs[0].hunks[0].header.range.clone()],
1113            "@@ -1,7 +1,6 @@\n"
1114        );
1115    }
1116
1117    #[test]
1118    fn empty_commit() {
1119        let input = "commit 6c9991b0006b38b439605eb68baff05f0c0ebf95\nAuthor: altsem <alltidsemester@pm.me>\nDate:   Sun Jun 16 19:01:00 2024 +0200\n\n    feat: -n argument to limit log\n            \n        ";
1120
1121        let mut parser = Parser::new(input);
1122        let diffs = parser.parse_diff().unwrap();
1123        assert_eq!(diffs.len(), 0);
1124    }
1125
1126    #[test]
1127    fn binary_file() {
1128        let input = "commit 664b2f5a3223f48d3cf38c7b517014ea98b9cb55\nAuthor: altsem <alltidsemester@pm.me>\nDate:   Sat Apr 20 13:43:23 2024 +0200\n\n    update vhs/rec\n\ndiff --git a/vhs/help.png b/vhs/help.png\nindex 876e6a1..8c46810 100644\nBinary files a/vhs/help.png and b/vhs/help.png differ\ndiff --git a/vhs/rec.gif b/vhs/rec.gif\nindex 746d957..333bc94 100644\nBinary files a/vhs/rec.gif and b/vhs/rec.gif differ\ndiff --git a/vhs/rec.tape b/vhs/rec.tape\nindex bd36591..fd56c37 100644\n--- a/vhs/rec.tape\n+++ b/vhs/rec.tape\n@@ -4,7 +4,7 @@ Set Height 800\n Set Padding 5\n \n Hide\n-Type \"git checkout 3259529\"\n+Type \"git checkout f613098b14ed99fab61bd0b78a4a41e192d90ea2\"\n Enter\n Type \"git checkout -b demo-branch\"\n Enter\n";
1129
1130        let mut parser = Parser::new(input);
1131        let diffs = parser.parse_diff().unwrap();
1132        assert_eq!(diffs.len(), 3);
1133    }
1134
1135    #[test]
1136    fn conflicted_file() {
1137        let input = "diff --cc new-file\nindex 32f95c0,2b31011..0000000\n--- a/new-file\n+++ b/new-file\n@@@ -1,1 -1,1 +1,5 @@@\n- hi\n -hey\n++<<<<<<< HEAD\n++hi\n++=======\n++hey\n++>>>>>>> other-branch\n";
1138
1139        let mut parser = Parser::new(input);
1140        let diffs = parser.parse_diff().unwrap();
1141        assert_eq!(diffs.len(), 1);
1142        assert_eq!(diffs[0].header.status, Status::Unmerged);
1143        assert_eq!(&input[diffs[0].header.old_file.range.clone()], "new-file");
1144        assert_eq!(&input[diffs[0].header.new_file.range.clone()], "new-file");
1145    }
1146
1147    #[test]
1148    fn unmerged_path() {
1149        let input = "* Unmerged path new-file\n* Unmerged path new-file-2\n";
1150        let mut parser = Parser::new(input);
1151        let diff = parser.parse_diff().unwrap();
1152
1153        assert_eq!(diff.len(), 2);
1154        assert_eq!(diff[0].header.status, Status::Unmerged);
1155        assert_eq!(&input[diff[0].header.old_file.range.clone()], "new-file");
1156        assert_eq!(&input[diff[0].header.new_file.range.clone()], "new-file");
1157        assert!(diff[0].hunks.is_empty());
1158        assert_eq!(diff[1].header.status, Status::Unmerged);
1159        assert_eq!(&input[diff[1].header.old_file.range.clone()], "new-file-2");
1160        assert_eq!(&input[diff[1].header.new_file.range.clone()], "new-file-2");
1161        assert!(diff[1].hunks.is_empty());
1162    }
1163
1164    #[test]
1165    fn missing_newline_before_final() {
1166        let input = "diff --git a/vitest.config.ts b/vitest.config.ts\nindex 97b017f..bcd28a0 100644\n--- a/vitest.config.ts\n+++ b/vitest.config.ts\n@@ -14,4 +14,4 @@ export default defineConfig({\n     globals: true,\n     setupFiles: ['./src/test/setup.ts'],\n   },\n-})\n\\ No newline at end of file\n+});";
1167
1168        let mut parser = Parser::new(input);
1169        let diffs = parser.parse_diff().unwrap();
1170        assert_eq!(diffs.len(), 1);
1171        assert_eq!(diffs[0].header.status, Status::Modified);
1172        assert_eq!(diffs[0].hunks.len(), 1);
1173        let changes = &diffs[0].hunks[0].content.changes;
1174        assert_eq!(changes.len(), 1);
1175        assert_eq!(
1176            &input[changes[0].old.clone()],
1177            "-})\n\\ No newline at end of file\n"
1178        );
1179        assert_eq!(&input[changes[0].new.clone()], "+});");
1180    }
1181
1182    #[test]
1183    fn filenames_with_spaces() {
1184        let input = "\
1185            diff --git a/file one.txt b/file two.txt\n\
1186            index 5626abf..f719efd 100644\n\
1187            @@ -1 +1 @@\n\
1188            -one\n\
1189            +two\n\
1190            ";
1191        let mut parser = Parser::new(input);
1192        let diff = parser.parse_diff().unwrap();
1193        assert_eq!(diff[0].header.old_file.fmt(input), "file one.txt");
1194        assert_eq!(diff[0].header.new_file.fmt(input), "file two.txt");
1195    }
1196
1197    // #[test]
1198    // fn filenames_with_spaces_ambiguous() {
1199    //     let input = "\
1200    //         diff --git a/file one.txt b/file two.txt b/file three.txt\n\
1201    //         index 5626abf..f719efd 100644\n\
1202    //         @@ -1 +1 @@\n\
1203    //         -one\n\
1204    //         +two\n\
1205    //         ";
1206    //     let mut parser = Parser::new(input);
1207    //     let result = parser.parse_diff();
1208    //     std::assert_matches!(result, Err(_));
1209    // }
1210
1211    #[test]
1212    fn added_file_with_spaces_in_name() {
1213        let input = "diff --git a/something with space.md b/something with space.md\n\
1214            new file mode 100644\n\
1215            index 0000000..e69de29\n";
1216        let mut parser = Parser::new(input);
1217        let diff = parser.parse_diff().unwrap();
1218        assert_eq!(diff.len(), 1);
1219        assert_eq!(diff[0].header.status, Status::Added);
1220        assert_eq!(
1221            diff[0].header.old_file.fmt(input),
1222            "something with space.md"
1223        );
1224        assert_eq!(
1225            diff[0].header.new_file.fmt(input),
1226            "something with space.md"
1227        );
1228    }
1229
1230    #[test]
1231    fn utilizes_other_old_new_header_when_ambiguous() {
1232        let input = "\
1233            diff --git a/file one.txt b/file two.txt\n\
1234            index 5626abf..f719efd 100644\n\
1235            --- a/file one.txt\n\
1236            +++ b/file two.txt\n\
1237            @@ -1 +1 @@\n\
1238            -one\n\
1239            +two\n\
1240            ";
1241
1242        let mut parser = Parser::new(input);
1243        let diff = parser.parse_diff().unwrap();
1244        assert_eq!(diff[0].header.old_file.fmt(input), "file one.txt");
1245        assert_eq!(diff[0].header.new_file.fmt(input), "file two.txt");
1246    }
1247
1248    #[test]
1249    fn partially_unmerged() {
1250        let input = "diff --git a/src/config.rs b/src/config.rs\nindex a22a438..095d9c7 100644\n--- a/src/config.rs\n+++ b/src/config.rs\n@@ -15,6 +15,7 @@ const DEFAULT_CONFIG: &str = include_str!(\"default_config.toml\");\n pub(crate) struct Config {\n     pub general: GeneralConfig,\n     pub style: StyleConfig,\n+    pub editor: EditorConfig,\n     pub bindings: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,\n }\n \n@@ -148,6 +149,13 @@ pub struct SymbolStyleConfigEntry {\n     mods: Option<Modifier>,\n }\n \n+#[derive(Default, Debug, Deserialize)]\n+pub struct EditorConfig {\n+    pub default: Option<String>,\n+    pub show: Option<String>,\n+    pub commit: Option<String>,\n+}\n+\n impl From<&StyleConfigEntry> for Style {\n     fn from(val: &StyleConfigEntry) -> Self {\n         Style {\ndiff --git a/src/default_config.toml b/src/default_config.toml\nindex eaf97e7..b5a29fa 100644\n--- a/src/default_config.toml\n+++ b/src/default_config.toml\n@@ -10,6 +10,10 @@ confirm_quit.enabled = false\n collapsed_sections = []\n refresh_on_file_change.enabled = true\n \n+[editor]\n+# show = \"zed -a\"\n+# commit = \"zile\"\n+\n [style]\n # fg / bg can be either of:\n # - a hex value: \"#707070\"\n* Unmerged path src/ops/show.rs";
1251
1252        let mut parser = Parser::new(input);
1253        let diffs = parser.parse_diff().unwrap();
1254        assert_eq!(diffs.len(), 3);
1255        assert_eq!(diffs[2].header.status, Status::Unmerged);
1256        assert_eq!(
1257            &input[diffs[2].header.old_file.range.clone()],
1258            "src/ops/show.rs"
1259        );
1260        assert_eq!(
1261            &input[diffs[2].header.new_file.range.clone()],
1262            "src/ops/show.rs"
1263        );
1264    }
1265
1266    #[test]
1267    fn parse_custom_prefixes() {
1268        let input = "diff --git i/file1.txt w/file2.txt\n\
1269        index 0000000..1111111 100644\n\
1270        --- i/file1.txt\n\
1271        +++ w/file2.txt\n\
1272        @@ -1,2 +1,2 @@ fn main() {\n\
1273        -foo\n\
1274        +bar\n";
1275        let mut parser = Parser::new(input);
1276        let diffs = parser.parse_diff().unwrap();
1277        assert_eq!(diffs.len(), 1, "Expected one diff block");
1278
1279        let diff = &diffs[0];
1280        let old_file_str = diff.header.old_file.fmt(input);
1281        assert_eq!(old_file_str, "file1.txt", "Old file does not match");
1282
1283        let new_file_str = diff.header.new_file.fmt(input);
1284        assert_eq!(new_file_str, "file2.txt", "New file does not match");
1285    }
1286
1287    #[test]
1288    fn parse_quoted_filenames() {
1289        let input = "\
1290            diff --git \"a/\\303\\266\" \"b/\\303\\266\"\n\
1291            new file mode 100644\n\
1292            index 0000000..e69de29\n\
1293            diff --git \"a/\\\"\" \"b/\\\\\"\n\
1294            new file mode 100644\n\
1295            index 0000000..e69de29\n\
1296            diff --git \"a/\\'\" \"b/\\v\"\n\
1297            new file mode 100644\n\
1298            index 0000000..e69de29\n\
1299            diff --git \"a/\\t\" \"b/\\r\"\n\
1300            new file mode 100644\n\
1301            index 0000000..e69de29\n\
1302            diff --git \"a/\\n\" \"b/\\f\"\n\
1303            new file mode 100644\n\
1304            index 0000000..e69de29\n\
1305            diff --git \"a/\\E\" \"b/\\e\"\n\
1306            new file mode 100644\n\
1307            index 0000000..e69de29\n\
1308            diff --git \"a/\\e\" \"b/\\b\"\n\
1309            new file mode 100644\n\
1310            index 0000000..e69de29\n\
1311            diff --git \"a/\\a\" \"b/\\a\"\n\
1312            new file mode 100644\n\
1313            index 0000000..e69de29\n\
1314            diff --git \"a/l\\303\\266l space\" \"b/l\\303\\266l space\"\n\
1315            new file mode 100644\n\
1316            index 0000000..e69de29";
1317
1318        let mut parser = Parser::new(input);
1319        let diffs = parser.parse_diff().unwrap();
1320        assert_eq!(diffs.len(), 9);
1321        assert_eq!(diffs[0].header.old_file.fmt(input), "ö", "Old file");
1322        assert_eq!(diffs[0].header.new_file.fmt(input), "ö", "New file");
1323        assert_eq!(diffs[1].header.old_file.fmt(input), "\"", "Old file");
1324        assert_eq!(diffs[1].header.new_file.fmt(input), "\\", "New file");
1325        assert_eq!(diffs[2].header.old_file.fmt(input), "'", "Old file");
1326        assert_eq!(diffs[2].header.new_file.fmt(input), "\u{b}", "New file");
1327        assert_eq!(diffs[3].header.old_file.fmt(input), "\t", "Old file");
1328        assert_eq!(diffs[3].header.new_file.fmt(input), "\r", "New file");
1329        assert_eq!(diffs[4].header.old_file.fmt(input), "\n", "Old file");
1330        assert_eq!(diffs[4].header.new_file.fmt(input), "\u{c}", "New file");
1331        assert_eq!(diffs[8].header.old_file.fmt(input), "löl space", "Old file");
1332        assert_eq!(diffs[8].header.new_file.fmt(input), "löl space", "New file");
1333    }
1334
1335    #[test]
1336    fn errors_when_bad_escaped_char() {
1337        let input = "\
1338            diff --git \"a/\\y\" \"b/\\y\"\n\
1339            new file mode 100644\n\
1340        ";
1341
1342        let mut parser = Parser::new(input);
1343        assert!(parser.parse_diff().is_err());
1344    }
1345
1346    #[test]
1347    fn parse_header_noprefix() {
1348        let input = "\
1349            diff --git Cargo.lock Cargo.lock\n\
1350            index 1f88e5a..3b8ea64 100644\n\
1351            --- Cargo.lock\n\
1352            +++ Cargo.lock";
1353
1354        let mut parser = Parser::new(input);
1355        let diffs = parser.parse_diff().unwrap();
1356        assert_eq!(diffs.len(), 1, "Expected one diff block");
1357
1358        let diff = &diffs[0];
1359        let old_file_str = diff.header.old_file.fmt(input);
1360        assert_eq!(old_file_str, "Cargo.lock", "Old file does not match");
1361
1362        let new_file_str = diff.header.new_file.fmt(input);
1363        assert_eq!(new_file_str, "Cargo.lock", "New file does not match");
1364    }
1365}