Claude Code 源码分析第 19 课 · 第 02
第19课

搜索工具

源文件:tools/GlobTool/ · tools/GrepTool/ · utils/ripgrep.ts · utils/glob.ts

概览

Claude Code 只暴露两种搜索原语:Glob 负责搜文件名,Grep 负责搜文件内容。两者看起来像两个独立工具,底层却都建立在同一套 ripgrep 调用链之上。

这套设计的重点不是“搜索功能很多”,而是“把高性能遍历、统一输出预算、结果排序和平台兼容性都压进同一个搜索内核里”。因此理解搜索工具,实际上是在理解 Claude Code 如何把 ripgrep 包装成适合模型调用的 API。

Tool 面向用户的名称 它搜索什么 Returns 硬限制
Glob Search File names (全局模式) 按 mtime 排序的文件路径数组 100 个文件(可通过 globLimits)
Grep Search File contents (regex) 路径、线路或计数取决于模式 250 行/文件(默认 head_limit)
这两个工具共享面向用户的名称“搜索”。 在终端 UI 中,您不会看到“Glob”或“Grep”,而是看到“搜索”。 不同的名称适用于模型决定调用哪个 API/模式层。 GlobTool 甚至重复使用 GrepTool's renderToolResultMessage 直接(参见 GlobTool/UI.tsx 第 53 课 行)。

2. 将 Glob 委托给 ripgrep

尽管被命名为“Glob”,但该工具并未使用 Node 的 fs.glob, fast-glob,或任何 JavaScript glob 库。 它完全委托给 ripgrep 通过 utils/glob.ts.

显示:utils/glob.ts 中的 glob()
export async function glob(
  filePattern: string,
  cwd: string,
  { limit, offset }: { limit: number; offset: number },
  abortSignal: AbortSignal,
  toolPermissionContext: ToolPermissionContext,
): Promise<{ files: string[]; truncated: boolean }> {
  // …absolute-path extraction, ignore-pattern assembly…

  const args = [
    '--files',        // list files instead of searching content
    '--glob', searchPattern,
    '--sort=modified',
    ...(noIgnore ? ['--no-ignore'] : []),
    ...(hidden  ? ['--hidden']    : []),
  ]

  const allPaths = await ripGrep(args, searchDir, abortSignal)

  const truncated = allPaths.length > offset + limit
  const files = allPaths.slice(offset, offset + limit)
  return { files, truncated }
}

关键见解:ripgrep 与 --files --glob <pattern> 是一个高性能的全局遍历。 Ripgrep 的多线程目录遍历器是 significantly 比 Node 更快 fs.readdir大型代码库上基于 glob 的实现。 相同的二进制文件可重复用于名称搜索 (Glob) 和内容搜索 (Grep)。

绝对路径模式被分解

ripgrep's --glob 标志仅接受相对模式。 当模型传递绝对路径模式时,例如 /src/utils/*.ts, extractGlobBaseDirectory() 将其分成 baseDir (/src/utils)和一个亲戚 relativePattern (*.ts)。 然后搜索运行 searchDir = /src/utils.

