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    ///
199    /// # Example
200    ///
201    /// ```
202    /// let input = "diff --git a/file1.txt b/file2.txt\n\
203    /// index 0000000..1111111 100644\n\
204    /// --- a/file1.txt\n\
205    /// +++ b/file2.txt\n\
206    /// @@ -1,2 +1,2 @@\n\
207    /// -foo\n\
208    /// +bar\n";
209    ///
210    /// let diff = gitu::gitu_diff::Parser::new(input).parse_diff().unwrap();
211    /// assert_eq!(diff[0].header.range, 0..97);
212    /// ```
213    pub fn parse_diff<'b>(&'b mut self) -> Result<'b, Vec<FileDiff>> {
214        log::trace!("Parser::parse_diff\n{:?}", self);
215        let mut diffs = vec![];
216
217        if self.input.is_empty() {
218            return Ok(vec![]);
219        }
220
221        if !diffs.is_empty() {
222            return Ok(diffs);
223        }
224
225        self.skip_until_diff_header().map_err(|errors| ParseError {
226            errors,
227            parser: self.clone(),
228        })?;
229
230        while self.is_at_diff_header() {
231            diffs.push(self.file_diff().map_err(|errors| ParseError {
232                errors,
233                parser: self.clone(),
234            })?);
235        }
236
237        self.eof().map_err(|errors| ParseError {
238            errors,
239            parser: self.clone(),
240        })?;
241
242        Ok(diffs)
243    }
244
245    fn skip_until_diff_header(&mut self) -> ThinResult<()> {
246        log::trace!("Parser::skip_until_diff_header\n{:?}", self);
247        while self.cursor < self.input.len() && !self.is_at_diff_header() {
248            self.consume_until_after(Self::newline_or_eof)?;
249        }
250
251        Ok(())
252    }
253
254    fn is_at_diff_header(&mut self) -> bool {
255        self.peek("diff") || self.peek("*")
256    }
257
258    fn file_diff(&mut self) -> ThinResult<FileDiff> {
259        log::trace!("Parser::file_diff\n{:?}", self);
260        let diff_start = self.cursor;
261        let header = self.diff_header().map_err(|mut err| {
262            err.try_push(ThinParseError {
263                expected: "<diff header>",
264            });
265            err
266        })?;
267
268        let mut hunks = vec![];
269
270        if header.status == Status::Unmerged {
271            self.skip_until_diff_header()?;
272        } else {
273            while self.peek("@@") {
274                hunks.push(self.hunk().map_err(|mut err| {
275                    err.try_push(ThinParseError { expected: "<hunk>" });
276                    err
277                })?);
278            }
279        }
280
281        Ok(FileDiff {
282            range: diff_start..self.cursor,
283            header,
284            hunks,
285        })
286    }
287
288    fn diff_header(&mut self) -> ThinResult<DiffHeader> {
289        log::trace!("Parser::diff_header\n{:?}", self);
290        let diff_header_start = self.cursor;
291        let mut diff_type = Status::Modified;
292
293        if let Ok(unmerged) = self.unmerged_file() {
294            return Ok(unmerged);
295        }
296
297        let (mut old_file, mut new_file, is_conflicted) = self
298            .conflicted_file()
299            .or_else(|_| self.old_new_file_header())?;
300
301        if is_conflicted {
302            diff_type = Status::Unmerged;
303        }
304
305        if self.peek("new file") {
306            diff_type = Status::Added;
307            self.consume_until_after(Self::newline)?;
308        } else if self.peek("deleted file") {
309            diff_type = Status::Deleted;
310            self.consume_until_after(Self::newline)?;
311        }
312
313        if self.consume("similarity index").is_ok() {
314            self.consume_until_after(Self::newline)?;
315        }
316
317        if self.consume("dissimilarity index").is_ok() {
318            self.consume_until_after(Self::newline)?;
319        }
320
321        if self.peek("index") {
322        } else if self.peek("old mode") {
323            self.consume_until_after(Self::newline)?;
324            if self.peek("new mode") {
325                self.consume_until_after(Self::newline)?;
326            }
327        } else if self.peek("new mode") {
328            self.consume_until_after(Self::newline)?;
329        } else if self.peek("deleted file mode") {
330            diff_type = Status::Deleted;
331            self.consume_until_after(Self::newline)?;
332        } else if self.peek("new file mode") {
333            diff_type = Status::Added;
334            self.consume_until_after(Self::newline)?;
335        } else if self.peek("copy from") {
336            diff_type = Status::Copied;
337            self.consume_until_after(Self::newline)?;
338            self.consume("copy to")?;
339            self.consume_until_after(Self::newline)?;
340        } else if self.peek("rename from") {
341            diff_type = Status::Renamed;
342            self.consume_until_after(Self::newline)?;
343            self.consume("rename to")?;
344            self.consume_until_after(Self::newline)?;
345        }
346
347        if self.peek("index") {
348            self.consume("index ")?;
349            self.consume_until_after(Self::newline_or_eof)?;
350        }
351
352        if self.peek("Binary files ") {
353            self.consume_until_after(Self::newline_or_eof)?;
354        }
355
356        if self.consume("--- ").is_ok() {
357            old_file = self.diff_header_path(Self::newline_or_eof)?;
358            self.newline_or_eof()?;
359            self.consume("+++ ")?;
360            new_file = self.diff_header_path(Self::newline_or_eof)?;
361            self.newline_or_eof()?;
362        }
363
364        Ok(DiffHeader {
365            range: diff_header_start..self.cursor,
366            old_file,
367            new_file,
368            status: diff_type,
369        })
370    }
371
372    fn unmerged_file(&mut self) -> ThinResult<DiffHeader> {
373        log::trace!("Parser::unmerged_file\n{:?}", self);
374        let unmerged_path_prefix = self.consume("* Unmerged path ")?;
375        let file = self.diff_header_path(Self::newline_or_eof)?;
376        self.newline_or_eof()?;
377
378        Ok(DiffHeader {
379            range: unmerged_path_prefix.start..self.cursor,
380            old_file: file.clone(),
381            new_file: file,
382            status: Status::Unmerged,
383        })
384    }
385
386    fn old_new_file_header(&mut self) -> ThinResult<(FilePath, FilePath, bool)> {
387        log::trace!("Parser::old_new_file_header\n{:?}", self);
388        self.consume("diff --git ")?;
389
390        // Vary the delimiter of file-paths depending on whether it is quoted, prefixed, or lacks prefix.
391        // e.g.
392        // "a/file-1.txt" "b/file-1.txt" -> whitespace delimiter
393        // a/file-1.txt b/file-1.txt     -> whitespace delimiter, followed by a prefix ("b/")
394        // file-1.txt file-1.txt         -> whitespace delimiter
395        let delim: ParseFn<Range<usize>> = if self.peek("\"") {
396            Self::ascii_whitespace
397        } else if self.peek_fn(Self::diff_header_path_prefix) {
398            |p: &mut Parser<'_>| {
399                let start = p.cursor;
400                p.ascii_whitespace()
401                    .and_then(|_| p.diff_header_path_prefix())?;
402                Ok(start..p.cursor)
403            }
404        } else {
405            Self::ascii_whitespace
406        };
407
408        let old_path = self.diff_header_path(delim)?;
409        self.ascii_whitespace()?;
410        let new_path = self.diff_header_path(Self::newline_or_eof)?;
411        self.newline_or_eof()?;
412
413        Ok((old_path, new_path, false))
414    }
415
416    /// When the header path is not quoted ("..."), then `stop_lookahead` is used.
417    fn diff_header_path(
418        &mut self,
419        stop_lookahead: ParseFn<'a, Range<usize>>,
420    ) -> ThinResult<FilePath> {
421        log::trace!("Parser::diff_header_path\n{:?}", self);
422        if self.consume("\"").ok().is_some() {
423            self.diff_header_path_prefix().ok();
424            let quoted = self.quoted()?;
425            Ok(FilePath {
426                range: quoted,
427                is_quoted: true,
428            })
429        } else {
430            self.diff_header_path_prefix().ok();
431            let consumed = self.consume_until_before(stop_lookahead)?;
432            Ok(FilePath {
433                range: consumed,
434                is_quoted: false,
435            })
436        }
437    }
438
439    fn quoted(&mut self) -> ThinResult<Range<usize>> {
440        log::trace!("Parser::quoted\n{:?}", self);
441        let start = self.cursor;
442
443        while !self.peek("\"") {
444            if self.peek("\\") {
445                self.escaped()?;
446            } else {
447                self.cursor += 1
448            }
449        }
450
451        let range = start..self.cursor;
452        self.consume("\"")?;
453        Ok(range)
454    }
455
456    fn escaped(&mut self) -> ThinResult<Range<usize>> {
457        log::trace!("Parser::escaped\n{:?}", self);
458        let start = self.cursor;
459
460        self.consume("\\")?;
461        match self.input.get(self.cursor..self.cursor + 1) {
462            Some("a" | "b" | "e" | "E" | "f" | "n" | "r" | "t" | "v" | "\'" | "\"" | "\\") => {
463                self.cursor += 1;
464            }
465            Some("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") => {
466                self.cursor += 1;
467                for _ in 0..2 {
468                    if !matches!(
469                        self.input.get(self.cursor..self.cursor + 1),
470                        Some("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
471                    ) {
472                        break;
473                    }
474
475                    self.cursor += 1;
476                }
477            }
478            _ => {
479                self.cursor = start;
480                return Err(array_vec![ThinParseError {
481                    expected: "<escaped char>",
482                }]);
483            }
484        }
485
486        Ok(start..self.cursor)
487    }
488
489    fn diff_header_path_prefix(&mut self) -> ThinResult<Range<usize>> {
490        log::trace!("Parser::diff_header_path_prefix\n{:?}", self);
491        let start = self.cursor;
492        self.ascii_lowercase()
493            .and_then(|_| self.consume("/"))
494            .map_err(|_| {
495                self.cursor = start;
496                array_vec![ThinParseError {
497                    expected: "<diff header path prefix (e.g. 'a/' or 'b/')>",
498                }]
499            })?;
500
501        Ok(start..self.cursor)
502    }
503
504    fn ascii_lowercase(&mut self) -> ThinResult<Range<usize>> {
505        log::trace!("Parser::ascii_lowercase\n{:?}", self);
506        let start = self.cursor;
507        let is_ascii_lowercase = self
508            .input
509            .get(self.cursor..)
510            .and_then(|s| s.chars().next())
511            .is_some_and(|c| c.is_ascii_lowercase());
512
513        if is_ascii_lowercase {
514            self.cursor += 1;
515            Ok(start..self.cursor)
516        } else {
517            Err(array_vec![ThinParseError {
518                expected: "<ascii lowercase char>",
519            }])
520        }
521    }
522
523    fn conflicted_file(&mut self) -> ThinResult<(FilePath, FilePath, bool)> {
524        log::trace!("Parser::conflicted_file\n{:?}", self);
525        self.consume("diff --cc ")?;
526        let file = self.diff_header_path(Self::newline_or_eof)?;
527        self.newline_or_eof()?;
528        Ok((file.clone(), file, true))
529    }
530
531    fn hunk(&mut self) -> ThinResult<Hunk> {
532        log::trace!("Parser::hunk\n{:?}", self);
533        let hunk_start = self.cursor;
534        let header = self.hunk_header().map_err(|mut err| {
535            err.try_push(ThinParseError {
536                expected: "<hunk header>",
537            });
538            err
539        })?;
540        let content = self.hunk_content().map_err(|mut err| {
541            err.try_push(ThinParseError {
542                expected: "<hunk content>",
543            });
544            err
545        })?;
546
547        Ok(Hunk {
548            range: hunk_start..self.cursor,
549            header,
550            content,
551        })
552    }
553
554    fn hunk_content(&mut self) -> ThinResult<HunkContent> {
555        log::trace!("Parser::hunk_content\n{:?}", self);
556        let hunk_content_start = self.cursor;
557        let mut changes = vec![];
558
559        while self.cursor < self.input.len()
560            && [" ", "-", "+", "\\"]
561                .into_iter()
562                .any(|prefix| self.peek(prefix))
563        {
564            self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
565            changes.push(self.change()?);
566            self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
567        }
568
569        Ok(HunkContent {
570            range: hunk_content_start..self.cursor,
571            changes,
572        })
573    }
574
575    fn hunk_header(&mut self) -> ThinResult<HunkHeader> {
576        log::trace!("Parser::hunk_header\n{:?}", self);
577        let hunk_header_start = self.cursor;
578
579        self.consume("@@ -")?;
580        let old_line_start = self.number()?;
581        let old_line_count = if self.consume(",").is_ok() {
582            self.number()?
583        } else {
584            1
585        };
586        self.consume(" +")?;
587        let new_line_start = self.number()?;
588        let new_line_count = if self.consume(",").is_ok() {
589            self.number()?
590        } else {
591            1
592        };
593        self.consume(" @@")?;
594        self.consume(" ").ok();
595
596        let (fn_ctx, newline) = self.consume_until_after(Self::newline_or_eof)?;
597
598        Ok(HunkHeader {
599            range: hunk_header_start..self.cursor,
600            old_line_start,
601            old_line_count,
602            new_line_start,
603            new_line_count,
604            fn_ctx: fn_ctx.start..newline.end,
605        })
606    }
607
608    fn change(&mut self) -> ThinResult<Change> {
609        log::trace!("Parser::change\n{:?}", self);
610        let removed = self.consume_lines_while_prefixed(|parser| parser.peek("-"))?;
611        let removed_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
612        let added = self.consume_lines_while_prefixed(|parser| parser.peek("+"))?;
613        let added_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
614
615        Ok(Change {
616            old: removed.start..removed_meta.end,
617            new: added.start..added_meta.end,
618        })
619    }
620
621    fn consume_lines_while_prefixed(
622        &mut self,
623        pred: fn(&Parser) -> bool,
624    ) -> ThinResult<Range<usize>> {
625        log::trace!("Parser::consume_lines_while_prefixed\n{:?}", self);
626        let start = self.cursor;
627        while self.cursor < self.input.len() && pred(self) {
628            self.consume_until_after(Self::newline_or_eof)?;
629        }
630
631        Ok(start..self.cursor)
632    }
633
634    fn number(&mut self) -> ThinResult<u32> {
635        log::trace!("Parser::number\n{:?}", self);
636        let digit_count = &self
637            .input
638            .get(self.cursor..)
639            .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).count())
640            .unwrap_or(0);
641
642        if digit_count == &0 {
643            return Err(array_vec![ThinParseError {
644                expected: "<number>",
645            }]);
646        }
647
648        self.cursor += digit_count;
649        Ok(self
650            .input
651            .get(self.cursor - digit_count..self.cursor)
652            .ok_or(array_vec![ThinParseError {
653                expected: "<number>",
654            }])?
655            .parse()
656            .unwrap())
657    }
658
659    fn newline_or_eof(&mut self) -> ThinResult<Range<usize>> {
660        log::trace!("Parser::newline_or_eof\n{:?}", self);
661        self.newline().or_else(|_| self.eof()).map_err(|_| {
662            array_vec![ThinParseError {
663                expected: "<newline or eof>",
664            }]
665        })
666    }
667
668    fn newline(&mut self) -> ThinResult<Range<usize>> {
669        log::trace!("Parser::newline\n{:?}", self);
670        self.consume("\r\n")
671            .or_else(|_| self.consume("\n"))
672            .map_err(|_| {
673                array_vec![ThinParseError {
674                    expected: "<newline>",
675                }]
676            })
677    }
678
679    fn ascii_whitespace(&mut self) -> ThinResult<Range<usize>> {
680        log::trace!("Parser::ascii_whitespace\n{:?}", self);
681        self.consume(" ")
682            .or_else(|_| self.consume("\t"))
683            .or_else(|_| self.consume("\n"))
684            .or_else(|_| self.consume("\r\n"))
685            .or_else(|_| self.consume("\r"))
686            .or_else(|_| self.consume("\x0C"))
687            .map_err(|_| {
688                array_vec![ThinParseError {
689                    expected: "<ascii whitespace>",
690                }]
691            })
692    }
693
694    fn eof(&mut self) -> ThinResult<Range<usize>> {
695        log::trace!("Parser::eof\n{:?}", self);
696        if self.cursor == self.input.len() {
697            Ok(self.cursor..self.cursor)
698        } else {
699            Err(array_vec![ThinParseError { expected: "<eof>" }])
700        }
701    }
702
703    /// Scans through the input, moving the cursor byte-by-byte
704    /// until the provided parse_fn will succeed, or the input has been exhausted.
705    /// Returns a tuple of the bytes scanned up until the match, excluding the match itself.
706    /// The resulting cursor will end up before the match.
707    fn consume_until_before<T: ParsedRange>(
708        &mut self,
709        parse_fn: fn(&mut Parser<'a>) -> ThinResult<T>,
710    ) -> ThinResult<Range<usize>> {
711        log::trace!("Parser::consume_until_before\n{:?}", self);
712        let start = self.cursor;
713        let found = self.find(parse_fn).map_err(|mut err| {
714            err.try_push(ThinParseError {
715                expected: "to consume the match",
716            });
717            err
718        })?;
719        self.cursor = found.range().start;
720        Ok(start..found.range().start)
721    }
722
723    /// Scans through the input, moving the cursor byte-by-byte
724    /// until the provided parse_fn will succeed, or the input has been exhausted.
725    /// Returns a tuple of the bytes scanned up until the match, and the match itself.
726    /// The resulting cursor will end up after the match.
727    fn consume_until_after<T: ParsedRange>(
728        &mut self,
729        parse_fn: fn(&mut Parser<'a>) -> ThinResult<T>,
730    ) -> ThinResult<(Range<usize>, T)> {
731        log::trace!("Parser::consume_until_after\n{:?}", self);
732        let start = self.cursor;
733        let found = self.find(parse_fn).map_err(|mut err| {
734            err.try_push(ThinParseError {
735                expected: "to consume the match",
736            });
737            err
738        })?;
739        self.cursor = found.range().end;
740        Ok((start..found.range().start, found))
741    }
742
743    /// Scans through the input byte-by-byte
744    /// until the provided parse_fn will succeed, or the input has been exhausted.
745    /// Returning the match. Does not step the parser.
746    fn find<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> ThinResult<T> {
747        log::trace!("Parser::find\n{:?}", self);
748        let mut sub_parser = self.clone();
749        let mut error = None;
750
751        for pos in self.cursor..=self.input.len() {
752            sub_parser.cursor = pos;
753            match parse_fn(&mut sub_parser) {
754                Ok(result) => return Ok(result),
755                Err(err) => {
756                    if error.is_none() {
757                        error = Some(err);
758                    }
759                    continue;
760                }
761            }
762        }
763
764        let mut errors = error.unwrap();
765        errors.try_push(ThinParseError {
766            expected: "to find a match",
767        });
768
769        Err(errors)
770    }
771
772    fn peek_fn<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> bool {
773        let mut p = Parser {
774            input: self.input,
775            cursor: self.cursor,
776        };
777        parse_fn(&mut p).is_ok()
778    }
779
780    /// Consumes `expected` from the input and moves the cursor past it.
781    /// Returns an error if `expected` was not found at the cursor.
782    fn consume(&mut self, expected: &'static str) -> ThinResult<Range<usize>> {
783        let start = self.cursor;
784
785        if !self.peek(expected) {
786            return Err(array_vec![ThinParseError { expected }]);
787        }
788
789        self.cursor += expected.len();
790        Ok(start..self.cursor)
791    }
792
793    /// Returns true if `expected` is found at the cursor.
794    fn peek(&self, pattern: &str) -> bool {
795        self.input
796            .get(self.cursor..)
797            .is_some_and(|s| s.starts_with(pattern))
798    }
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    #[test]
806    fn parse_empty_input() {
807        let mut parser = Parser::new("");
808        let diffs = parser.parse_diff().unwrap();
809        assert!(diffs.is_empty(), "Expected empty vector for empty input");
810    }
811
812    #[test]
813    fn parse_valid_diff() {
814        let input = "diff --git a/file1.txt b/file2.txt\n\
815            index 0000000..1111111 100644\n\
816            --- a/file1.txt\n\
817            +++ b/file2.txt\n\
818            @@ -1,2 +1,2 @@ fn main() {\n\
819            -foo\n\
820            +bar\n";
821        let mut parser = Parser::new(input);
822        let diffs = parser.parse_diff().unwrap();
823        assert_eq!(diffs.len(), 1, "Expected one diff block");
824
825        let diff = &diffs[0];
826        let old_file_str = diff.header.old_file.fmt(input);
827        assert_eq!(old_file_str, "file1.txt", "Old file does not match");
828
829        let new_file_str = diff.header.new_file.fmt(input);
830        assert_eq!(new_file_str, "file2.txt", "New file does not match");
831
832        assert_eq!(diff.hunks.len(), 1, "Expected one hunk");
833        let hunk = &diff.hunks[0];
834
835        assert_eq!(hunk.header.old_line_start, 1, "Old line start should be 1");
836        assert_eq!(hunk.header.old_line_count, 2, "Old line count should be 2");
837        assert_eq!(hunk.header.new_line_start, 1, "New line start should be 1");
838        assert_eq!(hunk.header.new_line_count, 2, "New line count should be 2");
839
840        let func_ctx = &input[hunk.header.fn_ctx.clone()];
841        assert_eq!(func_ctx, "fn main() {\n", "Expected function context");
842
843        assert_eq!(
844            hunk.content.changes.len(),
845            1,
846            "Expected one change in the hunk"
847        );
848        let change = &hunk.content.changes[0];
849        let removed_str = &input[change.old.clone()];
850        assert_eq!(removed_str, "-foo\n", "Removed line does not match");
851        let added_str = &input[change.new.clone()];
852        assert_eq!(added_str, "+bar\n", "Added line does not match");
853    }
854
855    #[test]
856    fn parse_multiple_diffs() {
857        let input = "diff --git a/file1.txt b/file1.txt\n\
858            index 0000000..1111111 100644\n\
859            --- a/file1.txt\n\
860            +++ b/file1.txt\n\
861            @@ -1,1 +1,1 @@\n\
862            -foo\n\
863            +bar\n\
864            diff --git a/file2.txt b/file2.txt\n\
865            index 2222222..3333333 100644\n\
866            --- a/file2.txt\n\
867            +++ b/file2.txt\n\
868            @@ -2,2 +2,2 @@\n\
869            -baz\n\
870            +qux\n";
871        let mut parser = Parser::new(input);
872        let diffs = parser.parse_diff().unwrap();
873        assert_eq!(diffs.len(), 2, "Expected two diff blocks");
874
875        let diff1 = &diffs[0];
876        let old_file1 = &input[diff1.header.old_file.range.clone()];
877        assert_eq!(old_file1, "file1.txt", "First diff old file mismatch");
878
879        let diff2 = &diffs[1];
880        let old_file2 = &input[diff2.header.old_file.range.clone()];
881        assert_eq!(old_file2, "file2.txt", "Second diff old file mismatch");
882    }
883
884    #[test]
885    fn parse_crlf_input() {
886        let input = "diff --git a/file.txt b/file.txt\r\n\
887            index 0000000..1111111 100644\r\n\
888            --- a/file.txt\r\n\
889            +++ b/file.txt\r\n\
890            @@ -1,1 +1,1 @@\r\n\
891            -foo\r\n\
892            +bar\r\n";
893        let mut parser = Parser::new(input);
894        let diffs = parser.parse_diff().unwrap();
895        assert_eq!(diffs.len(), 1, "Expected one diff block for CRLF input");
896        let diff = &diffs[0];
897        let old_file = &input[diff.header.old_file.range.clone()];
898        assert_eq!(
899            old_file, "file.txt",
900            "Old file does not match in CRLF input"
901        );
902    }
903
904    #[test]
905    fn parse_malformed_input_missing_diff_header() {
906        let input = "--- a/file.txt\n+++ b/file.txt\n";
907        let mut parser = Parser::new(input);
908        assert_eq!(parser.parse_diff().unwrap().len(), 0);
909    }
910
911    #[test]
912    fn parse_malformed_input_missing_hunk_header() {
913        let input = "diff --git a/file.txt b/file.txt\n\
914            index 0000000..1111111 100644\n\
915            --- a/file.txt\n\
916            +++ b/file.txt\n\
917            foo\n";
918        let mut parser = Parser::new(input);
919        assert!(parser.parse_diff().is_err());
920    }
921
922    #[test]
923    fn parse_malformed_input_invalid_number() {
924        let input = "diff --git a/file.txt b/file.txt\n\
925            index 0000000..1111111 100644\n\
926            --- a/file.txt\n\
927            +++ b/file.txt\n\
928            @@ -a,1 +1,1 @@\n\
929            -foo\n\
930            +bar\n";
931        let mut parser = Parser::new(input);
932        assert!(parser.parse_diff().is_err());
933    }
934
935    #[test]
936    fn parse_malformed_input_extra_characters() {
937        let input = "diff --git a/file.txt b/file.txt\n\
938            index 0000000..1111111 100644\n\
939            --- a/file.txt\n\
940            +++ b/file.txt\n\
941            @@ -1,1 +1,1 @@\n\
942            -foo\n\
943            +bar\n\
944            unexpected\n";
945        let mut parser = Parser::new(input);
946        assert!(parser.parse_diff().is_err());
947    }
948
949    #[test]
950    fn unified_diff_break() {
951        let input = "diff --git a/file.txt b/file.txt\r\n\
952            index 0000000..1111111 100644\r\n\
953            --- a/file.txt\r\n\
954            +++ b/file.txt\r\n\
955            @@ -1,1 +1,1 @@\r\n\
956            -foo\r\n\
957            +bar\r\n";
958        let mut parser = Parser::new(input);
959        let _ = parser.parse_diff();
960    }
961
962    #[test]
963    fn new_file() {
964        let input = "diff --git a/file.txt b/file.txt\r\n\
965            new file mode 100644\r\n\
966            index 0000000..1111111\r\n\
967            --- /dev/null\r\n\
968            +++ b/file.txt\r\n\
969            @@ -0,0 +1,1 @@\r\n\
970            +bar\r\n";
971        let mut parser = Parser::new(input);
972        let diffs = parser.parse_diff().unwrap();
973        assert_eq!(diffs.len(), 1, "Expected one diff block for new file test");
974        let diff = &diffs[0];
975        let old_file_str = diff.header.old_file.fmt(input);
976        assert_eq!(old_file_str, "/dev/null",);
977        let new_file_str = diff.header.new_file.fmt(input);
978        assert_eq!(new_file_str, "file.txt",);
979    }
980
981    #[test]
982    fn omitted_line_count() {
983        let input = "diff --git a/file.txt b/file.txt\r\n\
984            index 0000000..1111111 100644\r\n\
985            --- a/file.txt\r\n\
986            +++ b/file.txt\r\n\
987            @@ -1 +1 @@\r\n\
988            -foo\r\n\
989            +bar\r\n";
990        let mut parser = Parser::new(input);
991        let diffs = parser.parse_diff().unwrap();
992        assert_eq!(
993            diffs.len(),
994            1,
995            "Expected one diff block for omitted line count test"
996        );
997        let diff = &diffs[0];
998        let hunk = &diff.hunks[0];
999        assert_eq!(hunk.header.old_line_count, 1, "Old line count should be 1");
1000        assert_eq!(hunk.header.new_line_count, 1, "New line count should be 1");
1001    }
1002
1003    #[test]
1004    fn new_empty_files() {
1005        let input = "diff --git a/file-a b/file-a\n\
1006             new file mode 100644\n\
1007             index 0000000..e69de29\n\
1008             diff --git a/file-b b/file-b\n\
1009             new file mode 100644\n\
1010             index 0000000..e69de29\n";
1011        let mut parser = Parser::new(input);
1012        let diffs = parser.parse_diff().unwrap();
1013        assert_eq!(diffs.len(), 2, "Expected two diff blocks for new files");
1014        assert_eq!(diffs[0].header.status, Status::Added);
1015        assert_eq!(diffs[0].hunks.len(), 0, "Expected no hunks in first diff");
1016        assert_eq!(diffs[1].header.status, Status::Added);
1017        assert_eq!(diffs[1].hunks.len(), 0, "Expected no hunks in second diff");
1018    }
1019
1020    #[test]
1021    fn deleted_file() {
1022        let input = "diff --git a/Cargo.lock b/Cargo.lock\n\
1023            deleted file mode 100644\n\
1024            index 6ae58a0..0000000\n\
1025            --- a/Cargo.lock\n\
1026            +++ /dev/null\n";
1027        let mut parser = Parser::new(input);
1028        let diffs = parser.parse_diff().unwrap();
1029        assert_eq!(diffs.len(), 1, "Expected two diff blocks for new files");
1030        assert_eq!(diffs[0].header.status, Status::Deleted);
1031    }
1032
1033    #[test]
1034    fn mode_change() {
1035        let input = "diff --git a/test-file b/test-file\n\
1036            old mode 100644\n\
1037            new mode 100755\n\
1038            index 1234567..1234567 100644\n\
1039            --- a/test-file\n\
1040            +++ b/test-file\n";
1041        let mut parser = Parser::new(input);
1042        let diffs = parser.parse_diff().unwrap();
1043        assert_eq!(diffs.len(), 1, "Expected one diff block for mode change");
1044        assert_eq!(diffs[0].header.status, Status::Modified);
1045    }
1046
1047    #[test]
1048    fn mode_change_only() {
1049        // Mode change without any content changes (no hunks)
1050        let input = "diff --git a/script.sh b/script.sh\n\
1051            old mode 100644\n\
1052            new mode 100755\n";
1053        let mut parser = Parser::new(input);
1054        let diffs = parser.parse_diff().unwrap();
1055        assert_eq!(
1056            diffs.len(),
1057            1,
1058            "Expected one diff block for mode-only change"
1059        );
1060        assert_eq!(diffs[0].header.status, Status::Modified);
1061        assert_eq!(
1062            diffs[0].hunks.len(),
1063            0,
1064            "Expected no hunks for mode-only change"
1065        );
1066    }
1067
1068    #[test]
1069    fn commit() {
1070        let input = "commit 9318f4040de9e6cf60033f21f6ae91a0f2239d38\n\
1071            Author: altsem <alltidsemester@pm.me>\n\
1072            Date:   Wed Feb 19 19:25:37 2025 +0100\n\
1073            \n\
1074                chore(release): prepare for v0.28.2\n\
1075            \n\
1076            diff --git a/.recent-changelog-entry b/.recent-changelog-entry\n\
1077            index 7c59f63..b3d843c 100644\n\
1078            --- a/.recent-changelog-entry\n\
1079            +++ b/.recent-changelog-entry\n\
1080            @@ -1,7 +1,6 @@\n\
1081            -## [0.28.1] - 2025-02-13\n\
1082            +## [0.28.2] - 2025-02-19\n ### 🐛 Bug Fixes\n \n\
1083            -- Change logging level to reduce inotify spam\n\
1084            -- Don't refresh on `gitu.log` writes (gitu --log)\n\
1085            +- Rebase menu opening after closing Neovim\n";
1086        let mut parser = Parser::new(input);
1087        let diffs = parser.parse_diff().unwrap();
1088        assert_eq!(
1089            &input[diffs[0].header.old_file.range.clone()],
1090            ".recent-changelog-entry"
1091        );
1092        assert_eq!(
1093            &input[diffs[0].header.new_file.range.clone()],
1094            ".recent-changelog-entry"
1095        );
1096        assert_eq!(diffs[0].header.status, Status::Modified);
1097        assert_eq!(
1098            &input[diffs[0].hunks[0].header.range.clone()],
1099            "@@ -1,7 +1,6 @@\n"
1100        );
1101    }
1102
1103    #[test]
1104    fn empty_commit() {
1105        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        ";
1106
1107        let mut parser = Parser::new(input);
1108        let diffs = parser.parse_diff().unwrap();
1109        assert_eq!(diffs.len(), 0);
1110    }
1111
1112    #[test]
1113    fn binary_file() {
1114        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";
1115
1116        let mut parser = Parser::new(input);
1117        let diffs = parser.parse_diff().unwrap();
1118        assert_eq!(diffs.len(), 3);
1119    }
1120
1121    #[test]
1122    fn conflicted_file() {
1123        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";
1124
1125        let mut parser = Parser::new(input);
1126        let diffs = parser.parse_diff().unwrap();
1127        assert_eq!(diffs.len(), 1);
1128        assert_eq!(diffs[0].header.status, Status::Unmerged);
1129        assert_eq!(&input[diffs[0].header.old_file.range.clone()], "new-file");
1130        assert_eq!(&input[diffs[0].header.new_file.range.clone()], "new-file");
1131    }
1132
1133    #[test]
1134    fn unmerged_path() {
1135        let input = "* Unmerged path new-file\n* Unmerged path new-file-2\n";
1136        let mut parser = Parser::new(input);
1137        let diff = parser.parse_diff().unwrap();
1138
1139        assert_eq!(diff.len(), 2);
1140        assert_eq!(diff[0].header.status, Status::Unmerged);
1141        assert_eq!(&input[diff[0].header.old_file.range.clone()], "new-file");
1142        assert_eq!(&input[diff[0].header.new_file.range.clone()], "new-file");
1143        assert!(diff[0].hunks.is_empty());
1144        assert_eq!(diff[1].header.status, Status::Unmerged);
1145        assert_eq!(&input[diff[1].header.old_file.range.clone()], "new-file-2");
1146        assert_eq!(&input[diff[1].header.new_file.range.clone()], "new-file-2");
1147        assert!(diff[1].hunks.is_empty());
1148    }
1149
1150    #[test]
1151    fn missing_newline_before_final() {
1152        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+});";
1153
1154        let mut parser = Parser::new(input);
1155        let diffs = parser.parse_diff().unwrap();
1156        assert_eq!(diffs.len(), 1);
1157        assert_eq!(diffs[0].header.status, Status::Modified);
1158        assert_eq!(diffs[0].hunks.len(), 1);
1159        let changes = &diffs[0].hunks[0].content.changes;
1160        assert_eq!(changes.len(), 1);
1161        assert_eq!(
1162            &input[changes[0].old.clone()],
1163            "-})\n\\ No newline at end of file\n"
1164        );
1165        assert_eq!(&input[changes[0].new.clone()], "+});");
1166    }
1167
1168    #[test]
1169    fn filenames_with_spaces() {
1170        let input = "\
1171            diff --git a/file one.txt b/file two.txt\n\
1172            index 5626abf..f719efd 100644\n\
1173            @@ -1 +1 @@\n\
1174            -one\n\
1175            +two\n\
1176            ";
1177        let mut parser = Parser::new(input);
1178        let diff = parser.parse_diff().unwrap();
1179        assert_eq!(diff[0].header.old_file.fmt(input), "file one.txt");
1180        assert_eq!(diff[0].header.new_file.fmt(input), "file two.txt");
1181    }
1182
1183    // #[test]
1184    // fn filenames_with_spaces_ambiguous() {
1185    //     let input = "\
1186    //         diff --git a/file one.txt b/file two.txt b/file three.txt\n\
1187    //         index 5626abf..f719efd 100644\n\
1188    //         @@ -1 +1 @@\n\
1189    //         -one\n\
1190    //         +two\n\
1191    //         ";
1192    //     let mut parser = Parser::new(input);
1193    //     let result = parser.parse_diff();
1194    //     std::assert_matches!(result, Err(_));
1195    // }
1196
1197    #[test]
1198    fn added_file_with_spaces_in_name() {
1199        let input = "diff --git a/something with space.md b/something with space.md\n\
1200            new file mode 100644\n\
1201            index 0000000..e69de29\n";
1202        let mut parser = Parser::new(input);
1203        let diff = parser.parse_diff().unwrap();
1204        assert_eq!(diff.len(), 1);
1205        assert_eq!(diff[0].header.status, Status::Added);
1206        assert_eq!(
1207            diff[0].header.old_file.fmt(input),
1208            "something with space.md"
1209        );
1210        assert_eq!(
1211            diff[0].header.new_file.fmt(input),
1212            "something with space.md"
1213        );
1214    }
1215
1216    #[test]
1217    fn utilizes_other_old_new_header_when_ambiguous() {
1218        let input = "\
1219            diff --git a/file one.txt b/file two.txt\n\
1220            index 5626abf..f719efd 100644\n\
1221            --- a/file one.txt\n\
1222            +++ b/file two.txt\n\
1223            @@ -1 +1 @@\n\
1224            -one\n\
1225            +two\n\
1226            ";
1227
1228        let mut parser = Parser::new(input);
1229        let diff = parser.parse_diff().unwrap();
1230        assert_eq!(diff[0].header.old_file.fmt(input), "file one.txt");
1231        assert_eq!(diff[0].header.new_file.fmt(input), "file two.txt");
1232    }
1233
1234    #[test]
1235    fn partially_unmerged() {
1236        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";
1237
1238        let mut parser = Parser::new(input);
1239        let diffs = parser.parse_diff().unwrap();
1240        assert_eq!(diffs.len(), 3);
1241        assert_eq!(diffs[2].header.status, Status::Unmerged);
1242        assert_eq!(
1243            &input[diffs[2].header.old_file.range.clone()],
1244            "src/ops/show.rs"
1245        );
1246        assert_eq!(
1247            &input[diffs[2].header.new_file.range.clone()],
1248            "src/ops/show.rs"
1249        );
1250    }
1251
1252    #[test]
1253    fn parse_custom_prefixes() {
1254        let input = "diff --git i/file1.txt w/file2.txt\n\
1255        index 0000000..1111111 100644\n\
1256        --- i/file1.txt\n\
1257        +++ w/file2.txt\n\
1258        @@ -1,2 +1,2 @@ fn main() {\n\
1259        -foo\n\
1260        +bar\n";
1261        let mut parser = Parser::new(input);
1262        let diffs = parser.parse_diff().unwrap();
1263        assert_eq!(diffs.len(), 1, "Expected one diff block");
1264
1265        let diff = &diffs[0];
1266        let old_file_str = diff.header.old_file.fmt(input);
1267        assert_eq!(old_file_str, "file1.txt", "Old file does not match");
1268
1269        let new_file_str = diff.header.new_file.fmt(input);
1270        assert_eq!(new_file_str, "file2.txt", "New file does not match");
1271    }
1272
1273    #[test]
1274    fn parse_quoted_filenames() {
1275        let input = "\
1276            diff --git \"a/\\303\\266\" \"b/\\303\\266\"\n\
1277            new file mode 100644\n\
1278            index 0000000..e69de29\n\
1279            diff --git \"a/\\\"\" \"b/\\\\\"\n\
1280            new file mode 100644\n\
1281            index 0000000..e69de29\n\
1282            diff --git \"a/\\'\" \"b/\\v\"\n\
1283            new file mode 100644\n\
1284            index 0000000..e69de29\n\
1285            diff --git \"a/\\t\" \"b/\\r\"\n\
1286            new file mode 100644\n\
1287            index 0000000..e69de29\n\
1288            diff --git \"a/\\n\" \"b/\\f\"\n\
1289            new file mode 100644\n\
1290            index 0000000..e69de29\n\
1291            diff --git \"a/\\E\" \"b/\\e\"\n\
1292            new file mode 100644\n\
1293            index 0000000..e69de29\n\
1294            diff --git \"a/\\e\" \"b/\\b\"\n\
1295            new file mode 100644\n\
1296            index 0000000..e69de29\n\
1297            diff --git \"a/\\a\" \"b/\\a\"\n\
1298            new file mode 100644\n\
1299            index 0000000..e69de29\n\
1300            diff --git \"a/l\\303\\266l space\" \"b/l\\303\\266l space\"\n\
1301            new file mode 100644\n\
1302            index 0000000..e69de29";
1303
1304        let mut parser = Parser::new(input);
1305        let diffs = parser.parse_diff().unwrap();
1306        assert_eq!(diffs.len(), 9);
1307        assert_eq!(diffs[0].header.old_file.fmt(input), "ö", "Old file");
1308        assert_eq!(diffs[0].header.new_file.fmt(input), "ö", "New file");
1309        assert_eq!(diffs[1].header.old_file.fmt(input), "\"", "Old file");
1310        assert_eq!(diffs[1].header.new_file.fmt(input), "\\", "New file");
1311        assert_eq!(diffs[2].header.old_file.fmt(input), "'", "Old file");
1312        assert_eq!(diffs[2].header.new_file.fmt(input), "\u{b}", "New file");
1313        assert_eq!(diffs[3].header.old_file.fmt(input), "\t", "Old file");
1314        assert_eq!(diffs[3].header.new_file.fmt(input), "\r", "New file");
1315        assert_eq!(diffs[4].header.old_file.fmt(input), "\n", "Old file");
1316        assert_eq!(diffs[4].header.new_file.fmt(input), "\u{c}", "New file");
1317        assert_eq!(diffs[8].header.old_file.fmt(input), "löl space", "Old file");
1318        assert_eq!(diffs[8].header.new_file.fmt(input), "löl space", "New file");
1319    }
1320
1321    #[test]
1322    fn errors_when_bad_escaped_char() {
1323        let input = "\
1324            diff --git \"a/\\y\" \"b/\\y\"\n\
1325            new file mode 100644\n\
1326        ";
1327
1328        let mut parser = Parser::new(input);
1329        assert!(parser.parse_diff().is_err());
1330    }
1331
1332    #[test]
1333    fn parse_header_noprefix() {
1334        let input = "\
1335            diff --git Cargo.lock Cargo.lock\n\
1336            index 1f88e5a..3b8ea64 100644\n\
1337            --- Cargo.lock\n\
1338            +++ Cargo.lock";
1339
1340        let mut parser = Parser::new(input);
1341        let diffs = parser.parse_diff().unwrap();
1342        assert_eq!(diffs.len(), 1, "Expected one diff block");
1343
1344        let diff = &diffs[0];
1345        let old_file_str = diff.header.old_file.fmt(input);
1346        assert_eq!(old_file_str, "Cargo.lock", "Old file does not match");
1347
1348        let new_file_str = diff.header.new_file.fmt(input);
1349        assert_eq!(new_file_str, "Cargo.lock", "New file does not match");
1350    }
1351}