Roc 格式化器如何保持 `` 分隔注释原样:快照测试用例 `hash_separator_comment_formatting.md` 深度解析
2026/9/18 18:39:49 网站建设 项目流程

Roc 格式化器如何保持###分隔注释原样:快照测试用例hash_separator_comment_formatting.md深度解析

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

导读:Roc 编译器内置的格式化器(roc fmt)在重排源码时会自动为#注释补一个空格,但这一规则不能无差别套用到##文档注释与###分隔注释上。本文以仓库中的快照测试用例 test/snapshots/hash_separator_comment_formatting.md 为骨架,逐段拆解该用例的八个阶段输出,并结合src/fmt/fmt.zigsrc/parse/tokenize.zigsrc/snapshot_tool/main.zig等源码,讲清「三井号分隔注释为什么不会被插入空格」的完整机制,同时给出快照测试的生成、更新与校验命令,帮助你掌握 Roc 编译器快照测试体系的读法与用法。

一、用例文件总览:一条注释引发的格式化回归测试

hash_separator_comment_formatting.md是 Roc 编译器test/snapshots/目录下的一个普通快照测试(ordinary snapshot)。这类测试的核心思想在 test/snapshots/README.md 中有明确说明:通过捕获源码在词法分析、解析、规范化、类型检查等各个编译阶段的输出,对编译器行为进行全流水线验证,一旦行为发生意外变化即可检出回归。

本用例的META段直接点明了被测行为:

description=Triple hash ### separators should not have space inserted type=file:Foo.roc
  • description:描述被测语义——「三井号###分隔注释不应被插入空格」;
  • type=file:Foo.roc:被测单元是一个名为Foo.roc的整文件。

整个用例的测试源码(SOURCE段)极为精简,只有两行:

### This is a separator comment Foo := [A]

第一行是以###开头的「分隔注释」(separator comment),第二行是一个 Roc 顶层类型别名声明Foo := [A](一个只含标签A的标签联合类型)。本文要回答的核心问题就是:格式化器处理这段代码后,###前面不能多出一个空格变成### This is...之外的形式(确切地说,不能把###变成## #或在#后插入空格破坏三井号序列)。

二、逐段拆解快照文件的八个阶段

快照文件的章节顺序由快照工具固定。在 src/snapshot_tool/main.zig 中可以看到标准顺序:

META → SOURCE → EXPECTED → PROBLEMS → TOKENS → PARSE → FORMATTED → CANONICALIZE → TYPES

type=mono的用例顺序略有不同:MONOFORMATTED紧跟SOURCE,见 main.zig#L1330-L1339。)各章节标题常量定义在 main.zig#L1597-L1610。

2.1 EXPECTED 与 PROBLEMS:没有诊断报告

# EXPECTED NIL # PROBLEMS NIL

这两段都输出NIL,表示这段源码在编译过程中没有产生任何诊断报告(错误、警告均为零)。根据 test/snapshots/README.md 的说明,普通快照的PROBLEMS段保存的是每个reporting.Report的规范化 S-表达式序列化(见src/reporting/report_sexpr.zig),包含严重级别、标题、源码区域及完整的文档结构;NIL即代表编译未产生任何报告。

EXPECTEDPROBLEMS之所以分开,是为了让「语义变化」与「呈现变化」互不干扰:普通快照只钉住诊断的语义,而渲染细节(框线字符、ANSI 转义、换行包裹等)由type=reporting的快照在reporting/目录单独钉住。

2.2 TOKENS:词法层面注释被完全吞掉

# TOKENS ~~~zig UpperIdent,OpColonEqual,OpenSquare,UpperIdent,CloseSquare, EndOfFile, ~~~

这是词法分析(tokenization)阶段的输出。值得注意:### This is a separator comment这一整行没有产生任何 token。原因是词法分析器在chompTrivia(吞掉「琐碎内容」)时把注释与空白一并跳过,见 src/parse/tokenize.zig#L785-L814:

} else if (b == '#') { self.pos += 1; while (self.pos < self.buf.len and self.pos != '\n' and self.pos != '\r') { self.pos += 1; } }

只要遇到#,游标就一路前进到行尾,整条注释(无论一个#、两个##还是三个###)都不进入 token 流。因此剩余的真实 token 只有:

