ripgrep 源码搜索:从零命中追到可审计证据
那个明明存在、却怎么也搜不到的旧接口
一次支付接口迁移中,维护者运行 rg 'legacyCharge',终端没有任何输出,于是判断旧调用已经清零。发布后,一个被 .gitignore 排除的本地生成目录仍把旧接口编进制品;另一个同名字符串藏在 UTF-16 配置里,也没有进入默认结果。问题不在搜索速度,而在团队把“当前命令没有返回匹配”误当成了“整个交付语料不存在”。
ripgrep 的可贵之处恰好也是它最容易制造误判的地方:它会递归、并行地搜索文本,同时主动跳过噪声。每次结论都由三个输入共同决定:从哪里开始遍历、哪些路径和文件能进入候选集、模式用哪种引擎解释。只有把这三层和退出码一起保存,零命中才是一份可以复核的证据。
先让每台开发机得到同一个 rg
ripgrep 的可执行文件名是 rg。以下行为以 15.2.0 发布线为基线;15.2.0 修复了跨多个搜索目录时的 Git ignore 匹配问题,并改进了超大目录树遍历,所以从较早版本升级时,ignore 冲突与多根目录查询必须重新跑误漏检样例。项目采用 MIT 或 Unlicense 双许可,是安装在开发机或 Runner 上、继承当前进程文件权限的本地 CLI;它没有需要部署的服务端,也没有“托管版/自托管版”之分。
Windows 可以从 winget、Scoop 或 Chocolatey 安装,macOS 常用 Homebrew,Linux 优先使用发行版仓库;需要严格固定版本时,可从 官方发布页 下载发布制品并按随附摘要校验后进入内部工具镜像。Ubuntu Snap 已不再是上游推荐入口。下面任选与机器一致的一条,不要在同一台机器混用多个包管理器维护同一个二进制。
winget install BurntSushi.ripgrep.MSVC
# 或:scoop install ripgrep
# 或:choco install ripgrep
rg --versionbrew install ripgrep # macOS
sudo apt-get install ripgrep # Debian / Ubuntu
sudo dnf install ripgrep # Fedora
rg --version版本输出证明命令解析到了某个二进制,还要检查实际路径,避免 IDE 终端、CI 和系统终端使用不同安装:
Get-Command rg | Select-Object -ExpandProperty Sourcecommand -v rg项目不应假设所有发行版仓库都提供相同功能。团队把经验证的版本线写进开发环境清单或工具镜像,并在脚本启动时记录 rg --version;该输出还会报告 PCRE2 是否可用。升级时重新跑 ignore、多根目录、多行、编码、二进制和结构化输出样例,而不是只确认安装成功。14.1.1 曾修复会造成假阴性的匹配优化缺陷,15.2.0 又修复 Git ignore 多目录匹配,说明“版本能运行”不能代替搜索正确性回归。
一次搜索实际经过了什么
rg PATTERN PATH 先从 PATH 遍历目录,再让 ignore、隐藏文件、二进制和符号链接策略缩小候选文件,最后才把字节解码并送入正则引擎。默认模式是 Rust regex 语法,擅长避免灾难性回溯,但不支持反向引用和环视;只有查询确实需要这些能力时才启用 PCRE2。
这条链没有预建源码索引,也不生成 AST。目录遍历与 ignore 匹配由 ignore 库组织,多个 ignore glob 会编译成集合并在并行遍历中判定路径;进入文件后,默认 regex 引擎使用有限自动机、字面量提取与 SIMD 等优化寻找文本。结果因此只代表“这次遍历实际读到的字节”,不会像索引服务那样代表另一个已同步快照,也不会像 AST/LSP 那样区分注释、字符串、重载或符号身份。
这也解释了为什么“正则写对了”仍可能零命中:文件也许根本没有走到引擎。排障时先问候选语料,再问模式,顺序不能反过来。
正向实验:建立可重复的搜索基线
在一个临时目录创建最小仓库。下列 Bash 命令适用于 Linux、macOS、Git Bash 或 WSL;PowerShell 用户可以创建同名文件后直接执行相同的 git 与 rg 命令。
mkdir rg-lab && cd rg-lab
git init -q
mkdir -p src generated .secrets
printf 'export const charge = legacyCharge(order);\n' > src/payment.ts
printf 'legacyCharge generated\n' > generated/client.ts
printf 'legacyCharge hidden\n' > .secrets/note.txt
printf 'generated/\n' > .gitignore
printf '.secrets/\n' > .rgignore
git add .gitignore .rgignore src/payment.ts先执行默认查询并立即读取退出码:
rg -n -F 'legacyCharge'; status=$?
printf 'exit=%s\n' "$status"预期只有 src/payment.ts,并得到 exit=0。-n 输出行号,-F 把输入当字面量,避免函数名中的正则元字符改变含义。此时 generated/client.ts 被 .gitignore 排除,.secrets/note.txt 同时受隐藏路径和 .rgignore 约束。
把查询限定到 TypeScript 并显示上下文,可以得到更适合代码评审的结果:
rg -n -F 'legacyCharge' --type ts -C 1 src
rg --type-list | rg '^ts:'--type ts 本质上是一组内置 glob,不是语言解析器;--type-list 才是当前二进制实际采用的扩展名证据。仓库有自定义后缀时,可在当前命令增加类型:
rg --type-add 'service:*.{ts,tsx,vue}' --type service -F 'legacyCharge' src--type-add 默认只影响这次进程。把它放进共享配置前,应先验证所有目标机器的版本与 shell 解析一致。
ignore 的优先级必须按冲突结果理解
默认递归搜索会跳过隐藏文件、含 NUL 字节的二进制文件和被 ignore 规则排除的路径,也不会跟随符号链接。ignore 不是一张扁平名单:Git 的规则包括仓库内 .gitignore、仓库的 $GIT_DIR/info/exclude 和 Git 全局 core.excludesFile;在冲突时,.ignore 可覆盖 Git ignore,.rgignore 又可覆盖 .ignore。同一层规则遵循 gitignore 风格的“更具体位置与后出现规则决定最终状态”,命令行 -g/--glob 则相当于最具体的临时规则。
现在用更高优先级的 .rgignore 重新纳入生成文件:
printf '!generated/\n.secrets/\n' > .rgignore
rg -n -F 'legacyCharge'预期结果同时出现 src/payment.ts 和 generated/client.ts。这不是 .gitignore 失效,而是 ripgrep 专属规则显式覆盖了它。若只想临时纳入某个路径,不要改团队文件:
rg -n -F 'legacyCharge' -g 'generated/**'遇到意外跳过时运行:
rg --debug -F 'legacyCharge' .--debug 会输出路径被哪类规则决定的诊断。它适合排障,不适合作为稳定机器接口;自动化应消费 --json,而不是解析 debug 文本。
Git 工作树识别为什么会改变结果
.gitignore 的语义依赖仓库边界。默认情况下,ripgrep 需要识别到 Git 仓库,才会把 Git ignore 规则作为版本控制规则应用;父目录中的 .gitignore 也只在同一工作树边界内参与。源码压缩包、CI 导出的无 .git 目录、容器 COPY 后的裁剪目录,可能因此与开发机得到不同候选集。
用一个不含 .git 元数据的目录可以稳定暴露这个差异:
mkdir ../rg-export
cp -R src generated .gitignore ../rg-export/
cd ../rg-export
rg -n -F 'legacyCharge'
rg --no-require-git -n -F 'legacyCharge'第一条在当前版本上会搜索 generated,因为这里不是已识别的 Git 工作树;第二条要求即使没有仓库元数据也读取 Git ignore,预期只剩 src/payment.ts。因此,CI 若使用源码导出包,应显式选择 --no-require-git 或改用 --ignore-file 指定受控规则,不能假设开发机默认行为会自动复制过去。反过来,--no-require-git 会让任意父目录的 Git ignore 参与搜索,起始路径必须受控,并用 --debug 验证没有继承用户目录中的意外规则。
-u 不是一个模糊的“全搜”开关
重复 -u 会逐级关闭默认过滤:
rg -u -n -F 'legacyCharge' # 不采用 ignore 规则
rg -uu -n -F 'legacyCharge' # 再纳入隐藏文件和目录
rg -uuu -n -F 'legacyCharge' # 再把二进制按文本搜索在实验仓库中,-u 应纳入 generated,但仍不进入隐藏的 .secrets;-uu 才会出现 .secrets/note.txt。-uuu 还会把含 NUL 的文件当文本,可能向终端写控制字符。三个层级都不会自动跟随符号链接;那是独立的 -L/--follow 决策,可能越出仓库、重复遍历或触碰敏感目录。
生产脚本不宜用 -uuu 掩盖语料不清。更稳妥的方式是显式组合 --hidden、--no-ignore-vcs、-g 与受控起始目录,让每个放宽动作都能被审查。
glob、type 和配置字段怎样改变候选集
-g '*.ts' 只纳入匹配 glob 的路径,-g '!vendor/**' 排除路径;后出现的 glob 可以覆盖先前规则。至少有一个正向 glob 时,文件必须匹配某个正向 glob才有资格进入搜索。shell 可能提前展开 *,因此 glob 应加引号。
rg -F 'legacyCharge' -g '*.ts' -g '!generated/**'
rg -F 'legacyCharge' --type ts --type-not dts团队常用选项可以写进一个普通文本配置,每行一个 shell 参数。rg 不会自动从仓库发现这个文件,必须通过 RIPGREP_CONFIG_PATH 启用:
--hidden
--glob=!.git/**
--type-add=service:*.{ts,tsx,vue}
--smart-caseexport RIPGREP_CONFIG_PATH="$PWD/.config/ripgrep.conf"
rg --type service legacyCharge
rg --no-config legacyCharge--hidden 会扩大到点文件,必须同时保留 .git、密钥目录和构建缓存排除;--smart-case 在模式含大写字母时切换为区分大小写,会改变命中集合;--no-config 是排除个人配置干扰的复现实验入口。共享脚本最好直接写全关键参数,或把配置路径固定为仓库内受评审文件,不依赖用户环境变量。
正则、PCRE2 与多行匹配的升级信号
先用默认引擎和字面量解决大多数源码搜索:
rg -n -F 'legacyCharge('
rg -n '\blegacyCharge\s*\('只有需要环视或反向引用时才使用 -P/--pcre2,并在目标二进制上验证 PCRE2 可用:
rg -n -P 'legacyCharge\((?!testOrder)' srcPCRE2 提高表达力,也可能引入回溯成本。来自用户或外部系统的模式不应直接进入无限制 PCRE2 查询;至少限制根目录、文件类型和文件大小,并由调用方设置进程超时。文本正则仍不理解重载、注释、字符串、动态调用或类型身份;当问题变成“这个符号的所有引用”或“只改某种语法结构”,应升级到 LSP 或 AST 工具。
默认按行匹配。跨行结构需要 -U/--multiline 允许一次匹配越过换行,而点号是否匹配换行仍由 --multiline-dotall 或模式内 (?s) 决定:
printf 'legacyCharge(\n order\n)\n' > src/multiline.ts
rg -n -U 'legacyCharge\(\s*order\s*\)' src
rg -n -U '(?s)legacyCharge\(.*?\)' src反向实验是去掉 -U。即使正则看起来包含 \s*,匹配也不会跨过行边界,退出码应为 1。在大文件上启用多行会改变缓冲与匹配成本,应先用 type、glob 和 --max-filesize 缩小语料。
编码和二进制:字节没有进入同一条解释链
UTF-8 是最常见的源码基线,但遗留配置可能使用 UTF-16、GBK 或其他编码。-E/--encoding 指定解码器;错误编码可能得到零命中或解码替换,而不是一条显眼的业务错误。
Set-Content -Path legacy-utf16.txt -Value 'legacyCharge' -Encoding Unicode
rg -n -E utf-16le -F legacyCharge legacy-utf16.txt在类 Unix 环境也可用 iconv 创建样例。预期指定正确编码后退出 0;用错误编码查询并检查退出码,不能把无输出直接解释为不存在。ripgrep 没有“列出编码”的子命令;可用 rg --help 查看 --encoding 的本机说明,具体标签以它引用的 WHATWG Encoding Standard 为准,并在目标版本上用样例文件验证。
ripgrep 用 NUL 字节启发式识别二进制,但“发现 NUL 就简单跳过整个文件”并不精确。递归遍历的默认模式会在判为二进制后停止该文件;显式传入单个文件时会进入二进制模式,通常搜索到首个匹配或文件末尾,并用提示代替把原始二进制整段写到终端。--binary 可把遍历文件也切到这种保守探测模式,-a/--text 或第三个 -u 则完全关闭二进制检测,可能污染终端并在大文件上显著增加内存。
内部读取策略还会影响检测证据:使用内存映射时,NUL 检查集中在文件开头的一小段和匹配行;使用缓冲读取时,会检查搜索经过的全部字节。因此,一个前半段含匹配、末尾才出现 NUL 的大文件,可能随 mmap 策略呈现不同提示。需要稳定复核二进制判定时增加 --no-mmap,并做一组反证:先创建“开头有目标、末尾有 NUL”的 fixture,分别执行默认查询、--binary --no-mmap 与 -a,保存退出码、标准输出和标准错误。调查未知文件仍应先用 file、十六进制查看器或隔离输出文件确认格式;源码治理中更好的动作通常是排除真正二进制,并修正本应为文本的生成物编码。
--replace 只改输出,绝不会保存文件
下面的命令看起来完成了替换,实际只重写匹配结果的显示:
rg 'legacyCharge' src/payment.ts --replace 'chargeV2'
rg -n -F 'legacyCharge' src/payment.ts
git diff -- src/payment.ts第一条会打印 chargeV2,第二条仍能命中旧文本,git diff 为空。这组证据必须一起出现,因为 --replace/-r 的语义是“格式化输出中的匹配片段”,不是原地编辑器。捕获组如 $1 也只参与输出模板。
若要真正改代码,先用 rg 冻结候选集,再交给理解目标语言的重构工具;最低限度也应让编辑器生成 diff,并执行格式化、编译、测试和重复搜索。不能把 rg --replace 接重定向覆盖原文件:上下文、文件名、行号、颜色和多次匹配都可能让输出不再是合法源文件。
退出码与 JSON:让自动化区分“没有”与“失败”
rg 的关键退出状态是:至少找到一处匹配且没有错误为 0,没有匹配且没有错误为 1,参数、路径、读取或其他错误为 2。错误优先于命中;即使标准输出已经出现候选,只要同时有文件读取失败,最终状态仍可能是 2。shell 中直接写 set -e; rg pattern 会把合法的零命中当异常退出;粗暴追加 || true 又会吞掉真正错误。脚本应显式分支:
set +e
rg -n -F 'legacyCharge' src
status=$?
set -e
case "$status" in
0) echo '发现候选,进入人工分类' ;;
1) echo '当前受控语料零命中' ;;
*) echo "搜索执行失败,exit=$status" >&2; exit "$status" ;;
esac供程序消费时使用 --json。输出是逐行 JSON 事件,包含 begin、match、context、end 和最终 summary 等类型;消费者应按 type 分派,不解析彩色文本或依赖终端布局。
rg --json -F 'legacyCharge' src > rg-result.jsonl
rg -n '"type":"(match|summary)"' rg-result.jsonl保存证据时同时记录命令参数、起始提交、工作树状态、rg --version、配置文件摘要和退出码。单独保存命中数量无法解释漏检来自 ignore、编码、路径权限还是模式。
项目接入:把搜索变成只读门禁
迁移旧 API 时,可以在仓库提供一个只读检查脚本。它固定根目录和类型,排除已批准样例,并把“命中旧 API”定义为门禁失败:
#!/usr/bin/env bash
set -euo pipefail
if rg --no-config -n -F 'legacyCharge' \
--type-add 'app:*.{ts,tsx}' --type app \
-g '!test/fixtures/**' src; then
echo '仍存在 legacyCharge 候选' >&2
exit 1
else
status=$?
if [ "$status" -eq 1 ]; then
echo '受控源码语料已清零'
else
echo "ripgrep 执行失败,exit=$status" >&2
exit "$status"
fi
fi脚本故意使用 --no-config,防止开发者个人配置改变 CI 结果;允许排除项必须具体到受评审目录,不能用宽泛 vendor/** 隐藏未知代码。若制品包含生成代码,生成步骤之后还要对生成目录重复检查。若仓库使用 submodule、稀疏检出或 LFS 指针,门禁只能证明当前工作区已有文件;CI 必须先把交付所需语料完整物化。
清理、回滚与故障定位
本地实验只创建文件,没有守护进程。离开实验目录后删除 rg-lab 与 rg-export 即可;若设置了 RIPGREP_CONFIG_PATH,执行 unset RIPGREP_CONFIG_PATH,PowerShell 使用 Remove-Item Env:RIPGREP_CONFIG_PATH。卸载则交回原包管理器,例如 winget uninstall BurntSushi.ripgrep.MSVC 或 brew uninstall ripgrep。
线上排障可以按第一证据分型。默认零命中而 -u 命中,先查 ignore;-u 不命中而 -uu 命中,查隐藏路径;只有 -uuu 或 -a 命中,查二进制/NUL;显式 -E 命中,查编码;开发机与导出目录不同,查 Git 工作树识别和配置;退出 2,先修路径、权限或参数,禁止记成零命中。每次修复后回到原始受控命令再跑一遍,确保不是用无限放宽的查询掩盖问题。
容量、权限、敏感数据与成本
本地搜索没有服务端订阅费,但成本会转移到 CPU、磁盘 I/O、终端输出和人的审查时间。大型 monorepo 应先以目录、type、glob 和 --max-filesize 限定候选,再按需开启 -U、PCRE2、压缩文件预处理或符号链接跟随;用代表性仓库测量耗时和峰值内存,阈值随仓库规模和 Runner 预算制定。
权限模型很简单也很直接:rg 能读取当前进程身份可读的任何路径。--hidden、-u、-L 和过宽起始目录可能把 .env、私钥、依赖缓存或相邻工作区打印进终端、CI 日志和 JSON 制品。共享脚本使用仓库相对根目录、明确排除凭据目录,日志制品设置访问控制和保留期;不要把真实密钥当搜索样例,也不要上传包含源码片段的结果到公共工单。
长期治理关注的是“同一查询是否仍代表同一语料”。团队需要维护受控版本、共享配置、排除规则 owner、升级回归样例和零命中的证据字段。回归 fixture 至少保留一个应命中的正例和一个必须排除的反例,并覆盖多根目录、ignore 重新纳入、UTF-16、末尾 NUL 与 PCRE2;这样既能发现漏检,也能发现放宽参数造成的误报。新增 .rgignore、全局 Git ignore 或生成目录时,应像改构建输入一样评审;自动化若从文本搜索升级为改写,读权限与写权限分离,rg 只生成候选,写入由受保护分支、diff、测试和回滚流程接管。
什么时候换工具
只需快速确认字面量、正则或跨文件文本时,rg 是低成本首选;主要按文件名、扩展名、大小和路径收敛候选时,fd 更自然;需要交互挑选时,把候选交给 fzf。当结论依赖符号身份、重载和调用关系时使用 LSP;依赖语法节点时使用 AST 工具;跨多个未检出的仓库、默认分支和权限域时使用带索引与授权同步的代码搜索平台。
选择标准不是“哪个工具更强”,而是哪一层证据足以支撑当前决策。rg 的停止点是文本证据:它能准确回答受控字节语料里哪些行匹配,却不能证明编译器如何解析这些文本,更不能凭一次零命中替整个交付系统宣告不存在。
遇到版本差异时,先查 ripgrep Guide 的自动过滤、编码与二进制章节、目标二进制的 rg --help 和对应 Release Notes。命令帮助决定本机实际字段,发布说明用于识别修复、行为变化与需要补跑的回归,而不是拿最新版说明替代当前二进制证据。
