Read OSS

遍历 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 系统

VisitVisitMut 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_programvisit_expressionvisit_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,并将各节点分发给相关规则处理。

VisitMutVisit 的可变版本,为每个节点提供 &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.scopingctx.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.ancestryctx.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 ——提供对 rightoperator 的访问,但包含 left
  • BinaryExpressionRight ——提供对 leftoperator 的访问,但包含 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 生成代码

VisitTraverse,以及 CloneInTakeInDummyGetSpan 和布局断言,均由 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,生成 StructDefEnumDef,捕获所有字段、变体及其类型信息。

第三阶段(Resolve):将各类型关联起来——将每个字段的类型解析为对应的 TypeId,并解析 #[visit]#[scope]#[generate_derive] 等自定义属性。

第四阶段(Generate):运行各 Generator 和 Derive,生成以下代码:

  • VisitVisitMut trait(位于 crates/oxc_ast_visit
  • Traverse trait、walk_* 函数以及 Ancestor 类型(位于 crates/oxc_traverse
  • CloneInTakeInDummyGetSpanContentEq 的实现
  • 用于验证 #[repr(C)] 类型尺寸的布局断言
  • SemanticBuilder 的作用域创建钩子

第五阶段(Write):将生成的代码写入相关 crate 的 src/generated/ 目录,并提交到 git。

为什么选择提前生成而非 Proc Macro?

ast_tools 文档给出了两点理由:

  1. 编译时间:Proc macro 在每次 cargo build 时都会运行,而 ast_tools 只在修改 AST 类型时才需要执行(just ast)。对于拥有数百个生成 trait 实现的项目而言,这能节省可观的构建时间。

  2. 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 但不需要祖先信息,VisitMutTraverse 更简单直接。

下一步

掌握了遍历系统之后,第 5 篇将进入 lint 检查器的实战:了解 oxlint 如何编排并行文件处理、lint 规则如何通过 Rule trait 接入 visitor,以及 LintContext 如何为规则提供检测和修复问题所需的一切。