显示:extractGlobBaseDirectory() 逻辑
export function extractGlobBaseDirectory(pattern: string): {
  baseDir: string; relativePattern: string
} {
  // Find first glob special char: * ? [ {
  const match = pattern.match(/[*?[{]/)

  if (!match || match.index === undefined) {
    // Literal path — use dirname / basename split
    return { baseDir: dirname(pattern), relativePattern: basename(pattern) }
  }

  const staticPrefix = pattern.slice(0, match.index)
  const lastSep = Math.max(
    staticPrefix.lastIndexOf('/'),
    staticPrefix.lastIndexOf(sep)
  )

  if (lastSep === -1) return { baseDir: '', relativePattern: pattern }
  return {
    baseDir: staticPrefix.slice(0, lastSep),
    relativePattern: pattern.slice(lastSep + 1)
  }
}

环境变量控制行为

  • CLAUDE_CODE_GLOB_NO_IGNORE=false - 尊重 .gitignore (默认:忽略它,包括所有内容)
  • CLAUDE_CODE_GLOB_HIDDEN=false — 排除隐藏文件(默认:包括它们)
为什么忽略 .gitignore 默认情况下? Claude 需要查看构建工件、生成的文件以及 node_modules 结构来回答诸如“是否安装了此依赖项?”之类的问题。 或“构建发出了哪些文件?” 默认情况下尊重 gitignore 会隐藏太多上下文。

3. Grep的三种输出模式

The output_mode 参数控制传递的 ripgrep 标志和返回数据的形状。 每种模式都是不同的 ripgrep 调用策略。

files_with_matches

默认模式。 退货 仅文件路径 至少包含一个匹配项。 低令牌成本 - 非常适合“哪些文件使用此模式?”

rg-l

输出排序依据 mtime (最近修改的最先)。 结果: filenames[], numFiles.

content

返回 匹配线 前后有可选的上下文行。 支持 -n (行号), -B/-A/-C 上下文,多行模式。

rg(无-l/-c)

Result: content 细绳, numLines.

count

返回每个文件 匹配计数 in filename:N 格式。 对于“这种模式出现的频率和位置?”很有用。

rg-c

Result: content 字符串(原始), numMatches, numFiles.

显示:GrepTool.ts 中的输出模式调度
// Add output mode flags
if (output_mode === 'files_with_matches') {
  args.push('-l')
} else if (output_mode === 'count') {
  args.push('-c')
}
// content mode: no flag needed — rg defaults to printing match lines

// Line numbers only apply in content mode
if (show_line_numbers && output_mode === 'content') {
  args.push('-n')
}

// Context flags (-C supersedes -B/-A)
if (output_mode === 'content') {
  if (context !== undefined) {
    args.push('-C', context.toString())
  } else if (context_c !== undefined) {
    args.push('-C', context_c.toString())
  } else {
    if (context_before !== undefined) args.push('-B', context_before.toString())
    if (context_after  !== undefined) args.push('-A', context_after.toString())
  }
}

files_with_matches 模式下的 mtime 排序

ripgrep 返回文件路径后, GrepTool runs Promise.allSettled(results.map(_ => fs.stat(_))) 获取 mtimes,然后按降序排序。 最近修改的文件首先出现。 这是有意为之的:最近更改的文件与当前任务最相关。

测试模式绕过实时排序。 When process.env.NODE_ENV === 'test',结果按文件名排序,确保 VCR 测试装置中的确定性排序。 这是代码库中的常见模式。
显示:mtime排序逻辑
const stats = await Promise.allSettled(
  results.map(_ => getFsImplementation().stat(_))
)
const sortedMatches = results
  .map((_, i) => {
    const r = stats[i]!
    return [_, r.status === 'fulfilled' ? (r.value.mtimeMs ?? 0) : 0] as const
  })
  .sort((a, b) => {
    if (process.env.NODE_ENV === 'test') return a[0].localeCompare(b[0])
    const timeComparison = b[1] - a[1]
    return timeComparison === 0 ? a[0].localeCompare(b[0]) : timeComparison
  })
  .map(_ => _[0])

4.分页:head_limit和offset

两个工具都支持 head_limit and offset 像 Unix 一样工作的参数 tail -n +N | head -N 管道。 默认的 head_limit 是 250 - 对于大多数搜索来说足够慷慨,同时防止广泛模式上的上下文膨胀。

graph LR A["ripgrep raw output\n(e.g. 10,000 lines)"] --> B["applyHeadLimit(items, limit, offset)"] B --> C["items.slice(offset, offset+limit)\ne.g. slice(0, 250)"] C --> D["Return to model\nwith appliedLimit hint"] D --> E["Model calls again\nwith offset=250"]
显示:applyHeadLimit() 实现
function applyHeadLimit<T>(
  items: T[],
  limit: number | undefined,
  offset: number = 0,
): { items: T[]; appliedLimit: number | undefined } {
  // Explicit 0 = unlimited escape hatch
  if (limit === 0) {
    return { items: items.slice(offset), appliedLimit: undefined }
  }
  const effectiveLimit = limit ?? 250    // DEFAULT_HEAD_LIMIT
  const sliced = items.slice(offset, offset + effectiveLimit)
  // appliedLimit is ONLY set when truncation occurred
  // so the model knows there may be more results to page
  const wasTruncated = items.length - offset > effectiveLimit
  return {
    items: sliced,
    appliedLimit: wasTruncated ? effectiveLimit : undefined,
  }
}

三个关键设计决策 applyHeadLimit:

  1. limit=0 是无限逃生舱口。 模型可以通过 head_limit=0 当它明确想要所有结果而不管大小时。 模式描述警告“谨慎使用——大型结果集浪费上下文。”
  2. appliedLimit 仅当发生截断时才设置。 如果完整结果符合限制, appliedLimit is undefined。 仅当实际上有更多内容需要翻页时,模型才会看到分页提示。
  3. 头部限制发生在路径相对化之前。 在内容模式下,每一行都需要字符串操作将绝对路径转换为相对路径。 通过先切片,您可以避免处理将被丢弃的数千行。 返回 10k 行的宽泛模式仅使保留的 250 行相对化。
20KB 持久阈值。 源代码中的评论说:“无限制的内容模式 grep 可以填充到 20KB 持久阈值(~6-24K 令牌/grep 重会话)。250 对于探索性搜索来说足够慷慨,同时防止上下文膨胀。” 超过 20KB 的工具结果将持久保存到磁盘,而不是保留在提示中,从而减少上下文中的标记。 250 行默认值使结果远低于此阈值。

工具结果块中的分页

当发生截断时,工具结果块包括 [Showing results with pagination = limit: 250] 注解。 模型读取此内容并知道它可以再次调用该工具 offset=250 获取下一页。

5. ripgrep 二进制解析

每个 Grep 和 Glob 调用最终都会调用 ripgrep。 但 which ripgrep 二进制运行依赖于三模式解析链,每个进程评估一次并进行记忆。

Check USE_BUILTIN_RIPGREP
如果错误:尝试系统 rg
如果在 PATH 上找不到:检查捆绑模式
如果捆绑 (Bun): embedded
Else: vendor/ripgrep/<arch>-<platform>/rg

system

用户有 USE_BUILTIN_RIPGREP 设置为假值并且 rg 位于路径上。 通过命令名称使用系统安装的二进制文件 rg (不是解析的路径 - 请参阅下面的安全说明)。

embedded

在捆绑(本机)Bun 模式下运行。 ripgrep 静态编译为 Bun 可执行文件。 生成通过 process.execPath with argv0='rg' — 过程检查 argv[0] 并作为 ripgrep 调度。

builtin

默认 npm 安装。 特定于平台的二进制文件发布于 vendor/ripgrep/<arch>-<platform>/rg[.exe]。 在 macOS 上,可能需要代码签名步骤(见下文)。

显示:getRipgrepConfig() 记忆工厂
type RipgrepConfig = {
  mode: 'system' | 'builtin' | 'embedded'
  command: string
  args: string[]
  argv0?: string
}

const getRipgrepConfig = memoize((): RipgrepConfig => {
  const userWantsSystemRipgrep = isEnvDefinedFalsy(process.env.USE_BUILTIN_RIPGREP)

  if (userWantsSystemRipgrep) {
    const { cmd: systemPath } = findExecutable('rg', [])
    if (systemPath !== 'rg') {
      // SECURITY: Use command name 'rg', NOT systemPath
      // Prevents ./rg.exe in cwd from being executed (PATH hijacking)
      return { mode: 'system', command: 'rg', args: [] }
    }
  }

  if (isInBundledMode()) {
    return {
      mode: 'embedded',
      command: process.execPath,
      args: ['--no-config'],
      argv0: 'rg',
    }
  }

  // builtin: platform-specific vendored binary
  const command = process.platform === 'win32'
    ? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
    : path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
  return { mode: 'builtin', command, args: [] }
})

内置二进制文件的 macOS 代码签名

在 macOS,销售的 rg 二进制文件仅附带链接器签名(不是临时签名)。 macOS Gatekeeper 阻止未签名或最低签名的二进制文件运行。 第一次使用时, codesignRipgrepIfNecessary() 检查 linker-signed in codesign -vv 输出并重新签署二进制文件 codesign --sign - (自签名/临时)。 它还会删除隔离 xattr (com.apple.quarantine)将 macOS 添加到下载的文件中。

这仅适用于 builtin mode. 假定嵌入式和系统 ripgrep 二进制文件已经正确签名。 协同设计检查也由 alreadyDoneSignCheck — 每个进程生命周期最多运行一次。

生成策略:execFile 与 spawn

嵌入模式(与 argv0)必须使用 spawn() — 节点的 execFile 不支持 argv0 需要覆盖才能使 Bun 可执行文件相信它是 rg。 所有其他模式均使用 execFile 与一个 maxBuffer: 20MB cap.

6. 性能架构

超时策略

超时是平台感知的: 20秒 在标准平台上, 60秒 在 WSL 上(文件 I/O 损失是 3-5 倍)。 可重写通过 CLAUDE_CODE_GLOB_TIMEOUT_SECONDS。 Kill 升级首先使用 SIGTERM,然后在 5 秒后使用 SIGKILL — 因为在深度文件系统遍历上被阻止的 ripgrep 可能不会响应 SIGTERM。

使用单线程回退进行 EAGAIN 重试

在资源受限的环境(Docker、CI)中,ripgrep 可能会失败并显示 EAGAIN(os error 11 /“资源暂时不可用”)生成工作线程时。 重试策略是精确的:一次重试 -j 1 (单线程)仅适用于该特定调用。 以前的版本在全局范围内保留单线程模式,但这会导致大型存储库超时,其中 EAGAIN 是暂时的启动错误。

显示:EAGAIN重试逻辑
if (!isRetry && isEagainError(stderr)) {
  logForDebugging(`rg EAGAIN error, retrying with -j 1`)
  logEvent('tengu_ripgrep_eagain_retry', {})
  ripGrepRaw(
    args, target, abortSignal,
    (retryError, retryStdout, retryStderr) =>
      handleResult(retryError, retryStdout, retryStderr, true),
    true, // singleThread = true for this call only
  )
  return
}

function isEagainError(stderr: string): boolean {
  return (
    stderr.includes('os error 11') ||
    stderr.includes('Resource temporarily unavailable')
  )
}

用于文件计数的流式传输 (ripGrepFileCount)

遥测呼叫 countFilesRoundedRg() 使用专用的流计数器而不是 ripGrep()。 在具有 247k 文件的存储库中,将所有路径缓冲到字符串中,然后按换行符分割,会在内存中实现约 16MB。 流版本计算每个块的换行字节数——峰值内存是一个流块(~64KB)。 为了隐私,结果四舍五入到最接近的 10 次方(例如,8 → 10、42 → 100、8500 → 10000)。

缓冲区上限:20MB

The MAX_BUFFER_SIZE 常量设置为 20MB。 具有 200k+ 文件的大型 monorepos 可以轻松生成大于 Node 默认 1MB 的标准输出 execFile 缓冲。 如果超出缓冲区,ripgrep 将返回一个 ERR_CHILD_PROCESS_STDIO_MAXBUFFER 错误 - 代码将此视为部分结果并删除最后一行(可能不完整)。

路径相对化节省了令牌

所有返回的路径都从绝对路径转换为相对路径(通过 toRelativePath) 在包含在发送到模型的工具结果中之前。 绝对路径如 /Users/moiz/project/src/utils/ripgrep.ts 花费更多的代币 src/utils/ripgrep.ts。 在包含 250 个文件的结果集中,每次调用可以节省数百个令牌。

并发安全

Glob 和 Grep 都声明 isConcurrencySafe() = true。 它们是只读操作,没有共享可变状态——模型可以(并且确实)在同一回合内并行发出多个搜索调用。

行长度上限:500 个字符

Grep 追加 --max-columns 500 每个 ripgrep 调用。 这可以防止 Base64 编码的数据、缩小的 JavaScript 或其他长单行内容淹没输出。 超过 500 个字符的行将被截断 [omitted long line] 来自 ripgrep 的注释。

7.VCS 目录排除

Grep 会自动从每次搜索中排除这些版本控制系统目录:

const VCS_DIRECTORIES_TO_EXCLUDE = [
  '.git', '.svn', '.hg', '.bzr', '.jj', '.sl',
] as const

其中包括 Git、Subversion、Mercurial、Bazaar、Jujutsu 和 Sapling。 这些目录包含大型二进制对象、包文件和索引数据库 - 搜索它们会产生噪音并且速度可能非常慢。

全局确实 not 明确应用这些排除,但使用 --no-ignore 默认情况下,让 ripgrep 自己的遍历逻辑处理它们 - ripgrep 的内置行为会跳过 .git 目录除非被覆盖。

8. 模式安全:前导破折号问题

如果正则表达式模式以破折号开头(例如, -v),ripgrep 会将其解释为命令行标志而不是搜索模式。 GrepTool 正确处理这个问题:

// If pattern starts with dash, use -e flag to specify it as a pattern
// This prevents ripgrep from interpreting it as a command-line option
if (pattern.startsWith('-')) {
  args.push('-e', pattern)
} else {
  args.push(pattern)
}

The -e flag 告诉 ripgrep“接下来的是一个模式,而不是一个标志。” 这是 shell 工具常见的一类注入漏洞:如果用户提供的文本天真地附加到 CLI 命令,则前导破折号可以劫持标志解析。

安全说明:UNC 路径绕过。 Glob 和 Grep 都跳过文件系统 stat 要求以以下开头的路径 \\ or // (Windows 上的 UNC 路径)。 评论解释道:“安全:跳过 UNC 路径的文件系统操作以防止 NTLM 凭据泄漏。” 一个 stat 对 UNC 路径的调用会触发 SMB 身份验证握手,从而可能将 NTLM 哈希泄露给恶意服务器。

9. GrepTool 中的 Glob 模式解析

The glob GrepTool 的参数接受一种或多种文件模式来过滤搜索哪些文件。 解析逻辑处理两种情况:

显示:glob 模式分割逻辑
if (glob) {
  const globPatterns: string[] = []
  const rawPatterns = glob.split(/\s+/)

  for (const rawPattern of rawPatterns) {
    // Brace patterns must NOT be split on commas
    // e.g. "*.{ts,tsx}" is one pattern, not ["*.{ts", "tsx}"]
    if (rawPattern.includes('{') && rawPattern.includes('}')) {
      globPatterns.push(rawPattern)
    } else {
      // Split comma-separated patterns without braces
      globPatterns.push(...rawPattern.split(',').filter(Boolean))
    }
  }

  for (const globPattern of globPatterns.filter(Boolean)) {
    args.push('--glob', globPattern)
  }
}

模型可以通过 glob: "*.js,*.ts" (逗号分隔)或 glob: "*.{ts,tsx}" (大括号扩展)。 解析器正确识别大括号模式并将它们作为单个传递 --glob ripgrep 的参数,它本身处理大括号扩展。

The type 参数提供了另一种选择: type: "js" 映射到 ripgrep 的 --type js,它使用 ripgrep 的内置文件类型定义(包括 .js and .jsx)。 这是 更有效率 而不是 glob,因为 ripgrep 在启动时解析类型定义,而不是根据模式匹配每个路径。

要点

1
Glob 是带有 --files 的 ripgrep。 不涉及 JavaScript glob 库。 Glob 和 Grep 都委托给相同的 ripgrep 二进制文件,这使得它们在任何代码库大小上都可以快速运行,并且其遍历行为保持一致。
2
三种输出模式,三种 ripgrep 策略。 files_with_matches (-l), content (无标志),以及 count (-c)是根本不同的 ripgrep 调用——而不是后处理变体。 选择正确的模式可以避免不必要的工作。
3
路径相对化之前的头部限制是一种有意的优化。 广泛的模式可以返回数万行。 在对每行进行字符串处理之前切片至 250 可消除多余的工作。 显式的 head_limit=0 是逃生舱口。
4
二进制分辨率是三层的并且是可记忆的。 system → embedded → builtin。 安全细节并不明显:当使用系统 ripgrep 时,该命令是拼写的 "rg" (不是解析的路径)以防止本地路径劫持 rg.exe.
5
EAGAIN 重试的范围仅限于单个调用。 全局持续使用单线程模式会导致大型存储库的回归。 重试使用 -j 1 仅针对该一次调用,并在调用后立即恢复多线程行为。 暂时性错误不应永久降低性能。
6
mtime 排序是默认的相关性信号。 In files_with_matches 模式,结果按修改时间排序(最近的在前)。 假设是:您刚刚编辑的文件比几个月没有碰过的文件与当前任务更相关。

知识检查

Q1. Glob 工具在内部真正依赖的是什么?
正确! 虽然名字叫 Glob,但它并没有走 JS glob 库,而是直接借 ripgrep 做高性能文件遍历和模式过滤。
Q2. files_with_matches 模式与 content 模式的关键差异是什么?
正确! 这不是简单的输出格式差异,而是两条不同的 ripgrep 调用路径。只要文件列表时,用 -l 的路径能显著省 token。
Q3. 为什么 Grep/Glob 的结果会在某些模式下按修改时间排序?
正确! 这是 Claude Code 明确加入的相关性信号,不是 ripgrep 默认行为。最近改过的文件往往更值得优先展示给模型。
Q4. 当搜索模式以破折号开头,比如 -v,代码为什么要改用 -e pattern
正确! 这本质上是命令行注入防护。前导破折号如果直接拼接到命令后面,会被 ripgrep 当成 flag,而不是待搜索模式。
Q5. 为什么在资源受限环境下,ripgrep 遇到 EAGAIN 时只对当前调用回退到 -j 1
正确! 旧做法是全局切到单线程,但那会把后面所有大仓库搜索都拖慢。现在只对当前失败调用退一次,属于更窄范围的性能回退。
0/5