Skip to content

Apply Patch语言与语法

从当前 Lark grammar 和 parser 源码解释 Apply Patch 的环境标识、文件操作、hunk、context、move 与 EOF 语法。

基于rust-v0.150.0
CodexRustExecutionApplyPatch

Apply Patch语言与语法 ​

Apply Patch 不是 unified diff 的别名,而是一种由 *** Begin Patch / *** End Patch 包围、以文件操作为顶层 hunk 的小语言。当前 parser 支持可选 Environment ID、Add File、Delete File、Update File、Move to、context 行、增删上下文行和 End of File marker;解析结果先是 ApplyPatchArgs 与 Hunk,之后才由 invocation/文件更新算法验证能否真正应用。

本文面向已掌握 Rust enum、文本 parser 和 fixture 测试的读者,承接ExecServer并发与集成测试。范围是 apply-patch parser、grammar 和调用识别,不展开 context 匹配、原子写入和流式 parser。读完后,你应能从一个 patch.txt 判断它属于哪种 hunk,并知道 parser 已经证明什么、还没有证明什么。

1. 顶层语法 ​

1.1 Grammar ​

源码注释直接列出官方 grammar:start 由 begin、可选 environment ID、一个或多个 hunk 和 end 组成;hunk 分为 add/delete/update。

源码位置:codex-rs/apply-patch/src/parser.rs :: grammar module documentation

text
start: begin_patch environment_id? hunk+ end_patch
environment_id: "*** Environment ID: " filename 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?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.+)/ LF
eof_line: "*** End of File" LF

1.2 边界与环境 ​

parser 会 trim 外层文本,再检查首尾 marker。当前 parse mode 是 Lenient,因此也能剥掉 <<'EOF' ... EOF 包装,但内部 patch 仍需严格 Begin/End。

源码位置:codex-rs/apply-patch/src/parser.rs :: parse_patch_text、check_patch_boundaries_lenient

rust
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);

Environment ID 只能紧跟 Begin marker 出现一次,空值或重复值都会被拒绝。它进入 ApplyPatchArgs, 用于把 patch 绑定到目标执行环境,但不会改变 hunk 的文件语法。

源码位置:codex-rs/apply-patch/src/streaming_parser.rs :: ENVIRONMENT_ID_MARKER、StreamingPatchParser::environment_id

rust
if matches!(self.state.mode, StreamingParserMode::StartedPatch)
    && let Some(environment_id) = trimmed.strip_prefix(ENVIRONMENT_ID_MARKER)
{
    if self.state.environment_id.is_some() {
        return Err(InvalidPatchError(
            "apply_patch environment_id cannot be specified more than once".to_string(),
        ));
    }
    let environment_id = environment_id.trim();
    if environment_id.is_empty() {
        return Err(InvalidPatchError(
            "apply_patch environment_id cannot be empty".to_string(),
        ));
    }
    self.state.environment_id = Some(environment_id.to_string());
}

2. Hunk模型 ​

2.1 三类操作 ​

parser 不直接写 filesystem,而是把语法转成三个 Hunk variant。UpdateFile 还携带可选 move_path 和有序 chunks。

源码位置:codex-rs/apply-patch/src/parser.rs :: Hunk

rust
pub enum Hunk {
    AddFile { path: PathBuf, contents: String },
    DeleteFile { path: PathBuf },
    UpdateFile {
        path: PathBuf,
        move_path: Option<PathBuf>,
        chunks: Vec<UpdateFileChunk>,
    },
}

2.2 Update chunk ​

每个 chunk 可带一个 context;old_lines 必须出现在 context 之后,new_lines 是替换结果;End of File marker 将 is_end_of_file 置为真。

源码位置:codex-rs/apply-patch/src/parser.rs :: UpdateFileChunk

rust
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,
}

context_line_indices 记录以空格前缀显式写出的上下文行在 old/new 两侧的位置。某行文本即使在 old/new 中相同,也可能只是改动结果碰巧相同,不一定是语法中的 context 行;保留 CRLF 等行尾格式时, 后续算法需要这个来源信息。

源码位置:codex-rs/apply-patch/src/parser.rs :: UpdateFileChunk::push_context_line

rust
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);
}

3. 文件操作 ​

3.1 Add与Delete ​

Add 要求至少一行以 + 开头的内容;Delete 只声明路径,实际文件存在性在后续 verify/apply 阶段检查。parser 能证明语法,不证明路径状态。

源码位置:codex-rs/apply-patch/tests/fixtures/scenarios/001_add_file/patch.txt

text
*** Begin Patch
*** Add File: bar.md
+This is a new file
*** End Patch

3.2 Update与Move ​

