从 `12.34()` 看 Roc 编译器错误诊断管线:浮点字面量调用快照测试深度解析
2026/9/21 2:00:27 网站建设 项目流程

【免费下载链接】roc

A fast, friendly, functional language.

项目地址:https://gitcode.com/GitHub_Trending/ro/roc
点击查看免费下载

导读

本文以 Roc 语言编译器仓库中的 eval 快照测试 test/snapshots/eval/call_float_literal.md 为核心线索,完整剖析"直接调用浮点字面量(如x = 12.34())"这一非法表达式如何在 Roc 编译器的词法分析、语法分析、规范化、类型推断与诊断报告各阶段被逐层捕获,并最终输出Missing Method运行时错误报告。读完本文,你将掌握 Roc 快照测试文件的完整结构与阅读方法、编译器各阶段中间表示(TOKENS / PARSE / CANONICALIZE / TYPES)的含义,以及诊断报告在 src/check/report.zig 中的真实生成逻辑。

快照测试:编译器行为的“黄金基线”

Roc 编译器使用快照测试(snapshot tests)来锁定编译管线各阶段的输出。仓库 test/snapshots/README.md 对此有权威说明:

Snapshot tests that validate compiler behavior by capturing the output of each compilation stage for specific Roc code examples.

也就是说,每个快照文件把一段 Roc 源码在**词法化(tokenization)、解析(parsing)、规范化(canonicalization)、类型检查(type checking)**等阶段的输出固化为期望结果,当编译器行为发生意外变化时,这些快照能立刻暴露回归。

快照测试还刻意做了“语义”与“呈现”的分离:

  • 普通快照type=filesnippetexpr等)只记录诊断的语义。其PROBLEMS区段是每个reporting.Report的规范 S 表达式序列化(见 src/reporting/report_sexpr.zig),包含严重级别、标题、源码区域以及完整的文档结构(文本、标注、源码摘录、下划线),不含任何渲染器细节(无框线字符、ANSI 转义、换行或标记)。
  • 报告快照type=reporting,位于reporting/目录)则锁定渲染器的最终输出,把同一批语义报告渲染成REPORTCLIMARKDOWNHTMLLSP等全部面向用户的格式。

本篇文章的主角call_float_literal.md属于普通快照(type=snippet),它的价值在于验证:编译器是否在“调用浮点字面量”这种非法场景下产生正确的诊断语义

解剖快照文件:九个区段的完整拼图

test/snapshots/eval/call_float_literal.md 全文只有 74 行,却浓缩了 Roc 编译器几乎完整的编译前端。其九个区段与编译阶段一一对应,顺序与 src/snapshot_tool/main.zig 中定义的输出顺序一致:METASOURCEEXPECTEDPROBLEMSTOKENSPARSEFORMATTEDCANONICALIZETYPES

META:测试的元信息

description=Calling a float literal directly (type error) type=snippet

description一句话概括了测试意图:直接调用浮点字面量是一个类型错误(type error)。type=snippet表明这是一个代码片段级快照,会走完整的编译诊断流程。

SOURCE:待测源码

x = 12.34()

这是整个测试的核心输入:一个名为x的顶层声明,其初始化表达式12.34()试图把浮点字面量12.34当作函数来调用。注意括号紧贴字面量、中间没有空格,这正是语法上“函数应用(application)”的形态。

EXPECTED:错误摘要

MISSING METHOD - call_float_literal.md:1:5:1:10

EXPECTED区段是错误报告的一行摘要:错误标题为MISSING METHOD,位置是文件第 1 行第 5 列到第 10 列(1:5:1:10,行号从 1 开始、列号从 0 开始)。对照源码x = 12.34():第 1 行第 5 个字符起恰好是12.34这个字面量的起点,长度 5(1:51:10覆盖了12.34的 5 个字符)。这印证了错误被精确定位到被调用的浮点字面量本身,而非整个声明或调用表达式。按 src/snapshot_tool/main.zig 的说明,EXPECTED是从PROBLEMS渲染结果直接生成的,因此两者永远不会漂移。

