Roc 编译器快照测试实战:以 record_mixed_types.md 剖析混合类型记录字面量的编译流水线验证
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
Roc 编译器仓库使用“快照测试”(snapshot testing)把每段示例代码经过词法、语法、规范化(canonicalization)与类型检查各阶段的产物固化为金色文件(golden file),以此捕获编译器行为回归。本文以 test/snapshots/records/record_mixed_types.md 这个真实快照文件为主线,完整拆解一份包含字符串、整数、布尔标签、列表与浮点数字段的记录字面量是如何被分词、解析、规范化并推导出类型的,并结合 src/canonicalize/ 源码解释其中一个字段为何被标记为nested_value_not_found错误节点。读完后你将掌握 Roc 快照文件各小节的含义、快照工具的更新命令,以及如何从源码定位编译诊断的产生点。
快照测试在 Roc 编译器中的角色
根据 test/snapshots/README.md 的说明,快照测试通过捕获特定 Roc 代码示例在每个编译阶段的输出来验证编译器行为,覆盖的阶段包括分词(tokenization)、解析(parsing)、规范化(canonicalization)与类型检查(type checking)等。每个快照文件记录“期望输出”:编译器实际运行后,工具会将产物与文件内容比对,任何差异都会导致测试失败,从而检测编译器行为发生意外变化时的回归。
金色快照文件直接提交进仓库,由 Git 跟踪并随代码库一起接受审查——这一点在 src/snapshot_tool/README.md 中有明确描述:“The golden snapshots are committed to the repository and are therefore tracked by Git and checked along with any changes to the codebase.”
快照还做了职责拆分,使语义变更与呈现变更不会出现在同一批文件里:
- 普通快照(
type=file、snippet、expr等)捕获诊断的语义。其PROBLEMS小节包含每个reporting.Report的规范化 S 表达式序列化(由src/reporting/report_sexpr.zig完成):severity、标题、源码区域以及完整的文档结构(文本、注释、源码摘录、下划线)。其中不含任何渲染器细节——没有盒线字符、ANSI 转义、折行或标记。NIL表示编译未产生任何报告。 - Reporting 快照(
type=reporting,位于reporting/目录)锁定渲染器输出:把同一批语义报告依次渲染为REPORT、CLI、MARKDOWN、HTML、LSP各格式各占一节。布局、折行、标点和标记只在这里被钉死。
因此,纯渲染器改动只应影响reporting/下的文件;诊断语义变化则体现在普通快照中。
快照文件逐节剖析:record_mixed_types.md
下面逐节解读这份“记录字面量混合字段类型”(Record with mixed field types)的快照。
META:元信息
description=Record with mixed field types type=exprdescription是该用例的说明;type=expr表明 SOURCE 小节被当作一个独立的顶层表达式来编译(而非整个模块file或片段snippet)。
SOURCE:被测的 Roc 代码
{ name: "Alice", age: 30, active: Bool.true, scores: [95, 87, 92], balance: 1250.75 }这一行 Roc 代码是一个含 5 个字段的记录字面量,恰好覆盖了多种字段类型:字符串("Alice")、整数(30)、带限定符的布尔标签(Bool.true)、整数列表([95, 87, 92])与浮点字面量(1250.75)——这正是该快照想固化的“混合类型记录”场景。
EXPECTED 与 PROBLEMS
两节均为NIL:EXPECTED是运行期期望输出,PROBLEMS是诊断语义的 S 表达式序列化,NIL分别表示该快照不期望运行期可观测输出、且此快照未钉入诊断报告(按 README 约定,NIL表示编译未产生报告)。
TOKENS:词法分析产物
OpenCurly,LowerIdent,OpColon,StringStart,StringPart,StringEnd,Comma,LowerIdent,OpColon,Int,Comma, LowerIdent,OpColon,UpperIdent,NoSpaceDotLowerIdent,Comma,LowerIdent,OpColon,OpenSquare,Int,Comma, Int,Comma,Int,CloseSquare,Comma,LowerIdent,OpColon,Float,CloseCurly, EndOfFile,词法器把 SOURCE 切成 31 个 token 后以EndOfFile收尾。可以逐段对应回源码:
OpenCurly/CloseCurly:记录的花括号边界;- 每个
name:字段名由LowerIdent+OpColon组成; - 字符串字面量
"Alice"被切成三段:StringStart、StringPart、StringEnd,说明词法器按“起始/内容/结束”分段处理字符串; 30、95、87、92识别为Int,1250.75识别为Float;- 布尔值部分
Bool.true产生两个相邻 token:UpperIdent(Bool)与NoSpaceDotLowerIdent(紧跟点号的小写标识符),列表边界则是OpenSquare/CloseSquare。
PARSE:语法分析树(S 表达式)
(e-record (field (field "name") (e-string (e-string-part (raw "Alice")))) (field (field "age") (e-int (raw "30"))) (field (field "active") (e-ident (raw "Bool.true"))) (field (field "scores") (e-list (e-int (raw "95")) (e-int (raw "87")) (e-int (raw "92")))) (field (field "balance") (e-frac (raw "1250.75"))))记录字面量被解析为e-record节点,每个字段是(field (field "字段名") <字段值表达式>)的二层结构。注意此阶段的几个要点:
- 字段值保持“原始字面量”语义:字符串是
e-string包一个e-string-part,整数是e-int (raw "30"),浮点是e-frac (raw "1250.75"),raw参数保留了源码中的原始文本; Bool.true此时只是一个e-ident (raw "Bool.true")——解析器并不理解Bool模块或true标签,它只看到“一个带点号的限定标识符原文”;- 列表
[95, 87, 92]是e-list包裹三个e-int。
FORMATTED:格式化幂等性
NO CHANGENO CHANGE表示把 SOURCE 交给 Roc 格式化器(formatter)后输出与输入完全一致,即该写法已经符合roc fmt的规范形态,格式化是幂等的。
CANONICALIZE:规范化之后的表达式
(e-record (fields (field (name "name") (e-string (e-literal (string "Alice")))) (field (name "age") (e-num (value "30"))) (field (name "active") (e-runtime-error (tag "nested_value_not_found"))) (field (name "scores") (e-list (elems (e-num (value "95")) (e-num (value "87")) (e-num (value "92"))))) (field (name "balance") (e-frac-dec (value "1250.75")))))规范化阶段把语法树转换为编译器内部的 CIR(Canonical IR)表达式,对比 PARSE 小节可以看到几处实质性变化:
- 字段结构从
(field (field "name") ...)变为(field (name "name") ...),列表从直接平铺元素变为显式的(elems ...)包装; - 字符串从
(e-string-part (raw "Alice"))变为(e-string (e-literal (string "Alice")))——raw原文被提升为语义化的string值; - 数字字面量
e-int (raw "30")变为e-num (value "30"):从“源码里的原始文本”提升为“已求值的数字”; e-frac (raw "1250.75")变为e-frac-dec (value "1250.75"):dec后缀说明该浮点字面量被固定为 Roc 的Dec数字类型(这也是 TYPES 小节中balance: Dec的来源);active字段变成了(e-runtime-error (tag "nested_value_not_found")):Bool.true这个限定引用在规范化时未能解析,该字段表达式被替换为一个带错误标签的运行时错误节点。
TYPES:类型检查结果
(expr (type "{ active: Error, age: Dec, balance: Dec, name: Str, scores: List(Dec) }"))整个顶层表达式被推断出记录类型{ active: Error, age: Dec, balance: Dec, name: Str, scores: List(Dec) }(字段按字母序排列):
| 字段 | 推断类型 | 说明 |
|---|---|---|
name | Str | 字符串字面量 |
age | Dec | 裸数字字面量默认归入Dec |
active | Error | 规范化失败,表达式为错误节点,故字段类型记为Error |
scores | List(Dec) | 列表元素为数字字面量,按同一约定推得List(Dec) |
balance | Dec | e-frac-dec的直接体现 |
active: Error与 CANONICALIZE 小节中的e-runtime-error节点一一对应:该字段因为规范化失败而无法参与正常类型推断,类型检查器只能给它记为Error。
源码印证:nested_value_not_found 诊断如何产生
快照中active字段的错误标签并非偶然,可以在规范化器源码中完整追踪到它的定义与产生路径:
诊断结构定义:src/canonicalize/Diagnostic.zig 中定义了该诊断的载荷结构:
nested_value_not_found: struct { parent_name: Ident.Idx, nested_name: Ident.Idx, region: Region, },它携带“父名”(如
Bool)与“嵌套名”(如true)两个标识符索引及源码区域,正对应Bool.true这种两段式引用。诊断的产生点:src/canonicalize/Can.zig 中处理限定标识符的规范化路径里,当按模块名解析不到对应模块、且多段引用链也找不到关联项时,就会生成该诊断:
const parent_ident = try self.joinedQualifierIdent(qualifier_tokens); return try self.canonicalizedMalformedExpr(Diagnostic{ .nested_value_not_found = .{ .parent_name = parent_ident, .nested_name = ident, .region = region, } });随后
canonicalizedMalformedExpr把表达式替换为错误节点——这就是 CANONICALIZE 小节中(e-runtime-error (tag "nested_value_not_found"))的由来。节点到 CIR 诊断的落地:src/canonicalize/NodeStore.zig 负责把诊断节点反查回
CIR.Diagnostic,保证下游(类型检查、报告渲染)拿到的仍是完整的nested_value_not_found载荷。
从源码结构看,Bool.true在这份快照所处的编译器版本里走的是“模块限定引用”解析路径而非内置布尔标签路径,因此解析失败被规范化为错误节点而不是布尔常量;快照的作用正是把这个行为钉死,防止后续重构让Bool.true的处理悄悄改变。
快照工具的生成与更新命令
依据 test/snapshots/README.md 的 Usage 一节,快照的日常操作命令为:
- 生成全部快照:
zig build run-snapshot-tool - 更新指定快照:
zig build run-snapshot-tool -- <file_path>(例如本文的test/snapshots/records/record_mixed_types.md) - 从 problems 更新期望值:
zig build run-snapshot-tool -- <file_path> --update-expected - 在
META中加source_escapes=true可将回车字节以\r形式写入SOURCE - 调试 REPL 求值轨迹:
zig build run-snapshot-tool -- <repl_snapshot.md> --trace-eval(仅对type=repl的单个快照文件有效;release 构建需加-Dtrace-eval=true启用)
小结
record_mixed_types.md虽然只有一个记录字面量,却完整串起了 Roc 编译器的四条流水线:TOKENS 记录分词粒度(含NoSpaceDotLowerIdent这类与点号相邻的 token 细节),PARSE 记录纯语法结构(Bool.true尚是原文标识符),CANONICALIZE 记录语义解析与数字定型(e-frac-dec)以及解析失败的错误节点,TYPES 记录最终推断出的记录类型{ active: Error, age: Dec, balance: Dec, name: Str, scores: List(Dec) }。配合 src/snapshot_tool/ 描述的金色文件机制与 src/canonicalize/Diagnostic.zig 中的诊断定义,这类快照既是测试基线,也是阅读编译器实现时最直观的“行为文档”:每一个 S 表达式节点都能在src/canonicalize/与src/parse/中找到对应的产生代码。
【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考