CW Logo
Back to posts

从 grep 到 ripgrep:终端代码搜索的演进与权衡

Developer ToolsAI-written

在 Unix 哲学里,grep 是那种几乎不需要解释的积木。它的名字源自 ed 文本编辑器里的命令 g/re/p(global regular expression print),设计目的很单纯:接收输入流或文件,逐行进行正则表达式匹配,然后输出命中的内容。半个世纪以来,无论是排查系统日志、在管道中过滤进程列表,还是在 Shell 脚本中做格式校验,grep 一直在那里。

但如果你在一个现代的前端或后端工程根目录下运行过递归搜索,很可能遇到过相似的尴尬场景:

bash
grep -rn "getUserProfile" .

终端在卡顿了几秒甚至几十秒后,倾泻出数万行来自 node_modules、打包产物 dist/、或者 .git/ 内部打包对象的二进制碎片。为了得到真正想要的那两行业务代码,你不得不追加冗长的过滤参数:

bash
grep -rn --exclude-dir={node_modules,.git,dist} "getUserProfile" src/

这就是开发体验出现断层的地方。grep 诞生在一个文件组织相对扁平、文本即一切的年代;而今天我们的开发环境充满了庞大的依赖树、构建缓存、版本控制元数据和深层目录。

正是在这一背景下,出现了一系列专为代码仓库打造的搜索工具。从 ack、The Silver Searcher(ag),再到由 Andrew Gallant(BurntSushi)用 Rust 编写的 ripgrep(命令名 rg),终端代码搜索的默认行为与性能边界被重新定义。

默认行为的转变:尊重工程约定

很多人初次接触 ripgrep 时,最直观的震撼是“它怎么这么快”。但只要仔细拆解日常场景就会发现,它快的第一原因往往不是算法有多神化,而是它默认跳过了那 90% 你根本不想搜的内容。

在任何 Git 仓库中,执行:

bash
rg "getUserProfile"

你不需要手动加上 -r(递归),也不需要手动告诉它别搜构建目录或依赖。ripgrep 默认具备以下几项开箱即用的行为:

  1. 自动递归:不带文件路径参数时,默认从当前目录开始递归向下扫描。
  2. 遵守忽略规则:原生解析 .gitignore、.ignore 和 .rgignore,自动避开项目显式忽略的文件与文件夹。
  3. 跳过隐藏与二进制文件:默认略过以 . 开头的隐藏文件(例如 .git/)以及检测出的二进制文件。
  4. 终端友好:在交互式终端中默认启用高亮、文件名分组并附带行号;重定向至管道时则自动退回为纯文本单行格式。

这并不意味着 ripgrep 把内容藏起来了。它通过设计紧凑的 -u(unrestricted)参数层级提供了清晰的逃生通道:

  • rg pattern:默认过滤(跳过 ignore 规则、跳过隐藏文件、跳过二进制)。
  • rg -u pattern:搜索隐藏文件(但仍遵守 ignore 规则)。
  • rg -uu pattern:搜索隐藏文件并忽略 ignore 规则(即搜遍几乎所有文本文件)。
  • rg -uuu pattern:搜索所有内容,包括将二进制文件视作文本一并搜索。

这种分级把“程序员在仓库里的直觉”做成了默认行为,同时把“系统级的穷举搜索”留为可组合的选项。相比每次在 grep 后面手动拼接 --exclude-dir,这种工效学的改善在日常开发中节省了大量注意力。

速度从何而来:不仅是多线程

即便在排除了 .gitignore 和庞大依赖之后,在纯文本的大规模匹配上,ripgrep 依然展现出了极高的吞吐能力。这得益于它在系统底层与文本处理架构上的几项关键设计:

1. 投机式的多线程目录遍历

传统的递归工具(包括简单的脚本包装)往往采用单线程遍历目录,或者在找到文件列表后再批量喂给搜索进程。ripgrep 实现了一个高度并发的目录遍历库(ignore crate),多个工作线程一边并发遍历文件系统树、评估复杂的 gitignore 规则,一边直接将准备好的文件描述符递交给搜索管道,让磁盘 I/O 调度和 CPU 解码保持高度重叠。

2. 正则引擎与 SIMD 字符跳跃

在单文件内容匹配阶段,真正的开销并不总是完整的正则表达式状态机演算。许多查询虽然写成了正则表达式,但其内部包含固定的字面量前缀或子串(例如 fn handle_[a-z]+ 中的 handle_)。

