遍历 AST 的两种方式:Visit 与 Traverse 系统
前置知识
- ›访问者设计模式
- ›Rust trait 系统与 unsafe 代码概念
- ›第 1-3 篇:架构、AST 设计以及 Parser/Semantic
遍历 AST 的两种方式:Visit 与 Traverse 系统
Oxc 流水线中的每个工具都需要遍历 AST。lint 检查器逐节点扫描违规,transformer 原地重写节点,minifier 则反复优化直至无法继续压缩。然而这些工具的需求各不相同:lint 检查器只需读取 AST,而 transformer 必须对其进行修改。此外,transformer 需要感知父节点,而 lint 检查器通常不需要。
Oxc 通过两套独立的遍历系统来应对这些差异化的需求:Visit(只读、自动生成)和 Traverse(可变、支持祖先节点访问)。两者均由 ast_tools 生成——这是一个构建时代码生成器,它读取带注解的 AST 定义,输出数千行 visitor 代码。理解这两套系统,是编写 lint 规则、transform 逻辑或任何处理 Oxc AST 代码的前提。
Visit/VisitMut 系统
Visit 和 VisitMut trait 位于 crates/oxc_ast_visit,内容完全由代码生成。该 crate 的源码只有寥寥几行,见 crates/oxc_ast_visit/src/lib.rs:
mod generated {
pub mod visit;
pub mod visit_mut;
}
pub use generated::{visit::*, visit_mut::*};
(实际文件还包含一个通过 feature flag 控制的可选 utf8_to_utf16 模块,但核心是这两个生成的 visitor 模块。)
生成的 Visit trait 为每种 AST 节点类型提供一个方法(例如 visit_program、visit_expression、visit_binding_identifier)。每个方法都有默认实现,负责递归遍历子节点——也就是所谓的 "walk" 函数。使用者只需覆盖自己关心的节点类型对应的方法即可。
classDiagram
class Visit {
+visit_program(&Program)
+visit_statement(&Statement)
+visit_expression(&Expression)
+visit_binding_identifier(&BindingIdentifier)
+visit_identifier_reference(&IdentifierReference)
...hundreds more...
}
class SemanticBuilder {
Implements Visit
"Builds scope tree"
}
class LintRule {
"Uses run() per node"
}
Visit <|.. SemanticBuilder
note for LintRule "Lint rules use run(AstNode)\nnot Visit directly"
SemanticBuilder(详见第 3 篇)是 Visit 的主要使用者,它实现了数十个 visitor 方法,用于构建作用域树、绑定符号并解析引用。
Lint 规则如何使用 Visitor
Lint 规则并不直接实现 Visit——它们通过 run() 方法接收单个 AstNode(详见第 5 篇)。但 lint 的基础设施在内部使用 Visit 遍历 AST,并将各节点分发给相关规则处理。
VisitMut 是 Visit 的可变版本,为每个节点提供 &mut 引用。当你需要修改 AST 但不需要访问祖先节点时,可以使用它。
Traverse 系统:带祖先访问的可变遍历
Visit 系统有一个根本性的局限:在 visitor 方法内部,你只能看到当前节点及其子节点,无法向上查看父节点或祖父节点。而许多 transform 场景恰恰需要这些上下文信息。例如,将 arguments 转换为 rest 参数,就需要知道当前函数是否是箭头函数。
crates/oxc_traverse 中的 Traverse 系统解决了这个问题。其设计思路在 crates/oxc_traverse/src/lib.rs#L1-L61 的模块文档中有详细说明:
sequenceDiagram
participant T as Traverse impl
participant W as walk_* functions
participant S as Ancestor Stack
participant N as AST Node
W->>S: push(ProgramWithoutBody)
W->>N: raw pointer to body[0]
W->>T: enter_statement(&mut stmt, ctx)
Note right of T: ctx.parent() returns<br/>ProgramWithoutBody
T->>T: transform statement
W->>T: exit_statement(&mut stmt, ctx)
W->>S: pop()
Traverse trait 为每种节点类型提供 enter_* 和 exit_* 两个钩子。enter 钩子在遍历子节点之前触发,exit 钩子在之后触发。每个钩子都会收到一个 TraverseCtx,提供以下能力:
- 祖先节点访问:
ctx.parent()、ctx.ancestor(n)——返回Ancestor枚举变体 - 作用域数据:
ctx.scoping、ctx.current_scope_id() - AST 构建器:
ctx.ast——用于在 arena 中分配新的 AST 节点
TraverseCtx 定义于 crates/oxc_traverse/src/context/mod.rs#L33-L60,同时提供"直接"和"命名空间"两种 API:
| 直接调用 | 命名空间调用 |
|---|---|
ctx.parent() |
ctx.ancestry.parent() |
ctx.current_scope_id() |
ctx.scoping.current_scope_id() |
ctx.alloc(thing) |
ctx.ast.alloc(thing) |
命名空间 API 的存在是为了解决借用检查器的问题:如果你通过 ctx.parent() 持有一个 &Ancestor,就无法再通过 ctx 修改作用域树,因为两者都借用了 ctx。命名空间形式允许你独立借用 ctx.ancestry 和 ctx.scoping,从而绕过这个限制。
安全模型:裸指针与祖先节点排除
Traverse 系统的安全模型是 Oxc 代码库中最精妙的部分。问题在于:Rust 的别名规则不允许同时持有对子节点的 &mut 引用和对其父节点的 & 引用,因为父节点拥有子节点。然而,带祖先访问的可变遍历恰恰需要做到这一点。
解决方案采用了两种技术:
1. Walk 函数中的裸指针
生成的 walk_* 函数在向下遍历树时不创建 & 或 &mut 引用,而是使用绕过别名检查的裸指针。引用只在边界处创建——即调用 enter_* 和 exit_* 时:
// Conceptual simplified version of a walk function
unsafe fn walk_binary_expression(traverser, node_ptr, ctx) {
// Push ancestor - NO reference created to parent
ctx.push_stack(Ancestor::BinaryExpressionLeft(...));
// Get raw pointer to left child - NO reference to parent
let left_ptr = addr_of_mut!((*node_ptr).left);
// NOW create &mut reference, only to the child
walk_expression(traverser, left_ptr, ctx);
ctx.pop_stack();
}
2. 祖先节点排除
每个 Ancestor 变体会刻意排除当前正在遍历的那个字段。对于 BinaryExpression,存在两个独立的变体:
BinaryExpressionLeft——提供对right和operator的访问,但不包含leftBinaryExpressionRight——提供对left和operator的访问,但不包含right
这不只是命名约定——这些类型在实现层面就没有访问被排除字段的方法。如果你从右侧进入时尝试访问 bin_expr_ref.right(),代码将无法编译:
fn enter_numeric_literal(&mut self, node: &mut NumericLiteral<'a>, ctx: &mut TraverseCtx<'a>) {
if let Ancestor::BinaryExpressionRight(bin_expr_ref) = ctx.parent() {
// This compiles - left is a different branch
let _ = bin_expr_ref.left();
// This would NOT compile - right is where we came from
// let _ = bin_expr_ref.right();
}
}
flowchart TB
subgraph "BinaryExpression (parent)"
OP[operator: ==]
LEFT[left: IdentifierReference]
RIGHT[right: NumericLiteral]
end
subgraph "Ancestor::BinaryExpressionRight"
A_OP[✅ operator]
A_LEFT[✅ left]
A_RIGHT[❌ right - excluded]
end
RIGHT -.->|"traversing into"| A_RIGHT
style A_RIGHT fill:#f99
style A_OP fill:#9f9
style A_LEFT fill:#9f9
提示: Traverse 系统的安全性完全依赖于代码生成的正确性。模块文档中明确警告不要手动编辑生成文件。请始终使用
just ast重新生成,切勿手动修改crates/oxc_traverse/src/generated/下的任何文件。
使用 ast_tools 生成代码
Visit、Traverse,以及 CloneIn、TakeIn、Dummy、GetSpan 和布局断言,均由 ast_tools 流水线生成。这是一个提前(ahead-of-time)代码生成器,而非 proc macro,定义于 tasks/ast_tools/src/main.rs。
整个流水线分为五个阶段:
flowchart LR
L[Phase 1: Load] --> P[Phase 2: Parse]
P --> R[Phase 3: Resolve]
R --> G[Phase 4: Generate]
G --> W[Phase 5: Write]
L -.-|"Read .rs files,\nfind #[ast] types"| L
P -.-|"Parse type defs,\nbuild TypeDefs"| P
R -.-|"Link types via TypeId,\nresolve attributes"| R
G -.-|"Run Generators\nand Derives"| G
W -.-|"Write output\nto disk"| W
第一阶段(Load):读取所有依赖 oxc_ast_macros 的 crate 中的 .rs 源文件,用 syn 解析,识别带有 #[ast] 注解的类型并为其分配 TypeId。
第二阶段(Parse):完整解析每个类型的 syn AST,生成 StructDef 或 EnumDef,捕获所有字段、变体及其类型信息。
第三阶段(Resolve):将各类型关联起来——将每个字段的类型解析为对应的 TypeId,并解析 #[visit]、#[scope]、#[generate_derive] 等自定义属性。
第四阶段(Generate):运行各 Generator 和 Derive,生成以下代码:
Visit和VisitMuttrait(位于crates/oxc_ast_visit)Traversetrait、walk_*函数以及Ancestor类型(位于crates/oxc_traverse)CloneIn、TakeIn、Dummy、GetSpan、ContentEq的实现- 用于验证
#[repr(C)]类型尺寸的布局断言 SemanticBuilder的作用域创建钩子
第五阶段(Write):将生成的代码写入相关 crate 的 src/generated/ 目录,并提交到 git。
为什么选择提前生成而非 Proc Macro?
ast_tools 文档给出了两点理由:
-
编译时间:Proc macro 在每次
cargo build时都会运行,而ast_tools只在修改 AST 类型时才需要执行(just ast)。对于拥有数百个生成 trait 实现的项目而言,这能节省可观的构建时间。 -
IDE 可导航性:生成的代码就是提交到 git 的普通
.rs文件,你可以在 IDE 中点击跳转定义、直接阅读生成代码,也可以用 grep 搜索实现——这些在 proc macro 输出中都难以做到。
crates/oxc_ast_macros/src/lib.rs#L12-L47 中的 #[ast] 属性宏在编译时几乎什么都不做——它只是添加 #[repr(C)] 和一个空操作的 #[derive(Ast)] 来启用辅助属性。真正的工作发生在执行 just ast 的时候。
Traverse 与 Visit:如何选择
| 特性 | Visit/VisitMut | Traverse |
|---|---|---|
| 遍历方向 | 自顶向下 | 自顶向下(enter + exit) |
| 可变性 | VisitMut 支持 | 支持 |
| 祖先节点访问 | 不支持 | 支持(父节点、祖父节点……) |
| 作用域数据 | 不支持 | 支持(通过 TraverseCtx) |
| 主要使用者 | SemanticBuilder、lint 基础设施 | Transformer、Minifier |
| 安全模型 | 标准 Rust 引用 | 裸指针 + 类型级别的字段排除 |
如果只是分析 AST 而不修改,选 Visit;如果需要转换 AST 并依赖父节点上下文,选 Traverse;如果需要修改 AST 但不需要祖先信息,VisitMut 比 Traverse 更简单直接。
下一步
掌握了遍历系统之后,第 5 篇将进入 lint 检查器的实战:了解 oxlint 如何编排并行文件处理、lint 规则如何通过 Rule trait 接入 visitor,以及 LintContext 如何为规则提供检测和修复问题所需的一切。