☰
ReScript 编译器语法模块开发指南:手写解析器、构建调试与测试流程解析
2026/9/28 2:28:38 网站建设 项目流程
  • 编译器
  • 编程语言
  • 开发工具

【免费下载链接】rescript-compiler

ReScript is a robustly typed language that compiles to efficient and human-readable JavaScript.

项目地址:https://gitcode.com/gh_mirrors/re/rescript-compiler
点击查看免费下载

导读:本文基于 ReScript 编译器仓库中的 docs/Syntax.md 开发文档,系统讲解 ReScript 语法前端(.res/.resi文件的词法扫描、语法解析与格式化打印)的定位、构建方式、调试手段、测试与基准流程,并结合 compiler/syntax/ 下的源码实现,说明res_parser、res_core、res_driver等模块的调用关系与底层原理。读完本文,你将能够独立搭建语法模块的开发环境、运行核心与 roundtrip 测试、用 CLI 调试解析结果,并理解错误报告与编译器主仓库保持视觉一致的工程策略。

ReScript 是一门编译为高效、可读 JavaScript 的强类型语言,其编译器仓库中最重要的组成部分之一,是位于compiler/syntax/的语法前端:它负责把 ReScript 源码(.res、.resi)扫描、解析成 OCaml 的Parsetree,并反向把 AST 格式化打印回 ReScript 源码。与很多语言使用 Yacc/Bison 等解析器生成器不同,ReScript 的语法模块完全手写了扫描器与递归下降解析器(compiler/syntax/src/res_scanner.ml、compiler/syntax/src/res_core.ml),并自带格式化打印机(compiler/syntax/src/res_printer.ml)。


为什么选择手写解析器

文档原文引用了 Jonathan Blow 与 Casey Muratori 关于"为什么生产级语言要手写解析器"的讨论(视频《Making Programming Language Parsers, etc》),并摘录了 J. Blow 的一段话:"One reason why I switched off these parser tools is that the promises didn't really materialize. The amount of work that I had to do to change a yacc script from one language to a variant of that language was more than if I hand wrote the code myself."(我放弃解析器工具的一个原因是它的承诺并没有兑现:把一份 yacc 脚本从一个语言改造成其变体,所花的功夫比我自己手写代码还多。)

ReScript 语法采用手写实现的核心原因可以归纳为三点:

  1. 语法演进频繁:ReScript 语言仍在快速演化(如 JSX v4、数据优先管道->、数组字面量[...]等语法变更),手写递归下降解析器对语法的增删改更直接可控,不像生成器那样需要维护"语法描述语言 → 目标语言"的额外转换层。
  2. 错误信息质量:手写解析器可以在任意位置插入上下文(如文档中提到的"面包屑" breadcrumb 机制,见 compiler/syntax/src/res_parser.ml 中的leave_breadcrumb/eat_breadcrumb),给出"看到=>时期待一个匹配模式"这类精准提示,而不是生成器常见的"unexpected token"。
  3. 与打印器(printer)天然配套:ReScript 语法模块同时承担"解析"与"格式化输出"两个职责,两者共享同一套词法/语法知识,手写实现更利于保持 AST 往返一致(roundtrip 稳定)。

从源码看,扫描器确实保留了Diamond模式(set_diamond_mode/in_diamond_mode,compiler/syntax/src/res_scanner.ml),用于处理字符串插值等需要切换词法上下文的场景;解析器通过regions与begin_region/end_region控制错误是否上报,这些都印证了手写解析器在工程上的灵活度。


环境要求与依赖安装

开发语法模块需要以下前置条件:

要求说明
OCaml4.10 或更高版本
操作系统macOS、Linux 或 Windows

在rescript-compiler 仓库根目录执行以下命令,安装全部依赖(含测试依赖):

opam install . --deps-only --with-test

该命令会读取仓库根目录的rescript.opam与 dune-project,安装解析、格式化、测试所需的全部 opam 依赖。

构建语法源码

在仓库根目录运行:

make # 等价于 dune build

构建完成后会得到三个二进制产物(Windows 上带有.exe扩展名):

产物用途
res_parser语法 CLI,用于解析/打印.res、.resi文件(仅作仓库测试调试用途,见 compiler/syntax/cli/res_cli.ml 与 compiler/syntax/cli/dune 中的public_name res_parser)
syntax_tests核心测试程序,见 tests/syntax_tests/dune 中的public_name syntax_tests
syntax_benchmarks性能基准程序,见 tests/syntax_benchmarks/dune 中的public_name syntax_benchmarks

