008.代码编辑策略

好用的 coding agent 不只是会写代码,而是会用低破坏性的方式改代码

两种编辑工具

Claude Code 提供两种文件编辑工具,各有其适用场景:

工具 策略 适用场景 破坏性
FileEditTool search-and-replace 修改已有文件中的特定部分
FileWriteTool 全文件覆盖写入 创建新文件或完整重写

系统提示词明确指引模型:优先使用 FileEditTool。只有在创建全新文件或需要完整重写时,才使用 FileWriteTool

FileEditTool:Search-and-Replace 方法

FileEditTool 是 Claude Code 代码编辑的核心工具,采用精确字符串替换策略

输入 Schema

{
  file_path: string    // 要编辑的文件绝对路径
  old_string: string   // 要替换的精确字符串
  new_string: string   // 替换后的新字符串
  replace_all?: boolean // 是否替换所有出现位置(默认 false)
}

工作原理

FileEditTool 不需要行号、不需要正则表达式。它的工作方式极其简单:

  1. 在文件中精确查找 old_string
  2. 确保 old_string 在文件中唯一出现(除非 replace_all=true
  3. 将其替换为 new_string
  4. 如果 old_string 不唯一,返回错误,要求提供更多上下文

为什么 Search-and-Replace 优于其他方案

这个设计选择背后有深刻的工程考量:

1. 低破坏性

Search-and-replace 只修改目标文本,文件的其余部分完全不变。相比之下,全文件写入可能:

2. 可验证性

每次编辑都有明确的"before"和"after"。用户可以精确看到什么被改了 —— 这比看一个完整的新文件要容易得多

3. 抗幻觉

模型需要提供文件中实际存在的精确字符串。如果模型"幻觉"了不存在的代码,编辑会直接失败并返回错误,而不是静默地写入错误内容

4. Token 效率

对大文件的小修改,search-and-replace 只需要发送修改点附近的上下文,而不是整个文件内容

5. Git 友好

Search-and-replace 产生的 diff 最小化、最精确。自动化 PR 创建时,reviewer 看到的是干净的、有针对性的变更

与备选方案的对比

在确定 search-and-replace 方案之前,有必要理解为什么其他看似合理的方案被排除了:

幻觉安全是 search-and-replace 最被低估的优势

输入预处理管线

在进入核心的验证和执行流程之前,模型的输入会先经过一个预处理阶段。normalizeFileEditInput() 在 validateInput 之前被调用,负责清洗模型输出中常见的瑕疵:

1. 尾部空白裁剪

模型生成代码时经常在行尾添加多余的空格或 tab。stripTrailingWhitespace() 会对 new_string 的每一行去除尾部空白字符。但这个规则有一个重要的例外:.md 和 .mdx 文件不做尾部空白裁剪。这是因为在 Markdown 语法中,行尾的两个空格表示硬换行(<br>),裁剪掉会改变文档的语义

// Markdown 使用两个尾部空格作为硬换行——裁剪会改变语义
const isMarkdown = /\.(md|mdx)$/i.test(file_path)
const normalizedNewString = isMarkdown
  ? new_string
  : stripTrailingWhitespace(new_string)

2. API 反消毒(Desanitization)

Claude API 出于安全考虑,会将某些 XML 标签"消毒"(sanitize)为短形式,防止模型输出被误解析为 API 控制标签。例如:

消毒后(模型看到的) 原始形式(文件中的)
<fnr> <function_results>
<n> / </n> <name> / </name>
<s> / </s> <system> / </system>
\n\nH: \n\nHuman:
\n\nA: \n\nAssistant:

当模型输出的 old_string 无法精确匹配文件内容时,desanitizeMatchString() 会尝试将这些消毒后的短形式还原为原始标签。如果还原后能匹配成功,同样的替换也会应用到 new_string,确保编辑的一致性

这个预处理阶段对用户完全透明 —— 大多数情况下用户不会意识到它的存在。但对于编辑包含 XML 标签或 Human: / Assistant: 等特殊字符串的文件(例如 prompt 模板文件),它是编辑能否成功的关键

完整验证管线

FileEditTool 的 validateInput() 方法实现了一个多层验证管线,在真正执行编辑之前拦截各种问题。验证的顺序是刻意设计的:低成本的检查在前,需要文件 I/O 的检查在中,依赖文件内容的检查在后。这样在早期阶段就能拦截的问题不会浪费后续的磁盘读取开销

完整的验证步骤和对应的错误码:

步骤 错误码 检查内容 目的
1 0 checkTeamMemSecrets() 防止将密钥写入团队记忆文件
2 1 old_string === new_string 拒绝无意义的空操作
3 2 权限 deny 规则匹配 尊重用户配置的路径排除规则
4 UNC 路径检测 安全:防止 Windows NTLM 凭据泄露
5 10 文件大小 > 1 GiB 防止 V 8 字符串长度限制导致 OOM
6 文件编码检测 通过 BOM 判断 UTF-16 LE 还是 UTF-8
7 4 文件不存在 + old_string 非空 找不到目标文件,尝试给出相似文件建议
8 3 old_string 为空 + 文件已有内容 阻止用"创建新文件"的方式覆盖已有文件
9 5 .ipynb 扩展名检测 重定向到 NotebookEditTool
10 6 readFileState 缺失或 isPartialView 文件未被读取——必须先读
11 7 mtime > readTimestamp.timestamp 文件被外部修改——需要重新读取
12 8 findActualString() 返回 null old_string 在文件中不存在
13 9 匹配数 > 1 且 replace_all=false 多个匹配但未指定全局替换
14 10 validateInputForSettingsFileEdit() Claude 配置文件的 JSON Schema 校验

几个值得展开讨论的步骤:

唯一性约束

在上面的验证管线中,步骤 13 的唯一性约束值得单独讨论。FileEditTool 要求 old_string 在文件中唯一出现。如果不唯一,编辑失败并提示:

Found N matches of the string to replace, but replace_all is false.
To replace all occurrences, set replace_all to true.
To replace only one occurrence, please provide more context to uniquely identify the instance.

找到了 N 个要替换的字符串匹配项,但 replace_all 为 false。
要替换所有匹配项,请将 replace_all 设置为 true。
要仅替换一个实例,请提供更多上下文以唯一标识该实例。

这个约束的设计哲学是"宁可失败也不猜测":

实现细节:从匹配到写入

引号标准化

文件中可能包含弯引号(curly quotes),这种情况在从 Word、Google Docs 或网页复制过来的代码中很常见。但模型输出的始终是直引号(straight quotes)。如果不做处理,old_string 会因为引号不匹配而查找失败。

Claude Code 在 utils.ts 中实现了一套引号标准化机制:

// normalizeQuotes() 将所有弯引号转为直引号进行匹配
function normalizeQuotes(str: string): string {
  return str
    .replaceAll('\u201C', '"')   // "left double curly → straight
    .replaceAll('\u201D', '"')   // "right double curly → straight
    .replaceAll('\u2018', "'")   // 'left single curly → straight
    .replaceAll('\u2019', "'")   // 'right single curly → straight
}

findActualString() 实现了两阶段匹配策略:

function findActualString(fileContent: string, searchString: string): string | null {
  // 第一阶段:精确匹配
  if (fileContent.includes(searchString)) {
    return searchString
  }

  // 第二阶段:标准化引号后重试
  const normalizedSearch = normalizeQuotes(searchString)
  const normalizedFile = normalizeQuotes(fileContent)

  const searchIndex = normalizedFile.indexOf(normalizedSearch)
  if (searchIndex !== -1) {
    // 返回文件中的原始字符串(保留弯引号)
    return fileContent.substring(searchIndex, searchIndex + searchString.length)
  }

  return null
}

需要注意的是,当通过引号标准化匹配成功后,Claude Code 还会通过 preserveQuoteStyle() 将 new_string 中的直引号转换回弯引号,以保持文件的排版一致性。这个函数使用启发式规则判断引号的开闭位置 —— 前面是空白或开括号的是左引号,否则是右引号 —— 并且正确处理缩略语中的撇号(如 don't

除了引号标准化,还有一套反消毒(desanitization)机制:Claude API 会将某些 XML 标签(如 <function_results><name> 等)消毒为短形式(<fnr><n>),模型输出编辑时用的是消毒后的形式。desanitizeMatchString() 在匹配失败时自动还原这些标签

Diff 生成

getPatchForEdit() 负责将编辑操作转化为结构化的 diff patch:

function getPatchForEdits({ filePath, fileContents, edits }): {
  patch: StructuredPatchHunk[]
  updatedFile: string
} {
  let updatedFile = fileContents

  for (const edit of edits) {
    const previousContent = updatedFile
    updatedFile = applyEditToFile(updatedFile, edit.old_string, edit.new_string, edit.replace_all)

    // 如果编辑没有改变任何内容,抛出错误
    if (updatedFile === previousContent) {
      throw new Error('String not found in file. Failed to apply edit.')
    }
  }

  // 使用 diff 库的 structuredPatch 生成 hunk
  // 注意:先将 tab 转为空格用于显示目的
  const patch = getPatchFromContents({
    filePath,
    oldContent: convertLeadingTabsToSpaces(fileContents),
    newContent: convertLeadingTabsToSpaces(updatedFile),
  })

  return { patch, updatedFile }
}

在调用 structuredPatch 之前,会对内容中的 & 和 $ 字符进行转义(替换为特殊 token),因为 diff 库在处理这些字符时存在 bug。diff 计算后再反转义回来

删除操作的特殊处理

当 new_string 为空时(即删除操作),applyEditToFile() 有一个贴心的细节:它会检查文件中是否存在 old_string + '\n' —— 如果存在,会连同尾部的换行符一起删除。这防止了删除一行代码后留下一个空行的常见问题

if (newString !== '') {
  return f(originalContent, oldString, newString)  // 正常替换,精确执行
}

// 删除场景:如果 old_string 后面紧跟换行符,连同换行一起删除
const stripTrailingNewline =
  !oldString.endsWith('\n') && originalContent.includes(oldString + '\n')

return stripTrailingNewline
  ? f(originalContent, oldString + '\n', newString)  // 删除 old_string + 换行
  : f(originalContent, oldString, newString)          // 仅删除 old_string

举个例子:假设文件内容是 line1\nline2\nline3\n,模型想删除 line2。如果直接替换 "line2" 为 "",结果是 line1\n\nline3\n —— 多出一个空行。有了这个处理,实际删除的是 "line2\n",结果是 line1\nline3\n,符合用户预期

注意这个行为只在 new_string 为空时触发,正常的替换操作不受影响。这是一个很好的"做正确的事"设计 —— 用户不需要知道这个机制的存在,但它让删除操作的结果始终符合直觉

编辑去重

在实际使用中,模型偶尔会因为重试逻辑等原因发送重复的编辑请求。Claude Code 通过 areFileEditsInputsEquivalent() 进行语义去重 —— 它不只是比较两组编辑的字面值是否相同,而是将两组编辑分别应用到当前文件内容,比较最终结果是否一致:

function areFileEditsEquivalent(edits1, edits2, originalContent): boolean {
  // 快速路径:字面值完全相同
  if (edits1.length === edits2.length &&
      edits1.every((e1, i) => e1.old_string === edits2[i].old_string && ...)) {
    return true
  }

  // 慢速路径:分别应用两组编辑,比较结果
  const result1 = getPatchForEdits({ fileContents: originalContent, edits: edits1 })
  const result2 = getPatchForEdits({ fileContents: originalContent, edits: edits2 })
  return result1.updatedFile === result2.updatedFile
}

这种语义比较能识别出"输入不同但效果相同"的编辑。例如,两组编辑可能使用了不同长度的 old_string 上下文,但最终修改的内容完全一致 —— 它们会被正确判定为等价,避免重复执行

FileWriteTool:全文件写入

FileWriteTool 的定位是创建新文件或完整重写:

{
  file_path: string    // 文件绝对路径
  content: string      // 完整文件内容
}

系统提示词中的使用指引:

换行符策略:为什么 Write 始终使用 LF

FileWriteTool 在写入磁盘时始终使用 LF 换行符,不保留原文件的换行风格:

// Write 是全内容替换 —— 模型发送的显式换行符就是它的意图。不要改写它们。
writeTextContent(fullFilePath, content, enc, 'LF')

这个决策来自一个真实的 bug 教训。源码注释记录了历史:

Previously we preserved the old file's line endings (or sampled the repo via ripgrep for new files), which silently corrupted e.g. bash scripts with \r on Linux when overwriting a CRLF file or when binaries in cwd poisoned the repo sample.

以前我们保留了旧文件的行尾(或通过 ripgrep 对新文件的 repo 进行采样),这会在覆盖 CRLF 文件或 cwd 中的二进制文件毒害 repo 示例时,在 Linux 上用 \r 默默地损坏 bash 脚本

旧版本会保留原文件的换行风格(如果是新文件,会通过 ripgrep 采样仓库中其他文件的换行风格来决定)。但这导致了两个问题:

  1. 在 Linux 上覆盖一个 CRLF 文件时,Write 会给新内容也加上 \r,导致 bash 脚本因为行尾的 \r 无法执行
  2. 当工作目录中有二进制文件时,ripgrep 采样可能将二进制内容误判为 CRLF,污染新文件的换行符

这与 FileEditTool 形成了刻意的不对称

FileEditTool FileWriteTool
换行符 保留原文件的换行风格 始终 LF
编码 保留原文件编码 保留原文件编码
设计原则 最小变更 —— 只改目标文本 模型意图 —— 内容即真相

为什么两者不同?

编码检测

FileEditTool 的验证管线中包含一个 BOM(Byte Order Mark)检测步骤,用于正确读取非 UTF-8 编码的文件:

const fileBuffer = await fs.readFileBytes(fullFilePath)
const encoding: BufferEncoding =
  fileBuffer.length >= 2 &&
  fileBuffer[0] === 0xff &&
  fileBuffer[1] === 0xfe
    ? 'utf16le'
    : 'utf8'

如果文件前两个字节是 0xFF 0xFE(UTF-16 LE 的 BOM),使用 UTF-16 LE 解码;否则默认 UTF-8。完整的 detectEncodingForResolvedPath() 还能识别 UTF-8 BOM(0xEF 0xBB 0xBF),空文件默认 UTF-8(而非 ASCII),避免在后续写入 emoji 或中文时出现编码损坏

安全验证

FileWriteTool 在执行写入前会进行多层安全检查,与 FileEditTool 共享核心验证逻辑:

// 1. 团队记忆密钥检查
const secretError = checkTeamMemSecrets(fullFilePath, content)

// 2. 权限 deny 规则匹配
const denyRule = matchingRuleForInput(fullFilePath, ..., 'edit', 'deny')

// 3. Windows UNC 路径检查:防止 NTLM 凭据泄露
if (fullFilePath.startsWith('\\\\') || fullFilePath.startsWith('//')) {
  return { result: true }  // 跳过文件系统操作,交由权限系统处理
}

// 4. 文件存在性检查 + mtime 验证
const fileStat = await fs.stat(fullFilePath)
const lastWriteTime = Math.floor(fileStat.mtimeMs)

// 5. 读取前置检查
const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
  return { result: false, message: 'File has not been read yet.' }
}

// 6. 外部修改检测
if (lastWriteTime > readTimestamp.timestamp) {
  return { result: false, message: 'File has been modified since read.' }
}

FileWriteTool 与 FileEditTool 共享同一套 readFileState 缓存机制 —— 对已有文件,必须先读取才能写入。这个约束在代码层面强制执行,而不仅仅是提示词层面的建议。值得注意的是,如果文件不存在(ENOENT),验证直接通过 —— 因为这是"创建新文件"的正常场景

FileEditTool 还额外检查文件大小(1GiB 上限),防止 V8/Bun 字符串长度限制(约 2^30 字符)导致的 OOM:

const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiB
const { size } = await fs.stat(fullFilePath)
if (size > MAX_EDIT_FILE_SIZE) {
  return { result: false, message: `File is too large to edit (${formatFileSize(size)}).` }
}

多文件编辑协调

当需要跨多个文件进行协调修改时(如重命名一个被广泛引用的函数),Claude Code 的策略是:

串行编辑

由于 FileEditTool 的 isReadOnly() 返回 false,多个文件编辑操作会串行执行。这确保:

原子性考量

单个 FileEditTool 调用是原子的 —— 要么成功替换,要么完全不修改。但跨多个文件的编辑序列不是原子的。如果中间失败,已完成的编辑不会回滚

这是一个有意的设计权衡:

级联编辑保护

当多个编辑操作在同一文件上依次执行时,有一个微妙的风险:前一个编辑插入的文本可能被后一个编辑意外匹配到。getPatchForEdits() 通过子串检查来防止这种级联错误:

const appliedNewStrings: string[] = []

for (const edit of edits) {
  const oldStringToCheck = edit.old_string.replace(/\n+$/, '')

  // 检查当前 old_string 是否是之前任何 new_string 的子串
  for (const previousNewString of appliedNewStrings) {
    if (oldStringToCheck !== '' && previousNewString.includes(oldStringToCheck)) {
      throw new Error(
        'Cannot edit file: old_string is a substring of a new_string from a previous edit.'
      )
    }
  }

  // ... 执行编辑 ...
  appliedNewStrings.push(edit.new_string)
}

举个例子:

注意检查前会对 old_string 去除尾部换行(replace(/\n+$/, '')),避免因为换行符差异导致的误判

Worktree 隔离

对于大规模重构,AgentTool 支持 Git Worktree 隔离模式。子 Agent 在独立的 Worktree 中工作,完成后由用户决定是否合并:

{
  prompt: "重构所有 API 处理函数...",
  isolation: 'worktree'  // 在独立 Worktree 中工作
}

编辑前的读取要求

系统提示词强制要求:编辑文件前必须先读取

You MUST use your Read tool at least once in the conversation
before editing. This tool will error if you attempt an edit
without reading the file.

在编辑之前,您必须在对话中至少使用一次阅读工具。如果您在不读取文件的情况下尝试编辑,此工具将出错。

这不仅是提示词层面的约束 —— FileEditTool 的实现中实际检查 readFileState 缓存,如果文件未被读取过,会返回错误(error code 6)

这个设计确保模型:

  1. 了解文件的当前状态
  2. 不会基于过时的记忆进行编辑
  3. 能提供正确的 old_string

没有这个约束会怎样?

考虑一个真实的使用场景来理解读取前置的必要性:

  1. 场景 1:过期记忆
    • 用户在对话的第 3 轮让 Claude 修改 utils.ts 中的 formatDate() 函数
    • Claude 在第 1 轮读取过这个文件,知道函数签名是 function formatDate(date: Date)
    • 但在第 2 轮中,用户在 IDE 中手动将签名改为 function formatDate(date: Date, locale?: string)
    • 如果没有读取前置约束,Claude 会基于对话历史中的旧版本生成 old_string: "function formatDate(date: Date)"
    • 这个字符串在当前文件中已经不存在了(因为多了 locale 参数),编辑会失败
    • 更糟糕的情况是,如果 Claude 使用 FileWriteTool 全文件重写,旧版本的内容会直接覆盖用户刚做的手动修改
  2. 场景 2:isPartialView 的陷阱
    • 某些文件在 Read 时会被注入额外内容(如 HTML 文件的注释会被剥离,MEMORY.md 会被截断)
    • 这些文件的 readFileState 会被标记为 isPartialView: true
    • 如果允许基于部分视图进行编辑,模型看到的内容与文件真实内容不一致,old_string 极有可能匹配失败或匹配到错误的位置

读取前置约束的实现也很值得注意 —— 它区分了"完全没读"和"读了但是部分视图"两种情况,对两者都拒绝编辑:

const readTimestamp = toolUseContext.readFileState.get(fullFilePath)
if (!readTimestamp || readTimestamp.isPartialView) {
  return {
    result: false,
    message: 'File has not been read yet. Read it first before writing to it.',
    errorCode: 6,
  }
}

并发安全:文件状态缓存

readFileState 是工具上下文中的一个缓存,记录每个文件的读取状态:

// readFileState 中每个文件的缓存条目
interface FileStateEntry {
  content: string       // 文件内容(用于匹配验证和内容比较)
  timestamp: number     // 读取时的 mtime(用于外部修改检测)
  offset?: number       // 读取起始行(部分读取时记录)
  limit?: number        // 读取行数(部分读取时记录)
  isPartialView?: boolean // 是否为部分视图
}

编辑前的并发检测流程:

  1. 读取文件当前 mtime(通过 fs.statgetFileModificationTime
  2. 对比 readFileState 中缓存的 timestamp
  3. 如果 mtime > timestamp → 文件被外部修改 → 返回警告
  4. Windows 回退:mtime 不可靠时(云同步、杀毒软件等可能触发 mtime 变化),使用内容哈希/全文比较作为二次确认

这解决了一个常见的竞争条件:用户在 IDE 中编辑文件的同时,Claude Code 也在编辑同一个文件。mtime 检查能捕获这种并发修改,避免覆盖用户的手动改动

具体实现中,Windows 平台的 mtime 检查有特殊处理。因为 Windows 上的云同步(OneDrive)、杀毒软件等可能在不修改文件内容的情况下更新 mtime,所以当检测到 mtime 变化时,如果是完整读取(非 offset/limit 的部分读取),会额外比较文件内容 —— 内容相同则认为安全,可以继续编辑:

const isFullRead = lastRead.offset === undefined && lastRead.limit === undefined
const contentUnchanged = isFullRead && currentContent === lastRead.content
if (!contentUnchanged) {
  throw new Error('File unexpectedly modified')
}

编辑成功后,readFileState 会立即更新为新的内容和时间戳,防止后续编辑触发误报

缩进保持

系统提示词中有关于缩进的明确指引:

When editing text from Read tool output, ensure you preserve
the exact indentation (tabs/spaces) as it appears AFTER the
line number prefix.

从读取工具输出编辑文本时,请确保保留行号前缀后出现的确切缩进(制表符/空格)。

这特别重要,因为 Read 工具的输出带有行号前缀(cat -n 格式),模型需要正确区分行号前缀和实际文件内容中的缩进

NotebookEditTool:Jupyter 编辑

对于 Jupyter Notebook(.ipynb 文件),Claude Code 提供专门的 NotebookEditTool,它理解 Notebook 的 cell 结构,在 cell 级别进行精确编辑

输入 Schema

{
  notebook_path: string     // .ipynb 文件的绝对路径
  cell_id?: string          // 目标 cell 的 ID(或 cell-N 格式的索引)
  new_source: string        // 新的 cell 内容
  cell_type?: 'code' | 'markdown'  // cell 类型(insert 时必须指定)
  edit_mode?: 'replace' | 'insert' | 'delete'  // 编辑模式(默认 replace)
}

工作原理

Jupyter Notebook 本质上是一个 JSON 文件,核心结构是 cells 数组。每个 cell 包含 cell_typesourcemetadata,以及 code cell 特有的 outputs 和 execution_count

NotebookEditTool 的三种编辑模式:

Cell 定位

Cell 的定位支持两种方式:

  1. 原生 cell ID:直接使用 notebook 中每个 cell 的 id 字段
  2. 索引格式cell-N 格式(如 cell-0cell-3),由 parseCellId() 解析为数字索引

权限与安全

NotebookEditTool 共享与 FileEditTool 相同的安全机制:

写入后同样更新 readFileState 缓存,保持与其他编辑工具的一致性

原子写入与 LSP 集成

FileEditTool 的 call() 方法实现了一个完整的编辑执行管线,从文件读取到写入后的各种副作用:

async call(input, context) {
  // === 写入前准备(可异步)===

  // 1. 发现技能目录(fire-and-forget)
  const newSkillDirs = await discoverSkillDirsForPaths([absoluteFilePath], cwd)
  addSkillDirectories(newSkillDirs).catch(() => {})  // 不等待

  // 2. 确保父目录存在
  await fs.mkdir(dirname(absoluteFilePath))

  // 3. 文件历史备份(按内容哈希去重,v1 备份格式)
  await fileHistoryTrackEdit(updateFileHistoryState, absoluteFilePath, messageId)

  // === 临界区:避免异步操作以保持原子性 ===

  // 4. 同步读取文件(带编码和换行符元数据)
  const { content, encoding, lineEndings } = readFileSyncWithMetadata(filePath)

  // 5. 过时检测(mtime + 内容比较)
  const lastWriteTime = getFileModificationTime(absoluteFilePath)
  if (lastWriteTime > lastRead.timestamp) { /* ... 抛出错误 */ }

  // 6. 引号标准化 + 查找匹配
  const actualOldString = findActualString(content, old_string)
  const actualNewString = preserveQuoteStyle(old_string, actualOldString, new_string)

  // 7. 生成 patch
  const { patch, updatedFile } = getPatchForEdit(/* ... */)

  // 8. 写入磁盘(保持原始编码和换行符)
  writeTextContent(absoluteFilePath, updatedFile, encoding, lineEndings)

  // === 写入后副作用 ===

  // 9. 通知 LSP 服务器
  lspManager.changeFile(absoluteFilePath, updatedFile)  // didChange
  lspManager.saveFile(absoluteFilePath)                  // didSave

  // 10. 通知 VSCode(用于 diff 视图)
  notifyVscodeFileUpdated(absoluteFilePath, originalContent, updatedFile)

  // 11. 更新 readFileState 缓存
  readFileState.set(absoluteFilePath, {
    content: updatedFile,
    timestamp: getFileModificationTime(absoluteFilePath),
  })

  // 12. 统计与遥测
  countLinesChanged(patch)
  logFileOperation({ operation: 'edit', tool: 'FileEditTool', filePath })
}

几个关键设计点:

临界区最小化

步骤 4-8 之间刻意避免任何 await 异步操作。注释中明确写道:

Please avoid async operations between here and writing to disk to preserve atomicity

请避免在此处和写入磁盘之间进行异步操作,以保持原子性

为什么这么重要?JavaScript/TypeScript 是单线程但基于事件循环的 —— 每个 await 都是一个让出控制权的点

如果在过时检测(步骤 5)和写入磁盘(步骤 8)之间有 await,另一个异步操作(如 linter 自动修复、IDE 保存)可能在这个间隙修改文件,导致写入覆盖外部修改

将文件历史备份和目录创建等可异步的操作提前到临界区之外,确保检测和写入之间没有断点

编码与换行符的完整往返管线

FileEditTool 对文件编码和换行符的处理遵循"读什么写什么"原则,整个管线分为三个阶段:

  1. 读取阶段readFileSyncWithMetadata()):
    • 检测编码:通过 BOM 判断(0xFF 0xFE → UTF-16 LE,0xEF 0xBB 0xBF → UTF-8 with BOM,默认 UTF-8)
    • 检测换行符:扫描原始内容前 4096 码元,统计 CRLF 和独立 LF 的数量,多数投票决定换行风格
    • 规范化内容:将所有 \r\n 统一为 \n,使内部处理全程基于 LF
  2. 处理阶段
    • 所有字符串匹配、替换、diff 生成都基于 LF 规范化后的内容
    • 这简化了 old_string 的匹配逻辑 —— 模型不需要关心目标文件是 LF 还是 CRLF
  3. 写入阶段writeTextContent()):
    • 如果检测到的原始换行符是 CRLF:先将内容中的 \n 全部替换为 \r\n
    • 使用检测到的原始编码写入磁盘

