Read OSS

从源码到语义:解析器与语义分析器

高级

前置知识

  • 了解 AST 基本概念与递归下降解析
  • 第 1-2 篇:架构概览与 Arena/AST 设计
  • 熟悉 ECMAScript 规范的基本结构

从源码到语义:解析器与语义分析器

解析器将一串扁平的字符序列转换为结构化的语法树;语义分析器则赋予这棵树实际意义——哪些变量处于作用域内、哪些名称指向哪些声明、控制流可以流向何处。在 Oxc 中,这两个阶段是刻意分离的:解析器专注于语法,始终产出一棵结构合法的 AST;而 SemanticBuilder 则在第二次遍历中承担更繁重的工作,负责作用域解析与符号绑定。这种分离是一个性能设计决策,理解它是理解 Oxc 为何高效的关键。

解析器架构概览

解析器位于 crates/oxc_parser,是一个手写的递归下降解析器——没有使用任何解析器生成工具。架构文档中解释了这一选择的理由:手写解析器生成的代码更快、错误信息更友好,也比自动生成的解析器更易于调试。代价是更多的实现工作量,但对于一个需要长期跟进不断演进的 ECMAScript 规范的项目来说,这是完全值得的。

主要入口是 Parser 结构体及其 parse() 方法,定义见 crates/oxc_parser/src/lib.rs#L1-L66。其 API 非常简洁:

let parser_return = Parser::new(&allocator, &source_text, source_type).parse();

三个输入(allocator、源文本、源类型),一个输出结构体。lib.rs#L144-L189 中的 ParserReturn 包含以下字段:

字段 类型 用途
program Program<'a> AST(即使有错误也始终存在)
module_record ModuleRecord<'a> import/export 声明信息
errors Vec<OxcDiagnostic> 遇到的语法错误
tokens Vec<'a, Token> 可选的 token 列表
panicked bool 解析器是否提前中止
sequenceDiagram
    participant S as Source Text
    participant L as Lexer
    participant P as Parser (recursive descent)
    participant A as Allocator
    participant R as ParserReturn
    
    S->>L: tokenize
    loop For each grammar production
        L->>P: next token
        P->>A: allocate AST node
    end
    P->>R: Program + errors + ModuleRecord

错误恢复

一个关键的设计特性:即使存在语法错误,program 字段也始终包含一棵结构合法的 AST。解析器通过跳过 token 直到找到同步点(例如分号或右花括号)来从错误中恢复。这意味着下游工具(如 linter)始终可以操作这棵 AST——它们只需检查 errors 字段,就能判断 AST 在语义层面是否可信。

当解析器遇到无法恢复的错误时,会将 panicked 置为 true 并返回一个空的 program。但这种情况很少见——大多数语法错误都是可以恢复的。

提示: 可解析的文件大小上限为 u32::MAX 字节(约 4GB),这是因为 Span 使用 u32 偏移量。该常量定义于 lib.rs#L113-L119,在 32 位系统上还会进一步受到 isize::MAX 的限制。

Lexer 与 Token 游标

解析器通过定义在 crates/oxc_parser/src/cursor.rs 中的游标抽象与 lexer 交互。游标提供了几个核心操作:

// Check current token kind
pub(crate) fn cur_kind(&self) -> Kind { ... }

// Check if current token matches
pub(crate) fn at(&self, kind: Kind) -> bool { ... }

// Advance to next token
pub(crate) fn advance(&mut self, kind: Kind) { ... }

// Get source text for current token
pub(crate) fn cur_src(&self) -> &'a str { ... }

游标还支持检查点机制,用于回溯。cursor.rs#L14-L21 中的 ParserCheckpoint 保存了 lexer 状态、当前 token、span 位置和错误计数——回退所需的一切信息:

pub struct ParserCheckpoint<'a> {
    lexer: LexerCheckpoint<'a>,
    cur_token: Token,
    prev_span_end: u32,
    errors_pos: usize,
    fatal_error: Option<FatalError>,
}

回溯机制是处理 TypeScript 中歧义语法的必要手段。例如,<T> 根据上下文可能是类型参数、JSX 元素,或者小于号比较。解析器会先尝试一种解释,如果失败则回退检查点,再尝试另一种。

flowchart TD
    A["Source: &lt;T&gt;(x)"] --> B{Try TypeScript cast}
    B -->|Success| C[TypeAssertionExpression]
    B -->|Fail: checkpoint rewind| D{Try JSX element}
    D -->|Success| E[JSXElement]
    D -->|Fail: checkpoint rewind| F[BinaryExpression with &lt;]

JS、TypeScript 与 JSX 解析

解析器将语法规则组织在三个子目录中:

目录 覆盖范围
crates/oxc_parser/src/js/ 核心 ECMAScript(语句、表达式、函数、类、模块)
crates/oxc_parser/src/ts/ TypeScript 扩展(类型、接口、枚举、装饰器)
crates/oxc_parser/src/jsx/ JSX/TSX 语法

这种组织方式与语言的分层结构完全对应:TypeScript 扩展 JavaScript,JSX 则在两者基础上进一步扩展。TypeScript 的解析规则在处理共享产生式时会调用 JS 的解析规则。

架构文档中还提到了一个重要的性能细节:解析器刻意避免执行任何作用域绑定或符号解析操作。检查变量是否已在当前作用域中声明、解析标识符指向哪个声明——这些工作全部推迟到语义分析阶段处理。这让解析器得以专注于一件事:构建语法正确的 AST,从而保持高效。

SemanticBuilder:作用域、符号与引用

