CJASTHelper是如何把AST还原成源码的?ToCangjiePass实现原理深度剖析
【免费下载链接】CJASTHelperCangjie Abstract Syntax Tree Helper项目地址: https://gitcode.com/Cangjie-SIG/CJASTHelper
🌟CJASTHelper是基于 Cangjie 开源编译器前端的AST 操作与源码转换工具,其中ToCangjiePass负责把解析、语义分析后的AST(抽象语法树)还原成可读的 Cangjie 源码。本文将带你完整看懂它的实现原理:访客模式如何遍历 AST、100 多种节点如何逐一打印、以及编译器“解糖”后的代码如何被逆向还原成你熟悉的语法。
为什么需要“AST 还原源码”?
Cangjie 编译器前端处理源码时,会经历多个阶段:
parse → (desugared-parse) → macro → sema → desugared-sema每个阶段的 AST 形态都不同(详见 doc/usage.md):
- 语法糖被展开(如
x[i]变成x.[](i)调用) - 属性访问变成 getter/setter 调用
- 构造函数调用变成
init()调用
CJASTHelper 的--dump-source选项能在任意阶段输出当时的源码,方便你观察编译器内部发生了什么。核心引擎就是本文主角——ToCangjiePass 源码还原 Pass。
整体架构:Pass 插件 + 访客模式
CJASTHelper 采用插件化 Pass 管线(架构总览见 doc/design.md)。to-cangjie这个 Pass 以动态库cjast_to_source_pass的形式加载,注册关系定义在 config/passes.json 中:
{ "group": "to-source", "names": ["to-cangjie", "to-java"], "lib": "cjast_to_source_pass", "dependencies": ["desugar"] }类继承结构非常清晰:
| 类 | 职责 | 位置 |
|---|---|---|
Pass | Pass 基类,定义Run(AstNode&)入口 | Pass.h |
ToSourcePass | 持有输出文件流 +Printer打印器 + 访客 | ToSourcePass.h |
ToCangjiePass | 真正实现全部节点打印逻辑 | ToCangjiePass.h |
ConstAstVisitor | 按节点类型分发 Before/Visit/After 回调 | ConstAstVisitor.h |
ToSourcePass::Run()就两件事:创建输出目录,然后Traverse(node, visitor)开始遍历(ToSourcePass.h)。
核心机制一:100+ 节点处理器的批量注册
Cangjie 的 AST 有上百种节点(声明、类型、模式、表达式……)。ToCangjiePass 通过宏EXPAND4批量注册每个节点的Visit处理器,例如 ToCangjiePass.cpp:
VarDecl、FuncDecl、ClassDecl等声明节点RefType、OptionType、FuncType等类型节点IfExpr、MatchExpr、LambdaExpr等表达式节点
每个Visit函数的套路高度一致:打印关键字 → 递归打印子节点 → 补充前缀/后缀。比如IfExpr的还原(ToCangjiePass.cpp):
- 打印
if,再打印条件表达式并包裹() - 打印
{,缩进后递归打印 then 块,再打印} - 如果有 else 分支,打印
else { ... }
配合Printer的Indent/Unindent能力,输出的源码天然带正确缩进。
核心机制二:Before 钩子拦截“解糖节点”
真正精彩的还原逻辑藏在Before钩子里。对 9 种会被解糖的节点(MainDecl、AssignExpr、UnaryExpr、BinaryExpr、CallExpr、RefExpr、SubscriptExpr、OptionType、MacroDecl,见 ToCangjiePass.h),Pass 提供了Before重写——它在默认遍历之前执行,有机会“劫持”打印过程。
以赋值表达式为例(ToCangjiePass.cpp):
- 节点没有
desugarExpr(未解糖)→ 返回Cont(),走正常 Visit 打印 - 有
desugarExpr且开启解糖模式 → 直接打印解糖结果,返回Skip() - 有
desugarExpr但要求还原源码 → 识别出x.[](i, y)这种“成员函数调用”结构,反向还原为x[i] = v
VisitResult::Skip()是关键:它告诉遍历器“这个节点我已处理完,别再走默认逻辑了”。这就是逆向解糖的实现骨架。
核心机制三:语义级还原(sema 阶段专属)
语义分析后的 AST 丢失了大量原始语法信息,还原难度最大。ToCangjiePass 用三个“尝试”函数链解决(ToCangjiePass.cpp):
| 还原场景 | 解糖/语义后形态 | 还原为 | 实现函数 |
|---|---|---|---|
| 构造函数调用 | init(args)调用 | A(args) | TryPrintInitCall |
| 属性读写 | x.$aget()/x.$aset(v) | x.a/x.a = v | TryRecoverPropCallExpr |
| 运算符重载 | a.+(b)/a.[](x) | a + b/a[x] | TryRecoverOverloadCallExpr |
其中运算符还原还会贴心地保留一条/* Desugared ... */注释,记录解糖后的原始形态,方便对照。
更复杂的for-in循环还原则展示了“模板匹配”功力:编译器把for (x in start..stop:step)解糖成var $iter-i = ...+while循环的四条语句块,Pass 断言块结构后重新拼装回for-in形态(ToCangjiePass.cpp)。
还有个精巧的辅助设施——解糖变量名字表desugaredVarId:编译器生成的$iter-compiler这类临时变量在还原时会被规范化(去$、-转_、重名加序号),并记录“原节点 → 新名字”的映射,后续所有引用处通过查表统一替换(ToCangjiePass.cpp)。
配置驱动:Desugar 与 Sema 双开关
所有还原行为由 PassConfig 的两个标志位控制:
- Desugar 模式:打印“解糖后”的源码(看编译器内部形态)
- Sema 模式:基于语义信息补齐类型标注、泛型实参、丢失的修饰符/注解
--dump-source=parse与--dump-source=desugared-sema的区别,本质就是这两个开关的组合。当 sema 阶段节点丢失了显式类型时,Pass 会用语义类型Ty兜底补全(ToCangjiePass.cpp)。
上手体验:一条命令还原源码
构建完成后(Linux/macOS 用bash build.sh -t Release -b),最常用的命令:
mkdir -p out ./build/bin/cjah --dump-source=desugared-sema --output-type=dylib main.cj --output-dir out # 产物:out/main_source.cj还可以叠加--filter-decls=func,class只还原顶层函数和类。完整选项见 doc/usage.md。
总结
ToCangjiePass 的实现可以浓缩为三层设计:
- 遍历层:
ConstAstVisitor按AstKind分发,宏批量注册 100+ 节点处理器 - 打印层:
Printer负责缩进与前缀/后缀包裹,让还原结果天然可读 - 还原层:
Before钩子 +Skip拦截 + 模板匹配,把解糖/语义化后的节点逆向还原为原始语法
这种“访客 + 钩子 + 模式匹配”的组合,是编译器工具中 AST→源码 反序列化问题的经典解法,对想实现类似 unparser 的同学很有参考价值。
📚 相关源码入口:src/passes/to_source/(还原 Pass 全实现)、src/passes/desugar/(解糖 Pass)、test/test_ci.cpp(管线回归测试)。
【免费下载链接】CJASTHelperCangjie Abstract Syntax Tree Helper项目地址: https://gitcode.com/Cangjie-SIG/CJASTHelper
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考