这意味着编辑一个 UTF-16 LE + CRLF 的文件(Windows 上的旧式文本文件),内部全程用 UTF-8 + LF 处理,写入时恢复为 UTF-16 LE + CRLF —— 文件的编码和换行风格完全不变

LSP 通知

编辑完成后立即通知 LSP 服务器,分为两步

  1. changeFile()(对应 textDocument/didChange)告知内容已修改
  2. saveFile()(对应 textDocument/didSave)触发 TypeScript server 等语言服务器的诊断更新

这些通知都是 fire-and-forget(.catch() 只做日志),不阻塞编辑返回。同时会清除该文件之前已交付的诊断信息(clearDeliveredDiagnosticsForFile),确保新诊断不会被去重过滤

LSP

LSP(Language Server Protocol) 是微软定义的一套协议,用于编辑器(如 VS Code、Cursor)和语言服务器之间的通信。语言服务器负责提供代码智能功能(自动补全、跳转定义、错误诊断等)

LSP 通信有两种模式:

  1. 请求-响应(Request/Response)
编辑器 ──请求──→ 语言服务器
编辑器 ←──响应── 语言服务器
  1. 通知(Notification)
编辑器 ──通知──→ 语言服务器   (单向,不需要回复)
编辑器 ←──通知── 语言服务器   (单向,不需要回复)

