- 编译器
- 编程语言
- 开发工具
【免费下载链接】rescript-compiler
ReScript is a robustly typed language that compiles to efficient and human-readable JavaScript.
导读:本文基于 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 语法采用手写实现的核心原因可以归纳为三点:
- 语法演进频繁:ReScript 语言仍在快速演化(如 JSX v4、数据优先管道
->、数组字面量[...]等语法变更),手写递归下降解析器对语法的增删改更直接可控,不像生成器那样需要维护"语法描述语言 → 目标语言"的额外转换层。 - 错误信息质量:手写解析器可以在任意位置插入上下文(如文档中提到的"面包屑" breadcrumb 机制,见 compiler/syntax/src/res_parser.ml 中的
leave_breadcrumb/eat_breadcrumb),给出"看到=>时期待一个匹配模式"这类精准提示,而不是生成器常见的"unexpected token"。 - 与打印器(printer)天然配套:ReScript 语法模块同时承担"解析"与"格式化输出"两个职责,两者共享同一套词法/语法知识,手写实现更利于保持 AST 往返一致(roundtrip 稳定)。
从源码看,扫描器确实保留了
Diamond模式(set_diamond_mode/in_diamond_mode,compiler/syntax/src/res_scanner.ml),用于处理字符串插值等需要切换词法上下文的场景;解析器通过regions与begin_region/end_region控制错误是否上报,这些都印证了手写解析器在工程上的灵活度。
环境要求与依赖安装
开发语法模块需要以下前置条件:
| 要求 | 说明 |
|---|---|
| OCaml | 4.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-testmake 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.resCLI 完整参数表
结合 compiler/syntax/cli/res_cli.ml 中的参数解析实现,res_parser支持以下选项:
| 选项 | 含义 | 默认值 |
|---|---|---|
-recover | 出错时仍输出部分 AST(partial ast) | 关闭 |
-print <target> | 输出目标:binary、ml、ast、sexp、comments、tokens或res | res |
-width <n> | 指定打印器的行宽(格式化宽度) | 100 |
-interface | 按接口文件(.resi)解析 | 关闭 |
-jsx-version <n> | 应用内置 ppx:none、3、4 | none |
-jsx-module <name> | 指定 JSX 模块 | react |
-typechecker | 按"传给类型检查器"的 AST 解析(而非打印器视角) | 关闭 |
-test-ast-conversion | 测试 AST 版本转换(v0 ↔ 当前版本往返) | 关闭 |
从源码看,
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 benchmake 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。
同步时的几条约定:
- 少量调整是允许的:个别行会按语法仓库的需要轻微改动,这些改动都在
res_diagnostics_printing_utils.ml文件内注释说明; - 保持轻量、零依赖:两份文件尽量不引入依赖,便于未来同步;
- 语法侧目前只有错误、没有警告,且一次解析可能产生多条错误(
diagnostics是一个列表); - 未来愿景:理想情况下错误报告逻辑最终应与 GenType、Reanalyze 统一,避免到处复制粘贴;
- 语法解析器在上报错误的位置见 compiler/syntax/src/res_diagnostics.ml(如
unexpected/expected/uident/lident/unclosed_string等类别构造函数,以及print_report打印函数); - 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 srcAPI 背后的调用链
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.
相关推荐
Tree-sitter 编写语法测试:以 Corpus 测试构建解析器的行为契约
Tree sitter 编写语法测试:以 Corpus 测试构建解析器的行为契约 Tree sitter 是一个面向编程工具的自增解析系统。在为一门语言编写语法
开发工具Mr. Data Converter安全考量:数据验证与XSS防护最佳方案
Mr. Data Converter安全考量:数据验证与XSS防护最佳方案 Mr. Data Converter是一款强大的开源工具,能够将Excel中的CSV
编译器编程语言开发工具Julia Compiler.jl 开发调试指南:以 stdlib 方式激活与测试编译器模块
Julia Compiler.jl 开发调试指南:以 stdlib 方式激活与测试编译器模块 导读 在 Julia 语言仓库中,编译器( Compiler/ 目
编程语言编译器语言运行时标准库JIT编译
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考