Token对应源码
UpperIdentFoo
OpColonEqual:=
OpenSquare[
UpperIdentA
CloseSquare]
EndOfFile文件结束

2.3 PARSE:注释不参与 AST

# PARSE ~~~clojure (file (type-mod) (statements (s-type-decl (header (name "Foo") (args)) (ty-tag-union (tags (ty (name "A"))))))) ~~~

解析阶段的 S-表达式确认了注释在语法树中同样「隐形」:根节点file下只有一个类型声明节点s-type-decl,其头部是名为Fooheader,类型本体是包含单个标签A的标签联合ty-tag-union。分隔注释既不影响type-mod(模块类型头),也不作为 AST 节点出现——它只作为源码「夹缝」中的文本被保留,供格式化阶段重新排版。

2.4 FORMATTED:核心断言——「NO CHANGE」

# FORMATTED ~~~roc NO CHANGE ~~~

这是整个用例的灵魂NO CHANGE表示格式化器输出的结果与SOURCE完全一致:### This is a separator comment原样保留,Foo := [A]原样保留。这正是METAdescription所声明的行为——###分隔注释不会被插入空格

要理解这条断言为何重要,需要看格式化器的注释刷新逻辑,见 src/fmt/fmt.zig#L3733-L3739:

try fmt.push('#'); const comment_text = between_text[comment_start..comment_end]; // Add space after # unless next char is space or # (preserves ## doc comments and ### separators) if (!isShebang(start_offset + i, comment_text) and comment_text.len > 0 and comment_text[0] != ' ' and comment_text[0] != '#') { try fmt.push(' '); } try fmt.pushAll(comment_text);

规则非常明确:格式化器在输出#之后,默认会补一个空格(把#foo规范化为# foo),但有两个例外:

  1. 注释文本下一个字符已经是空格# foo保持# foo);
  2. 注释文本下一个字符#——即##文档注释与###分隔注释,此时不插入空格,从而保住两井号/三井号序列的完整性。