通知是单向的,发出去就完了,不期望对方回复。 常见的通知有:

方向 通知 说明
编辑器 → 服务器 textDocument/didOpen "用户打开了某个文件"
编辑器 → 服务器 textDocument/didChange "用户修改了文件内容"
编辑器 → 服务器 textDocument/didSave "用户保存了文件"
编辑器 → 服务器 textDocument/didClose "用户关闭了文件"
服务器 → 编辑器 textDocument/publishDiagnostics "这个文件有 3 个错误、2 个警告"
服务器 → 编辑器 window/logMessage "输出一条日志"

为什么要用通知而不是请求?

因为很多事件是高频且不需要确认的。比如用户每敲一个字符,编辑器就要通过 didChange 告诉语言服务器文件内容变了,如果每次都要等回复,编辑器就会卡住。通知是异步的、不阻塞的

文件历史备份

fileHistoryTrackEdit() 在写入前捕获文件原始内容,使用内容哈希去重 —— 如果连续多次编辑没有改变内容,不会产生重复备份

备份格式为 v1(基于硬链接或复制),存储在 ~/.claude/fileHistory/ 目录下。由于是幂等操作,即使后续的过时检测失败导致编辑中止,多出的备份也不会影响状态一致性

技能目录发现

当编辑的文件位于某个技能目录中时,discoverSkillDirsForPaths() 会识别出该目录,并触发动态技能加载。此外 activateConditionalSkillsForPaths() 会激活路径匹配的条件技能

