# 文件工具：读、写、改

> read 的分页、嗅探与嵌套指令文件，edit 的九级匹配级联，write 与 apply_patch 的写入流程，以及基于 ripgrep 的 glob 与 grep。

- 作者：David（道雾轩）
- 专栏：OpenCode 源码解读（https://daiw.net/manual/opencode.md）
- 最后更新：2026-09-15
- 原文：https://daiw.net/manual/opencode/file-tools
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

文件类工具共用一副骨架：相对路径按实例目录解析 → [external_directory 检查](https://daiw.net/manual/opencode/tool-contract) → 用 `ctx.ask` 询问 `read`、`edit`、`glob` 或 `grep` 权限 → 干活。写类工具最后还要发布文件事件、跑格式化器、收集 LSP 诊断。

## read：分页、嗅探与附件

几条硬上限写在文件顶部：

```ts
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MAX_BYTES = 50 * 1024
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
const SAMPLE_BYTES = 4096
```

（`packages/opencode/src/tool/read.ts:13`）

参数只有 `filePath`、`offset`（从 1 开始的行号）和 `limit`。输出包在 `<path>`、`<type>`、`<content>` 标签里，每行加上“行号、冒号、空格”前缀；末尾给出续读指引，例如 `(Showing lines 1-2000 of 5231. Use offset=2001 to continue.)`，撞上 50 KB 时则提示 `Output capped at 50 KB`。read 自己设置了 `metadata.truncated`，通用截断不会再截它。

- **嗅探**：先读前 4096 字节，按魔数识别 PNG、JPEG、GIF、BMP、PDF、WebP。JPEG、PNG、GIF、WebP 与 PDF 整个读出，作为 base64 附件返回；其余文件若扩展名在黑名单里（zip、exe、class、wasm、pyc 等），或样本中出现 NUL 字节、不可打印字符超过 30%，就报 `Cannot read binary file`。
- **找不到文件**：在同目录做一次宽松的文件名匹配，最多给出 3 个 `Did you mean one of these?` 候选。
- **目录**：同样能读，条目排序、子目录带 `/`，按 offset / limit 分页。
- **LSP 预热**：读完文本后在后台 `touchFile`，失败不影响读取。

最容易被忽略的是**嵌套指令文件**。`Instruction.resolve` 会从被读文件所在目录向上走到项目根（不含根目录），沿途寻找 `AGENTS.md`（及 `CLAUDE.md`、已弃用的 `CONTEXT.md`），把尚未进入系统提示、此前也没加载过的指令文件追加到输出末尾的 `<system-reminder>` 里。子目录的约定因此是“读到才加载”，不必一开始就塞进系统提示（见[系统提示词与指令文件](https://daiw.net/manual/opencode/system-prompt)）。

读权限的模式是相对路径，默认规则对 `*.env`、`*.env.*` 询问、对 `*.env.example` 放行，见[权限系统](https://daiw.net/manual/opencode/permission)。

## edit：九级匹配级联

参数是 `filePath`、`oldString`、`newString`、`replaceAll`。真正替换之前有三道处理：

- `oldString` 为空只用于新建文件，文件已存在时报错，要求改用 write；
- 同一路径的编辑经进程内信号量串行（`edit.ts` 里的 `locks`），防止并行调用互相覆盖；
- 文件含 CRLF 时，先把 `oldString`、`newString` 转成 CRLF 再匹配；UTF-8 BOM 读时剥离、写回时恢复。

核心是 `replace()`。文件头注释写明这些策略参考了 Cline 的 diff-apply 评测代码和 Gemini CLI 的 `editCorrector.ts`。九个 Replacer 依次上阵：

| 顺序 | Replacer | 匹配方式 |
| --- | --- | --- |
| 1 | `SimpleReplacer` | 原样查找 |
| 2 | `LineTrimmedReplacer` | 逐行比较去掉首尾空白后的内容 |
| 3 | `BlockAnchorReplacer` | 3 行以上时以首尾行为锚，中间行按 Levenshtein 相似度（阈值 0.65）择优 |
| 4 | `WhitespaceNormalizedReplacer` | 连续空白压成一个空格后比较 |
| 5 | `IndentationFlexibleReplacer` | 去掉公共缩进后比较 |
| 6 | `EscapeNormalizedReplacer` | 把 `\n`、`\t`、引号等转义还原后比较 |
| 7 | `TrimmedBoundaryReplacer` | 去掉整段首尾空白后比较 |
| 8 | `ContextAwareReplacer` | 首尾行锚定、行数相同、中间非空行至少一半一致 |
| 9 | `MultiOccurrenceReplacer` | 枚举全部精确出现，配合 `replaceAll` |

每个 Replacer 产出候选片段，循环这样消费它们（删减后）：

```ts
for (const search of replacer(content, oldString)) {
  const index = content.indexOf(search)
  if (index === -1) continue
  notFound = false
  if (isDisproportionateMatch(search, oldString)) {
    // ...
  }
  if (replaceAll) {
    return content.replaceAll(search, newString)
  }
  const lastIndex = content.lastIndexOf(search)
  if (index !== lastIndex) continue
  return content.substring(0, index) + newString + content.substring(index + search.length)
}
```

（`packages/opencode/src/tool/edit.ts:705`）

候选必须真在原文里；非 `replaceAll` 模式还要求它唯一，否则换下一个候选、下一级策略。全部失败时区分两种错误：从没找到报 `Could not find oldString in the file...`，找到过但都不唯一报 `Found multiple matches for oldString...`。

`isDisproportionateMatch` 是模糊匹配的保险：匹配片段的行数达到“`oldString` 行数加 3”与“两倍行数”中的较大者，或者（多行时）字符数比 `oldString` 多出 500 以上且超过 4 倍，就拒绝替换、要求模型重读文件。宽松匹配省掉了大量“空白不一致”导致的失败重试，这道闸则防止锚点匹配一口吞掉半个文件。

写入之后：格式化器若改动了文件，按原 BOM 状态回写；发布 `FileSystem.Event.Edited` 与 `Watcher.Event.Updated`；`ctx.metadata` 推送 diff 和增删行数；最后只报告**本文件**的错误级 LSP 诊断，每个文件最多 20 条。权限询问发生在写入前，元数据里带着完整 diff，TUI 的授权框据此渲染差异视图。

## write：整文件覆盖

write 同样先算 diff 供授权，BOM 取“旧文件有或新内容有”。它与 edit 的区别在诊断范围：除本文件外，还附上最多 5 个其他文件的 LSP 错误（`MAX_PROJECT_DIAGNOSTICS_FILES`）。

<Callout type="warn">
write.txt 与 edit.txt 都写着“没先用 Read 读过文件就会失败”，但 v1.18.30 的 `write.ts`、`edit.ts` 中没有对应检查，这句话目前只起引导作用。
</Callout>

## apply_patch：给 GPT 系列的补丁格式

[上一篇](https://daiw.net/manual/opencode/tool-contract)提到，GPT 系列模型拿到的是 `apply_patch` 而不是 edit 与 write。补丁是面向文件的精简 diff（节选自 `packages/opencode/src/tool/apply_patch.txt:18`）：

```text
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
```

解析在 `packages/opencode/src/patch/index.ts`，类型注释称与“Rust 实现”对齐。解析器相当宽容，外面包了 `cat <<'EOF'` 一类 heredoc 也能剥掉。执行分两段：

1. **校验**：逐个 hunk 计算新内容和 diff。Update 先用 `@@` 后的上下文行定位，再用 `seekSequence` 寻找旧行，匹配逐级放宽：精确、忽略行尾空白、忽略首尾空白、把弯引号、各种破折号、省略号和不间断空格归一为 ASCII。任何一处失败都报 `apply_patch verification failed`，此时还没有写任何文件。
2. **应用**：一次 `ask` 同时询问全部文件的 `edit` 权限，元数据里带每个文件的 diff；通过后逐个写入、移动或删除，再格式化、发事件、收集诊断，输出以 `A`、`M`、`D` 开头的文件清单。

## glob 与 grep：交给 ripgrep

两者都调用 core 的 `Ripgrep` 服务（`packages/core/src/ripgrep.ts`），结果上限都是 100 条：

| | glob | grep |
| --- | --- | --- |
| rg 参数 | `--files` 加 `--glob` | `--json --hidden --no-messages` |
| 隐藏文件 | 不搜 | 搜 |
| 排除 | `**/.git/**` | 同左 |
| 输出 | 绝对路径列表 | 按文件分组的 `Line N: 文本`，单行超过 2000 字符截断 |
| 权限模式 | glob 模式 | 正则 |

两份说明都把“开放式、需要多轮搜索”的任务引向 task 工具，让子代理去探索；grep.txt 还叮嘱统计匹配数时用 Shell 直接跑 `rg`。

写类工具末尾的格式化与诊断由 `Format`、`LSP` 两个服务提供，配置与内置清单见 [LSP 与格式化器](https://daiw.net/manual/opencode/lsp-and-format)。实验开关 `OPENCODE_EXPERIMENTAL_LSP_TOOL` 还会放出 `lsp` 工具，提供跳转定义、查找引用、hover、调用层级等 9 种操作。

下一篇：[Shell 工具](https://daiw.net/manual/opencode/shell-tool)——bash 与 PowerShell 的选择、超时与输出截断，以及用 tree-sitter 解析命令做权限判定。