同样的逻辑也出现在文件末尾注释处理函数flushCommentsEOF中(fmt.zig#L3655-L3661),并附有完全一致的注释说明。这两处共同保证了:无论注释出现在语句之间还是文件末尾,###分隔符都不会被「美化」成带空格的形式。此外flushComments(fmt.zig#L3687 起)还负责处理##文档注释的特殊排版——若文档注释紧跟代码行(仅隔一个换行)且前一个 token 不是注释,会在其上方补一个空行(见 fmt.zig#L3719-L3726),保证文档注释与代码之间视觉分隔清晰。

还有一处需要留意的例外是 shebang:若注释位于文件最开头且内容是#!,格式化器通过isShebang(fmt.zig#L3677-L3684)识别并跳过空格插入,否则会破坏可执行 Roc 脚本的 shebang 行。

由此可以总结出 Roc 格式化器对三类#注释的完整处理矩阵:

注释形态类型格式化行为
# comment普通注释#后若紧跟非空格非#字符,则插入一个空格
## comment文档注释(doc comment)#后不插空格,保持##;必要时在文档注释与代码之间补空行
### comment分隔注释(separator comment)#后不插空格,保持###原样
#! ...(文件首行)shebang完全跳过格式化处理

关于##文档注释,可在 src/docs/extract.zig#L66-L113 看到模块级文档注释提取逻辑:##行位于文件开头且连续时,会被收集为模块文档。这也解释了为什么格式化器必须对##格外小心——它承载着可被文档系统提取的语义信息。

2.5 CANONICALIZE 与 TYPES:语义阶段的等价性确认

# CANONICALIZE ~~~clojure (can-ir (s-nominal-decl (ty-header (name "Foo")) (ty-tag-union (ty-tag-name (name "A"))))) ~~~ # TYPES ~~~clojure (inferred-types (defs) (type_decls (nominal (type "Foo") (ty-header (name "Foo")))) (expressions)) ~~~

CANONICALIZE是规范化阶段(canonicalization)产生的 CIR(Canonical IR)S-表达式,TYPES是类型推断阶段的输出。两者都只包含Foo这一个名义类型声明,defsexpressions为空,进一步确认:分隔注释对语义层毫无影响,编译流水线的「真正产物」只有类型Foo

三、快照测试如何生成与更新

test/snapshots/下的每个.md文件都可由快照工具重新生成。工具入口是 src/snapshot_tool/main.zig(构建目标名snapshot,见 build.zig#L4231-L4246),通过 Zig Build 步骤暴露给开发者,命令汇总如下:

命令作用
zig build run-snapshot-tool生成/刷新全部快照文件
zig build run-snapshot-tool -- <file_path>只处理指定快照文件(如zig build run-snapshot-tool -- test/snapshots/hash_separator_comment_formatting.md
zig build run-snapshot-tool -- <file_path> --update-expected用实际输出更新EXPECTED/PROBLEMS等期望段
zig build run-snapshot-tool -- <repl_snapshot.md> --trace-eval对 REPL 快照开启解释器追踪调试(仅type=repl、单文件)
zig build run-check-snapshots重新生成快照,若与已跟踪文件有差异则失败
zig build check-snapshot-diff仅做差异检查(git diff --exit-code test/snapshots

其中run-check-snapshotscheck-snapshot-diff的定义可分别在 build.zig#L2996-L3029 与 build.zig#L1323-L1393 找到——后者通过git diff --exit-code test/snapshots保证「已跟踪快照与重新生成结果完全一致」,任何未提交的格式化器行为变化都会让 CI 失败,这正是快照测试的核心价值:把格式化器、词法、解析、类型检查的每一次细微行为变化显式暴露在 diff 中

一个细节:为什么快照输出不随编译器版本漂移

格式化器在 src/fmt/fmt.zig#L40-L50 定义了Options.compiler_version字段:当它为null时,格式化不会重写文件头中roc: "..."的版本钉扎。该字段的注释明确写道,「快照工具、playground 以及格式化器自身的 round-trip 测试」必须让输出不随构建它的编译器变化——因此快照测试在调用格式化时不会传入版本号,保证快照文件在任何 nightly 下重新生成都逐字节稳定。

四、在命令行中亲手验证

快照测试之外的日常验证路径是roc fmt子命令。其参数解析在 src/cli/cli_args.zig#L827-L864,支持:

  • roc fmt [DIRECTORY_OR_FILES]:格式化指定文件或目录(缺省时格式化当前目录下的main.roc);
  • roc fmt --check:只检查不写回,若有文件需要格式化则返回非零退出码;
  • roc fmt --stdin:从 stdin 读入源码、把格式化结果写到 stdout。

格式化命令的实现位于 src/cli/main.zig#L16225-L16284:--check模式会汇总所有未格式化文件并打印「The following file(s) failedroc fmt --check: ...」,全部通过时打印All formatting valid.;普通模式则打印格式化成功的文件数。

你可以用下面的命令亲手验证本用例的行为:

# 准备一个与 SOURCE 段相同的文件 printf '### This is a separator comment\nFoo := [A]\n' > Foo.roc # 从 stdin 格式化,观察 ### 是否保持原样 roc fmt --stdin < Foo.roc # 期望输出: # ### This is a separator comment # Foo := [A] # 再验证普通注释会被补空格 printf '#bar\nFoo := [A]\n' | roc fmt --stdin # 期望输出: # # bar # Foo := [A]

对比两个输出即可直观看到:#bar会被规范化为# bar,而### This is a separator comment因为第三个字符是#而逃过空格插入——与快照用例FORMATTED: NO CHANGE的断言完全吻合。

五、小结:一条快照用例的完整价值链条

hash_separator_comment_formatting.md虽只有 52 行,却串起了 Roc 编译器测试体系的一条完整价值链条:

  1. 词法层(tokenize.zig):###注释作为 trivia 被跳过,不产生 token;
  2. 解析层(Parser.zig):注释不进 AST,Foo := [A]解析为类型声明节点;
  3. 格式化层(fmt.zig):flushComments在补空格时对#后接#的情况放行,保证###分隔注释原样输出;
  4. 语义层(canonicalize / types):注释对 CIR 与类型推断零影响;
  5. CI 层(build.zig + snapshot_tool/main.zig):任何阶段的输出漂移都会在run-check-snapshots中暴露为 diff。

换句话说,这一条「小小的」格式化快照用例,实际上同时守护了词法、解析、格式化、规范化、类型检查五个阶段的行为契约——这正是 Roc 快照测试体系「以极简源码覆盖全流水线」的设计精髓。

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

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

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

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

立即咨询