PROBLEMS:完整的诊断报告(语义层)

这是整个快照文件信息量最大的部分,以 S 表达式形式记录了编译器产出的唯一一条report

(reports (report (severity runtime_error) (title "Missing Method") (region (start 1 5) (end 1 10)) (headline (reflow "This") (reflow " ") (annotated code "from_numeral") (reflow " ") (reflow "method is being called on a value whose type doesn't have that method.")) (document (source-region (file "call_float_literal.md") (start 1 5) (end 1 10) (annotation error) (line-text "x = 12.34()")) (line-break) (reflow "The value's type, which does not have a method named ") (annotated code "from_numeral") (reflow ",") (reflow " ") (reflow "is:") (line-break) (line-break) (annotation-start code-block) (indent 1) (text "({}) -> _ret") (annotation-end))))

逐字段解读这条报告的语义:

  • severityruntime_error,即“运行时错误”级别。在 Roc 中,这类错误通常在编译期被拒绝,但报告模型将其归类为运行时错误语义。
  • title"Missing Method"——方法缺失。
  • region(start 1 5) (end 1 10),与EXPECTED摘要一致,精确覆盖字面量12.34
  • headline:拼接出人话——"Thisfrom_numeralmethod is being called on a value whose type doesn't have that method."(from_numeral这个方法被调用在了一个没有该方法的值的类型上)。
  • document:完整的报告正文结构,包含源码摘录行x = 12.34()、标注annotation error,然后解释“该值的类型(没有名为from_numeral的方法)是:”,最后以代码块形式给出该值的类型快照({}) -> _ret

({}) -> _ret是一个值得展开的类型形态:它表示“接收空记录{}、返回任意类型_ret的函数”。也就是说,编译器把字面量12.34在此时推断为一个未解析(未默认化)的数字字面量占位类型——它尚未被确定成F64Dec或任何具体数字类型,因此被表示成“某个函数类型”,而from_numeral是其隐含的转换方法名。

TOKENS:词法分析结果

LowerIdent,OpAssign,Float,NoSpaceOpenRound,CloseRound, EndOfFile,

词法阶段把源码切成 token 流:

  • LowerIdent:小写标识符x
  • OpAssign:赋值运算符=
  • Float:浮点字面量12.34
  • NoSpaceOpenRound:紧贴前一个 token 的左圆括号((无空格变体,这正是调用形态的标志);
  • CloseRound:右圆括号)
  • EndOfFile:文件结束。

注意NoSpaceOpenRound这个 token 类型的存在说明词法器会区分“紧贴”与“分离”的括号——12.34()(12.34之间没有空格,被标记为NoSpaceOpenRound

PARSE:语法分析结果(AST)

(file (type-mod) (statements (s-decl (p-ident (raw "x")) (e-apply (e-frac (raw "12.34"))))))

语法分析构建抽象语法树(AST):

  • 文件包含一个声明语句s-decl
  • 模式是标识符xp-ident);
  • 表达式是e-apply(函数应用),其函数部分是e-frac(浮点数字面量表达式,raw 文本为12.34)。

也就是说,语法上12.34()是完全合法的——它就是一个“把12.34当作被调用函数”的应用表达式。这解释了为什么错误要到类型检查阶段才暴露:语法分析不关心“字面量能否被调用”。

FORMATTED:格式化验证

NO CHANGE

Roc 编译器自带格式化器(formatter)。NO CHANGE表示源码x = 12.34()已经是规范的格式化结果,无需任何改写。这个区段保证了快照源码不会因格式化差异产生“伪回归”。

CANONICALIZE:规范化后的规范 IR

(can-ir (d-let (p-assign (ident "x")) (e-call (constraint-fn-var 213) (e-runtime-error (tag "erroneous_value_expr")))))

规范化阶段把 AST 转成编译器内部使用的规范 IR(Canonical IR):

  • d-let:一个 let 绑定声明;
  • 赋值模式是x
  • 右侧表达式e-call:一次函数调用,被调用的函数是一个约束函数变量constraint-fn-var 213(数字 213 是该变量在当前快照中的唯一 ID,不同的快照/编译上下文编号会不同);
  • 调用参数是e-runtime-error (tag "erroneous_value_expr")——一个标记为erroneous_value_expr(错误值表达式)的运行时错误节点

