Codex Handbook
apply-patch/src/parser.rs 660 lines
//! This module is responsible for parsing & validating a patch into a list of "hunks".//! (It does not attempt to actually check that the patch can be applied to the filesystem.)//!//! The official Lark grammar for the apply-patch format is://!//! start: begin_patch environment_id? hunk+ end_patch//! begin_patch: "*** Begin Patch" LF//! environment_id: "*** Environment ID: " filename LF//! end_patch: "*** End Patch" LF?//!//! hunk: add_hunk | delete_hunk | update_hunk//! add_hunk: "*** Add File: " filename LF add_line+//! delete_hunk: "*** Delete File: " filename LF//! update_hunk: "*** Update File: " filename LF change_move? change?//! filename: /(.+)///! add_line: "+" /(.+)/ LF -> line//!//! change_move: "*** Move to: " filename LF//! change: (change_context | change_line)+ eof_line?//! change_context: ("@@" | "@@ " /(.+)/) LF//! change_line: ("+" | "-" | " ") /(.+)/ LF//! eof_line: "*** End of File" LF//!//! The parser below is a little more lenient than the explicit spec and allows for//! leading/trailing whitespace around patch markers.use crate::ApplyPatchArgs;use crate::streaming_parser::StreamingPatchParser;use codex_utils_absolute_path::AbsolutePathBuf;#[cfg(test)]use codex_utils_absolute_path::test_support::PathBufExt;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 = "@@";/// Currently, the only OpenAI model that knowingly requires lenient parsing is/// gpt-4.1. While we could try to require everyone to pass in a strictness/// param when invoking apply_patch, it is a pain to thread it through all of/// the call sites, so we resign ourselves allowing lenient parsing for all/// models. See [`ParseMode::Lenient`] for details on the exceptions we make for/// gpt-4.1.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 should be in order, i.e. the `change_context` of one chunk        /// should occur later in the file than the previous chunk.        chunks: Vec<UpdateFileChunk>,    },}impl Hunk {    pub fn resolve_path(&self, cwd: &AbsolutePathBuf) -> AbsolutePathBuf {        let path = match self {            Hunk::UpdateFile { path, .. } => path,            Hunk::AddFile { .. } | Hunk::DeleteFile { .. } => self.path(),        };        AbsolutePathBuf::resolve_path_against_base(path, cwd)    }    /// Returns the path affected by this hunk, using the move destination for rename hunks.    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, PartialEq, Clone)]pub struct UpdateFileChunk {    /// A single line of context used to narrow down the position of the chunk    /// (this is usually a class, method, or function definition.)    pub change_context: Option<String>,    /// A contiguous block of lines that should be replaced with `new_lines`.    /// `old_lines` must occur strictly after `change_context`.    pub old_lines: Vec<String>,    pub new_lines: Vec<String>,    /// If set to true, `old_lines` must occur at the end of the source file.    /// (Tolerance around trailing newlines should be encouraged.)    pub is_end_of_file: bool,}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 {    /// Parse the patch text argument as is.    Strict,    /// GPT-4.1 is known to formulate the `command` array for the `local_shell`    /// tool call for `apply_patch` call using something like the following:    ///    /// ```json    /// [    ///   "apply_patch",    ///   "<<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",    /// ]    /// ```    ///    /// This is a problem because `local_shell` is a bit of a misnomer: the    /// `command` is not invoked by passing the arguments to a shell like Bash,    /// but are invoked using something akin to `execvpe(3)`.    ///    /// This is significant in this case because where a shell would interpret    /// `<<'EOF'...` as a heredoc and pass the contents via stdin (which is    /// fine, as `apply_patch` is specified to read from stdin if no argument is    /// passed), `execvpe(3)` interprets the heredoc as a literal string. To get    /// the `local_shell` tool to run a command the way shell would, the    /// `command` array must be something like:    ///    /// ```json    /// [    ///   "bash",    ///   "-lc",    ///   "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: README.md\n@@...\n*** End Patch\nEOF\n",    /// ]    /// ```    ///    /// In lenient mode, we check if the argument to `apply_patch` starts with    /// `<<'EOF'` and ends with `EOF\n`. If so, we strip off these markers,    /// trim() the result, and treat what is left as the patch text.    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,    })}/// Checks the start and end lines of the patch text for `apply_patch`,/// returning an error if they do not match the expected markers.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)}/// If we are in lenient mode, we check if the first line starts with `<<EOF`/// (possibly quoted) and the last line ends with `EOF`. There must be at least/// 4 lines total because the heredoc markers take up 2 lines and the patch text/// must have at least 2 lines.////// If successful, returns the lines of the patch text that contain the patch/// contents, excluding the heredoc markers.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()],                    is_end_of_file: false                }]            }        ]    );    // Update hunk followed by another hunk (Add File).    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()],                    is_end_of_file: false                }],            },            AddFile {                path: PathBuf::from("other.py"),                contents: "content\n".to_string()            }        ]    );    // Update hunk without an explicit @@ header for the first chunk should parse.    // Use a raw string to preserve the leading space diff marker on the context line.    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()],                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()],                    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()],                    is_end_of_file: false                }]            },        ]    );}#[test]fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() {    let cwd_dir = tempfile::tempdir().unwrap();    let cwd = cwd_dir.path().to_path_buf().abs();    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"),        ),        (            DeleteFile {                path: PathBuf::from("relative-delete.py"),            },            cwd.join("relative-delete.py"),        ),        (            UpdateFile {                path: PathBuf::from("relative-update.py"),                move_path: None,                chunks: Vec::new(),            },            cwd.join("relative-update.py"),        ),        (            AddFile {                path: absolute_add.to_path_buf(),                contents: String::new(),            },            absolute_add,        ),        (            DeleteFile {                path: absolute_delete.to_path_buf(),            },            absolute_delete,        ),        (            UpdateFile {                path: absolute_update.to_path_buf(),                move_path: None,                chunks: Vec::new(),            },            absolute_update,        ),    ] {        assert_eq!(hunk.resolve_path(&cwd), 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()],            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()        ))    );}