为什么query-json放弃menhir改用手写递归下降解析器?Parser源码级剖析
2026/8/25 9:12:23 网站建设 项目流程

为什么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

三个事实佐证这次重构已经落地:

  1. 依赖清单里不再有 menhir。项目根目录 dune-project 声明的核心依赖只剩sedlex(词法分析)、zarith(大整数)、mosaic(REPL)等,构建期解析器生成器已被移除
  2. 库依赖同样干净。核心库 source/dune 中只列了json sedlex re unix zarith,没有任何 parser 生成代码的引用
  3. 旧文档还留有"案发现场"。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 中支持locationcontextssuggestion三个维度的错误类型,解析器能做到:

  • 指出出错的具体行列,并画出^^^指针
  • 类型不匹配时给出"期望 vs 实际"的对比
  • 缺失键时列出可用键名,并建议使用?

这种"每一步都掌握位置信息"的写法,只有控制解析流程本身才能实现——这正是手写递归下降的天然优势。

2️⃣ 可移植:同一内核编译到浏览器

dune-project 里声明了三个包:

  • query-json:原生二进制 CLI
  • query-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 覆盖:

  1. parse_primary看到DOT,进入parse_dot,取出键books
  2. parse_postfix发现后面跟着[,进入parse_bracket_access
  3. 读数字 token1,遇到]收尾,生成Index [1]
  4. 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询