Rust Port Step 4: BuildHIR / HIR Lowering
Goal
Port BuildHIR.ts (~4555 lines) and HIRBuilder.ts (~955 lines) into Rust equivalents in compiler/crates/react_compiler_lowering/. This is the first major compiler pass — it converts a Babel AST + scope info into the HIR control-flow graph representation.
The Rust port should be structurally as close to the TypeScript as possible: viewing the TS and Rust side by side, the logic should look, read, and feel similar while working naturally in Rust.
Current status: M1-M13 fully implemented. All statement types, expression types, destructuring, function expressions, JSX, switch/try-catch, for-of/in, optional chaining, and recursive lowering are complete. No todo!() stubs remain. cargo check passes. Remaining work: test against fixtures and fix divergences from TypeScript output.
Known issues to fix:
- All collection types must use
IndexMap/IndexSet(from theindexmapcrate), notBTreeMap/BTreeSet/HashMap/HashSet. This is critical forHIR.blockswhereBTreeMapdestroys RPO insertion ordering. - Functions
lower_function,lower_function_to_value,gather_captured_context,lower_object_property_key,lower_typetake&Expression. The AST crate usesExpressionfor keys and doesn't have standaloneFunction/ObjectPropertyKey/TypeAnnotationtypes, so&Expressionis correct for the current AST structure. When these functions are implemented, they should pattern-match on the specific expression variants internally. VariableBinding::Identifier.binding_kindisString— must be aBindingKindenum.HirBuilderis missingcomponent_scope: ScopeIdfield (needed forgather_captured_contextin M9).build_temporary_placehelper is missing (listed in M4).mark_predecessorsfallthrough handling: VERIFIED — matches TSeachTerminalSuccessor(does not include fallthroughs, correct).GotoVariant::Breakusage: VERIFIED — matches TS for bothremove_unnecessary_try_catchandremove_dead_do_while_statements.
Crate Layout
compiler/crates/
react_compiler_lowering/
Cargo.toml
src/
lib.rs # pub fn lower() entry point
build_hir.rs # lowerStatement, lowerExpression, lowerAssignment, etc.
hir_builder.rs # HIRBuilder struct
react_compiler_hir/
Cargo.toml
src/
lib.rs # HIR types: HirFunction, BasicBlock, Instruction, Terminal, Place, etc.
environment.rs # Environment struct (arenas, counters, config)
react_compiler_diagnostics/
Cargo.toml
src/
lib.rs # CompilerError, CompilerDiagnostic, ErrorCategory, etc.
Dependencies
# react_compiler_lowering/Cargo.toml
[dependencies]
react_compiler_ast = { path = "../react_compiler_ast" }
react_compiler_hir = { path = "../react_compiler_hir" }
react_compiler_diagnostics = { path = "../react_compiler_diagnostics" }
Key Design Decisions
1. No NodePath — Work Directly with AST Structs + ScopeInfo
The TypeScript lower() takes a NodePath<t.Function> and uses Babel's traversal API (path.get(), path.scope.getBinding(), etc.) extensively. The Rust port works with deserialized react_compiler_ast structs and the ScopeInfo from step 2.
TypeScript pattern:
function lowerStatement(builder: HIRBuilder, stmtPath: NodePath<t.Statement>) {
switch (stmtPath.type) {
case 'IfStatement': {
const stmt = stmtPath as NodePath<t.IfStatement>;
const test = lowerExpressionToTemporary(builder, stmt.get('test'));
...
}
}
}
Rust equivalent:
fn lower_statement(builder: &mut HirBuilder, stmt: &ast::Statement) {
match stmt {
ast::Statement::IfStatement(stmt) => {
let test = lower_expression_to_temporary(builder, &stmt.test);
...
}
}
}
The mapping is direct: stmtPath.type switch becomes match stmt, stmt.get('test') becomes &stmt.test, type narrowing via as NodePath<T> becomes Rust's match arm binding.
2. Binding Resolution via ScopeInfo
The TypeScript resolveIdentifier() and resolveBinding() methods use Babel's scope API (path.scope.getBinding(), babelBinding.scope, babelBinding.path.isImportSpecifier(), etc.). The Rust port replaces all of this with ScopeInfo lookups.
TypeScript (HIRBuilder.resolveIdentifier()):
const babelBinding = path.scope.getBinding(originalName);
if (babelBinding === outerBinding) {
if (path.isImportDefaultSpecifier()) { ... }
}
const resolvedBinding = this.resolveBinding(babelBinding.identifier);
Rust equivalent:
fn resolve_identifier(&mut self, name: &str, start_offset: u32) -> VariableBinding {
// Look up via ScopeInfo instead of Babel's scope API
let binding_id = self.scope_info.resolve_reference(start_offset);
match binding_id {
None => VariableBinding::Global { name: name.to_string() },
Some(binding) => {
if binding.scope == self.scope_info.program_scope {
// Module-level binding — check import info
match &binding.import {
Some(import) => match import.kind {
ImportBindingKind::Default => VariableBinding::ImportDefault { ... },
ImportBindingKind::Named => VariableBinding::ImportSpecifier { ... },
ImportBindingKind::Namespace => VariableBinding::ImportNamespace { ... },
},
None => VariableBinding::ModuleLocal { name: name.to_string() },
}
} else {
let identifier = self.resolve_binding(name, binding_id.unwrap());
VariableBinding::Identifier { identifier, binding_kind: BindingKind::from(&binding.kind) }
}
}
}
}
Key differences:
resolveBinding()keying: TypeScript uses Babel node reference identity (mapping.node === node) to distinguish same-named variables in different scopes. Rust usesBindingIdfromScopeInfo— the map becomesIndexMap<BindingId, IdentifierId>instead ofMap<string, {node, identifier}>. This is simpler and more correct.isContextIdentifier(): TypeScript checksenv.isContextIdentifier(binding.identifier). Rust checks whether the binding's scope is an ancestor of the current function's scope but not the program scope — this is aScopeInfoquery.gatherCapturedContext(): TypeScript traverses the function with Babel's traverser to find free variable references. Rust walks the AST directly usingScopeInfo.reference_to_bindingto identify references that resolve to bindings in ancestor scopes.
3. HIRBuilder Struct
The HIRBuilder class maps to a Rust struct with &mut self methods. The closure-based APIs (enter(), loop(), label(), switch()) translate to methods that take impl FnOnce(&mut Self) -> T.
pub struct HirBuilder<'a> {
completed: IndexMap<BlockId, BasicBlock>,
current: WipBlock,
entry: BlockId,
scopes: Vec<Scope>,
context: IndexMap<BindingId, Option<SourceLocation>>,
bindings: IndexMap<BindingId, IdentifierId>,
used_names: IndexMap<String, BindingId>,
instruction_table: Vec<Instruction>,
function_scope: ScopeId,
component_scope: ScopeId, // outermost component/hook scope, for gather_captured_context
env: &'a mut Environment,
scope_info: &'a ScopeInfo,
exception_handler_stack: Vec<BlockId>,
fbt_depth: u32,
}
Closure patterns: The TypeScript enter() method creates a new block, sets it as current, runs a closure, then restores the previous block. In Rust:
impl<'a> HirBuilder<'a> {
fn enter(&mut self, kind: BlockKind, f: impl FnOnce(&mut Self, BlockId) -> Terminal) -> BlockId {
let wip = self.reserve(kind);
let wip_id = wip.id;
self.enter_reserved(wip, |this| f(this, wip_id));
wip_id
}
fn enter_reserved(&mut self, wip: WipBlock, f: impl FnOnce(&mut Self) -> Terminal) {
let prev = std::mem::replace(&mut self.current, wip);
let terminal = f(self);
let completed = std::mem::replace(&mut self.current, prev);
self.completed.insert(completed.id, BasicBlock {
kind: completed.kind,
id: completed.id,
instructions: completed.instructions,
terminal,
preds: IndexSet::new(),
phis: Vec::new(),
});
}
fn loop_scope<T>(
&mut self,
label: Option<String>,
continue_block: BlockId,
break_block: BlockId,
f: impl FnOnce(&mut Self) -> T,
) -> T {
self.scopes.push(Scope::Loop { label, continue_block, break_block });
let value = f(self);
self.scopes.pop();
value
}
}
Variable capture across closures: TypeScript frequently assigns variables inside enter() closures that are read after:
let callee: Place | null = null;
builder.enter('block', () => {
callee = lowerExpressionToTemporary(builder, ...);
return { kind: 'goto', ... };
});
// callee is used here
In Rust, this pattern is handled by returning values from the closure:
let (block_id, callee) = {
let block_id = builder.enter('block', |builder, _block_id| {
// We can't easily return extra values from enter() since it expects Terminal
// Instead, compute callee before/after enter(), or restructure
...
});
// Alternative: compute the value and store it on builder temporarily
};
For cases where this is awkward, use a temporary field on the builder or restructure the code to compute the value outside the closure. The specific approach depends on the case — see the incremental implementation milestones for details.
4. Source Locations
TypeScript accesses node.loc directly. Rust accesses node.base.loc (through the BaseNode flattened into each AST struct). Helper:
fn loc_from_node(base: &BaseNode) -> SourceLocation {
base.loc.as_ref().map(|l| hir::SourceLocation::from(l)).unwrap_or(GENERATED_SOURCE)
}
5. Error Handling
Following the port notes:
CompilerError.invariant(cond, ...)→if !cond { panic!(...) }or dedicatedcompiler_invariant!macroCompilerError.throwTodo(...)→return Err(CompilerDiagnostic::todo(...))builder.recordError(...)→builder.record_error(...)(accumulates on Environment)- Non-null assertions (
!) →.unwrap()or.expect("...")
The lower() function returns Result<HirFunction, CompilerError> for invariant/thrown errors, while accumulated errors go to env.errors.
6. todo!() Strategy for Incremental Implementation
BuildHIR is too large (4555 lines) for a single implementation pass. Use Rust's todo!() macro to stub unimplemented branches:
fn lower_statement(builder: &mut HirBuilder, stmt: &ast::Statement) {
match stmt {
ast::Statement::IfStatement(s) => lower_if_statement(builder, s),
ast::Statement::ReturnStatement(s) => lower_return_statement(builder, s),
ast::Statement::BlockStatement(s) => lower_block_statement(builder, s),
// Stubbed — will be filled in later milestones
ast::Statement::ForStatement(_) => todo!("lower ForStatement"),
ast::Statement::WhileStatement(_) => todo!("lower WhileStatement"),
ast::Statement::SwitchStatement(_) => todo!("lower SwitchStatement"),
ast::Statement::TryStatement(_) => todo!("lower TryStatement"),
// ... etc
}
}
This "fog of war" approach allows:
- The code to compile at every step
- Tests to run for fixtures that only use implemented features
- Clear visibility into what remains
- Agents to pick up individual
todo!()arms and implement them
Structural Mapping: TypeScript → Rust
Top-Level Functions
| TypeScript (BuildHIR.ts) | Rust (build_hir.rs) | Notes |
|---|---|---|
lower(func, env, bindings, capturedRefs) |
pub fn lower(ast: &ast::File, scope_info: &ScopeInfo, env: &mut Environment) -> Result<HirFunction, CompilerError> |
Entry point. Takes the full File (extracts the function internally) |
lowerStatement(builder, stmtPath, label) |
fn lower_statement(builder: &mut HirBuilder, stmt: &ast::Statement, label: Option<&str>) |
~30 match arms |
lowerExpression(builder, exprPath) |
fn lower_expression(builder: &mut HirBuilder, expr: &ast::Expression) -> InstructionValue |
~40 match arms |
lowerExpressionToTemporary(builder, exprPath) |
fn lower_expression_to_temporary(builder: &mut HirBuilder, expr: &ast::Expression) -> Place |
|
lowerValueToTemporary(builder, value) |
fn lower_value_to_temporary(builder: &mut HirBuilder, value: InstructionValue) -> Place |
|
lowerAssignment(builder, loc, kind, target, value, assignmentStyle) |
fn lower_assignment(builder: &mut HirBuilder, ...) |
Handles destructuring patterns |
lowerIdentifier(builder, exprPath) |
fn lower_identifier(builder: &mut HirBuilder, name: &str, start: u32, loc: SourceLocation) -> Place |
|
lowerMemberExpression(builder, exprPath) |
fn lower_member_expression(builder: &mut HirBuilder, expr: &ast::MemberExpression) -> InstructionValue |
|
lowerOptionalMemberExpression(builder, exprPath) |
fn lower_optional_member_expression(builder: &mut HirBuilder, expr: &ast::OptionalMemberExpression) -> InstructionValue |
|
lowerOptionalCallExpression(builder, exprPath) |
fn lower_optional_call_expression(builder: &mut HirBuilder, expr: &ast::OptionalCallExpression) -> InstructionValue |
|
lowerArguments(builder, args, isDev) |
fn lower_arguments(builder: &mut HirBuilder, args: &[ast::Expression], is_dev: bool) -> Vec<PlaceOrSpread> |
|
lowerFunctionToValue(builder, expr) |
fn lower_function_to_value(builder: &mut HirBuilder, expr: &ast::Function) -> InstructionValue |
|
lowerFunction(builder, expr) |
fn lower_function(builder: &mut HirBuilder, expr: &ast::Function) -> LoweredFunction |
Recursive lower() call. Returns LoweredFunction (not FunctionId) |
lowerJsxElementName(builder, name) |
fn lower_jsx_element_name(builder: &mut HirBuilder, name: &ast::JSXElementName) -> JsxTag |
|
lowerJsxElement(builder, child) |
fn lower_jsx_element(builder: &mut HirBuilder, child: &ast::JSXChild) -> Option<Place> |
|
lowerObjectMethod(builder, property) |
fn lower_object_method(builder: &mut HirBuilder, method: &ast::ObjectMethod) -> ObjectProperty |
|
lowerObjectPropertyKey(builder, key) |
fn lower_object_property_key(builder: &mut HirBuilder, key: &ast::ObjectPropertyKey) -> ObjectPropertyKey |
|
lowerReorderableExpression(builder, expr) |
fn lower_reorderable_expression(builder: &mut HirBuilder, expr: &ast::Expression) -> Place |
|
isReorderableExpression(builder, expr) |
fn is_reorderable_expression(builder: &HirBuilder, expr: &ast::Expression) -> bool |
|
lowerType(node) |
fn lower_type(node: &ast::TypeAnnotation) -> Type |
|
gatherCapturedContext(fn, componentScope) |
fn gather_captured_context(func: &ast::Function, scope_info: &ScopeInfo, parent_scope: ScopeId) -> IndexMap<BindingId, Option<SourceLocation>> |
AST walk replaces Babel traverser |
captureScopes({from, to}) |
fn capture_scopes(scope_info: &ScopeInfo, from: ScopeId, to: ScopeId) -> IndexSet<ScopeId> |
HIRBuilder Methods
| TypeScript (HIRBuilder.ts) | Rust (hir_builder.rs) | Notes |
|---|---|---|
constructor(env, options?) |
HirBuilder::new(env, scope_info, function_scope, bindings, context, entry_block_kind) |
|
push(instruction) |
builder.push(instruction) |
|
terminate(terminal, nextBlockKind) |
builder.terminate(terminal, next_block_kind) |
|
terminateWithContinuation(terminal, continuation) |
builder.terminate_with_continuation(terminal, continuation) |
|
reserve(kind) |
builder.reserve(kind) |
Returns WipBlock |
complete(block, terminal) |
builder.complete(block, terminal) |
|
enter(kind, fn) |
builder.enter(kind, |b, id| { ... }) |
Closure takes &mut Self |
enterReserved(wip, fn) |
builder.enter_reserved(wip, |b| { ... }) |
|
enterTryCatch(handler, fn) |
builder.enter_try_catch(handler, |b| { ... }) |
|
loop(label, continue, break, fn) |
builder.loop_scope(label, continue_block, break_block, |b| { ... }) |
|
label(label, break, fn) |
builder.label_scope(label, break_block, |b| { ... }) |
|
switch(label, break, fn) |
builder.switch_scope(label, break_block, |b| { ... }) |
|
lookupBreak(label) |
builder.lookup_break(label) |
|
lookupContinue(label) |
builder.lookup_continue(label) |
|
resolveIdentifier(path) |
builder.resolve_identifier(name, start_offset) |
Uses ScopeInfo |
resolveBinding(node) |
builder.resolve_binding(name, binding_id) |
Keyed by BindingId |
isContextIdentifier(path) |
builder.is_context_identifier(name, start_offset) |
Uses ScopeInfo |
makeTemporary(loc) |
builder.make_temporary(loc) |
|
build() |
builder.build() |
Returns (HIR, Vec<Instruction>) — the HIR plus the flat instruction table |
recordError(error) |
builder.record_error(error) |
Post-Build Helpers (HIRBuilder.ts)
These helper functions in HIRBuilder.ts run after build() and clean up the CFG:
| TypeScript | Rust | Notes |
|---|---|---|
getReversePostorderedBlocks(func) |
get_reverse_postordered_blocks(hir) |
RPO sort + unreachable removal |
removeUnreachableForUpdates(fn) |
remove_unreachable_for_updates(hir) |
|
removeDeadDoWhileStatements(func) |
remove_dead_do_while_statements(hir) |
|
removeUnnecessaryTryCatch(fn) |
remove_unnecessary_try_catch(hir) |
|
markInstructionIds(func) |
mark_instruction_ids(hir) |
Assigns EvaluationOrder |
markPredecessors(func) |
mark_predecessors(hir) |
Must include fallthrough blocks — verify each_terminal_successor matches TS eachTerminalSuccessor |
createTemporaryPlace(env, loc) |
create_temporary_place(env, loc) |
Implementation notes for post-build helpers:
remove_unnecessary_try_catchandremove_dead_do_while_statements: Verify that theGotoVariantused when replacing terminals matches the TS equivalent. Currently usesGotoVariant::Break— confirm this is correct.mark_predecessors: Theeach_terminal_successorfunction must visit fallthrough blocks for terminals likeTry, not just direct successors. Compare against TSeachTerminalSuccessorbehavior.
Statement Lowering: Match Arm Inventory
The lowerStatement function has ~30 match arms. Grouped by complexity:
Tier 1 — Trivial (1-10 lines each)
EmptyStatement— no-opDebuggerStatement— singleDebuggerinstructionExpressionStatement— delegate tolower_expression_to_temporaryBreakStatement—builder.lookup_break()+ goto terminalContinueStatement—builder.lookup_continue()+ goto terminalThrowStatement— lower expression + throw terminal
Tier 2 — Simple control flow (10-30 lines each)
ReturnStatement— lower expression + return terminalBlockStatement— iterate body statementsIfStatement— reserve blocks, enter consequent/alternate, branch terminalWhileStatement— test block + body block + loop scopeLabeledStatement— delegate with label, or create label scope
Tier 3 — Complex control flow (30-100 lines each)
ForStatement— init/test/update/body blocks, loop scopeForOfStatement— iterator protocol (GetIterator, IteratorNext, etc.)ForInStatement— similar to ForOfDoWhileStatement— body-first loopSwitchStatement— case discrimination with fall-throughTryStatement— try/catch/finally blocks with exception handler stack
Tier 4 — Variable declarations and assignments (30-80 lines)
VariableDeclaration— iterate declarators, handle destructuringFunctionDeclaration— hoist function, lower body
Tier 5 — Pass-through / error (1-10 lines each)
- TypeScript/Flow declarations —
todo!()or skip - Import/Export declarations — error (shouldn't appear in function body)
WithStatement— error (unsupported)ClassDeclaration— lower class expressionEnumDeclaration/TSEnumDeclaration— error
Expression Lowering: Match Arm Inventory
The lowerExpression function has ~40 match arms. Grouped by complexity:
Tier 1 — Literals and simple values (1-10 lines each)
NullLiteral,BooleanLiteral,NumericLiteral,StringLiteral—PrimitiveinstructionRegExpLiteral—RegExpLiteralinstructionIdentifier— delegate tolower_identifierMetaProperty—LoadGlobalforimport.metaTSNonNullExpression,TSInstantiationExpression— unwrap inner expressionTypeCastExpression,TSAsExpression,TSSatisfiesExpression— unwrap inner expression
Tier 2 — Operators (10-30 lines each)
BinaryExpression— lower operands +BinaryExpressioninstructionUnaryExpression— lower operand +UnaryExpressioninstructionUpdateExpression— read + increment + store (prefix vs postfix)SequenceExpression— lower all expressions, return last
Tier 3 — Object/Array construction (20-50 lines each)
ObjectExpression— properties, spread, computed keysArrayExpression— elements with holes and spreadsTemplateLiteral— quasis + expressionsTaggedTemplateExpression— tag + template
Tier 4 — Calls and member access (20-50 lines each)
CallExpression— callee + arguments +CallExpression/MethodCallinstructionNewExpression— similar to CallExpressionMemberExpression— object + property +PropertyLoad/ComputedLoadOptionalCallExpression— optional chain with test blocksOptionalMemberExpression— optional chain with test blocks
Tier 5 — Control flow expressions (30-80 lines each)
ConditionalExpression— if-like CFG with value blocksLogicalExpression— short-circuit evaluation with blocksAssignmentExpression— delegates tolower_assignment(destructuring)
Tier 6 — Complex (50-150 lines each)
JSXElement— tag + props + children + fbt handlingJSXFragment— children onlyArrowFunctionExpression/FunctionExpression— recursivelower_functionAwaitExpression— lower value + await instruction
Assignment Lowering
lowerAssignment (~500 lines in BuildHIR.ts) handles destructuring and is the most complex single function after the statement/expression switches. It processes:
Match arms by target type:
Identifier—StoreLocalinstruction (with const/let/reassign distinction)MemberExpression—PropertyStore/ComputedStoreinstructionArrayPattern— emitDestructurewithArrayPatterncontaining items, holes, rest elements, and default valuesObjectPattern— emitDestructurewithObjectPatterncontaining properties, computed keys, rest elements, and default valuesAssignmentPattern— default value handling: lower the default, emit a conditional assignment
Rust approach:
The destructuring patterns map directly — the AST struct fields (elements, properties, rest) correspond to the Babel API calls. The main difference is accessing nested patterns through struct fields instead of path.get().
Recursive Lowering for Nested Functions
lowerFunction() calls lower() recursively for function expressions, arrow functions, and object methods. Key considerations for Rust:
-
Shared Environment: Parent and child share
&mut Environment. This works because the recursive call completes before the parent continues. -
Shared Bindings: The parent's
bindingsmap is passed to the child so inner functions can resolve references to outer variables. In Rust, this is&IndexMap<BindingId, IdentifierId>— the parent's bindings are cloned or borrowed by the child. -
Context gathering:
gatherCapturedContext()walks the function's AST to find free variable references. In Rust, this walks the AST structs usingScopeInfoto identify references that resolve to bindings in ancestor scopes (between the function's scope and the component scope). -
Function arena storage: The returned
HirFunctionis stored inenv.functions(the function arena) and referenced byFunctionIdin theFunctionExpressioninstruction value.
fn lower_function(builder: &mut HirBuilder, func: &ast::Function) -> LoweredFunction {
let captured_context = gather_captured_context(func, builder.scope_info, builder.component_scope);
let lowered = lower(func, builder.scope_info, builder.env, Some(&builder.bindings), captured_context)?;
lowered
}
Incremental Implementation Plan
M1: Scaffold + Infrastructure
Goal: Crate structure compiles, lower() entry point exists, returns todo!().
-
Create
compiler/crates/react_compiler_diagnostics/withCompilerDiagnostic,CompilerError,ErrorCategory,CompilerErrorDetail,CompilerSuggestionOperation. -
Create
compiler/crates/react_compiler_hir/with core types:- ID newtypes:
BlockId,IdentifierId,InstructionId(index into the flat instruction table),EvaluationOrder(sequential numbering assigned duringmarkInstructionIds()— this was previously calledInstructionIdin the TypeScript compiler),DeclarationId,ScopeId,FunctionId,TypeId HirFunction,HIR,BasicBlock,WipBlock,BlockKindInstruction,InstructionValue(enum with all ~40 variants, each stubbed astodo!()for fields)Terminal(enum with all variants)Place,Identifier,MutableRange,SourceLocationEffect,InstructionKind,GotoVariant,BindingKind(enum:Var,Let,Const,Param,Using,AwaitUsing,CatchParam,ImplicitConst)Environment(counters, arenas, config, errors)FloatValue(u64)— wrapper type for f64 values that needEq/Hash(stores raw bits viaf64::to_bits()for deterministic comparison)
- ID newtypes:
-
Create
compiler/crates/react_compiler_lowering/with:hir_builder.rs:HirBuilderstruct with all methods stubbedbuild_hir.rs:lower_statement()andlower_expression()with all arms astodo!()lib.rs:pub fn lower()that creates a builder and returnstodo!()
-
Verify:
cargo checkpasses.
M2: HIRBuilder Core
Goal: HIRBuilder methods work — can create blocks, terminate them, build the CFG.
-
Implement
HirBuilder::new(),push(),terminate(),terminate_with_continuation(),reserve(),complete(),enter_reserved(),enter(). -
Implement scope methods:
loop_scope(),label_scope(),switch_scope(),lookup_break(),lookup_continue(). -
Implement
enter_try_catch(),resolve_throw_handler(). -
Implement
make_temporary(),record_error(). -
Implement
build()including the post-build passes:get_reverse_postordered_blocks()remove_unreachable_for_updates()remove_dead_do_while_statements()remove_unnecessary_try_catch()mark_instruction_ids()mark_predecessors()
M3: Binding Resolution
Goal: resolve_identifier() and resolve_binding() work with ScopeInfo.
-
Implement
resolve_binding()— mapsBindingIdtoIdentifierId, creating new identifiers on first encounter. UsesIndexMap<BindingId, IdentifierId>instead of the TypeScriptMap<string, {node, identifier}>. -
Implement
resolve_identifier()— dispatches to Global, ImportDefault, ImportSpecifier, ImportNamespace, ModuleLocal, or Identifier based onScopeInfolookups. -
Implement
is_context_identifier()— checks if a reference resolves to a binding in an ancestor scope. -
Implement
gather_captured_context()— walks AST to find free variable references usingScopeInfo.
M4: lower() Entry Point + Basic Statements
Goal: Can lower simple functions with ReturnStatement, ExpressionStatement, BlockStatement, VariableDeclaration (simple, non-destructuring).
-
Implement the
lower()function body: parameter processing, body lowering, final return terminal,builder.build(). -
Implement statement arms:
ReturnStatementExpressionStatementBlockStatementEmptyStatementVariableDeclaration(simplelet x = expronly, destructuring astodo!())
-
Implement basic expression arms:
Identifier(vialower_identifier)NullLiteral,BooleanLiteral,NumericLiteral,StringLiteralBinaryExpressionUnaryExpression
-
Implement helpers:
lower_expression_to_temporary(),lower_value_to_temporary(),build_temporary_place(). -
Test: Run
test-rust-port.sh HIRon simple fixtures.
M5: Control Flow
Goal: Branches and loops work.
IfStatement— consequent/alternate blocks, branch terminalWhileStatement— test/body blocks, loop scopeForStatement— init/test/update/body blocksDoWhileStatement— body-first loop patternBreakStatement,ContinueStatementLabeledStatement
M6: Expressions — Calls and Members
Goal: Function calls and property access work.
CallExpression— including method calls (callee is MemberExpression)NewExpressionMemberExpression— PropertyLoad/ComputedLoadlower_arguments()— spread handlingSequenceExpression
M7: Expressions — Short-circuit and Ternary
Goal: Control-flow expressions produce correct CFG.
ConditionalExpression— if-like structure with value blocksLogicalExpression— short-circuit&&,||,??AssignmentExpression— simple identifier/member assignment (destructuring deferred)
M8: Expressions — Remaining
Goal: All expression types handled.
ObjectExpression— properties, methods, computed, spreadArrayExpression— elements, holes, spreadsTemplateLiteral,TaggedTemplateExpressionUpdateExpression— prefix/postfix increment/decrementRegExpLiteralAwaitExpressionTypeCastExpression,TSAsExpression,TSSatisfiesExpression,TSNonNullExpression,TSInstantiationExpressionMetaProperty
M9: Function Expressions + Recursive Lowering
Goal: Nested functions work.
ArrowFunctionExpression,FunctionExpression— calllower_function()lower_function()— recursivelower()with captured contextgather_captured_context()— AST walk for free variables- Function arena storage via
FunctionId FunctionDeclarationstatement — hoisted function lowering
M10: JSX
Goal: JSX elements and fragments lower correctly.
JSXElement— tag, props, children, fbt handlingJSXFragment— childrenlower_jsx_element_name()— identifier, member expression, builtin tag dispatchlower_jsx_element()— child lowering (text, expression, element, spread)lower_jsx_member_expression()trimJsxText()— whitespace normalization
M11: Destructuring + Complex Assignments
Goal: Full destructuring support.
lower_assignment()forArrayPattern— items, holes, rest, defaultslower_assignment()forObjectPattern— properties, computed keys, rest, defaultslower_assignment()forAssignmentPattern— default valuesVariableDeclarationwith destructuring patterns- Param destructuring in
lower()entry point
M12: Switch + Try/Catch + Remaining
Goal: All statement types handled, complete coverage.
SwitchStatement— case discrimination, fall-through, breakTryStatement— try/catch/finally blocks, exception handler stackForOfStatement— iterator protocolForInStatement— for-in loweringWithStatement— errorClassDeclaration— class expression lowering- Type declarations — skip/pass-through
- Import/Export declarations — error
OptionalCallExpression,OptionalMemberExpression— optional chaininglowerReorderableExpression(),isReorderableExpression()
M13: Polish + Full Test Coverage
Goal: All fixtures pass, no remaining todo!() in production paths.
- Remove all remaining
todo!()stubs — replace with proper errors for truly unsupported syntax - Run
test-rust-port.sh HIRon all 1714 fixtures - Debug and fix any divergences from TypeScript output
- Handle edge cases: error recovery, Babel bug workarounds (where applicable), fbt depth tracking
Key Rust Patterns
Pattern 1: Switch/Case → Match
Every switch (stmtPath.type) and switch (exprPath.type) becomes a match on the AST enum. Rust's exhaustive matching ensures no cases are missed (unlike TypeScript where the default arm might hide bugs).
Pattern 2: path.get('field') → Direct Field Access
// TypeScript
const test = stmt.get('test');
const body = stmt.get('body');
// Rust
let test = &stmt.test;
let body = &stmt.body;
Pattern 3: Type Guards → Match Arms
// TypeScript
if (param.isIdentifier()) { ... }
else if (param.isObjectPattern()) { ... }
// Rust
match param {
ast::PatternLike::Identifier(id) => { ... }
ast::PatternLike::ObjectPattern(pat) => { ... }
}
Pattern 4: hasNode() → Option Checks
// TypeScript
const alternate = stmt.get('alternate');
if (hasNode(alternate)) { ... }
// Rust
if let Some(alternate) = &stmt.alternate { ... }
Pattern 5: Instruction Construction
// TypeScript
builder.push({
id: makeInstructionId(0),
lvalue: { ...place },
value: { kind: 'LoadGlobal', name, binding, loc },
effects: null,
loc: exprLoc,
});
// Rust
builder.push(Instruction {
id: InstructionId(0), // renumbered by markInstructionIds
lvalue: place.clone(),
value: InstructionValue::LoadGlobal { name, binding, loc },
effects: None,
loc: expr_loc,
});
Risks and Mitigations
Risk 1: gatherCapturedContext() Without Babel Traverser
Impact: Medium. The TypeScript version uses fn.traverse() to find free variable references.
Mitigation: Write a manual AST walker that visits all Identifier nodes in a function body and checks ScopeInfo.reference_to_binding for each one. This is simpler than Babel's traverser because we don't need the full visitor infrastructure — just recursive pattern matching over AST node types.
Risk 2: Variable Capture Across enter() Closures
Impact: Low-Medium. ~15-20 places in BuildHIR.ts assign variables inside enter() closures that are read outside.
Mitigation: Case-by-case restructuring. Options include: (a) returning the value from the closure via a tuple, (b) storing it on the builder temporarily, (c) restructuring to compute the value before/after the enter() call. Each instance is small and mechanical.
Risk 3: isReorderableExpression() Recursive Analysis
Impact: Low. This function deeply analyzes expressions to determine reorderability. Mitigation: Direct recursive pattern matching on AST structs — actually simpler in Rust than TypeScript because there's no NodePath overhead.
Risk 4: Optional Chaining Lowering Complexity
Impact: Medium. lowerOptionalCallExpression() and lowerOptionalMemberExpression() (~250 lines combined) generate complex CFG structures with multiple blocks for null checks.
Mitigation: Port last (M12), after all simpler patterns are verified. The CFG generation logic maps directly — it's just verbose.
Risk 5: fbt/fbs Special Handling
Impact: Low. The fbt handling in JSXElement lowering uses Babel's path.traverse() for counting nested fbt tags.
Mitigation: Replace with a simple recursive AST walk that counts JSXNamespacedName nodes matching the fbt tag name. The fbtDepth counter on the builder is trivial.