从源码到语义:解析器与语义分析器
前置知识
- ›了解 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: <T>(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 <]
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,并构建三个关键数据结构:
- 作用域树 —— 带有父指针和标志位的词法作用域树
- 符号表 —— 所有已声明的名称(变量、函数、类、类型),包含其标志位和位置信息
- 引用列表 —— 所有标识符引用,并解析到对应的符号
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,
// ...
}
ScopeTable 和 SymbolTable 通过 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上启用了cfgfeature。否则 CFG 不会被构建,你的规则也就无从获取相关数据。
关注点分离的价值
解析器与语义分析器之间刻意保持的分离值得着重强调。在很多工具中,这两个关注点是混在一起的——解析器在解析过程中顺带进行变量解析,导致代码难以维护,也难以优化。
在 Oxc 中:
- 解析器产出语法合法的语法树,对作用域和符号一无所知。
- SemanticBuilder 遍历这棵树并添加语义含义。它通过
Cell(内部可变性)填充 AST 中的scope_id和symbol_id字段,这也是这些字段在 AST 中类型为Cell<Option<ScopeId>>的原因。
这种分离意味着解析器可以独立于语义分析进行优化,而语义分析器也可以在 AST 发生变动后(transformer 正是如此)以较低的代价重新运行。
下一步
了解了 AST 是如何生成并被赋予语义含义之后,第 4 篇将深入探讨遍历 AST 的两套机制:语义 builder 和 lint 规则使用的只读 Visit/VisitMut trait,以及 transformer 和 minifier 使用的可变 Traverse 系统。我们还将看到 ast_tools 代码生成器如何从带注解的 AST 定义中同时生成这两套接口。