解析完成后,流水线的下一个阶段(正如我们在第 1 篇的 CompilerInterface 中看到的)是语义分析。crates/oxc_semantic/src/builder.rs#L68-L120 中的 SemanticBuilder 使用 Visit trait 遍历已解析的 AST,并构建三个关键数据结构:

  1. 作用域树 —— 带有父指针和标志位的词法作用域树
  2. 符号表 —— 所有已声明的名称(变量、函数、类、类型),包含其标志位和位置信息
  3. 引用列表 —— 所有标识符引用,并解析到对应的符号
pub struct SemanticBuilder<'a> {
    pub(crate) source_text: &'a str,
    pub(crate) source_type: SourceType,
    pub(crate) errors: RefCell<Vec<OxcDiagnostic>>,
    pub(crate) current_scope_id: ScopeId,
    pub(crate) nodes: AstNodes<'a>,
    pub(crate) scoping: Scoping,
    pub(crate) unresolved_references: UnresolvedReferences<'a>,
    // ...
}

builder 实现了 Visit trait(我们将在第 4 篇详细介绍这个只读 AST 访问器),从上到下遍历整棵 AST。每遇到一个创建新作用域的节点(函数、块、for 循环等),就向作用域树压入一个新作用域;每遇到一个绑定(变量声明、函数参数、import 说明符),就创建一个符号;每遇到一个标识符引用,就尝试将其解析到某个外层作用域中的符号。

sequenceDiagram
    participant AST as Parsed AST
    participant SB as SemanticBuilder
    participant ST as Scope Tree
    participant SYM as Symbol Table
    participant REF as References
    
    AST->>SB: visit(Program)
    SB->>ST: push_scope(Top)
    AST->>SB: visit(FunctionDeclaration)
    SB->>SYM: declare_symbol("myFunc")
    SB->>ST: push_scope(Function)
    AST->>SB: visit(BindingIdentifier "x")
    SB->>SYM: declare_symbol("x")
    AST->>SB: visit(IdentifierReference "x")
    SB->>REF: resolve("x") → SymbolId
    SB->>ST: pop_scope()

语义分析的输出与下游消费

语义分析的核心输出是 Scoping 结构体,定义于 crates/oxc_semantic/src/scoping.rs#L88-L100。它采用 Struct-of-Arrays(SoA)布局以提升内存效率:

pub struct Scoping {
    /* Symbol Table - single allocation for all symbol-indexed flat fields */
    symbol_table: SymbolTable,
    pub(crate) references: IndexVec<ReferenceId, Reference>,
    pub(crate) no_side_effects: FxHashSet<SymbolId>,

    /* Scope Tree - single allocation for all scope-indexed flat fields */
    scope_table: ScopeTable,
    // ...
}

ScopeTableSymbolTable 通过 multi_index_vec! 宏生成,将多个平行数组打包进单次内存分配中。以 scoping.rs#L42-L54 中的 ScopeTable 为例,它将父 ID、节点 ID 和标志位存储为三个平行数组,共享同一个长度和容量:

multi_index_vec! {
    struct ScopeTable<ScopeId> {
        parent_ids => parent_ids_mut: Option<ScopeId>,
        node_ids => node_ids_mut: NodeId,
        flags => flags_mut: ScopeFlags,
    }
}

Scoping 结构体会在整个流水线中流转。正如我们在第 1 篇的 CompilerInterface 中所见,它从语义分析传递到 transformer(transformer 在插入/删除绑定时会修改它),再到 inject/define 插件,最终流向 mangler 和 codegen。

flowchart LR
    SB[SemanticBuilder] -->|"into_scoping()"| S[Scoping]
    S -->|"build_with_scoping"| T[Transformer]
    T -->|"transformer_return.scoping"| S2[Updated Scoping]
    S2 --> I[InjectGlobalVariables]
    I --> D[ReplaceGlobalDefines]
    D --> M[Mangler]
    M --> CG[Codegen]

可选的控制流图

启用 cfg feature 后,SemanticBuilder 还会在遍历过程中构建控制流图(CFG)。CFG 定义在 crates/oxc_cfg 中,供需要推断可达性的高级 lint 规则使用。由于并非所有消费者都需要 CFG——它会为语义分析带来额外开销——因此将其置于 feature flag 之后。

条件编译通过 builder.rs#L43-L58 中的宏来实现:

#[cfg(feature = "cfg")]
macro_rules! control_flow {
    ($self:ident, |$cfg:tt| $body:expr) => {
        if let Some($cfg) = &mut $self.cfg { $body } else { Default::default() }
    };
}

提示: 如果你正在编写需要控制流信息的 lint 规则(例如死代码检测),请确保在 oxc_semantic 上启用了 cfg feature。否则 CFG 不会被构建,你的规则也就无从获取相关数据。

关注点分离的价值

解析器与语义分析器之间刻意保持的分离值得着重强调。在很多工具中,这两个关注点是混在一起的——解析器在解析过程中顺带进行变量解析,导致代码难以维护,也难以优化。

在 Oxc 中:

  • 解析器产出语法合法的语法树,对作用域和符号一无所知。
  • SemanticBuilder 遍历这棵树并添加语义含义。它通过 Cell(内部可变性)填充 AST 中的 scope_idsymbol_id 字段,这也是这些字段在 AST 中类型为 Cell<Option<ScopeId>> 的原因。

这种分离意味着解析器可以独立于语义分析进行优化,而语义分析器也可以在 AST 发生变动后(transformer 正是如此)以较低的代价重新运行。

下一步

了解了 AST 是如何生成并被赋予语义含义之后,第 4 篇将深入探讨遍历 AST 的两套机制:语义 builder 和 lint 规则使用的只读 Visit/VisitMut trait,以及 transformer 和 minifier 使用的可变 Traverse 系统。我们还将看到 ast_tools 代码生成器如何从带注解的 AST 定义中同时生成这两套接口。