1use 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
115type 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 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 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 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 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 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 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 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 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 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 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]
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}