为什么query-json放弃menhir改用手写递归下降解析器?Parser源码级剖析
【免费下载链接】query-jsonFaster, simpler and more portable implementation of jq-inspired language in OCaml项目地址: https://gitcode.com/gh_mirrors/qu/query-json
query-json 是一个用 OCaml 编写的 jq 风格 JSON 查询语言实现,主打更快、更简单、更可移植。它早期借助 menhir 生成解析器,而在 1.0.0 beta 版本中,项目把 menhir 解析器替换成了手写递归下降解析器。本文从源码层面拆解这次重构的动因与实现细节。
项目速览:query-json 是什么
query-json 的定位可以概括为一句话:像 sed 处理文本那样处理 JSON 的小语言。你给它一条查询语句和一个 JSON 文件,它返回查询结果。
它的几个特点:
- 🚀快:OCaml 编译为原生二进制,基准测试里全面对标 jq
- 🧩简单:函数统一 snake_case 命名(
to_string而非tostring),语义比 jq 更严格 - 🌐可移植:同一套内核既能编译成命令行工具,也能编译成 JavaScript 库在浏览器里跑
- ❓可选访问:用
?优雅处理缺失字段,如.missing?返回 null 而非报错
在终端里最基础的用法:
一次有记录的重构:从 menhir 到递归下降
这次替换在 CHANGES.md 的 1.0.0~beta-1 条目中有明确记录:
[REFACTOR] Replace menhir-based parser with hand-written recursive descent parser
三个事实佐证这次重构已经落地:
- 依赖清单里不再有 menhir。项目根目录 dune-project 声明的核心依赖只剩
sedlex(词法分析)、zarith(大整数)、mosaic(REPL)等,构建期解析器生成器已被移除 - 库依赖同样干净。核心库 source/dune 中只列了
json sedlex re unix zarith,没有任何 parser 生成代码的引用 - 旧文档还留有"案发现场"。benchmarks/README.md 中仍写着 "query-json uses Menhir, an LR(1) parser generator"——这是项目早期确实依赖 menhir 的旁证
为什么放弃 menhir?源码给出的四个理由
1️⃣ 精准到字符的语法错误提示
menhir 生成的解析器在错误处理上比较"一刀切",而 query-json 对错误体验投入了大量工作。手写的 source/Parser.ml 中,expect函数在每步都记录 token 的精确位置:
let expect stream expected = let token = stream.token in if token = expected then advance stream else let message = Printf.sprintf "expected %s, got %s" (Lexer.humanize expected) (Lexer.humanize token) in ...配合 source/Error.ml 中支持location、contexts、suggestion三个维度的错误类型,解析器能做到:
- 指出出错的具体行列,并画出
^^^指针 - 类型不匹配时给出"期望 vs 实际"的对比
- 缺失键时列出可用键名,并建议使用
?
这种"每一步都掌握位置信息"的写法,只有控制解析流程本身才能实现——这正是手写递归下降的天然优势。
2️⃣ 可移植:同一内核编译到浏览器
dune-project 里声明了三个包:
query-json:原生二进制 CLIquery-json-js:通过 js_of_ocaml 编译的 JS 库query-json-playground:基于 Melange/ReScript 的 Web 在线 playground
手写解析器是纯粹的 OCaml 函数,词法部分由sedlex在编译期展开为普通代码(见 source/dune 中的(pps ... sedlex.ppx)),没有额外的"生成物"环节。去掉 menhir 意味着整条构建链对所有目标平台都更干净,与项目"more portable"的口号完全一致。
3️⃣ 语法快速演进,手写更易维护
从 CHANGES.md 可以看到 1.0.0 beta 一口气加入的语法特性:foreach循环、reduce解构绑定(as [$i, $j])、字符串插值"\(expr)"、切片[1:3]、try/catch/finally、复合赋值+=等。
递归下降解析器中,每增加一条语法规则,就增加一个parse_xxx函数,结构与文法一一对应;而维护 LR(1) 文法时,每加一条规则都可能触发移/约冲突排查。对处于高速迭代的语言来说,手写的可控性价值巨大。
4️⃣ "simpler" 名副其实
menhir 是构建期的代码生成工具,意味着构建系统多一个环节。移除后,解析器就是一个 900 多行、可读性极高的 source/Parser.ml,新人可以直接通读全部解析逻辑。
手写递归下降解析器是如何工作的
词法层:sedlex 生成的 Lexer
source/Lexer.ml 定义了约 60 种 token,并用sedlex.regexp描述字面量模式,例如:
let decimal_number = [%sedlex.regexp? Plus digit, '.', Plus digit, Opt exponent | Plus digit, exponent]数字被刻意区分成INT/INT64/BIG_INT/DECIMAL四种 token,为后来"按最小适配类型存储数值"的高精度特性打下基础。此外还有humanize函数,把 token 渲染成人类可读的形式('['、"$x"、"text"),专门服务于错误消息。
核心骨架:stream + advance / peek / expect
解析器的全部状态装在一个轻量记录里(source/Parser.ml):
type stream = { buf : Sedlexing.lexbuf; (* 原始输入 *) mutable token : Lexer.token; (* 当前 lookahead token *) mutable start_pos : Lexing.position; mutable end_pos : Lexing.position; }围绕它只有三个动词:
| 函数 | 作用 |
|---|---|
advance | 从 Lexer 取下一个 token,并记录起止位置 |
peek | 只读当前 token,不消费 |
expect | 断言当前 token 等于期望值,不等则抛出带位置的解析错误 |
这就是递归下降的最小完备工具集:单 token 前瞻 + 位置追踪,既省内存又让每处错误都能定位到行列。
运算符优先级阶梯
优先级通过一长串互相递归的parse_xxx实现,层级从低到高依次是(见 source/Parser.ml):
parse_pipe_expr 管道 | 赋值 = += -= *= /= ?? parse_comma_expr 逗号 , parse_or_expr or parse_and_expr and parse_comparison == != < > <= >= parse_add_expr + - parse_mul_expr * / % parse_term → parse_postfix(. 键访问、[ ] 索引) parse_primary 字面量、变量、函数调用、{ } [ ] 构造每一层都是"先解析左操作数,再看是否出现本层运算符,是则递归右侧并循环"——这是递归下降处理中缀表达式的标准套路,OCaml 的尾递归优化保证了长表达式栈开销恒定。
一个真实例子:.books[1].author
这个典型查询的解析路径恰好被 source/test/Test_parse.ml 覆盖:
parse_primary看到DOT,进入parse_dot,取出键booksparse_postfix发现后面跟着[,进入parse_bracket_access- 读数字 token
1,遇到]收尾,生成Index [1] parse_postfix继续循环,发现.+author,再套一层Key
最终产出 AST:Pipe (Pipe (Key "books", Index [1]), Key "author")——层层嵌套的管道节点,与语法树天然同构。
字符串插值"Hello \(name)"则展示了手写解析器的另一重灵活:parse_interpolated_string 在解析表达式和扫描字符串片段之间反复横跳,把文字与子查询拼成Operation链——这种嵌套扫描逻辑放在 LR 文法里会非常别扭。
从这次重构学到的东西
- 错误提示是解析器的核心竞争力。手写解析器让你在每个 token 边界注入位置信息与上下文,换来的是用户能"看懂并自救"的报错
- 语言处于快速迭代期时,递归下降的维护成本更低。文法结构与函数结构一一对应,心智负担小
- 依赖减法也是架构决策。少一个构建期代码生成工具,多平台(原生 / JS / Web)构建就更简单
- 单 token 前瞻 + 位置追踪就足以支撑一个完整语言的前端,工具不必复杂
📂 想继续深挖的源码入口:
- source/Parser.ml —— 递归下降解析器主体(942 行,可全文通读)
- source/Lexer.ml —— sedlex 词法分析与 token 定义
- source/Ast.ml —— 语法树定义
- source/Error.ml —— 带位置、上下文与修复建议的错误模型
- source/test/Test_parse.ml —— "输入 → 期望 AST" 的回归用例
- CHANGES.md —— 完整的版本演进记录
【免费下载链接】query-jsonFaster, simpler and more portable implementation of jq-inspired language in OCaml项目地址: https://gitcode.com/gh_mirrors/qu/query-json
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考