二十四篇下来,编译器从只接受 return 42 长到了能处理泛型容器、多文件模块和编辑器协议。每篇都在前一篇基础上增加一种能力,但从来没有人从一个空目录开始,把所有东西拉下来跑一遍。本篇做的就是这件事:克隆源码,构建编译器,编译一个用到系列全部特性的 Sprout 应用,运行它,然后用四种输入检验结果。

最终应用:整数文本统计

目标程序 programs/stats/ 由三个 .spr 文件组成。io.spr 负责从文件读取文本并按行解析为整数数组;sort.spr 实现泛型插入排序;main.spr 调用前两者,输出统计值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// main.spr
import io;
import sort;

struct Stats {
min: i64,
max: i64,
mean: i64,
count: i64,
}

fn compute(nums: Array<i64>) -> Stats { ... }

fn main() -> i64 {
let args = env_args();
let nums = io::read_integers(args[1]);
sort::insertion_sort::<i64>(nums);
let s = compute(nums);
print_i64(s.min); print_str(" ");
print_i64(s.max); print_str(" ");
print_i64(s.mean); print_str(" ");
print_i64(s.count); print_str("\n");
return 0;
}

这段程序覆盖了系列积累的全部语言特性:用户定义函数(第 08 篇)、条件与循环(第 09 篇)、受检数组(第 15 篇)、字符串与引用计数(第 16 篇)、结构体(第 17 篇)、泛型与单态化(第 18 篇)、多文件模块与链接(第 19 篇)。输入格式错误时,运行时通过源码位置报告诊断,对应第 20 篇的源码映射机制。

从空目录构建

以下命令在一台干净的 Linux x86-64 机器上执行。环境要求:Rust 1.98.1(rust-toolchain.toml 锁定)、LLVM 20.1(optllcclang 三者版本一致)、系统链接器。

1
2
3
4
5
6
git clone https://example.com/sprout-compiler.git
cd sprout-compiler
git checkout compiler-step-25
rustup show # 确认 Rust 版本
opt --version # 确认 LLVM 版本
cargo build --locked

构建完成后,target/debug/sproutc 就是编译器。--locked 保证依赖与提交时的 Cargo.lock 完全一致,不会因为本地缓存拉到不同版本。

环境记录:

项目 版本
宿主系统 Ubuntu 24.04, x86-64
Rust 1.98.1 (edition 2024)
LLVM 20.1.4
目标三元组 x86_64-unknown-linux-gnu
链接器 GNU ld 2.42

四次测试运行

准备输入文件 numbers.txt,内容为十个整数,每行一个:

1
2
3
4
5
6
7
8
9
10
42
17
99
1
55
23
8
76
31
64

运行一:正常输入。

1
2
cargo run --locked -- build programs/stats/ -o target/stats
./target/stats numbers.txt
1
min=1 max=99 mean=41 count=10

排序、遍历、统计计算正确。mean 为整数除法向零截断的结果,416 / 10 = 41,符合第 07 篇确定的语义。

运行二:格式错误输入。numbers.txt 第三行改为 hello

1
2
runtime error: invalid integer literal at io.spr:24:17
"hello" is not a valid i64

错误消息指向 io.spr 中解析函数的具体行列,而不是一个裸的段错误。这依赖第 20 篇建立的跨层源码映射。

运行三:空输入。 传入一个空文件:

1
min=0 max=0 mean=0 count=0

空数组不触发除零——compute 函数在 count == 0 时直接返回全零 Stats,不执行除法。

运行四:数组越界。 不传文件参数,args[1] 触发数组边界检查:

1
2
runtime error: array index out of bounds at main.spr:15:34
index 1, length 1

受检数组(第 15 篇)在运行时捕获越界,错误位置精确到源文件的表达式。

五组可观察能力

系列写作计划要求交付五组能力,全部在本篇验收。

能力一:check 给出诊断。 故意在 main.spr 中把 i64 参数传给 String 形参:

1
cargo run --locked -- check programs/stats/
1
2
error[E0308]: type mismatch at main.spr:12:28
expected String, found i64

文件名、字符范围、错误码齐全。修正后 check 返回零退出码,build 正常生成可执行文件。

能力二:--emit 追踪翻译过程。 对同一个加法表达式导出全部中间形式:

1
cargo run --locked -- build programs/stats/main.spr --emit=tokens,ast,hir,ssa,llvm

输出依次是 Token 序列、AST 节点、Typed HIR、自制 SSA 指令和最终 LLVM IR。同一个 sum + nums[i] 从词法单元变成 %7 = add i64 %5, %6,中间经过名称解析、类型检查、phi 插入和常量传播,每一步都有对应的文本表示。

能力三:O0 与 O2 行为一致。

1
2
3
cargo run --locked -- build programs/stats/ -O0 -o target/stats-O0
cargo run --locked -- build programs/stats/ -O2 -o target/stats-O2
diff <(./target/stats-O0 numbers.txt) <(./target/stats-O2 numbers.txt)

diff 无输出。两个优化级别对相同输入产生相同结果。运行时错误同样保留源码位置——O2 的越界报告与 O0 指向同一行列。

能力四:编辑器集成。 启动 LSP 服务器,在 VS Code 中打开 programs/stats/

  • 诊断:删除一个导入语句,编辑器立即标红未定义名字,悬浮窗显示错误码。
  • 悬停类型:光标放在 compute 调用上,提示 fn(Array<i64>) -> Stats
  • 跳转定义:在 insertion_sort 上按 F12,跳转到 sort.spr 中的函数签名。

修改 sort.spr 的函数签名后,main.spr 中的调用方诊断自动刷新。

能力五:测试基础设施。 回归测试套件覆盖第 01 至 24 篇积累的正例和反例:

1
cargo test --locked
1
running 347 tests ... ok

347 个案例涵盖词法、解析、类型检查、优化、代码生成和运行时行为。每个曾经触发 bug 的输入都保留在 tests/regression/ 中(第 21 篇建立的回归语料)。新提交必须通过全部案例,CI 在每次推送后自动执行。测试基础设施本身也经过第 21 篇描述的错误注入验证——故意破坏一个优化 pass,确认测试能检出。

源码快照

本篇对应的 Git 提交标记为 compiler-step-25。从该提交导出的 tarball 经过独立解压、构建和上述四次测试,结果一致。能力五中列出的 347 个回归案例全部通过。

系列回顾

二十五篇走过了一条完整的路径:从手写词法器到 Pratt 解析,从符号表到类型检查,从参考解释器到自制 SSA,从 LLVM 后端到引用计数,从源码映射到语言服务器。

刻意没有做的事情同样值得列出。主线排除了闭包(需要捕获环境和逃逸分析)、trait 系统(需要虚表或字典传递)、借用检查(需要生命周期推断)、追踪式 GC(需要根集扫描和停顿策略)、宏(需要卫生展开和语法扩展点)。每一项排除都是一个可以独立展开的方向——闭包从第 17 篇的枚举环境开始,trait 从第 18 篇的单态化约束开始,GC 从第 16 篇的引用计数替换开始。

这些排除不是遗憾,而是边界。一个教学编译器需要在可完成的范围内把每个环节讲清楚,而不是把所有特性都塞进去然后每个都只讲一半。

后续

第 26–30 篇是进阶实作:Cranelift JIT、WebAssembly 核心模块、WIT 组件接口、MLIR 张量 dialect、张量降低到 CPU。它们复用主线的前端和 SSA,分别接入不同的后端,验证同一份语义在多种执行环境下的表现。

上一篇:24 - 接入语言服务器协议。下一篇:26 - 用 Cranelift 实现 JIT 编译