| 1 | # Rust Port Step 1: Babel AST Crate |
| 2 | |
| 3 | ## Goal |
| 4 | |
| 5 | Create a Rust crate (`compiler/crates/react_compiler_ast`) that precisely models the Babel AST structure, enabling JSON round-tripping: parse JS with Babel in Node.js, serialize to JSON, deserialize into Rust, re-serialize back to JSON, and get an identical result. |
| 6 | |
| 7 | This crate is the serialization boundary between the JS toolchain (Babel parser) and the Rust compiler. It must be a faithful 1:1 representation of Babel's AST output — not a simplified or custom IR. |
| 8 | |
| 9 | **Current status**: Complete (human reviewed). All 1714 compiler test fixtures round-trip successfully (0 failures). No `Unknown` catch-all variants remain. Scope types are defined separately in [rust-port-0002-scope-types.md](rust-port-0002-scope-types.md). |
| 10 | |
| 11 | --- |
| 12 | |
| 13 | ## Crate Structure |
| 14 | |
| 15 | ``` |
| 16 | compiler/crates/ |
| 17 | react_compiler_ast/ |
| 18 | Cargo.toml |
| 19 | src/ |
| 20 | lib.rs # Re-exports, top-level File/Program types |
| 21 | statements.rs # Statement enum and statement node structs |
| 22 | expressions.rs # Expression enum and expression node structs |
| 23 | literals.rs # Literal node structs (StringLiteral, NumericLiteral, etc.) |
| 24 | patterns.rs # PatternLike enum and pattern node structs |
| 25 | jsx.rs # JSX node structs and enums |
| 26 | declarations.rs # Import/export, TS declaration, and Flow declaration structs |
| 27 | common.rs # SourceLocation, Position, Comment, BaseNode, helpers |
| 28 | operators.rs # Operator enums (BinaryOperator, UnaryOperator, etc.) |
| 29 | tests/ |
| 30 | round_trip.rs # Round-trip test harness |
| 31 | ``` |
| 32 | |
| 33 | TypeScript and Flow annotation types are co-located with the module that uses them — TS/Flow expressions live in `expressions.rs`, TS/Flow declarations live in `declarations.rs`. Class-related types are split between `expressions.rs` (ClassExpression, ClassBody) and `statements.rs` (ClassDeclaration). There is no single `Node` enum; the union types (`Statement`, `Expression`, `PatternLike`) serve as the dispatch enums directly. |
| 34 | |
| 35 | ### Cargo.toml |
| 36 | |
| 37 | ```toml |
| 38 | [package] |
| 39 | name = "react_compiler_ast" |
| 40 | version = "0.1.0" |
| 41 | edition = "2024" |
| 42 | |
| 43 | [dependencies] |
| 44 | serde = { version = "1", features = ["derive"] } |
| 45 | serde_json = "1" |
| 46 | |
| 47 | [dev-dependencies] |
| 48 | walkdir = "2" |
| 49 | similar = "2" # for readable diffs in round-trip test |
| 50 | ``` |
| 51 | |
| 52 | No other dependencies. The crate is pure data types + serde. |
| 53 | |
| 54 | --- |
| 55 | |
| 56 | ## Core Design Decisions |
| 57 | |
| 58 | ### 1. Internally tagged via `"type"` field |
| 59 | |
| 60 | Babel AST nodes use a `"type"` field as the discriminant (e.g., `"type": "FunctionDeclaration"`). Serde's default externally-tagged enum format doesn't match this. Use **internally tagged** enums with `#[serde(tag = "type")]`: |
| 61 | |
| 62 | ```rust |
| 63 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 64 | #[serde(tag = "type")] |
| 65 | pub enum Statement { |
| 66 | BlockStatement(BlockStatement), |
| 67 | ReturnStatement(ReturnStatement), |
| 68 | IfStatement(IfStatement), |
| 69 | // ... |
| 70 | } |
| 71 | ``` |
| 72 | |
| 73 | Each variant's struct contains the node-specific fields. The `"type"` field is handled by serde's internal tagging. |
| 74 | |
| 75 | ### 2. BaseNode fields via flattening |
| 76 | |
| 77 | Every Babel node shares common fields (`start`, `end`, `loc`, `leadingComments`, etc.). A `BaseNode` struct is flattened into each node struct: |
| 78 | |
| 79 | ```rust |
| 80 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] |
| 81 | pub struct BaseNode { |
| 82 | #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] |
| 83 | pub node_type: Option<String>, |
| 84 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 85 | pub start: Option<u32>, |
| 86 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 87 | pub end: Option<u32>, |
| 88 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 89 | pub loc: Option<SourceLocation>, |
| 90 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 91 | pub range: Option<(u32, u32)>, |
| 92 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 93 | pub extra: Option<serde_json::Value>, |
| 94 | #[serde(default, skip_serializing_if = "Option::is_none", rename = "leadingComments")] |
| 95 | pub leading_comments: Option<Vec<Comment>>, |
| 96 | #[serde(default, skip_serializing_if = "Option::is_none", rename = "innerComments")] |
| 97 | pub inner_comments: Option<Vec<Comment>>, |
| 98 | #[serde(default, skip_serializing_if = "Option::is_none", rename = "trailingComments")] |
| 99 | pub trailing_comments: Option<Vec<Comment>>, |
| 100 | } |
| 101 | ``` |
| 102 | |
| 103 | The `node_type` field captures the `"type"` string when `BaseNode` is deserialized directly (not through a `#[serde(tag = "type")]` enum, which consumes the field). It defaults to `None` and is skipped when absent, so it doesn't interfere with round-tripping in either context. |
| 104 | |
| 105 | Each node struct flattens this: |
| 106 | |
| 107 | ```rust |
| 108 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 109 | pub struct FunctionDeclaration { |
| 110 | #[serde(flatten)] |
| 111 | pub base: BaseNode, |
| 112 | pub id: Option<Identifier>, |
| 113 | pub params: Vec<PatternLike>, |
| 114 | pub body: BlockStatement, |
| 115 | #[serde(default)] |
| 116 | pub generator: bool, |
| 117 | #[serde(default, rename = "async")] |
| 118 | pub is_async: bool, |
| 119 | // ... |
| 120 | } |
| 121 | ``` |
| 122 | |
| 123 | The `#[serde(flatten)]` + `#[serde(tag = "type")]` combination works correctly — the macro fallback described in the risk section was not needed. |
| 124 | |
| 125 | ### 3. Naming conventions |
| 126 | |
| 127 | - Rust struct/enum names: PascalCase matching the Babel type name exactly (e.g., `FunctionDeclaration`, `JSXElement`) |
| 128 | - Rust field names: snake_case, with `#[serde(rename = "camelCase")]` for JSON mapping |
| 129 | - Reserved words: `#[serde(rename = "async")]` on field `is_async: bool`, `#[serde(rename = "type")]` handled by internal tagging |
| 130 | - Operator strings: mapped via `#[serde(rename = "+")]` etc. on enum variants |
| 131 | |
| 132 | ### 4. Optional/nullable field patterns |
| 133 | |
| 134 | Babel's TypeScript definitions use several patterns. Map them consistently: |
| 135 | |
| 136 | | Babel TypeScript | JSON behavior | Rust type | |
| 137 | |---|---|---| |
| 138 | | `field: T` | Always present | `field: T` | |
| 139 | | `field?: T \| null` | Absent or `null` | `#[serde(default, skip_serializing_if = "Option::is_none")] field: Option<T>` | |
| 140 | | `field: Array<T \| null>` | Array with null holes | `field: Vec<Option<T>>` | |
| 141 | | `field: T \| null` (required but nullable) | Present, may be `null` | `field: Option<T>` (no `skip_serializing_if` — always serialize) | |
| 142 | |
| 143 | **Critical subtlety**: Some fields like `FunctionDeclaration.id` are typed `id?: Identifier | null` and appear as `"id": null` in JSON (present but null), not absent. The round-trip test catches any mismatches here. When Babel serializes `null` for a field, we must also serialize `null` — not omit it. The round-trip test is the source of truth for which fields use which pattern. |
| 144 | |
| 145 | A `nullable_value` custom deserializer in `common.rs` handles the case where a field needs to distinguish "absent" from "explicitly null" (deserializing the latter as `Some(Value::Null)`): |
| 146 | |
| 147 | ```rust |
| 148 | pub fn nullable_value<'de, D>( |
| 149 | deserializer: D, |
| 150 | ) -> Result<Option<Box<serde_json::Value>>, D::Error> |
| 151 | ``` |
| 152 | |
| 153 | ### 5. The `extra` field |
| 154 | |
| 155 | The `extra` field is an unstructured `Record<string, unknown>` in Babel. Use `serde_json::Value` to round-trip it exactly: |
| 156 | |
| 157 | ```rust |
| 158 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 159 | pub extra: Option<serde_json::Value>, |
| 160 | ``` |
| 161 | |
| 162 | ### 6. `#[serde(deny_unknown_fields)]` — do NOT use |
| 163 | |
| 164 | Babel's AST may include fields we don't model (e.g., from plugins, or parser-specific metadata). To ensure forward compatibility and avoid brittle failures, do **not** use `deny_unknown_fields`. Instead, unknown fields are silently dropped during deserialization. The round-trip test detects any fields we're missing, since they'll be absent in the re-serialized output. |
| 165 | |
| 166 | --- |
| 167 | |
| 168 | ## Node Type Coverage |
| 169 | |
| 170 | All node types that appear in the compiler's 1714 test fixtures are modeled and round-trip successfully. The types are organized as follows: |
| 171 | |
| 172 | ### Statements (`statements.rs`, ~25 types) |
| 173 | |
| 174 | The `Statement` enum is the top-level dispatch for all statement and declaration nodes. It includes direct statement types and also pulls in declaration variants (import/export, TS, Flow) to avoid a separate `StatementOrDeclaration` wrapper. |
| 175 | |
| 176 | **Statement types**: `BlockStatement`, `ReturnStatement`, `IfStatement`, `ForStatement`, `WhileStatement`, `DoWhileStatement`, `ForInStatement`, `ForOfStatement`, `SwitchStatement` (+ `SwitchCase`), `ThrowStatement`, `TryStatement` (+ `CatchClause`), `BreakStatement`, `ContinueStatement`, `LabeledStatement`, `ExpressionStatement`, `EmptyStatement`, `DebuggerStatement`, `WithStatement`, `VariableDeclaration` (+ `VariableDeclarator`), `FunctionDeclaration`, `ClassDeclaration` |
| 177 | |
| 178 | **Helper enums**: `ForInit` (VariableDeclaration | Expression), `ForInOfLeft` (VariableDeclaration | PatternLike), `VariableDeclarationKind` |
| 179 | |
| 180 | ### Declarations (`declarations.rs`, ~20 types) |
| 181 | |
| 182 | **Import/export**: `ImportDeclaration`, `ExportNamedDeclaration`, `ExportDefaultDeclaration`, `ExportAllDeclaration`, `ImportSpecifier` enum (ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier), `ExportSpecifier` enum (ExportSpecifier | ExportDefaultSpecifier | ExportNamespaceSpecifier), `ImportAttribute`, `ModuleExportName`, `Declaration` enum, `ExportDefaultDecl` enum |
| 183 | |
| 184 | **TypeScript declarations (pass-through)**: `TSTypeAliasDeclaration`, `TSInterfaceDeclaration`, `TSEnumDeclaration`, `TSModuleDeclaration`, `TSDeclareFunction` |
| 185 | |
| 186 | **Flow declarations (pass-through)**: `TypeAlias`, `OpaqueType`, `InterfaceDeclaration`, `DeclareVariable`, `DeclareFunction`, `DeclareClass`, `DeclareModule`, `DeclareModuleExports`, `DeclareExportDeclaration`, `DeclareExportAllDeclaration`, `DeclareInterface`, `DeclareTypeAlias`, `DeclareOpaqueType`, `EnumDeclaration` |
| 187 | |
| 188 | ### Expressions (`expressions.rs`, ~35 types) |
| 189 | |
| 190 | **Core**: `Identifier`, `CallExpression`, `MemberExpression`, `OptionalCallExpression`, `OptionalMemberExpression`, `BinaryExpression`, `LogicalExpression`, `UnaryExpression`, `UpdateExpression`, `ConditionalExpression`, `AssignmentExpression`, `SequenceExpression`, `ArrowFunctionExpression` (+ `ArrowFunctionBody` enum), `FunctionExpression`, `ObjectExpression` (+ `ObjectExpressionProperty` enum, `ObjectProperty`, `ObjectMethod`), `ArrayExpression`, `NewExpression`, `TemplateLiteral`, `TaggedTemplateExpression`, `AwaitExpression`, `YieldExpression`, `SpreadElement`, `MetaProperty`, `ClassExpression` (+ `ClassBody`), `PrivateName`, `Super`, `Import`, `ThisExpression`, `ParenthesizedExpression`, `JSXElement`, `JSXFragment`, `AssignmentPattern` |
| 191 | |
| 192 | **TypeScript expressions**: `TSAsExpression`, `TSSatisfiesExpression`, `TSNonNullExpression`, `TSTypeAssertion`, `TSInstantiationExpression` |
| 193 | |
| 194 | **Flow expressions**: `TypeCastExpression` |
| 195 | |
| 196 | TypeScript and Flow type annotation bodies (e.g., `TSTypeAnnotation`, type parameters) use `serde_json::Value` for pass-through round-tripping rather than fully-typed structs. This is sufficient since the compiler doesn't inspect these deeply. |
| 197 | |
| 198 | ### Literals (`literals.rs`, 7 types) |
| 199 | |
| 200 | `StringLiteral`, `NumericLiteral`, `BooleanLiteral`, `NullLiteral`, `BigIntLiteral`, `RegExpLiteral`, `TemplateElement` (+ `TemplateElementValue`) |
| 201 | |
| 202 | ### Patterns (`patterns.rs`, ~5 types) |
| 203 | |
| 204 | `PatternLike` enum: `Identifier`, `ObjectPattern`, `ArrayPattern`, `AssignmentPattern`, `RestElement`, `MemberExpression` |
| 205 | |
| 206 | `ObjectPatternProperty` enum: `ObjectProperty` (as `ObjectPatternProp`), `RestElement` |
| 207 | |
| 208 | ### JSX (`jsx.rs`, ~15 types) |
| 209 | |
| 210 | `JSXElement`, `JSXFragment`, `JSXOpeningElement`, `JSXClosingElement`, `JSXOpeningFragment`, `JSXClosingFragment`, `JSXAttribute`, `JSXSpreadAttribute`, `JSXExpressionContainer`, `JSXSpreadChild`, `JSXText`, `JSXEmptyExpression`, `JSXIdentifier`, `JSXMemberExpression`, `JSXNamespacedName` |
| 211 | |
| 212 | **Helper enums**: `JSXChild`, `JSXElementName`, `JSXAttributeItem`, `JSXAttributeName`, `JSXAttributeValue`, `JSXExpressionContainerExpr`, `JSXMemberExprObject` |
| 213 | |
| 214 | ### Operators (`operators.rs`, 5 enums) |
| 215 | |
| 216 | `BinaryOperator`, `LogicalOperator`, `UnaryOperator`, `UpdateOperator`, `AssignmentOperator` — all variants mapped to their JS string representations via `#[serde(rename)]`. |
| 217 | |
| 218 | ### Common types (`common.rs`) |
| 219 | |
| 220 | `Position` (line, column, optional index), `SourceLocation` (start, end, optional filename, optional identifierName), `Comment` enum (CommentBlock | CommentLine), `CommentData`, `BaseNode` |
| 221 | |
| 222 | ### Top-level types (`lib.rs`) |
| 223 | |
| 224 | `File`, `Program`, `SourceType`, `InterpreterDirective` |
| 225 | |
| 226 | ### Catch-all / Unknown variants: statements only |
| 227 | |
| 228 | Most enums do **not** have catch-all `Unknown(serde_json::Value)` variants: an unmodeled node type fails deserialization so the gap gets fixed rather than silently passing through an opaque blob. |
| 229 | |
| 230 | `Statement` is the one deliberate exception. Real TS module-interop syntax (`import x = require(...)`, `export = x`, `export as namespace X`) is legal Babel output that the model does not cover, and failing deserialization there failed entire files the TS reference compiles fine. `Statement::Unknown(UnknownStatement)` carries the complete raw node: top-level unknowns are preserved verbatim in output, function-body unknowns degrade to the standard `UnsupportedNode` bailout. Deserialization still dispatches modeled `type` tags through a typed helper, so a malformed modeled node errors with its precise message instead of degrading to `Unknown`; only genuinely unmodeled tags take the catch-all. The `known_statements!` macro in `statements.rs` is the single source for that dispatch. |
| 231 | |
| 232 | Expression/declaration/pattern enums keep the strict no-catch-all rule. |
| 233 | |
| 234 | This is distinct from unknown *fields*, which are silently dropped (see design decision #6 on `deny_unknown_fields`). An unknown field on a known node is harmless. |
| 235 | |
| 236 | ### Union types as enums |
| 237 | |
| 238 | Fields typed as `Expression`, `Statement`, `LVal`, `Pattern`, etc. in Babel are Rust enums with `#[serde(tag = "type")]`. Where fields accept a union of specific types (e.g., `ObjectExpression.properties: Array<ObjectMethod | ObjectProperty | SpreadElement>`), purpose-specific enums are used. |
| 239 | |
| 240 | --- |
| 241 | |
| 242 | ## Common Types |
| 243 | |
| 244 | ```rust |
| 245 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 246 | pub struct Position { |
| 247 | pub line: u32, |
| 248 | pub column: u32, |
| 249 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 250 | pub index: Option<u32>, |
| 251 | } |
| 252 | |
| 253 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 254 | pub struct SourceLocation { |
| 255 | pub start: Position, |
| 256 | pub end: Position, |
| 257 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 258 | pub filename: Option<String>, |
| 259 | #[serde(default, skip_serializing_if = "Option::is_none", rename = "identifierName")] |
| 260 | pub identifier_name: Option<String>, |
| 261 | } |
| 262 | |
| 263 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 264 | #[serde(tag = "type")] |
| 265 | pub enum Comment { |
| 266 | CommentBlock(CommentData), |
| 267 | CommentLine(CommentData), |
| 268 | } |
| 269 | |
| 270 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 271 | pub struct CommentData { |
| 272 | pub value: String, |
| 273 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 274 | pub start: Option<u32>, |
| 275 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 276 | pub end: Option<u32>, |
| 277 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 278 | pub loc: Option<SourceLocation>, |
| 279 | } |
| 280 | ``` |
| 281 | |
| 282 | Note: `Position.index` and `SourceLocation.filename` are `Option` — Babel doesn't always emit these fields. |
| 283 | |
| 284 | --- |
| 285 | |
| 286 | ## Top-Level Types |
| 287 | |
| 288 | ```rust |
| 289 | /// The root type returned by @babel/parser |
| 290 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 291 | pub struct File { |
| 292 | #[serde(flatten)] |
| 293 | pub base: BaseNode, |
| 294 | pub program: Program, |
| 295 | #[serde(default)] |
| 296 | pub comments: Vec<Comment>, |
| 297 | #[serde(default)] |
| 298 | pub errors: Vec<serde_json::Value>, |
| 299 | } |
| 300 | |
| 301 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 302 | pub struct Program { |
| 303 | #[serde(flatten)] |
| 304 | pub base: BaseNode, |
| 305 | pub body: Vec<Statement>, |
| 306 | #[serde(default)] |
| 307 | pub directives: Vec<Directive>, |
| 308 | #[serde(rename = "sourceType")] |
| 309 | pub source_type: SourceType, |
| 310 | #[serde(default)] |
| 311 | pub interpreter: Option<InterpreterDirective>, |
| 312 | #[serde(rename = "sourceFile", default, skip_serializing_if = "Option::is_none")] |
| 313 | pub source_file: Option<String>, |
| 314 | } |
| 315 | |
| 316 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 317 | #[serde(rename_all = "lowercase")] |
| 318 | pub enum SourceType { |
| 319 | Module, |
| 320 | Script, |
| 321 | } |
| 322 | ``` |
| 323 | |
| 324 | `Program.body` uses `Vec<Statement>` directly — declarations (import/export, TS, Flow) are variants of the `Statement` enum. |
| 325 | |
| 326 | --- |
| 327 | |
| 328 | ## Round-Trip Test Infrastructure |
| 329 | |
| 330 | ### Overview |
| 331 | |
| 332 | ``` |
| 333 | Node.js Rust |
| 334 | ────── ──── |
| 335 | fixture.js ──> @babel/parser ──> JSON ──> serde::from_str ──> serde::to_string ──> JSON |
| 336 | │ │ |
| 337 | └──────────────── diff ────────────────────────────┘ |
| 338 | ``` |
| 339 | |
| 340 | ### Node.js script: `compiler/scripts/babel-ast-to-json.mjs` |
| 341 | |
| 342 | Parses each fixture file with Babel and writes the AST JSON to a temp directory. Takes two arguments: source directory and output directory. |
| 343 | |
| 344 | ```javascript |
| 345 | import { parse } from '@babel/parser'; |
| 346 | // ... |
| 347 | const FIXTURE_DIR = process.argv[2]; // source dir with JS/TS files |
| 348 | const OUTPUT_DIR = process.argv[3]; // output dir for JSON files |
| 349 | ``` |
| 350 | |
| 351 | **Key details**: |
| 352 | - Uses `@babel/parser` directly (not Hermes) with `errorRecovery: true` and `allowReturnOutsideFunction: true` |
| 353 | - Selects plugins based on content: `['flow', 'jsx']` for files containing `@flow`, otherwise `['typescript', 'jsx']` |
| 354 | - Always uses `sourceType: 'module'` |
| 355 | - Matches `**/*.{js,ts,tsx,jsx}` files |
| 356 | - Writes each fixture's AST as a separate `.json` file |
| 357 | - Writes `.parse-error` marker files for fixtures that fail to parse (skipped by the Rust test) |
| 358 | |
| 359 | ### JSON normalization |
| 360 | |
| 361 | Before diffing, both the original and round-tripped JSON are normalized on the Rust side: |
| 362 | |
| 363 | 1. **Key ordering**: Both JSONs are parsed as `serde_json::Value`, keys are recursively sorted, then compared. |
| 364 | 2. **`undefined` vs absent**: `JSON.stringify` omits `undefined` values; serde's `skip_serializing_if = "Option::is_none"` does the same. |
| 365 | 3. **Number precision**: Whole-number floats (e.g., `1.0`) are normalized to integers (e.g., `1`) for comparison. |
| 366 | |
| 367 | ### Rust test: `compiler/crates/react_compiler_ast/tests/round_trip.rs` |
| 368 | |
| 369 | The test walks all `.json` files in the fixture directory, deserializes each into `File`, re-serializes, normalizes both sides, and diffs. It reports the first 5 failures with unified diffs (capped at 50 lines per fixture) using the `similar` crate. |
| 370 | |
| 371 | The fixture JSON directory is specified via the `FIXTURE_JSON_DIR` environment variable, with a fallback to `tests/fixtures/` alongside the test file. |
| 372 | |
| 373 | ### Test runner: `compiler/scripts/test-babel-ast.sh` |
| 374 | |
| 375 | ```bash |
| 376 | #!/bin/bash |
| 377 | set -e |
| 378 | # Usage: bash compiler/scripts/test-babel-ast.sh [fixture-source-dir] |
| 379 | # Defaults to the compiler's own test fixtures. |
| 380 | ``` |
| 381 | |
| 382 | Generates fixture JSONs into a temp dir, runs the Rust round-trip test, and cleans up. Accepts an optional fixture source directory argument. |
| 383 | |
| 384 | **Running the test**: |
| 385 | |
| 386 | ```bash |
| 387 | bash compiler/scripts/test-babel-ast.sh |
| 388 | ``` |
| 389 | |
| 390 | --- |
| 391 | |
| 392 | ## Remaining Work |
| 393 | |
| 394 | None — this plan is complete. All `Unknown` catch-all variants have been removed from every enum. During removal, three node types that were previously handled by the `Unknown` fallback were promoted to proper typed variants in the `Expression` enum: `JSXElement`, `JSXFragment`, and `AssignmentPattern`. |
| 395 | |
| 396 | Scope info types and scope resolution testing are tracked in [rust-port-0002-scope-types.md](rust-port-0002-scope-types.md). |
| 397 | |
| 398 | --- |
| 399 | |
| 400 | ## Resolved Risks |
| 401 | |
| 402 | ### `#[serde(flatten)]` + `#[serde(tag = "type")]` interaction |
| 403 | |
| 404 | This combination works correctly. No macro fallback was needed. The `BaseNode` is flattened into each node struct, and enums use `#[serde(tag = "type")]` for dispatch. The `BaseNode.node_type` field (renamed from `"type"`) handles the case where `BaseNode` is deserialized outside of a tagged enum context. |
| 405 | |
| 406 | ### Floating point precision |
| 407 | |
| 408 | Resolved via the `normalize_json` function in the round-trip test. Whole-number f64 values are normalized to i64 before comparison (e.g., `1.0` → `1`). |
| 409 | |
| 410 | ### Fixture parse failures |
| 411 | |
| 412 | 3 of 1717 fixtures fail to parse with `@babel/parser` and are skipped (marked with `.parse-error` files). This is expected — some fixtures use intentionally invalid syntax. |
| 413 | |
| 414 | ### Performance |
| 415 | |
| 416 | All 1714 fixtures round-trip in ~12 seconds (debug build). Not a concern. |
| 417 | |
| 418 | ### Field presence ambiguity |
| 419 | |
| 420 | Resolved empirically via the round-trip test. Fields that Babel always emits (even as `null`) use `Option<T>` without `skip_serializing_if`. Fields that may be absent use `#[serde(default, skip_serializing_if = "Option::is_none")]`. The test is the source of truth. |