工程决策:即使在开发模式也只构建生产二进制(无单独 dev 二进制)。文档明确说明原因——构建足够快,没必要维护两份产物;同时能保证每次 diff 都对(生产)二进制做真实基准,避免"dev 二进制快、生产二进制慢"的偏差。


日常开发循环:修改 → 构建 → 测试

语法开发的标准工作流如下:

# 每次修改源码后重新构建 make # 运行核心测试 make test # 运行扩展测试(roundtrip,Windows 上尚未完全支持) make roundtrip-test
  • make test:运行核心测试,包含语法 CLI 快照测试与 OCaml 单元测试。
  • make roundtrip-test:往返一致性测试,验证"解析 → 打印 → 再解析 → 再打印"是否收敛(幂等性)。
  • 如果测试输出了差异,请判断是有意为之(如新增语法的格式化结果变化)——若是,直接 check in 更新后的期望文件(expected 文件位于tests/syntax_tests/data/*/expected/下)。

测试机制详解:仓库根 Makefile 中,test-syntax目标调用 scripts/test_syntax.sh,该脚本以 20 个进程为一组并行跑快照测试:对syntax_tests/data/parsing/{errors,infiniteLoops,recovery}下的文件用-recover -print ml生成期望输出,对printer/conversion用默认res打印,对ast-mapping用-test-ast-conversion -jsx-version 4,对ppx/react用-jsx-version 4;随后用git diff对比expected/目录,任何差异都会导致脚本以非零退出码结束。而test-syntax-roundtrip目标设置ROUNDTRIP_TEST=1再运行同一脚本,额外执行三遍解析/打印的幂等性校验(sexpAst2 == sexpAst3、rescript1 == rescript2)。

单文件调试

调试语法时,先把代码写入test.res,然后使用dune exec调用res_parser查看各种视角的输出:

# 测试打印机(默认输出 res 格式化结果) dune exec -- res_parser test.res # 打印 AST dune exec -- res_parser -print ast test.res # 打印注释表(comment table) dune exec -- res_parser -print comments test.res # 显示对应的 OCaml 代码 dune exec -- res_parser -print ml test.res # 测试打印机,并修改默认打印宽度(默认 100) dune exec -- res_parser -print res -width 80 test.res

CLI 完整参数表

结合 compiler/syntax/cli/res_cli.ml 中的参数解析实现,res_parser支持以下选项:

选项含义默认值
-recover出错时仍输出部分 AST(partial ast)关闭
-print <target>输出目标:binary、ml、ast、sexp、comments、tokens或resres
-width <n>指定打印器的行宽(格式化宽度)100
-interface按接口文件(.resi)解析关闭
-jsx-version <n>应用内置 ppx:none、3、4none
-jsx-module <name>指定 JSX 模块react
-typechecker按"传给类型检查器"的 AST 解析(而非打印器视角)关闭
-test-ast-conversion测试 AST 版本转换(v0 ↔ 当前版本往返)关闭

从源码看,-print的每个目标对应一个独立的"打印引擎"(compiler/syntax/cli/res_cli.ml):binary→Res_driver_binary,ml→Res_driver_ml_printer,ast/sexp/comments→Res_ast_debugger,tokens→Res_token_debugger,res→Res_driver。其中tokens目标完全绕过解析,直接走词法扫描(if target = "tokens" then ...),是观察词法层面的利器。CLI 顶部还注明了该命令仅供仓库开发者测试使用,禁止用于生产("DO NOT use it in production")。

基准测试

make bench

make bench调用syntax_benchmarks可执行程序。基准脚本(tests/syntax_benchmarks/benchmark.ml)对tests/syntax_benchmarks/data/下的真实代码样本(RedBlackTree.res、Napkinscript.res、HeroGraphic.res)分别测量**解析(Parse)与打印(Print)**两项指标,输出每个用例的"每次运行毫秒数(ms/run)"与"每次运行分配的字数(allocs/run)"(JSON 格式)。每次基准前执行Gc.full_major ()强制完整 GC,保证测量稳定;打印基准还会构建Comment_table以计入注释处理开销。

开启 OCaml 栈回溯

遇到崩溃需要看堆栈时,在运行二进制前设置:

# 在运行二进制之前 export OCAMLRUNPARAM="b"

文档建议把这一行写进 shell 的 rc 文件(如~/.bashrc、~/.zshrc),这样每次打开 shell 都自动启用 OCaml 栈回溯,调试异常事半功倍。


源码目录结构

语法模块的源码组织如下(对应仓库 compiler/syntax/):

compiler/syntax/ ├── src/ # 全部解析器/打印机源码 └── cli/ # res_parser CLI 入口(res_cli.ml)
  • src/:包含所有解析器与打印机源码,包括:
    • 词法层:res_scanner.ml(扫描器)、res_token.ml(token 定义)
    • 语法层:res_core.ml(递归下降解析主体,约 7700 行)、res_grammar.ml(语法上下文枚举,用于错误信息)
    • 解析器状态机:res_parser.ml(mode、diagnostics、comments、regions等状态与make/next/expect/lookahead等原语)
    • 驱动与 API:res_driver.ml、res_core.mli
    • 打印层:res_printer.ml、res_doc.ml、res_comments_table.ml、res_multi_printer.ml
    • 诊断层:res_diagnostics.ml、res_reporting.ml、res_diagnostics_printing_utils.ml
    • JSX 处理:jsx_ppx.ml、jsx_v4.ml
    • AST 调试/转换:res_ast_debugger.ml、res_token_debugger.ml、res_parsetree_viewer.ml
  • benchmarks(即tests/syntax_benchmarks/)、cli、tests(即tests/syntax_tests/):存放用于测试/基准的可执行程序源码。

文档强调:不要随意改动目录结构(Don't change folder structure without notice),因为语法模块的 dune 构建规则、测试脚本的路径硬编码(如tests/syntax_tests/data、tests/syntax_benchmarks/data)都依赖这套结构。


错误报告机制:与编译器保持视觉统一

语法模块与 ReScript 编译器主仓库各自维护独立的错误报告机制(架构原因),但终端输出的错误样式必须统一。当前的做法是小心地同步复制错误报告逻辑:

  • 编译器侧的 compiler/core/ir_diagnostics.ml(文档中对应super_location.ml)与错误代码框逻辑(对应super_code_frame.ml);
  • 语法侧的 compiler/syntax/src/res_diagnostics_printing_utils.ml。

同步时的几条约定:

  1. 少量调整是允许的:个别行会按语法仓库的需要轻微改动,这些改动都在res_diagnostics_printing_utils.ml文件内注释说明;
  2. 保持轻量、零依赖:两份文件尽量不引入依赖,便于未来同步;
  3. 语法侧目前只有错误、没有警告,且一次解析可能产生多条错误(diagnostics是一个列表);
  4. 未来愿景:理想情况下错误报告逻辑最终应与 GenType、Reanalyze 统一,避免到处复制粘贴;
  5. 语法解析器在上报错误的位置见 compiler/syntax/src/res_diagnostics.ml(如unexpected/expected/uident/lident/unclosed_string等类别构造函数,以及print_report打印函数);
  6. ReScript 的编辑器插件(rescript-vscode)会同时解析来自编译器与语法模块两处的错误报告,统一渲染。

从源码看错误为何能"带上下文":compiler/syntax/src/res_diagnostics.ml 的print_report会针对特殊 token 做"最佳努力"提示,例如遇到|时若前一个字符是[,会提示"你是不是想写数组字面量[ ... ]?旧语法[| ... |]已改为[ ... ],快速修复:把[|换成[、|]换成]";若后一个字符是>,则提示"旧的数据末位管道|>已从语言中移除,请改用数据优先的->管道"。而解析器状态中的breadcrumbs((Grammar.t * Lexing.position) list)记录了解析到当前 token 时所处语法上下文的栈,错误信息据此生成"在=>之前期待匹配模式"这类精准文案(见 compiler/syntax/src/res_diagnostics.ml)。


语法模块 API 使用示例

文档给出了语法模块作为库被程序化调用的最小示例。以下代码将foo.res文件解析为structure(实现体)与signature(接口体),并输出诊断:

let filename = "foo.res" let src = FS.readFile filename let p = (* 面向 OCaml 类型检查器 *) let mode = Res_parser.ParseForTypeChecker in (* 若想面向打印机,则使用:let mode = Res_parser.Default in *) Res_parser.make ~mode src filename let structure = Res_core.parseImplementation p let signature = Res_core.parseSpecification p let () = match p.diagnostics with | [] -> () (* 没有问题 *) | diagnostics -> (* 解析器发现问题 *) Res_diagnostics.printReport diagnostics src

API 背后的调用链

  • Res_parser.make ~mode src filename创建解析器状态机(compiler/syntax/src/res_parser.ml),内部会调用Scanner.make创建扫描器,并预先扫描第一个 token(next parser_state),同时挂接扫描器的错误回调把词法错误追加进diagnostics。
  • mode只有两种取值(compiler/syntax/src/res_parser.mli):
    • ParseForTypeChecker:面向类型检查器,AST 按编译器需要的形式产出;
    • Default:面向打印机。 驱动层 compiler/syntax/src/res_driver.ml 在setup时根据for_printer布尔值选择相应模式。
  • Res_core.parseImplementation/parseSpecification(compiler/syntax/src/res_core.ml)分别以Grammar.Implementation/Grammar.Specification为入口,逐项解析Parsetree.structure/Parsetree.signature。
  • 面向编译器的更高级 API 是 compiler/syntax/src/res_driver.mli 中的parse_implementation/parse_interface(与 OCaml pparse driver 兼容,[@@raises Location.Error]),以及parsing_engine/print_engine记录:前者提供parse_implementation(_from_source)/parse_interface(_from_source)四件套并携带diagnostics、comments、invalid标记;后者提供带width与comments的打印四件套。parse_result中invalid = diagnostics <> []的判定可见 compiler/syntax/src/res_driver.ml。

这套 API 在仓库内被真实使用:syntax_tests的单元测试(tests/syntax_tests/res_test.ml)直接调用Res_parser.make、Res_core.parse_implementation,并验证 LF/CRLF 换行下pstr_loc.loc_start.pos_lnum的行号正确性;syntax_benchmarks(tests/syntax_benchmarks/benchmark.ml)也用同样方式构造 parser 后解析、打印并计时。此外 compiler/syntax/src/res_driver.mli 顶部注释说明parse_implementation/parse_interface即"ReScript 编译器内部使用的、兼容 OCaml pparse driver 的解析入口"——也就是说,编译器主流程正是通过这条 API 消费语法模块。

UTF-16 感知的定位信息(实现细节)

扫描器(compiler/syntax/src/res_scanner.ml)在记录位置时做了 UTF-16 兼容处理:offset是当前字节偏移,offset16是自行首以来的 UTF-16 码元数,line_offset是行首偏移;构造Lexing.position时pos_cnum = line_offset + offset16(见 compiler/syntax/src/res_scanner.ml)。这保证了在含 emoji、中文等多字节字符的源码中,错误定位与编辑器光标位置(按 UTF-16 计)一致——这正是 ReScript 语法需要手写扫描器(而非简单按字节扫描)的又一体现。


常见工作流小结

场景命令
安装依赖opam install . --deps-only --with-test
构建语法模块make(或dune build)
构建后重新验证make(增量构建,产物为res_parser、syntax_tests、syntax_benchmarks)
核心测试make test
roundtrip 扩展测试make roundtrip-test
单文件调试dune exec -- res_parser test.res(配合-print ast/comments/ml/res、-width 80等)
基准make bench
栈回溯export OCAMLRUNPARAM="b"

相关文档与源码索引

  • 语法模块源码目录:compiler/syntax/src/
  • CLI 实现与全部参数:compiler/syntax/cli/res_cli.ml
  • 解析器状态机与 API:compiler/syntax/src/res_parser.mli、compiler/syntax/src/res_parser.ml
  • 解析入口与打印引擎:compiler/syntax/src/res_driver.mli
  • 语法主体(递归下降解析):compiler/syntax/src/res_core.ml
  • 错误报告实现:compiler/syntax/src/res_diagnostics.ml
  • 语法快照测试目录:tests/syntax_tests/data/(含parsing/、printer/、conversion/、idempotency/、ast-mapping/、ppx/、api/、oprint/)
  • 测试脚本与基准:scripts/test_syntax.sh、tests/syntax_benchmarks/benchmark.ml

结语:ReScript 语法模块是一套完整的"手写扫描器 + 递归下降解析器 + 格式化打印机"体系,它在仓库中以库的形式存在,通过Res_parser/Res_core/Res_driver等 API 被编译器主流程消费。围绕 docs/Syntax.md 定义的开发循环(make→make test→make roundtrip-test→make bench)与res_parserCLI 调试手段,配合syntax_tests/data的快照测试与syntax_benchmarks的性能基准,你可以在不触碰编译器其余部分的情况下独立迭代、验证并贡献语法能力。

  • 编译器
  • 编程语言
  • 开发工具

【免费下载链接】rescript-compiler

ReScript is a robustly typed language that compiles to efficient and human-readable JavaScript.

项目地址:https://gitcode.com/gh_mirrors/re/rescript-compiler
点击查看免费下载

相关推荐

上一篇:ROMP项目安装与配置完全指南
下一篇:d3-scale-chromatic:D3.js 颜色方案的终极指南 - 从入门到精通

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询