解剖 Roc 快照测试:记录(Record)重复字段从词法、解析到类型推断的完整编译流水线
2026/9/18 13:21:36 网站建设 项目流程

解剖 Roc 快照测试:记录(Record)重复字段从词法、解析到类型推断的完整编译流水线

【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc

本篇以快照文件 error_duplicate_fields.md 为主体,逐段拆解 Roc 编译器处理「同一条记录字面量中字段名重复」这一错误场景时,词法(TOKENS)、语法解析(PARSE)、格式化(FORMATTED)、规范化(CANONICALIZE)与类型推断(TYPES)各阶段的真实产物,并结合 src/canonicalize/Can.zig 中的重复字段检测源码,说明该诊断在编译流水线中的落点,以及如何使用 snapshot 工具复现和更新这一基线。

快照测试是什么:一个「错误用例」的完整基线

Roc 仓库用快照测试(snapshot testing)来固化编译器各阶段的行为。如 test/snapshots/README.md 所述,每个快照文件针对一段 Roc 代码,捕获编译流水线中各阶段的输出——词法化、解析、规范化、类型检查等——作为已知正确的「黄金基线」(golden snapshot)提交进 Git;任何阶段行为发生漂移都会在测试比对中暴露。src/snapshot_tool/README.md 进一步说明,测试工具运行编译器并与这些黄金文件逐项比对,存在差异即判为失败。

而 error_duplicate_fields.md 正是 test/snapshots/records/ 目录下的一组记录字面量用例之一,META 段给出了它的身份定义:

description=Record with duplicate field names (error case) type=expr

description说明这是一个「记录中字段名重复」的错误用例;type=expr表示它按表达式快照处理(README 中列举的快照类型包括type=filesnippetexpr等),即把 SOURCE 中的内容当作一个独立表达式走完整条编译流水线。该目录下的兄弟用例(如 record_shorthand_fields.md、pattern_destructure_simple.md、statement_record_binding.md)覆盖了记录字面量、模式解构、绑定等正常形态,本篇用例则专门考察字段名重复这一异常形态下各阶段如何表现。

测试输入:一条含两组重复字段的记录字面量

SOURCE 段是整条流水线的输入,也是本用例的核心:

{ name: "Alice", age: 30, name: "Bob", email: "alice@example.com", age: 25 }

五个字段中name出现两次(值分别为"Alice""Bob"),age出现两次(值分别为3025),email仅一次。这为后续阶段提供了可观察的「去重/诊断」行为:解析阶段会如实保留全部 5 个字段,规范化阶段则将其收敛为 3 个字段——这一对比是本用例最有价值的技术信息。

TOKENS 阶段:28 个词法单元,重复字段在词法层完全不可见

词法化阶段把源码切分为如下词法单元(文件中的 TOKENS 段):

OpenCurly,LowerIdent,OpColon,StringStart,StringPart,StringEnd,Comma,LowerIdent,OpColon,Int,Comma,LowerIdent,OpColon,StringStart,StringPart,StringEnd,Comma,LowerIdent,OpColon,StringStart,StringPart,StringEnd,Comma,LowerIdent,OpColon,Int,CloseCurly, EndOfFile,

共 28 个单元(含末尾EndOfFile)。从中可以读出几个词法层事实:

  • 字符串字面量被拆成StringStart/StringPart/StringEnd三个单元,这为编译器后续支持多行字符串与字符串插片的增量处理保留了结构;
  • 整数字面量是单个Int单元(3025各自独立成词);
  • 字段名都是LowerIdent词法器只认 token 形态,不做任何「字段名是否已出现过」的判断——从 token 序列本身无法区分合法记录与重复字段记录。这解释了为何重复字段的处理必须留给更高层。

PARSE 阶段:纯结构转换,重复字段全部保留

解析阶段的产物是一份 S-expression 形式的 AST(PARSE 段):

(e-record (field (field "name") (e-string (e-string-part (raw "Alice")))) (field (field "age") (e-int (raw "30"))) (field (field "name") (e-string (e-string-part (raw "Bob")))) (field (field "email") (e-string (e-string-part (raw "alice@example.com")))) (field (field "age") (e-int (raw "25"))))

结构上:顶层是e-record节点,下辖 5 个field子节点,每个子节点形如(field (field "字段名") 值表达式)。两个关键事实:

  1. 解析器对重复字段不做任何拒绝nameage各出现两次,5 个字段在 AST 中完整存在。这说明 Roc 的解析阶段是纯粹的结构转换——它负责把 token 流组织成语法树,语义合法性(含字段唯一性)不是它的职责;
  2. 解析 IR 中的字段名以原始文本形式携带(如(field "name")),值表达式还保留着raw源文本((raw "Alice")(raw "30")),为后续阶段的字面量求值与诊断定位提供了依据。

FORMATTED 阶段:格式器不触碰语义错误

NO CHANGE

格式化阶段的结果是NO CHANGENO CHANGE表示该表达式按现有风格已经合规,格式化器原样输出,不会试图「修复」重复字段。这符合格式化工具的通用边界——它只关心排版,不做语义改写。

CANONICALIZE 阶段:去重发生在这里,「首次出现者胜出」

规范化(canonicalization)是本用例行为收敛的关键阶段,其产物(CANONICALIZE 段)为:

