diff --git a/src/gitu_diff.rs b/src/gitu_diff.rs index 5e72c7b36d..1f1d41eea7 100644 --- a/src/gitu_diff.rs +++ b/src/gitu_diff.rs @@ -245,7 +245,7 @@ impl<'a> Parser<'a> { fn skip_until_diff_header(&mut self) -> ThinResult<()> { log::trace!("Parser::skip_until_diff_header\n{:?}", self); while self.cursor < self.input.len() && !self.is_at_diff_header() { - self.consume_until(Self::newline_or_eof)?; + self.consume_until_after(Self::newline_or_eof)?; } Ok(()) @@ -304,59 +304,61 @@ impl<'a> Parser<'a> { if self.peek("new file") { diff_type = Status::Added; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } else if self.peek("deleted file") { diff_type = Status::Deleted; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } if self.consume("similarity index").is_ok() { - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } if self.consume("dissimilarity index").is_ok() { - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } if self.peek("index") { } else if self.peek("old mode") { - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; if self.peek("new mode") { - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } } else if self.peek("new mode") { - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } else if self.peek("deleted file mode") { diff_type = Status::Deleted; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } else if self.peek("new file mode") { diff_type = Status::Added; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } else if self.peek("copy from") { diff_type = Status::Copied; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; self.consume("copy to")?; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } else if self.peek("rename from") { diff_type = Status::Renamed; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; self.consume("rename to")?; - self.consume_until(Self::newline)?; + self.consume_until_after(Self::newline)?; } if self.peek("index") { self.consume("index ")?; - self.consume_until(Self::newline_or_eof)?; + self.consume_until_after(Self::newline_or_eof)?; } if self.peek("Binary files ") { - self.consume_until(Self::newline_or_eof)?; + self.consume_until_after(Self::newline_or_eof)?; } if self.consume("--- ").is_ok() { old_file = self.diff_header_path(Self::newline_or_eof)?; + self.newline_or_eof()?; self.consume("+++ ")?; new_file = self.diff_header_path(Self::newline_or_eof)?; + self.newline_or_eof()?; } Ok(DiffHeader { @@ -371,6 +373,7 @@ impl<'a> Parser<'a> { log::trace!("Parser::unmerged_file\n{:?}", self); let unmerged_path_prefix = self.consume("* Unmerged path ")?; let file = self.diff_header_path(Self::newline_or_eof)?; + self.newline_or_eof()?; Ok(DiffHeader { range: unmerged_path_prefix.start..self.cursor, @@ -383,25 +386,49 @@ impl<'a> Parser<'a> { fn old_new_file_header(&mut self) -> ThinResult<(FilePath, FilePath, bool)> { log::trace!("Parser::old_new_file_header\n{:?}", self); self.consume("diff --git ")?; - let old_path = self.diff_header_path(Self::ascii_whitespace)?; + + // Vary the delimiter of file-paths depending on whether it is quoted, prefixed, or lacks prefix. + // e.g. + // "a/file-1.txt" "b/file-1.txt" -> whitespace delimiter + // a/file-1.txt b/file-1.txt -> whitespace delimiter, followed by a prefix ("b/") + // file-1.txt file-1.txt -> whitespace delimiter + let delim: ParseFn> = if self.peek("\"") { + Self::ascii_whitespace + } else if self.peek_fn(Self::diff_header_path_prefix) { + |p: &mut Parser<'_>| { + let start = p.cursor; + p.ascii_whitespace() + .and_then(|_| p.diff_header_path_prefix())?; + Ok(start..p.cursor) + } + } else { + Self::ascii_whitespace + }; + + let old_path = self.diff_header_path(delim)?; + self.ascii_whitespace()?; let new_path = self.diff_header_path(Self::newline_or_eof)?; + self.newline_or_eof()?; Ok((old_path, new_path, false)) } - fn diff_header_path(&mut self, end: ParseFn<'a, Range>) -> ThinResult { + /// When the header path is not quoted ("..."), then `stop_lookahead` is used. + fn diff_header_path( + &mut self, + stop_lookahead: ParseFn<'a, Range>, + ) -> ThinResult { log::trace!("Parser::diff_header_path\n{:?}", self); if self.consume("\"").ok().is_some() { self.diff_header_path_prefix().ok(); let quoted = self.quoted()?; - self.ascii_whitespace().ok(); Ok(FilePath { range: quoted, is_quoted: true, }) } else { self.diff_header_path_prefix().ok(); - let (consumed, _) = self.consume_until(end)?; + let consumed = self.consume_until_before(stop_lookahead)?; Ok(FilePath { range: consumed, is_quoted: false, @@ -467,7 +494,7 @@ impl<'a> Parser<'a> { .map_err(|_| { self.cursor = start; array_vec![ThinParseError { - expected: "", + expected: "", }] })?; @@ -497,6 +524,7 @@ impl<'a> Parser<'a> { log::trace!("Parser::conflicted_file\n{:?}", self); self.consume("diff --cc ")?; let file = self.diff_header_path(Self::newline_or_eof)?; + self.newline_or_eof()?; Ok((file.clone(), file, true)) } @@ -565,7 +593,7 @@ impl<'a> Parser<'a> { self.consume(" @@")?; self.consume(" ").ok(); - let (fn_ctx, newline) = self.consume_until(Self::newline_or_eof)?; + let (fn_ctx, newline) = self.consume_until_after(Self::newline_or_eof)?; Ok(HunkHeader { range: hunk_header_start..self.cursor, @@ -597,7 +625,7 @@ impl<'a> Parser<'a> { log::trace!("Parser::consume_lines_while_prefixed\n{:?}", self); let start = self.cursor; while self.cursor < self.input.len() && pred(self) { - self.consume_until(Self::newline_or_eof)?; + self.consume_until_after(Self::newline_or_eof)?; } Ok(start..self.cursor) @@ -672,14 +700,35 @@ impl<'a> Parser<'a> { } } + /// Scans through the input, moving the cursor byte-by-byte + /// until the provided parse_fn will succeed, or the input has been exhausted. + /// Returns a tuple of the bytes scanned up until the match, excluding the match itself. + /// The resulting cursor will end up before the match. + fn consume_until_before( + &mut self, + parse_fn: fn(&mut Parser<'a>) -> ThinResult, + ) -> ThinResult> { + log::trace!("Parser::consume_until_before\n{:?}", self); + let start = self.cursor; + let found = self.find(parse_fn).map_err(|mut err| { + err.try_push(ThinParseError { + expected: "to consume the match", + }); + err + })?; + self.cursor = found.range().start; + Ok(start..found.range().start) + } + /// Scans through the input, moving the cursor byte-by-byte /// until the provided parse_fn will succeed, or the input has been exhausted. /// Returns a tuple of the bytes scanned up until the match, and the match itself. - fn consume_until( + /// The resulting cursor will end up after the match. + fn consume_until_after( &mut self, parse_fn: fn(&mut Parser<'a>) -> ThinResult, ) -> ThinResult<(Range, T)> { - log::trace!("Parser::consume_until\n{:?}", self); + log::trace!("Parser::consume_until_after\n{:?}", self); let start = self.cursor; let found = self.find(parse_fn).map_err(|mut err| { err.try_push(ThinParseError { @@ -720,6 +769,14 @@ impl<'a> Parser<'a> { Err(errors) } + fn peek_fn(&self, parse_fn: ParseFn<'a, T>) -> bool { + let mut p = Parser { + input: self.input, + cursor: self.cursor, + }; + parse_fn(&mut p).is_ok() + } + /// Consumes `expected` from the input and moves the cursor past it. /// Returns an error if `expected` was not found at the cursor. fn consume(&mut self, expected: &'static str) -> ThinResult> { @@ -1110,7 +1167,6 @@ mod tests { #[test] fn filenames_with_spaces() { - // This case is ambiguous, normally if there's ---/+++ headers, we can use that. let input = "\ diff --git a/file one.txt b/file two.txt\n\ index 5626abf..f719efd 100644\n\ @@ -1120,8 +1176,41 @@ mod tests { "; let mut parser = Parser::new(input); let diff = parser.parse_diff().unwrap(); - assert_eq!(diff[0].header.old_file.fmt(input), "file"); - assert_eq!(diff[0].header.new_file.fmt(input), "one.txt b/file two.txt"); + assert_eq!(diff[0].header.old_file.fmt(input), "file one.txt"); + assert_eq!(diff[0].header.new_file.fmt(input), "file two.txt"); + } + + // #[test] + // fn filenames_with_spaces_ambiguous() { + // let input = "\ + // diff --git a/file one.txt b/file two.txt b/file three.txt\n\ + // index 5626abf..f719efd 100644\n\ + // @@ -1 +1 @@\n\ + // -one\n\ + // +two\n\ + // "; + // let mut parser = Parser::new(input); + // let result = parser.parse_diff(); + // std::assert_matches!(result, Err(_)); + // } + + #[test] + fn added_file_with_spaces_in_name() { + let input = "diff --git a/something with space.md b/something with space.md\n\ + new file mode 100644\n\ + index 0000000..e69de29\n"; + let mut parser = Parser::new(input); + let diff = parser.parse_diff().unwrap(); + assert_eq!(diff.len(), 1); + assert_eq!(diff[0].header.status, Status::Added); + assert_eq!( + diff[0].header.old_file.fmt(input), + "something with space.md" + ); + assert_eq!( + diff[0].header.new_file.fmt(input), + "something with space.md" + ); } #[test] @@ -1207,11 +1296,14 @@ mod tests { index 0000000..e69de29\n\ diff --git \"a/\\a\" \"b/\\a\"\n\ new file mode 100644\n\ + index 0000000..e69de29\n\ + diff --git \"a/l\\303\\266l space\" \"b/l\\303\\266l space\"\n\ + new file mode 100644\n\ index 0000000..e69de29"; let mut parser = Parser::new(input); let diffs = parser.parse_diff().unwrap(); - assert_eq!(diffs.len(), 8); + assert_eq!(diffs.len(), 9); assert_eq!(diffs[0].header.old_file.fmt(input), "ö", "Old file"); assert_eq!(diffs[0].header.new_file.fmt(input), "ö", "New file"); assert_eq!(diffs[1].header.old_file.fmt(input), "\"", "Old file"); @@ -1222,6 +1314,8 @@ mod tests { assert_eq!(diffs[3].header.new_file.fmt(input), "\r", "New file"); assert_eq!(diffs[4].header.old_file.fmt(input), "\n", "Old file"); assert_eq!(diffs[4].header.new_file.fmt(input), "\u{c}", "New file"); + assert_eq!(diffs[8].header.old_file.fmt(input), "löl space", "Old file"); + assert_eq!(diffs[8].header.new_file.fmt(input), "löl space", "New file"); } #[test] diff --git a/src/tests/snapshots/gitu__tests__stage__stage_file_with_spaces_in_name.snap b/src/tests/snapshots/gitu__tests__stage__stage_file_with_spaces_in_name.snap new file mode 100644 index 0000000000..8670094c03 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__stage__stage_file_with_spaces_in_name.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/stage.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is up to date with 'origin/main'. | + | +▌Staged changes (1) | +▌added file with space.txt | + | + Recent commits | + b66a0bf main origin/main add initial-file | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +$ git add file with space.txt | +styles_hash: 5372e7d632c4cf90 diff --git a/src/tests/snapshots/gitu__tests__unstage__unstage_added_file_with_spaces_in_name.snap b/src/tests/snapshots/gitu__tests__unstage__unstage_added_file_with_spaces_in_name.snap new file mode 100644 index 0000000000..1d71d41f51 --- /dev/null +++ b/src/tests/snapshots/gitu__tests__unstage__unstage_added_file_with_spaces_in_name.snap @@ -0,0 +1,25 @@ +--- +source: src/tests/unstage.rs +expression: ctx.redact_buffer() +--- + On branch main | + Your branch is up to date with 'origin/main'. | + | + Untracked files | +▌file with space.txt | + | + Recent commits | + b66a0bf main origin/main add initial-file | + | + | + | + | + | + | + | + | + | + | +────────────────────────────────────────────────────────────────────────────────| +$ git restore --staged file with space.txt | +styles_hash: 4d31ee8109cfa98e diff --git a/src/tests/stage.rs b/src/tests/stage.rs index aad8dbf2f0..c84c5b7585 100644 --- a/src/tests/stage.rs +++ b/src/tests/stage.rs @@ -74,3 +74,10 @@ fn stage_deleted_executable_file() { run(&ctx.dir, &["rm", "script.sh"]); snapshot!(ctx, "jjs"); } + +#[test] +fn stage_file_with_spaces_in_name() { + let ctx = setup_clone!(); + run(&ctx.dir, &["touch", "file with space.txt"]); + snapshot!(ctx, "js"); +} diff --git a/src/tests/unstage.rs b/src/tests/unstage.rs index 794200ba3e..8655330f58 100644 --- a/src/tests/unstage.rs +++ b/src/tests/unstage.rs @@ -45,3 +45,11 @@ fn unstage_deleted_executable_file() { run(&ctx.dir, &["git", "rm", "script.sh"]); snapshot!(ctx, "jju"); } + +#[test] +fn unstage_added_file_with_spaces_in_name() { + let ctx = setup_clone!(); + run(&ctx.dir, &["touch", "file with space.txt"]); + run(&ctx.dir, &["git", "add", "file with space.txt"]); + snapshot!(ctx, "jju"); +}