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
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" LF1.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
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
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
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
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
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
*** Begin Patch
*** Add File: bar.md
+This is a new file
*** End Patch3.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
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
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
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
001_add/
input/
foo.md
expected/
foo.md
bar.md
patch.txt6. 验证
6.1 parser边界
parser 单测覆盖空 patch、错误 Begin/End、Add、空 Update hunk 和 marker 空白;它们证明语法错误位置,不证明 context 能在真实文件中匹配。
源码位置:codex-rs/apply-patch/src/parser.rs :: test_parse_patch
cd codex-rs
cargo test -p codex-apply-patch --lib parser -- --test-threads=16.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
cd codex-rs
cargo test -p codex-apply-patch --test all scenarios -- --test-threads=1这些测试不证明每个 patch 都能在任意真实工作区应用;parser 测试验证文本结构,invocation 测试验证命令 形态,scenario fixture 才验证预设文件树中的结果。权限、并发写入和平台文件系统边界属于后续专题。
7. 源码排查
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 的分阶段实现与错误定位。