(e-record (fields (field (name "name") (e-string (e-literal (string "Alice")))) (field (name "age") (e-num (value "30")))) (field (name "email") (e-string (e-literal (string "alice@example.com"))))))

与 PARSE 段对照,可以看到三处实质性变化:

  1. 字段从 5 个收敛为 3 个:第二个name: "Bob"与第二个age: 25被丢弃,保留的是每组重复中的首次出现值name: "Alice"age: 30)。这就是规范化器对重复字段采取的策略:后写覆盖不成立,而是首值胜出、重复项被静默舍弃进入类型推断;
  2. 字段名被内部化(intern):解析 IR 的(field "name")变为(name "name"),字段名不再只是裸文本,而是与规范化器统一的名字空间对接;
  3. 字面量被求值定型:字符串从(e-string-part (raw "Alice"))变成(e-literal (string "Alice")),整数从(e-int (raw "30"))变成(e-num (value "30"))——原始 token 文本被替换为带类型的字面量节点。

源码印证:seen-fields 栈与 duplicate_record_field 诊断

从 src/canonicalize/Can.zig 的源码结构看,规范化器对记录字面量维护了一个「已见字段」栈:

  • 字段scratch_seen_record_fields(见 Can.zig 处声明)按规范化深度压栈/弹栈,保证嵌套记录之间互不干扰;
  • 在处理.record表达式分支时,规范化器对每个字段先遍历当前栈顶以来已记录的字段,若field_name_ident.eql(seen_field.ident)命中,就推送一条duplicate_record_field诊断(见 Can.zig 的检测块),并跳过该重复字段的后续规范化;if (found_duplicate) continue;正是「重复项被丢弃、首值保留」这一行为的实现本体;
  • 该诊断的语义定义在 src/canonicalize/Diagnostic.zig:
duplicate_record_field: struct { field_name: Ident.Idx, duplicate_region: Region, original_region: Region, },

它同时记录重复字段的文本与其首次出现的位置区域duplicate_region/original_region),以便在报错时同时标注两处;Diagnostic.zig 进一步把该诊断的高亮区域映射到duplicate_region。从源码结构看,Can.zig 中存在多处相同的检测模式(记录字面量的不同规范化路径),说明这一检查覆盖了多条进入记录规范化的入口。

需要说明的是:本快照中 PROBLEMS 段为NIL,即该基线在生成时点记录的是「编译未产出报告」的行为。快照是生成时刻的冻结基线——若编译器版本演进后诊断行为发生变化(例如开始为重复字段推送duplicate_record_field报告),该文件的 PROBLEMS 段就会与当前行为不一致,需要按下一节的流程用 snapshot 工具重新生成。这也正体现了快照测试「行为变更必须显式更新基线」的工作方式。

TYPES 阶段:字段按名排序、整数默认 Dec

类型推断的最终产物(TYPES 段)为:

(expr (type "{ age: Dec, email: Str, name: Str }"))

这一行携带两个可直接验证的类型系统事实:

  • 记录类型以字段名字典序打印:源码中字段顺序为 name、age、email,类型表示却输出age: Dec, email: Str, name: Str。Roc 的结构性记录类型以字段名集合为身份,打印时统一排序,与书写顺序无关——这也是「字段顺序不影响类型相等」这类语义的展示基础;
  • 无后缀整数字面量的默认数值类型是Dec3025最终对应age: Dec,字符串对应name/email: Str。即一个裸整数在 Roc 中默认落在十进制浮点Dec上,若要整型需显式加类型后缀。

EXPECTED 段:NIL 的含义

EXPECTED 段为NIL。对该用例而言,它表示该快照不捕获求值结果——用例的关注点是「各编译阶段如何处理这条有语义毛病的表达式」,而非运行产物。这与type=expr的组合是 records 目录下错误类用例的常见形态。

如何在本仓库中复现与更新这一基线

结合 test/snapshots/README.md 给出的用法,围绕本用例的常用操作如下:

  • 全量重新生成所有快照:zig build run-snapshot-tool
  • 只更新本文件:zig build run-snapshot-tool -- test/snapshots/records/error_duplicate_fields.md
  • 用当前诊断(problems)反推更新期望值:zig build run-snapshot-tool -- test/snapshots/records/error_duplicate_fields.md --update-expected

执行后,工具会重新走一遍「词法 → 解析 → 格式化 → 规范化 → 类型检查」流水线,并把各阶段输出写回快照文件,与本文逐段拆解的结构一一对应。

小结

error_duplicate_fields.md 这份快照文件以极小的输入(一行记录字面量)钉住了 Roc 编译流水线的完整行为剖面:词法层对重复字段无感知(28 个 token 如实切分);解析层保持纯结构性,5 个字段全数入 AST;格式化层不越界改写语义;规范化层完成去重并采用「首次出现者胜出」策略,其实现对应 Can.zig 中基于scratch_seen_record_fields栈的逐字段比对,配套诊断定义于 Diagnostic.zig;类型层则以排序的字段集合给出{ age: Dec, email: Str, name: Str },同时演示了整数字面量默认Dec的规则。读懂这一份快照,等于掌握了 Roc 快照测试体系的读写方式,以及一个典型错误场景在编译器各阶段之间的传递规律。

【免费下载链接】rocA fast, friendly, functional language.项目地址: https://gitcode.com/GitHub_Trending/ro/roc

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

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

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

立即咨询