这使得编辑技能文件时,新技能可以立即被发现和使用

Diff 渲染

编辑完成后,Claude Code 需要在终端中向用户展示变更内容。这由 StructuredDiff 组件(src/components/StructuredDiff.tsx)负责

数据结构

Diff 渲染的核心数据来自 diff 库的 StructuredPatchHunk

// 来自 diff 库的 StructuredPatchHunk
interface StructuredPatchHunk {
  oldStart: number      // 原文件起始行号
  newStart: number      // 新文件起始行号
  oldLines: number      // 原文件涉及的行数
  newLines: number      // 新文件涉及的行数
  lines: string[]       // diff 行(前缀 +/-/空格 表示增/删/不变)
}

关键常量:

const CONTEXT_LINES = 3          // diff 上下文行数(diff 库参数)
const DIFF_TIMEOUT_MS = 5_000    // diff 计算超时(5 秒)

行号调整

当 getPatchForDisplay 接收的是文件的一个片段(如 readEditContext 提供的局部内容)而非完整文件时,hunk 的行号是相对于片段起始位置的。adjustHunkLineNumbers() 将其转换为文件级别的绝对行号:

function adjustHunkLineNumbers(hunks: StructuredPatchHunk[], offset: number) {
  return hunks.map(h => ({
    ...h,
    oldStart: h.oldStart + offset,
    newStart: h.newStart + offset,
  }))
}

