Gleam 编译器源码导读:多 crate 架构、编译流水线与快照测试实践
【免费下载链接】gleam⭐️ A friendly language for building type-safe, scalable systems!项目地址: https://gitcode.com/GitHub_Trending/gl/gleam
Gleam 是一门面向类型安全、可扩展系统的友好编程语言,本仓库即其官方编译器的完整源码。本文以 docs/compiler/README.md 为骨架,结合仓库内真实的 Rust 源码、Makefile 与测试工程,系统讲解 Gleam 编译器的整体结构、从源码到 Erlang/JavaScript 输出的编译流程,以及围绕快照(snapshot)测试建立的工程实践。读完本文,你将能够快速定位编译器各阶段对应的源码文件,理解依赖排序、类型推断、代码生成等核心环节的实现位置,并掌握在本仓库中运行与扩展测试的方法。
项目结构:分工清晰的 Rust 多 crate 架构
Gleam 编译器不是单个巨型二进制,而是由若干 Rust crate(在 Cargo 语境下即"项目")组成的多 crate 工作区。每一层职责单一、边界明确,这是理解整个代码库的第一把钥匙。
四个核心 Rust crate
根据 docs/compiler/README.md 的划分,编译器主要由四个 crate 构成:
| crate | 职责 | 源码入口 |
|---|---|---|
compiler-core | 解析、分析、编译 Gleam 项目;完全纯函数、无任何 IO | compiler-core/src/lib.rs |
language-server | Gleam 语言服务器:自动补全、代码操作(code actions)、悬停提示等 | language-server/src/lib.rs |
compiler-cli | 命令行界面,包装核心编译器与语言服务器,提供文件与控制台 IO | compiler-cli/src/lib.rs |
compiler-wasm | 通过 WebAssembly 暴露给 JavaScript 的核心编译器接口,可在浏览器中运行 | compiler-wasm/src/lib.rs |
纯核心 + IO 外壳是这套架构最重要的设计原则:compiler-core只负责"把代码变成什么",不关心"代码从哪来、结果写到哪"。所有与文件系统、标准输入输出、网络相关的操作都被推到compiler-cli等外围 crate 中。这样做带来的直接好处是:核心逻辑可以脱离具体运行环境被单元测试、被 wasm 复用,也能在语言服务器中安全调用而不阻塞 IO。
在 compiler-core/src/lib.rs 中可以看到该 crate 导出的全部模块,从parse、analyse、type_、erlang、javascript、metadata、docs到build,几乎一一对应编译流水线的各个阶段;同时文件顶部用#![deny(unsafe_code)]、#![deny(clippy::unwrap_used)]等 lint 规则严格要求代码安全性与健壮性——对编译器这种需要长期演进的基础设施而言,这种约束直接服务于可靠性。
仓库中的其他组件
除了 Rust 代码,仓库根目录还包含以下配套组件:
- Makefile:为常见开发任务定义快捷命令,运行
make help可查看全部目标,详见下文"测试"一节。 - test/:一系列(大部分为)Gleam 工程,充当编译器的集成测试夹具,例如 test/language、test/project_erlang、test/project_javascript。
- deny.toml:
cargo-deny的配置,用于保证编译器依赖的 Rust 库符合项目预期。 - containers/:用于为每个 Gleam 发布版本构建 OCI 容器的 Dockerfile 集合,例如 containers/erlang.dockerfile、containers/node.dockerfile。
- .github/workflows/:GitHub Actions 工作流定义,负责构建、测试与发布新版本。
- docs/:即本文所在目录,存放编译器相关的开发文档。
编译流水线:从源码到 Erlang/JavaScript
Gleam 编译器把模块编译成 Erlang 或 JavaScript 的过程在概念上是一条清晰的多级流水线。原文档给出了下面的流程示意图,它也是理解 compiler-core 内部各模块协作关系的最佳地图:
Gleam source code .cache binaries ▼ ▼ ┌────────────────────┐ ┌───────────────────────┐ │ Parser │ │ Metadata deserializer │ └────────────────────┘ └───────────────────────┘ │ │ Untyped AST Module metadata └─────────┐ ┌────────┘ │ ▼ ▼ │ ┌─────────────────────┐ │ │ Dependency sorter │ │ └─────────────────────┘ │ │ │ Untyped AST │ (sorted by deps) │ ▼ │ ┌───────────────────┐ │ │ Type checker │◄─────┘ └───────────────────┘ │ ┌────── Typed AST ──────┐ ▼ ▼ ┌────────────────────┐ ┌─────────────────────┐ │ Code generator │ │ Metadata serializer │ └────────────────────┘ └─────────────────────┘ │ │ │ ▼ Erlang or JavaScript .cache binaries printing algebra ▼ ┌────────────────────┐ │ Pretty printer │ └────────────────────┘ │ ▼ Erlang or JavaScript source code下面把每个阶段对应到仓库中的真实实现。
阶段一:解析(Parser)——源码到 Untyped AST
输入 Gleam 源码,输出"未类型化 AST(Untyped AST)",这一阶段由 compiler-core/src/parse.rs 完成。文件头部的注释详细记录了语法设计约定:
parse_x:解析某个语法片段,失败时不报错,通常返回Result<Option<A>, ParseError>;expect_x:解析某个通用/特定语法片段,失败时报错,返回Result<A, ParseError>;maybe_x:解析通用语法片段,成功则推进 token 流并返回Some(x),否则返回None。
表达式与 guard 中的运算符优先级采用Simple Precedence Parser 算法:维护表达式栈与未归约运算符栈两个栈,边消费输入边比较运算符优先级,从而支持e ::= expr op expr | expr这样运算符与表达式交替的通用文法。词法层面由 compiler-core/src/parse/lexer.rs 与 compiler-core/src/parse/token.rs 支撑。
阶段二:依赖排序(Dependency sorter)
依赖排序位于编译器前端,负责把模块按依赖关系排成拓扑序,保证被依赖的模块先被处理。核心实现在 compiler-core/src/dep_tree.rs 的toposort_deps函数中:它基于petgraph图库构建依赖图,若图中存在环(import cycle)则返回Error::Cycle,并利用find_cycle回溯出具体的循环路径,供后续错误诊断使用。对应的模块级测试(toposort_deps_test等)与集成测试用例(如 test-package-compiler/cases/import_cycle、test-package-compiler/cases/import_cycle_multi)共同验证了环检测与多模块环的报错行为。
阶段三:类型检查(Type checker)
依赖排序后的 Untyped AST 进入类型检查器,得到"已类型化 AST(Typed AST)"。这一阶段由 compiler-core/src/analyse.rs 与 compiler-core/src/type_ 模块共同实现。
从ModuleAnalyzerConstructor::infer_module(compiler-core/src/analyse.rs)的实现可以看到类型推断的典型执行顺序:
- 校验模块名合法性;
- 构建
EnvironmentArguments(当前包名、Gleam 版本、目标平台、可导入模块等); - 先处理 import,使任何位置都能引用被导入的内容;
- 注册自定义类型,使构造函数与函数可在模块中更早的位置被使用;
- 对类型别名做拓扑排序后逐一注册;
- 注册各函数的签名,再逐个语句做类型推断。
此外,.cache中已编译模块的元数据会通过元数据反序列化器(Metadata deserializer)读回,以ModuleInterface形式为类型检查提供跨模块的类型信息。
阶段四:代码生成与元数据序列化(分叉点)
拿到 Typed AST 后,编译器兵分两路:
- 代码生成器:面向目标语言生成代码。Erlang 后端位于 compiler-core/src/erlang.rs(核心结构为
Generator与FunctionGenerator),JavaScript 后端位于 compiler-core/src/javascript.rs。两个后端都以"打印代数(printing algebra)"的形式表达生成结果——先把 AST 转换为结构化文档,而不是直接拼接字符串。 - 元数据序列化器:将模块接口(
ModuleInterface)序列化为.cache二进制,供后续编译读取。实现在 compiler-core/src/metadata.rs:
pub fn encode(module: &ModuleInterface) -> Result<Vec<u8>, bitcode::Error> { bitcode::serialize(module) } pub fn decode(bytes: &[u8], ids: UniqueIdGenerator) -> Result<ModuleInterface, bitcode::Error> { bitcode::deserialize(bytes).map(|module| remap_type_variable_ids(module, ids)) }其中decode在反序列化后会调用remap_type_variable_ids重新映射类型变量 ID,避免跨模块缓存加载时产生 ID 冲突。
阶段五:Pretty printer——最终源码输出
代码生成器产出的"打印代数"文档最终交给 pretty printer 排版,输出可读的 Erlang 或 JavaScript 源码。这种"结构化文档 → 排版"的两段式设计,正是 Gleam 生成的 Erlang/JavaScript 代码始终具有良好缩进与一致风格的原因。
命令行入口与纯核心的对接
以上纯逻辑全部位于compiler-core,而真正的程序入口在 gleam-bin/src/main.rs(调用 compiler-cli),由 CLI 负责把磁盘上的.gleam源码喂给核心、把生成的源码写回文件系统。这也再次印证了"核心纯函数、外壳负责 IO"的架构原则。
测试:三层测试体系与快照测试
运行测试的 Makefile 目标
原文档定义了 6 个测试命令,仓库根目录的 Makefile 有对应实现:
| 命令 | 实际执行内容 |
|---|---|
make test | 运行所有测试:先cargo test --quiet跑 Rust 单元测试,再依次跑test/language、test/javascript_prelude、test/project_erlang、test/project_javascript、test/project_deno、test/hextarball、test/typescript_declarations、test/running_modules、test/subdir_ffi等集成测试。提交任何改动前都建议跑一遍 |
make test-watch | 文件保存时自动运行 Rust 单元测试(watchexec -e rs,toml,gleam,html "cargo test --quiet") |
make language-test | 运行 test/language 下的跨平台语言集成测试 |
make language-test-watch | 文件保存时自动运行上述语言集成测试 |
make javascript-prelude-test | 对编译到 JavaScript 时使用的 Gleam prelude 实现(compiler-core/templates/prelude.mjs)运行单元测试 |
make javascript-prelude-test-watch | 文件保存时自动运行 prelude 测试 |
所有*-watch命令都依赖watchexec程序,需要先安装。此外 Makefile 还提供了make help(列出全部目标)、make build(release 构建)、make install(把编译器安装到 PATH)等开发辅助命令。
以make language-test为例,其真实逻辑在 test/language/Makefile:依次对 Erlang、JavaScript(Node.js 运行时)、JavaScript(Deno 运行时)三个目标运行cargo run --quiet -- test --target ... --runtime ...,即同一套 Gleam 语言测试用例分别在三个后端/运行时上执行,验证跨平台一致性。而make javascript-prelude-test则会先把 compiler-core/templates/prelude.mjs 复制到测试目录再执行node main.mjs,测试结束后清理临时文件。
快照测试(Snapshot testing)
编译器大量使用基于cargo-insta的快照测试,这是本仓库最值得借鉴的测试实践。与传统"示例式测试"(同时手写输入与期望输出)不同,快照测试只需编写输入,工具会把首次运行产生的输出标记为 accepted 并保存为.snap文件;此后若某条测试的输出发生变化,即视为测试失败,开发者可以选择:
- 拒绝(reject)新版本——说明结果出错了;
- 接受(accept)新版本——将其作为新的正确输出写入仓库。
这套机制对编译器开发尤其有价值:当修改错误消息格式或代码生成输出格式时,手动更新所有期望输出既耗时又枯燥,而快照测试只需几秒即可完成全量更新。仓库中的快照文件数量庞大且分布广泛,例如:
- 解析器:compiler-core/src/parse/snapshots(266 个)
- Erlang 代码生成:compiler-core/src/erlang/snapshots(67 个)及其 tests 子目录
- JavaScript 代码生成:compiler-core/src/javascript/tests(834 个快照)
- 类型检查:compiler-core/src/type_/snapshots(32 个)与 tests
- 语言服务器:language-server/src/tests/snapshots(1511 个)
- 文档生成:compiler-core/src/docs/snapshots(47 个)
日常开发中推荐的工作流如下:
# 运行全部测试 make test # 交互式核对快照变更 cargo insta reviewcargo insta review会逐个展示变更的快照,让开发者在确认无误后统一接受,将改动固化进仓库。
结语
从整体看,Gleam 编译器是一个"纯核心 + IO 外壳"的多 crate 工程:compiler-core以无 IO 的纯函数形式完成解析、依赖排序、类型检查与双后端代码生成,compiler-cli、language-server、compiler-wasm分别以 CLI、LSP、Wasm 三种形态复用这一核心;快照测试则让编译器在输出格式频繁演进时仍能保持高度可控。对希望深入编译器实现或为 Gleam 贡献代码的开发者来说,docs/compiler/README.md 是绝佳的起点,本文对应梳理的源码路径则可以作为后续阅读的导航图。
【免费下载链接】gleam⭐️ A friendly language for building type-safe, scalable systems!项目地址: https://gitcode.com/GitHub_Trending/gl/gleam
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考