这是关键的一步:规范化阶段已经识别出12.34()是错误表达式,并将参数替换为erroneous_value_expr哨兵节点,使得后续类型检查不会在这个非法表达式上继续推导真实类型,而是让错误“短路”传播。这正好对应源码注释中所说的“产生错误,而不是崩溃”的测试目的——编译器以结构化方式把错误值继续沿管线传递,避免 panic。

TYPES:类型推断结果

(inferred-types (defs (patt (type "_a"))) (expressions (expr (type "_a"))))

类型检查阶段对x推断出的类型是_a——一个未确定的类型变量。由于初始化表达式本身是错误值(erroneous_value_expr),类型系统不会强行给出具体类型,而是保留一个不透明类型变量_a。这也与PROBLEMS区段里被调用值类型显示为({}) -> _ret的“待定”语义互相呼应:错误场景下,类型无法(也不需要)被完全确定。

错误报告是怎么生成的:源码级原理

PROBLEMS区段中的那条Missing Method报告,其真实生成逻辑位于 src/check/report.zig。其中与数字字面量调用最相关的是buildStaticDispatchDispatcherDoesNotImplMethod(src/check/report.zig),它对“类型未实现某静态分发方法”的情形做了字面量特判

// Special case: a literal used where a type lacking its `from_*` // conversion method is expected. if (data.origin.literalKind()) |kind| { return switch (kind) { // number literal used where a non-number type is expected .numeral => if (data.num_literal != null and data.num_literal.?.explicit_suffix) self.buildStaticDispatchMissingMethod(data) else self.buildNumberUsedAsNonNumber(data), // string/interpolation literal used where a non-string type is expected .quote, .interpolation => self.buildStringUsedAsNonString(data), }; }

其逻辑是:当错误源于一个字面量literalKind命中)时,按字面量种类分流——数字字面量(.numeral)若带显式后缀(如12.34f64)则走通用的buildStaticDispatchMissingMethod,否则走buildNumberUsedAsNonNumber;字符串与插值字面量则走buildStringUsedAsNonString。而buildStaticDispatchMissingMethod(src/check/report.zig)正是生成标题为Missing Method、严重级别为.runtime_error的报告的核心函数,报告正文结构(headline、类型快照代码块)与快照文件PROBLEMS区段完全吻合:

var report = try Report.init(self.gpa, "Missing Method", "", .runtime_error);

此外,src/check/Check.zig 中维护了一个“由字面量转换(from_numeral等)创建的 flex 变量工作列表”,并明确注释该列表在字面量变量解析后仍然保留——这解释了为什么from_numeral会作为缺失的方法名出现在报告中:数字字面量在 Roc 中通过from_numeral这一转换方法注入具体数字类型,当“调用字面量”导致该方法无法归属到任何具体数字类型时,编译器就报告该方法缺失。