Update File 可以只有 chunks,也可以带 Move to;move destination 影响 Hunk::path() 返回的目标,但 resolve_path() 对 UpdateFile 明确解析源路径。验证阶段先读取旧文件,应用完成后才写入 move destination。

源码位置:codex-rs/apply-patch/src/parser.rs :: Hunk::path、resolve_path

rust
pub fn path(&self) -> &Path {
    match self {
        Hunk::UpdateFile { move_path: Some(path), .. } => path,
        Hunk::UpdateFile { path, move_path: None, .. } => path,
        Hunk::AddFile { path, .. } | Hunk::DeleteFile { path } => path,
    }
}

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())
}

4. invocation识别 ​

直接 argv 形式要求命令名是 apply_patch/applypatch 且第二个参数就是 patch body;shell 形式先按目标 路径约定识别 bash/zsh/sh、PowerShell 或 cmd 的命令行,再通过 Tree-sitter Bash 提取唯一 heredoc 语句。PowerShell 可额外带 -NoProfile;可选 cd path && 会变成 workdir,额外前后命令仍被拒绝。

源码位置:codex-rs/apply-patch/src/invocation.rs :: maybe_parse_apply_patch

rust
match argv {
    [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => {
        match parse_patch(body) {
            Ok(source) => MaybeApplyPatch::Body(source),
            Err(e) => MaybeApplyPatch::PatchParseError(e),
        }
    }
    _ => match parse_shell_script(argv, cwd) {
        Some((shell, script)) => extract_apply_patch_from_shell(shell, script),
        None => MaybeApplyPatch::NotApplyPatch,
    },
}

源码位置:codex-rs/apply-patch/src/invocation.rs :: classify_shell、parse_shell_script

rust
match name.as_str() {
    "bash" | "zsh" | "sh" if matches!(flag, "-lc" | "-c") => Some(ApplyPatchShell::Unix),
    "pwsh" | "powershell" if flag.eq_ignore_ascii_case("-command") => {
        Some(ApplyPatchShell::PowerShell)
    }
    "cmd" if flag.eq_ignore_ascii_case("/c") => Some(ApplyPatchShell::Cmd),
    _ => None,
}

直接传入 raw patch body 而不是 apply_patch 命令会被 verified invocation 标记为 ImplicitInvocation,防止把模型文本误当成已授权的 patch 执行。

5. fixture结构 ​

每个 scenario 目录包含 input、patch.txt 和 expected 三部分。它把 parser 语法与最终文件状态分开:parser 测试可以断言 Hunk,端到端 fixture 才断言文件变化或失败后残留。

源码位置:codex-rs/apply-patch/tests/fixtures/scenarios/README.md

text
001_add/
  input/
    foo.md
  expected/
    foo.md
    bar.md
  patch.txt

6. 验证 ​

6.1 parser边界 ​

parser 单测覆盖空 patch、错误 Begin/End、Add、空 Update hunk 和 marker 空白;它们证明语法错误位置,不证明 context 能在真实文件中匹配。

源码位置:codex-rs/apply-patch/src/parser.rs :: test_parse_patch

text
cd codex-rs
cargo test -p codex-apply-patch --lib parser -- --test-threads=1

6.2 fixture场景 ​

Apply Patch fixture 测试覆盖 Add、多操作、move、缺 context、删除目录失败、Unicode 和 EOF marker;输入目录与 expected 目录分别构成 arrange/assert。

源码位置:codex-rs/apply-patch/tests/suite/scenarios.rs、tests/fixtures/scenarios

text
cd codex-rs
cargo test -p codex-apply-patch --test all scenarios -- --test-threads=1

这些测试不证明每个 patch 都能在任意真实工作区应用;parser 测试验证文本结构,invocation 测试验证命令 形态,scenario fixture 才验证预设文件树中的结果。权限、并发写入和平台文件系统边界属于后续专题。

7. 源码排查 ​

text
rg -n "BEGIN_PATCH_MARKER|ADD_FILE_MARKER|UPDATE_FILE_MARKER|EOF_MARKER" codex-rs/apply-patch/src/parser.rs
rg -n "enum Hunk|struct UpdateFileChunk|resolve_path|parse_patch" codex-rs/apply-patch/src
rg -n "maybe_parse_apply_patch|ImplicitInvocation|extract_apply_patch" codex-rs/apply-patch/src/invocation.rs

本篇的主线是:边界 marker 与可选 Environment ID 形成 patch 文本,parser 产出 Hunk 与 UpdateFileChunk,invocation 决定命令是否真的是 apply_patch,fixture 再把语法结果连接到文件状态。 下一篇ApplyPatch解析器将深入 parser 的分阶段实现与错误定位。