语法高亮

StructuredDiff 组件使用 color-diff 原生模块(Rust NAPI)进行语法高亮渲染。整个渲染流程有多层缓存优化:

  1. WeakMap 缓存:以 StructuredPatchHunk 对象引用为 key,缓存渲染结果。当 hunk 对象被 GC 回收时,缓存自动释放
  2. 参数化缓存键:缓存键包含 themewidthdimgutterWidthfirstLine(shebang 检测)和 filePath(语言检测),确保相同参数命中缓存
  3. 缓存上限:每个 hunk 最多保留 4 个缓存条目(覆盖正常使用场景中的宽度变化),超出后清空重建
const RENDER_CACHE = new WeakMap<StructuredPatchHunk, Map<string, CachedRender>>()

// 缓存键编码所有影响渲染的参数
const key = `${theme}|${width}|${dim ? 1 : 0}|${gutterWidth}|${firstLine ?? ''}|${filePath}`

终端渲染布局

在全屏模式下,diff 使用双列布局 —— gutter 列(行号和 +/- 标记)与 content 列分离:

// Gutter 宽度由最大行号的位数决定
function computeGutterWidth(patch: StructuredPatchHunk): number {
  const maxLineNumber = Math.max(
    patch.oldStart + patch.oldLines - 1,
    patch.newStart + patch.newLines - 1,
    1
  )
  return maxLineNumber.toString().length + 3  // 标记符(1) + 两个间隔空格
}

Gutter 列使用 <NoSelect> 包裹,这样用户在终端中选择复制 diff 内容时,不会包含行号 —— 这是一个细节但重要的用户体验优化。sliceAnsi 函数在切割 ANSI 彩色文本时保持转义序列的完整性,确保颜色不会因为列分割而错乱

当 gutter 宽度超过终端总宽度时(极窄终端场景),会自动回退到单列渲染,由 Rust 模块处理自动换行。如果原生模块不可用或语法高亮被禁用,则回退到 StructuredDiffFallback 组件进行纯文本渲染

设计亮点总结

这个编辑策略的精髓可以用一句话概括:宁可编辑失败让模型重试,也不要静默地写入错误内容