搜索工具
概览
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) |
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 上下文,多行模式。
Result: content 细绳, numLines.
count
返回每个文件 匹配计数 in filename:N 格式。 对于“这种模式出现的频率和位置?”很有用。
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,然后按降序排序。 最近修改的文件首先出现。 这是有意为之的:最近更改的文件与当前任务最相关。
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 - 对于大多数搜索来说足够慷慨,同时防止广泛模式上的上下文膨胀。
显示: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:
limit=0是无限逃生舱口。 模型可以通过head_limit=0当它明确想要所有结果而不管大小时。 模式描述警告“谨慎使用——大型结果集浪费上下文。”appliedLimit仅当发生截断时才设置。 如果完整结果符合限制,appliedLimitisundefined。 仅当实际上有更多内容需要翻页时,模型才会看到分页提示。- 头部限制发生在路径相对化之前。 在内容模式下,每一行都需要字符串操作将绝对路径转换为相对路径。 通过先切片,您可以避免处理将被丢弃的数千行。 返回 10k 行的宽泛模式仅使保留的 250 行相对化。
工具结果块中的分页
当发生截断时,工具结果块包括 [Showing results with pagination = limit: 250] 注解。 模型读取此内容并知道它可以再次调用该工具 offset=250 获取下一页。
5. ripgrep 二进制解析
每个 Grep 和 Glob 调用最终都会调用 ripgrep。 但 which ripgrep 二进制运行依赖于三模式解析链,每个进程评估一次并进行记忆。
USE_BUILTIN_RIPGREPrgembeddedvendor/ripgrep/<arch>-<platform>/rgsystem
用户有 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 命令,则前导破折号可以劫持标志解析。
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 在启动时解析类型定义,而不是根据模式匹配每个路径。
要点
files_with_matches (-l), content (无标志),以及 count (-c)是根本不同的 ripgrep 调用——而不是后处理变体。 选择正确的模式可以避免不必要的工作。head_limit=0 是逃生舱口。system → embedded → builtin。 安全细节并不明显:当使用系统 ripgrep 时,该命令是拼写的 "rg" (不是解析的路径)以防止本地路径劫持 rg.exe.-j 1 仅针对该一次调用,并在调用后立即恢复多线程行为。 暂时性错误不应永久降低性能。files_with_matches 模式,结果按修改时间排序(最近的在前)。 假设是:您刚刚编辑的文件比几个月没有碰过的文件与当前任务更相关。知识检查
files_with_matches 模式与 content 模式的关键差异是什么?-l 的路径能显著省 token。-v,代码为什么要改用 -e pattern?-j 1?