Read OSS

ソースコードから意味へ:パーサーとセマンティックアナライザー

上級

前提知識

  • AST の概念と再帰下降構文解析の理解
  • 第1・2回:アーキテクチャと Arena/AST 設計
  • ECMAScript 仕様の構造に対する基本的な知識

ソースコードから意味へ:パーサーとセマンティックアナライザー

パーサーは、文字の羅列を構造化されたツリーへと変換します。セマンティックアナライザーは、そのツリーに意味を与えます。どの変数がスコープ内に存在するか、どの名前がどの宣言を参照しているか、制御フローがどこへ向かうのか。こうした情報を付加するのがセマンティックアナライザーの役割です。Oxc では、この2つのステージを意図的に分離しています。パーサーは純粋に構文解析に専念し、常に有効な AST を生成します。一方、スコープ解決とシンボルバインディングというより重い処理は、SemanticBuilder が第2パスで担います。この分離はパフォーマンス上の選択であり、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();

入力は3つ(アロケーター、ソーステキスト、ソースタイプ)、出力は1つの構造体です。lib.rs#L144-L189 で定義される ParserReturn には以下のフィールドが含まれます。

フィールド 役割
program Program<'a> AST(エラーがあっても常に存在する)
module_record ModuleRecord<'a> import/export 宣言
errors Vec<OxcDiagnostic> 検出された構文エラー
tokens Vec<'a, 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 を保持します。パーサーはエラーに遭遇すると、セミコロンや閉じ波括弧といった同期ポイントが見つかるまでトークンをスキップしてリカバリーします。これにより、リンターなどの後続ツールは常に AST を処理できます。AST の意味的な信頼性を確認したい場合は、errors を参照するだけで十分です。

回復不能なエラーに遭遇した場合、パーサーは panicked = true をセットして空のプログラムを返します。ただし、このケースは稀で、ほとんどの構文エラーはリカバリー可能です。

メモ: Spanu32 のオフセットを使用しているため、パース可能なファイルの最大サイズは u32::MAX バイト(約4GB)です。この定数は lib.rs#L113-L119 に定義されており、32ビットシステムでは isize::MAX にさらに制限されます。

レキサーとトークンカーソル

パーサーはレキサーとのやり取りを、crates/oxc_parser/src/cursor.rs で定義されたカーソル抽象化を通じて行います。カーソルは以下の基本操作を提供します。

// 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-L21ParserCheckpoint は、レキサーの状態、現在のトークン、スパン位置、エラーカウントを保存し、巻き戻しに必要なすべての情報を保持します。

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 のパース

パーサーは文法ルールを3つのサブディレクトリに整理しています。

ディレクトリ 対象
crates/oxc_parser/src/js/ コア ECMAScript(文、式、関数、クラス、モジュール)
crates/oxc_parser/src/ts/ TypeScript 拡張(型、インターフェース、enum、デコレーター)
crates/oxc_parser/src/jsx/ JSX/TSX 構文

この構成は言語の階層構造を反映しています。TypeScript は JavaScript を拡張し、JSX はその両方を拡張します。TypeScript のパースルールは、共通の構文処理において JS のルールを呼び出します。

アーキテクチャドキュメントにある重要なパフォーマンス上のポイントとして、パーサーはスコープバインディングやシンボル解決を意図的に行いません。現在のスコープで変数が宣言済みかどうかの確認や、識別子がどの宣言を参照しているかの解決はすべて、セマンティックアナライザーに委ねられます。これによりパーサーは「構文的に正しい AST を構築する」という1つのことに集中でき、処理速度を維持できます。

SemanticBuilder:スコープ・シンボル・参照

パース後のパイプラインの次ステージ(第1回の CompilerInterface で確認したとおり)は、セマンティック解析です。crates/oxc_semantic/src/builder.rs#L68-L120SemanticBuilder は、Visit トレイトを使ってパース済み AST をトラバースし、3つの主要なデータ構造を構築します。

  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>,
    // ...
}

ビルダーは Visit(第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()

セマンティック解析の出力と後続処理への受け渡し

セマンティック解析の主要な出力は、crates/oxc_semantic/src/scoping.rs#L88-L100 で定義された Scoping 構造体です。メモリ効率を高めるために、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,
    // ...
}

ScopeTableSymbolTablemulti_index_vec! マクロを使って生成されており、複数の並列配列を単一のアロケーションにまとめます。たとえば scoping.rs#L42-L54ScopeTable は、親 ID・ノード ID・フラグを3つの並列配列として、単一の長さとキャパシティで管理します。

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 で確認したように、セマンティック解析からトランスフォーマー(バインディングの追加・削除に伴って内容を変更する)へと渡され、さらに inject/define プラグイン、マングラー、最終的に 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 フィーチャーが有効な場合、SemanticBuilder はトラバース中に制御フローグラフ(CFG)も構築します。CFG は crates/oxc_cfg で定義されており、到達可能性の推論が必要な高度なリントルールで使用されます。すべての利用者が CFG を必要とするわけではなく、CFG の構築はセマンティック解析にオーバーヘッドを加えるため、フィーチャーフラグで制御されています。

条件付きコンパイルは、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() }
    };
}

メモ: デッドコード検出など、制御フロー情報が必要なリントルールを書く場合は、oxc_semanticcfg フィーチャーを有効にしておく必要があります。有効にしていないと CFG が構築されず、ルールが参照するデータが存在しない状態になります。

関心の分離

パーサーとセマンティックアナライザーを意図的に分離している点は、改めて強調する価値があります。多くのツールでは、これらの関心事が混在しています。パーサーが解析しながら変数を解決しようとするため、コードの保守性も最適化も難しくなります。

Oxc では明確に分かれています。

  • パーサーは構文的に有効なツリーを生成します。スコープやシンボルについては何も知りません。
  • SemanticBuilderはそのツリーを走査し、意味的な情報を付加します。Cell(内部可変性)を通じて scope_idsymbol_id フィールドを埋めます。AST 上でこれらのフィールドが Cell<Option<ScopeId>> となっているのはそのためです。

この分離により、パーサーをセマンティック解析と独立して最適化でき、AST の変換後(トランスフォーマーが行う処理)にセマンティックアナライザーを低コストで再実行することも可能になります。

次回に向けて

AST がどのように生成され、意味的な情報が付加されるかを理解したところで、第4回ではその AST を走査する2つの仕組みを取り上げます。セマンティックビルダーやリントルールが使用する読み取り専用の Visit/VisitMut トレイトと、トランスフォーマーやミニファイアーが使用する可変の Traverse システムです。また、ast_tools コードジェネレーターが、注釈付きの AST 定義からこれら両方をどのように生成するかも見ていきます。