这个仓库会通过 Git 提交历史,一步步把 mini-compiler 搭出来。
目标不是一次写完一个复杂编译器,而是按最适合理解编译流程的顺序,让同一个小编译器慢慢长出来。
换句话说,这个仓库更在意“为什么下一步应该是它”,而不是“还能不能再多加一个功能”。
每一条提交只做一件事,所以这条历史本身就是学习路线。
你可以先看完整代码,再按顺序回看提交,观察同一条编译链路是怎么一步步长出来的。
常用方式:
git log --oneline --reverse如果你想看某一步的具体变化,可以继续用:
git show <commit-id>如果你想按顺序体验每一步,可以从最早的提交开始,一步步切过去看。
如果目标是学编译器的核心,而不是尽快做一个真正的语言,那顺序非常重要。
最容易走偏的方式,是太早去做这些东西:
- 先做变量作用域
- 先做类型检查
- 先做优化
- 先做复杂语法
这样虽然看起来“功能更多”,但会把编译器最核心的那条主线打散。
这个项目真正最值得先弄明白的,是这条链:
- 源码字符串怎么拆开
- 拆开的片段怎么变成树
- 树怎么被稳定遍历
- 源语言的树怎么变成目标语言的树
- 目标语言的树怎么打印成代码
- 这些步骤怎么串成一个 compiler
只有这条主线通了,后面的作用域、类型系统、优化、报错提示,才知道该挂在哪、为什么能挂上去。
| 步骤 | 当前问题 | 新增能力 | 为什么重要 |
|---|---|---|---|
| 1 | 源码是一整段字符串 | 分词 | 先得到最小片段 |
| 2 | tokens 还是平铺的 | 解析成语法树 | 保存嵌套关系 |
| 3 | 树需要被稳定访问 | 遍历器 | 把“走树”和“处理节点”分开 |
| 4 | 源语言和目标语言结构不同 | 转换语法树 | 让结构靠近目标代码 |
| 5 | 树不能直接运行 | 生成代码 | 得到最终字符串 |
| 6 | 步骤太散 | compiler 总入口 | 串起完整流程 |
flowchart LR
A["源码字符串"] --> B["tokenizer"]
B --> C["parser"]
C --> D["traverser"]
D --> E["transformer"]
E --> F["codegen"]
F --> G["目标代码"]
先把源码拆成一串 tokens。
这一步只回答一个问题:编译器拿到字符串以后,第一眼应该怎么把它切成更小、更容易理解的片段。
为什么下一步不是直接生成代码?
反例是:如果连 add、2、(、) 这些最小片段都没分清楚,后面就没法判断谁是谁的参数。
把 tokens 组织成语法树。
这一版解决的是“括号嵌套关系怎么保存”的问题。
(add 2 (subtract 4 2)) 不是一串平铺的词,而是 add 里面又包含了一个 subtract。语法树就是用来保存这种层级关系的。
为什么下一步不是直接改成目标代码?
反例是:如果每次改代码都手写一堆针对某种节点的判断,逻辑会散在很多地方。先有统一遍历方式,后面转换会更清楚。
给语法树加一个统一遍历器。
这一版解决的是“怎么稳定走完整棵树”的问题。
遍历器会先进入外层节点,再进入里面的节点,最后再从里面退出来。这样后面想对某种节点做处理,只需要告诉遍历器“遇到它时做什么”。
为什么下一步才开始转换?
反例是:如果没有遍历器,转换逻辑既要负责走树,又要负责改树,两件事混在一起会很难读。
把源语言语法树转换成目标语法树。
这一版解决的是“源语言结构和目标语言结构不一样”的问题。
源语言是 (add 2 4),目标语言更像 add(2, 4)。它们表达的是同一件事,但树的形状不同,所以需要先转换结构。
为什么下一步还不能直接结束?
反例是:转换后的树仍然只是对象结构,人和机器都不能直接把它当成最终代码运行。还需要把树重新打印成字符串。
把目标语法树生成 JavaScript 代码。
这一版解决的是“树怎么变回字符串”的问题。
转换后的树已经很接近 JavaScript,但还不是最终代码。代码生成器会按节点类型,把函数调用、数字、字符串这些节点打印出来。
为什么最后还要有 compiler?
反例是:如果使用者每次都要手动调用分词、解析、转换、生成代码四个步骤,使用体验会很碎,也不利于看清完整流程。
把所有阶段串成一个 compiler。
这一版解决的是“怎么从源码一步到最终代码”的问题。
最终入口只做一件事:把源码依次交给分词、解析、转换和代码生成,最后返回目标代码。
为什么到这里先停住?
反例是:如果刚跑通主线就加入变量、作用域、类型检查,学习者会分不清哪些是编译器主流程,哪些是更高级的语言能力。
现在这个 mini-compiler 可以把这种 Lisp 风格的调用:
(add 2 (subtract 4 2))
编译成 JavaScript 风格的调用:
add(2, subtract(4, 2));
它也支持字符串参数:
(print "hello")
会变成:
print("hello");
运行:
npm test它会检查每一个阶段,也会检查完整 compiler 的最终输出。
手写主流框架 / 工具的最小可运行实现,每个仓库只追「核心主线」,不堆功能。
| 仓库 | 内容 |
|---|---|
| mini-vue | 手写 Vue3:响应式 / runtime / 编译器 |
| mini-react | 手写 React:Fiber / reconciliation / Hooks |
| mini-koa | 手写 Koa:中间件洋葱模型 / context |
| mini-webpack | 手写 webpack:依赖图 / loader / plugin |
| mini-compiler(本仓库) | 手写 the-super-tiny-compiler:词法 / 语法 / 转换 / 生成 |
| ts-axios | 手写 axios(TypeScript 版) |
Talk is cheap. Read the code.