ripgrep 所依托的 Rust regex 和 aho-corasick 底层,利用了 SIMD(单指令多数据流)向量指令(如 AVX2、NEON)。它能在一个 CPU 周期内扫描数十个字节,极快地在内存块中寻找可能的字面量候选点,只有命中候选点时才触发更昂贵的状态机转移。配合内建的行换行符(\n)快速扫描,使得整行文本的吞吐速率非常接近内存带宽上限。

3. 正确且快速的 Unicode 处理

GNU grep 在设置了 UTF-8 语言环境(如 LC_ALL=en_US.UTF-8)时,性能经常会出现明显下滑,部分原因在于严苛的字符边界验证逻辑;而如果退回 LC_ALL=C,又会丢失对多字节字符和 Unicode 大小写折叠的正确支持。

ripgrep 从诞生之初就把 UTF-8 作为一等公民。它的正则引擎在字节层面直接构建匹配 UTF-8 编码序列的自动机,既保证了完整的 Unicode 属性支持,又避免了反复在字符(Char)与原始字节之间做昂贵的编解码转换。

常用命令对照

如果习惯了 grep 的传统参数,迁移到 ripgrep 几乎没有心智负担,但也有少量语法差异值得留意:

意图grepripgrep (rg)说明
基础递归搜索grep -rn "foo" .rg "foo"rg 默认递归并展示行号
忽略大小写grep -ri "foo" .rg -i "foo" 或 rg -S "foo"-S(smart case)在全小写时忽略,有大写时区分
全词匹配grep -rw "foo" .rg -w "foo"语义一致,匹配单词边界 \b
反向匹配grep -rv "foo" .rg -v "foo"打印不包含模式的行
仅列出文件名grep -rl "foo" .rg -l "foo"仅输出有匹配的文件路径
指定文件类型grep -rn --include="*.ts" "foo"rg -t ts "foo"rg 内置了常见类型预设,也可以用 -g "*.ts" 通配
跨多行匹配难(需借助 pcregrep 或 tr)rg -U --multiline "foo\nbar"rg 原生支持跨行模式
统计匹配行数grep -rc "foo" .rg -c "foo"输出各文件的匹配计数

一个特别好用的细节是 -t / -T(type 过滤)。不需要手写长通配符,rg -t rust "fn main" 就会自动涵盖 *.rs 文件;rg -T test "config" 则会排除测试相关的文件。你可以通过 rg --type-list 查看它支持的数十种预置语言规则。

什么时候依然需要 grep?

既然 ripgrep 在代码仓库和性能上有如此多优势,grep 会被完全淘汰吗?答案显然是否定的。

第一是普遍存在性(Ubiquity)。 无论你登录到一台最小化安装的 Alpine Linux 容器、嵌入式路由器、旧款 FreeBSD 机器,还是刚刚初始化的云主机,grep 永远都在那里,属于 POSIX 标准的核心组件。在编写通用的基础设施脚本、CI 镜像初始化逻辑或跨平台分发工具时,依赖 grep 意味着零额外依赖。

第二是单纯的流式处理与管道哲学。 当输入来源不是文件夹,而是管道重定向过来的纯文本流时:

bash
docker logs api-server | grep -E "ERROR|WARN"

这种场景下没有文件树需要遍历,没有 .gitignore 需要计算,也没有多核并行分治的发挥空间。grep 的极简与轻巧依然是管道中极其可靠的选择。

第三是高级正则表达式方言。 默认情况下,ripgrep 使用基于有限状态自动机(DFA/NFA)的正则引擎,能够保证最坏情况下的线性时间复杂度 $O(mn)$,从而避免正则表达式的灾难性回溯。但也正因如此,它默认不支持环视(lookaround)和反向引用(backreference)。虽然 ripgrep 提供了 -P(--pcre2)选项来切换到 PCRE2 引擎,但如果原本就有复杂的 GNU 正则或 Perl 正则脚本,直接使用熟悉的环境往往更稳妥。

小结

从 grep 到 ripgrep,并不是一个简单的“新语言重写旧工具”的故事。

它代表的是命令行工具在特定垂直领域内的自我进化:grep 坚守着通用文本流过滤的简单与基石地位;而 ripgrep 则精准切中了现代开发者在复杂代码仓库中的真实痛点,通过合理的工程约定、现代并发模型与 SIMD 优化,把“在代码里找东西”这件高频小事做到了极致流畅。

在终端里保留它们两个:写系统运维脚本与简单管道时随手用 grep,而在自己的代码仓库里定位问题时,敲下 rg。