从实现结构看(src/check/report.zig、src/check/report.zig),Missing Method报告还覆盖多种场景:静态分发目标非名义类型(buildStaticDispatchDispatcherNotNominal)、二元运算符操作数类型缺少对应方法(is_from_binop分支会改写 headline 为 “The value before this+operator has a type that doesn't have a+method.”)、以及where子句中的义务缺失等。本快照命中的是其中“字面量被当作函数调用”的分支。

同一场景的三种快照视角:横向对比

仓库中围绕“浮点字面量调用”共有三个快照文件,从不同角度验证同一类错误,放在一起对比能更完整地理解问题:

快照文件type源码关注点
test/snapshots/eval/call_float_literal.mdsnippetx = 12.34()顶层声明中的错误表达式,全编译管线诊断
test/snapshots/call_float_literal.mdexpr0.0()裸表达式(expr 模式)调用,最简形态
test/snapshots/eval/method_on_float_literal.mdrepl» 12.34.foo()REPL 中方法式调用,展示渲染输出与 Dec 默认化提示

其中type=expr的 test/snapshots/call_float_literal.md 是最精简变体:源码只有0.0()EXPECTED位置为1:1:1:4PARSE只有一层(e-apply (e-frac (raw "0.0")))CANONICALIZE同样是(e-call (constraint-fn-var 211) (e-runtime-error (tag "erroneous_value_expr"))),报告内容与本篇快照完全同构。这验证了:无论字面量是0.0还是12.34、无论是否嵌套在赋值声明中,编译器对“调用浮点字面量”的统一处理路径是一致的。

type=repl的 test/snapshots/eval/method_on_float_literal.md 展示了渲染层的最终用户输出,其中包含一个重要的额外细节:

The value's type, which does not have a method named `foo`, is: Dec **Hint:** This numeric literal was given the type `Dec` because it was never used as any concrete number type. To use a different numeric type, add a suffix or a type annotation.

这说明:当一个浮点字面量在整个程序中从未被用于任何具体数字类型时,编译器会通过“字面量默认化”(literal defaulting)机制把它默认为Dec(十进制小数类型),并给出提示——若要使用其他数字类型,可加后缀或类型注解(如12.34f64)。本篇文章的主角快照之所以显示({}) -> _ret而非Dec,是因为snippet模式下诊断发生在默认化之前、且类型尚未绑定到具体数字类型;这恰好体现了快照分层(语义快照 vs 报告快照)的价值。

如何在本地运行与维护这类快照

根据 test/snapshots/README.md 的 “Usage” 章节,这套快照测试通过 Zig 构建系统驱动:

# 生成/更新全部快照 zig build run-snapshot-tool # 仅运行/更新指定快照文件 zig build run-snapshot-tool -- <file_path> # 用当前实际诊断输出更新 EXPECTED 区段 zig build run-snapshot-tool -- <file_path> --update-expected # 调试 REPL 快照时开启解释器追踪(仅 debug 构建,且只能单文件) zig build run-snapshot-tool -- <repl_snapshot.md> --trace-eval

例如验证本文件可以执行:

zig build run-snapshot-tool -- test/snapshots/eval/call_float_literal.md

--update-expected会依据当前PROBLEMS渲染结果重新生成EXPECTED区段,从而保证“期望摘要”与“完整报告”永不漂移(见 src/snapshot_tool/main.zig 对--check-expected/--update-expected的说明)。此外,快照工具会做全局后处理:把被移除的 header 关键字改写为mod,该改写同样作用于 S 表达式输出内部。

维护这类快照文件时遵循的纪律是:语义变化只应体现在普通快照中,渲染变化只应体现在reporting/目录。若某次改动只影响渲染器(换行、标点、标记),则只有reporting/下的文件会变化;若诊断语义变化,则普通快照(可能连同reporting/)都会变化。本文主角文件属于前者,任何对Missing Method报告语义的改动都会在这里被快照立刻捕获。

结语

x = 12.34()这行看似简单的代码,在 Roc 编译器内部走完了“词法识别Float+NoSpaceOpenRound→ 语法构建e-apply(e-frac)→ 规范化生成erroneous_value_expr错误节点 → 类型推断留下未确定变量_a→ 诊断层产出Missing Methodfrom_numeral({}) -> _ret)运行时错误报告”的完整旅程。而 test/snapshots/eval/call_float_literal.md 这个 74 行的快照文件,用九个区段把这条管线固化为可回归验证的黄金基线。理解它,就等于掌握了 Roc 编译器错误诊断体系的一把钥匙:从快照结构到源码实现,从字面量默认化到静态分发检查,你都能顺着这条线索继续深入 src/check/report.zig、src/check/Check.zig 与 src/snapshot_tool/main.zig 中更广阔的实现细节。

【免费下载链接】roc

A fast, friendly, functional language.

项目地址:https://gitcode.com/GitHub_Trending/ro/roc
点击查看免费下载
上一篇:nuklear:轻量级ANSI C GUI库的Go绑定,让跨平台界面开发更简单
下一篇:CANN/GE:历史原型库设计文档(ES 场景)

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

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

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

立即咨询