| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| use crate::ApplyPatchArgs; |
| use crate::streaming_parser::StreamingPatchParser; |
| #[cfg(test)] |
| use codex_utils_absolute_path::test_support::PathBufExt; |
| use codex_utils_path_uri::PathUri; |
| use codex_utils_path_uri::PathUriParseError; |
| use std::path::Path; |
| use std::path::PathBuf; |
|
|
| use thiserror::Error; |
|
|
| pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; |
| pub(crate) const END_PATCH_MARKER: &str = "*** End Patch"; |
| pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: "; |
| pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: "; |
| pub(crate) const UPDATE_FILE_MARKER: &str = "*** Update File: "; |
| pub(crate) const MOVE_TO_MARKER: &str = "*** Move to: "; |
| pub(crate) const EOF_MARKER: &str = "*** End of File"; |
| pub(crate) const CHANGE_CONTEXT_MARKER: &str = "@@ "; |
| pub(crate) const EMPTY_CHANGE_CONTEXT_MARKER: &str = "@@"; |
|
|
| |
| |
| |
| |
| |
| |
| const PARSE_IN_STRICT_MODE: bool = false; |
|
|
| #[derive(Debug, PartialEq, Error, Clone)] |
| pub enum ParseError { |
| #[error("invalid patch: {0}")] |
| InvalidPatchError(String), |
| #[error("invalid hunk at line {line_number}, {message}")] |
| InvalidHunkError { message: String, line_number: usize }, |
| } |
| use ParseError::*; |
|
|
| #[derive(Debug, PartialEq, Clone)] |
| #[allow(clippy::enum_variant_names)] |
| pub enum Hunk { |
| AddFile { |
| path: PathBuf, |
| contents: String, |
| }, |
| DeleteFile { |
| path: PathBuf, |
| }, |
| UpdateFile { |
| path: PathBuf, |
| move_path: Option<PathBuf>, |
|
|
| |
| |
| chunks: Vec<UpdateFileChunk>, |
| }, |
| } |
|
|
| impl Hunk { |
| pub fn resolve_path(&self, cwd: &PathUri) -> Result<PathUri, PathUriParseError> { |
| let path = match self { |
| Hunk::UpdateFile { path, .. } => path, |
| Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(), |
| }; |
| cwd.join(&path.to_string_lossy()) |
| } |
|
|
| |
| pub fn path(&self) -> &Path { |
| match self { |
| Hunk::AddFile { path, .. } => path, |
| Hunk::DeleteFile { path } => path, |
| Hunk::UpdateFile { |
| move_path: Some(path), |
| .. |
| } => path, |
| Hunk::UpdateFile { |
| path, |
| move_path: None, |
| .. |
| } => path, |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| use Hunk::*; |
|
|
| #[derive(Debug, Default, PartialEq, Clone)] |
| pub struct UpdateFileChunk { |
| |
| |
| pub change_context: Option<String>, |
|
|
| |
| |
| pub old_lines: Vec<String>, |
| pub new_lines: Vec<String>, |
|
|
| |
| |
| pub context_line_indices: Vec<(usize, usize)>, |
|
|
| |
| |
| pub is_end_of_file: bool, |
| } |
|
|
| impl UpdateFileChunk { |
| |
| |
| pub(crate) fn push_context_line(&mut self, line: String) { |
| self.context_line_indices |
| .push((self.old_lines.len(), self.new_lines.len())); |
| self.old_lines.push(line.clone()); |
| self.new_lines.push(line); |
| } |
| } |
|
|
| pub fn parse_patch(patch: &str) -> Result<ApplyPatchArgs, ParseError> { |
| let mode = if PARSE_IN_STRICT_MODE { |
| ParseMode::Strict |
| } else { |
| ParseMode::Lenient |
| }; |
| parse_patch_text(patch, mode) |
| } |
|
|
| enum ParseMode { |
| |
| Strict, |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| Lenient, |
| } |
|
|
| fn parse_patch_text(patch: &str, mode: ParseMode) -> Result<ApplyPatchArgs, ParseError> { |
| let lines: Vec<&str> = patch.trim().lines().collect(); |
| let patch_lines = match mode { |
| ParseMode::Strict => check_patch_boundaries_strict(&lines)?, |
| ParseMode::Lenient => check_patch_boundaries_lenient(&lines)?, |
| }; |
|
|
| let patch = patch_lines.join("\n"); |
| let mut parser = StreamingPatchParser::default(); |
| parser.push_delta(&patch)?; |
| let hunks = parser.finish()?; |
| let environment_id = parser.environment_id().map(str::to_owned); |
| Ok(ApplyPatchArgs { |
| hunks, |
| patch, |
| workdir: None, |
| environment_id, |
| }) |
| } |
|
|
| |
| |
| fn check_patch_boundaries_strict<'a>(lines: &'a [&'a str]) -> Result<&'a [&'a str], ParseError> { |
| let (first_line, last_line) = match lines { |
| [] => (None, None), |
| [first] => (Some(first), Some(first)), |
| [first, .., last] => (Some(first), Some(last)), |
| }; |
| check_start_and_end_lines_strict(first_line, last_line)?; |
| Ok(lines) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| fn check_patch_boundaries_lenient<'a>( |
| original_lines: &'a [&'a str], |
| ) -> Result<&'a [&'a str], ParseError> { |
| let original_parse_error = match check_patch_boundaries_strict(original_lines) { |
| Ok(lines) => return Ok(lines), |
| Err(e) => e, |
| }; |
|
|
| match original_lines { |
| [first, .., last] => { |
| if (first == &"<<EOF" || first == &"<<'EOF'" || first == &"<<\"EOF\"") |
| && last.ends_with("EOF") |
| && original_lines.len() >= 4 |
| { |
| let inner_lines = &original_lines[1..original_lines.len() - 1]; |
| check_patch_boundaries_strict(inner_lines) |
| } else { |
| Err(original_parse_error) |
| } |
| } |
| _ => Err(original_parse_error), |
| } |
| } |
|
|
| fn check_start_and_end_lines_strict( |
| first_line: Option<&&str>, |
| last_line: Option<&&str>, |
| ) -> Result<(), ParseError> { |
| let first_line = first_line.map(|line| line.trim()); |
| let last_line = last_line.map(|line| line.trim()); |
|
|
| match (first_line, last_line) { |
| (Some(first), Some(last)) if first == BEGIN_PATCH_MARKER && last == END_PATCH_MARKER => { |
| Ok(()) |
| } |
| (Some(first), _) if first != BEGIN_PATCH_MARKER => Err(InvalidPatchError(String::from( |
| "The first line of the patch must be '*** Begin Patch'", |
| ))), |
| _ => Err(InvalidPatchError(String::from( |
| "The last line of the patch must be '*** End Patch'", |
| ))), |
| } |
| } |
|
|
| #[test] |
| fn test_parse_patch() { |
| assert_eq!( |
| parse_patch_text("bad", ParseMode::Strict), |
| Err(InvalidPatchError( |
| "The first line of the patch must be '*** Begin Patch'".to_string() |
| )) |
| ); |
| assert_eq!( |
| parse_patch_text("*** Begin Patch\nbad", ParseMode::Strict), |
| Err(InvalidPatchError( |
| "The last line of the patch must be '*** End Patch'".to_string() |
| )) |
| ); |
|
|
| assert_eq!( |
| parse_patch_text( |
| concat!( |
| "*** Begin Patch", |
| " ", |
| "\n*** Add File: foo\n+hi\n", |
| " ", |
| "*** End Patch" |
| ), |
| ParseMode::Strict |
| ) |
| .unwrap() |
| .hunks, |
| vec![AddFile { |
| path: PathBuf::from("foo"), |
| contents: "hi\n".to_string() |
| }] |
| ); |
| assert_eq!( |
| parse_patch_text( |
| "*** Begin Patch\n\ |
| *** Update File: test.py\n\ |
| *** End Patch", |
| ParseMode::Strict |
| ), |
| Err(InvalidHunkError { |
| message: "Update file hunk for path 'test.py' is empty".to_string(), |
| line_number: 2, |
| }) |
| ); |
| assert_eq!( |
| parse_patch_text( |
| "*** Begin Patch\n\ |
| *** End Patch", |
| ParseMode::Strict |
| ) |
| .unwrap() |
| .hunks, |
| Vec::new() |
| ); |
| assert_eq!( |
| parse_patch_text( |
| "*** Begin Patch\n\ |
| *** Add File: path/add.py\n\ |
| +abc\n\ |
| +def\n\ |
| *** Delete File: path/delete.py\n\ |
| *** Update File: path/update.py\n\ |
| *** Move to: path/update2.py\n\ |
| @@ def f():\n\ |
| - pass\n\ |
| + return 123\n\ |
| *** End Patch", |
| ParseMode::Strict |
| ) |
| .unwrap() |
| .hunks, |
| vec![ |
| AddFile { |
| path: PathBuf::from("path/add.py"), |
| contents: "abc\ndef\n".to_string() |
| }, |
| DeleteFile { |
| path: PathBuf::from("path/delete.py") |
| }, |
| UpdateFile { |
| path: PathBuf::from("path/update.py"), |
| move_path: Some(PathBuf::from("path/update2.py")), |
| chunks: vec![UpdateFileChunk { |
| change_context: Some("def f():".to_string()), |
| old_lines: vec![" pass".to_string()], |
| new_lines: vec![" return 123".to_string()], |
| context_line_indices: vec![], |
| is_end_of_file: false |
| }] |
| } |
| ] |
| ); |
| |
| assert_eq!( |
| parse_patch_text( |
| "*** Begin Patch\n\ |
| *** Update File: file.py\n\ |
| @@\n\ |
| +line\n\ |
| *** Add File: other.py\n\ |
| +content\n\ |
| *** End Patch", |
| ParseMode::Strict |
| ) |
| .unwrap() |
| .hunks, |
| vec![ |
| UpdateFile { |
| path: PathBuf::from("file.py"), |
| move_path: None, |
| chunks: vec![UpdateFileChunk { |
| change_context: None, |
| old_lines: vec![], |
| new_lines: vec!["line".to_string()], |
| context_line_indices: vec![], |
| is_end_of_file: false |
| }], |
| }, |
| AddFile { |
| path: PathBuf::from("other.py"), |
| contents: "content\n".to_string() |
| } |
| ] |
| ); |
|
|
| |
| |
| assert_eq!( |
| parse_patch_text( |
| r#"*** Begin Patch |
| *** Update File: file2.py |
| import foo |
| +bar |
| *** End Patch"#, |
| ParseMode::Strict |
| ) |
| .unwrap() |
| .hunks, |
| vec![UpdateFile { |
| path: PathBuf::from("file2.py"), |
| move_path: None, |
| chunks: vec![UpdateFileChunk { |
| change_context: None, |
| old_lines: vec!["import foo".to_string()], |
| new_lines: vec!["import foo".to_string(), "bar".to_string()], |
| context_line_indices: vec![(0, 0)], |
| is_end_of_file: false, |
| }], |
| }] |
| ); |
| } |
|
|
| #[test] |
| fn test_parse_patch_preserves_end_of_file_marker() { |
| let patch = |
| "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch"; |
| assert_eq!( |
| parse_patch(patch), |
| Ok(ApplyPatchArgs { |
| hunks: vec![UpdateFile { |
| path: PathBuf::from("file.txt"), |
| move_path: None, |
| chunks: vec![UpdateFileChunk { |
| change_context: None, |
| old_lines: Vec::new(), |
| new_lines: vec!["quux".to_string()], |
| context_line_indices: vec![], |
| is_end_of_file: true, |
| }], |
| }], |
| patch: patch.to_string(), |
| workdir: None, |
| environment_id: None, |
| }) |
| ); |
| } |
|
|
| #[test] |
| fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() { |
| let dir = tempfile::tempdir().unwrap(); |
| let absolute_delete = dir.path().join("absolute-delete.py").abs(); |
| let absolute_update = dir.path().join("absolute-update.py").abs(); |
| let patch_text = format!( |
| r#"*** Begin Patch |
| *** Add File: relative-add.py |
| +content |
| *** Delete File: {} |
| *** Update File: {} |
| @@ |
| -old |
| +new |
| *** End Patch"#, |
| absolute_delete.display(), |
| absolute_update.display() |
| ); |
|
|
| assert_eq!( |
| parse_patch_text(&patch_text, ParseMode::Strict) |
| .unwrap() |
| .hunks, |
| vec![ |
| AddFile { |
| path: PathBuf::from("relative-add.py"), |
| contents: "content\n".to_string() |
| }, |
| DeleteFile { |
| path: absolute_delete.to_path_buf() |
| }, |
| UpdateFile { |
| path: absolute_update.to_path_buf(), |
| move_path: None, |
| chunks: vec![UpdateFileChunk { |
| change_context: None, |
| old_lines: vec!["old".to_string()], |
| new_lines: vec!["new".to_string()], |
| context_line_indices: vec![], |
| is_end_of_file: false |
| }] |
| }, |
| ] |
| ); |
| } |
|
|
| #[test] |
| fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { |
| let cwd_dir = tempfile::tempdir().unwrap(); |
| let cwd = PathUri::from_host_native_path(cwd_dir.path()).unwrap(); |
| let absolute_dir = tempfile::tempdir().unwrap(); |
| let absolute_add = absolute_dir.path().join("absolute-add.py").abs(); |
| let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs(); |
| let absolute_update = absolute_dir.path().join("absolute-update.py").abs(); |
|
|
| for (hunk, expected_path) in [ |
| ( |
| AddFile { |
| path: PathBuf::from("relative-add.py"), |
| contents: String::new(), |
| }, |
| cwd.join("relative-add.py").unwrap(), |
| ), |
| ( |
| DeleteFile { |
| path: PathBuf::from("relative-delete.py"), |
| }, |
| cwd.join("relative-delete.py").unwrap(), |
| ), |
| ( |
| UpdateFile { |
| path: PathBuf::from("relative-update.py"), |
| move_path: None, |
| chunks: Vec::new(), |
| }, |
| cwd.join("relative-update.py").unwrap(), |
| ), |
| ( |
| AddFile { |
| path: absolute_add.to_path_buf(), |
| contents: String::new(), |
| }, |
| PathUri::from_abs_path(&absolute_add), |
| ), |
| ( |
| DeleteFile { |
| path: absolute_delete.to_path_buf(), |
| }, |
| PathUri::from_abs_path(&absolute_delete), |
| ), |
| ( |
| UpdateFile { |
| path: absolute_update.to_path_buf(), |
| move_path: None, |
| chunks: Vec::new(), |
| }, |
| PathUri::from_abs_path(&absolute_update), |
| ), |
| ] { |
| assert_eq!(hunk.resolve_path(&cwd), Ok(expected_path)); |
| } |
| } |
|
|
| #[test] |
| fn test_parse_patch_lenient() { |
| let patch_text = r#"*** Begin Patch |
| *** Update File: file2.py |
| import foo |
| +bar |
| *** End Patch"#; |
| let expected_patch = vec![UpdateFile { |
| path: PathBuf::from("file2.py"), |
| move_path: None, |
| chunks: vec![UpdateFileChunk { |
| change_context: None, |
| old_lines: vec!["import foo".to_string()], |
| new_lines: vec!["import foo".to_string(), "bar".to_string()], |
| context_line_indices: vec![(0, 0)], |
| is_end_of_file: false, |
| }], |
| }]; |
| let expected_error = |
| InvalidPatchError("The first line of the patch must be '*** Begin Patch'".to_string()); |
|
|
| let patch_text_in_heredoc = format!("<<EOF\n{patch_text}\nEOF\n"); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_heredoc, ParseMode::Strict), |
| Err(expected_error.clone()) |
| ); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_heredoc, ParseMode::Lenient), |
| Ok(ApplyPatchArgs { |
| hunks: expected_patch.clone(), |
| patch: patch_text.to_string(), |
| workdir: None, |
| environment_id: None, |
| }) |
| ); |
|
|
| let patch_text_in_single_quoted_heredoc = format!("<<'EOF'\n{patch_text}\nEOF\n"); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Strict), |
| Err(expected_error.clone()) |
| ); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_single_quoted_heredoc, ParseMode::Lenient), |
| Ok(ApplyPatchArgs { |
| hunks: expected_patch.clone(), |
| patch: patch_text.to_string(), |
| workdir: None, |
| environment_id: None, |
| }) |
| ); |
|
|
| let patch_text_in_double_quoted_heredoc = format!("<<\"EOF\"\n{patch_text}\nEOF\n"); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Strict), |
| Err(expected_error.clone()) |
| ); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_double_quoted_heredoc, ParseMode::Lenient), |
| Ok(ApplyPatchArgs { |
| hunks: expected_patch, |
| patch: patch_text.to_string(), |
| workdir: None, |
| environment_id: None, |
| }) |
| ); |
|
|
| let patch_text_in_mismatched_quotes_heredoc = format!("<<\"EOF'\n{patch_text}\nEOF\n"); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Strict), |
| Err(expected_error.clone()) |
| ); |
| assert_eq!( |
| parse_patch_text(&patch_text_in_mismatched_quotes_heredoc, ParseMode::Lenient), |
| Err(expected_error.clone()) |
| ); |
|
|
| let patch_text_with_missing_closing_heredoc = |
| "<<EOF\n*** Begin Patch\n*** Update File: file2.py\nEOF\n".to_string(); |
| assert_eq!( |
| parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Strict), |
| Err(expected_error) |
| ); |
| assert_eq!( |
| parse_patch_text(&patch_text_with_missing_closing_heredoc, ParseMode::Lenient), |
| Err(InvalidPatchError( |
| "The last line of the patch must be '*** End Patch'".to_string() |
| )) |
| ); |
| } |
|
|
| #[test] |
| fn test_parse_patch_environment_id_preamble() { |
| assert_eq!( |
| parse_patch_text( |
| "*** Begin Patch\n\ |
| *** Environment ID: remote\n\ |
| *** Add File: hello.txt\n\ |
| +hello\n\ |
| *** End Patch", |
| ParseMode::Strict |
| ), |
| Ok(ApplyPatchArgs { |
| hunks: vec![AddFile { |
| path: PathBuf::from("hello.txt"), |
| contents: "hello\n".to_string(), |
| }], |
| patch: "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch".to_string(), |
| workdir: None, |
| environment_id: Some("remote".to_string()), |
| }) |
| ); |
|
|
| assert_eq!( |
| parse_patch_text( |
| "*** Begin Patch\n\ |
| *** Environment ID: \n\ |
| *** Add File: hello.txt\n\ |
| +hello\n\ |
| *** End Patch", |
| ParseMode::Strict |
| ), |
| Err(InvalidPatchError( |
| "apply_patch environment_id cannot be empty".to_string() |
| )) |
| ); |
| } |
|
|