03 — Typed HIR (crates/dream-hir/src/)¶
HIR stores a program after its types and names have been checked. Later stages use these recorded facts instead of repeating the analysis. This chapter explains its structure and the rules its producer must satisfy.
What HIR adds over the AST¶
flowchart LR
subgraph AST
a1["Identifier \"x\""]
a2["Call \"foo\"(args)"]
a3["BinaryExpr a + b\n(no type)"]
end
subgraph HIR
h1["Var(Binding::Local(3))"]
h2["Call{callee: Callee{def, instance, ret}}"]
h3["HExpr{ty: int, Binary{Add, ..}}"]
end
a1 --> h1
a2 --> h2
a3 --> h3
Three resolutions happen at the AST→HIR boundary:
- Every expression gets a
TypeId.HExpr { ty, kind }—tyis the interned result type. - Every name becomes a
Binding.Local(LocalId),Global(GlobalId), orFunc(Callee). No more string lookups downstream. - Every call names a
Callee.{ def, instance, ret }— which definition, which monomorphized instance (if generic), and the concrete return type at this call site.
Control flow stays structured (if/while/for/foreach/switch). Flattening into a CFG is MIR's job — keeping it structured here makes HIR easy to produce from the analyzer and easy to read.
The top-level container¶
Hir (crates/dream-hir/src/mod.rs) holds three lists:
pub struct Hir {
pub functions: Vec<HFunction>, // non-generic + already-monomorphized bodies, in emission order
pub globals: Vec<HGlobal>,
pub instances: Vec<MonoInstance>, // the monomorphization worklist
}
MonoInstance { def: DefId, args: Vec<TypeId> } records concrete instances. HFunction.symbol carries each function's resolved structural symbol through MIR lowering; the backend does not append numeric type IDs. Hir.type_symbols carries the structural signature encodings used by C callback adapters, which likewise avoid interner numbers.
Hir.exports records explicit @export functions as (DefId, C symbol) pairs. Only emitted,
non-generic synchronous definitions enter this list. MIR carries it through pruning as a set of
reachability roots; LLVM uses the resolved definition and lowered signature to generate the plain
ABI wrapper and C header. Export spellings identify the external ABI, rather than semantic types
or definitions.
Hir.object_methods maps each concrete type to resolved to_string and hash_code override
definitions. MIR pruning and protocol dispatch follow these DefIds, not generated method
spellings, so changing an emitted symbol cannot silently select the default field formatter.
Hir.interfaces likewise records resolved method DefIds per concrete interface slot.
Pruning, devirtualization, effect summaries, function-value ownership and emitted dispatch
tables consume those definitions; source names and emitted symbols do not determine identity.
Hir.layouts holds the sole storage layout table, built from resolved struct, tuple and union
field definitions with TargetLayout { ptr_size, ptr_align }. It records field offsets and
aggregate size/alignment, including nested and packed value types. Semantic tables retain type
and visibility facts only. MIR carries this table unchanged; LLVM emission, debugger views,
element sizing and ABI JSON consume it directly. SizeOf(TypeId) expressions fold during MIR
lowering, after the complete target-specific table exists.
Functions, params, locals¶
HFunction carries def (its DefId), the source/table name, its resolved symbol, the instance args (empty unless this is a monomorphized copy), typed params, the ret type, a locals table, the structured body, and is_async.
Each HParam records ParamMode::{Borrow, Share, Sink, Ref}. Sema determines the mode from the parameter contract and resolved type; MIR lowering applies it directly. In release builds, the MIR param-modes pass can subsequently prove that an internal sink parameter does not escape and change its calling convention to borrowing before ownership insertion. It updates direct callers together with the callee; source-level contracts and exported signatures stay unchanged.
LocalId(u32) indexes locals uniquely within a function; parameters are just the first locals. HLocal/HParam record the declared ty so MIR's RcInsertion knows which locals are references and the backend knows how to allocate slots.
A ref parameter (fun f(ref x: int)) needs no dedicated HIR shape: it is boxed exactly like a captured let/parameter is (see Analyzer::boxed_locals), reusing HirEmit::boxed's .value-redirect convention — but the box type depends on whether the name is also closure-captured. If it is, hir_begin_function gives it a CaptureCell<T>-typed slot (the heap, ARC-managed box a closure capture already needs, since its storage may have to outlive this function's own stack frame) and the ref call site reuses that exact pointer. If it is not captured, the box is RefBox<T> instead — a struct (value type), so the box gets its own private slot in the existing shadow-stack frame (mir::backend::shared::valuetype::ValueFrame, the same mechanism a value-struct local already uses) with no heap allocation or reference counting: the box's storage only needs to be valid for the call, and the shadow stack already provides exactly that lifetime. A ref parameter's own box-typed slot is Borrow-classified in the callee's ValueFrame (LocalDecl::is_ref), so the incoming pointer — the caller's box, whichever kind it is — is aliased in place rather than copied, mirroring how the this receiver is already handled. Analyzer::analyze_ref_argument/hir_read_cell_ref are agnostic to which box kind backs a name; both are a single value: T field at offset 0, so the generic .value-field HIR shape works unchanged either way. v1 restricts a ref argument to a local variable or parameter place (not yet a struct field or array element); a lambda cannot declare its own ref parameter (the funcbox/call_indirect ABI has no ref-slot marshaling yet); and a lambda cannot capture an enclosing function's ref parameter (its RefBox<T> slot does not outlive the call, so a lambda that escaped past the call's return would be left with a dangling address — rejected in analyze_lambda).
Statements — HStmt¶
| Variant | Meaning |
|---|---|
Let { local, ty, value } |
typed binding |
Assign { place, value } |
store to an HPlace |
Expr(HExpr) |
evaluate for effect |
Return(Option<HExpr>) |
|
If / While / For / Foreach |
structured control flow (typed parts) |
Switch { scrutinee, arms, default } |
both C-style and pattern-matching forms; arms are HArm { pattern, body } |
Break / Continue (Option<label>) |
|
Await(HExpr) |
.await used as a value |
Pattern-matching switch lowering (analyzer):
- Flat unguarded arms — including or-patterns and small int/char literal ranges (inclusive span ≤ 256) expanded into multi-key arms — emit
HStmt::Switch(MIRbr_table). - Arms with guards or nested/literal sub-patterns use a hybrid: outer
HStmt::Switchon the variant tag / const key, with a residual if-chain (Discriminant/UnionField/ guard tests) inside each Switch arm. - Unexpanded ranges (or or-patterns that still need the chain after expansion) fall back to a full linear if-chain.
HPattern is Const(HExpr), Variant { def, variant, bindings } (binds the payload into fresh locals), or Wildcard.
HPlace is the assignable subset: Local, Global, Field { obj, field } (resolved field index, not a name), Index { array, index }.
Expressions — HExpr / HExprKind¶
HExpr { ty: TypeId, kind: HExprKind }. Every node is typed. Notable kinds:
- Literals:
IntLit,FloatLit,BoolLit,CharLit,StringLit. Var(Binding)— resolved read.Binary { op, lhs, rhs },Unary { op, operand }using the canonicalhir::BinOp/UnOp(crates/dream-hir/src/ops.rs) — not syntax tokens.- Calls:
Call { callee, args },MethodCall { receiver, callee, args },IndirectCall { target, args }. - Construction:
New { def, instance, args },UnionNew { def, variant, args },ArrayLit { elem_ty, elems }. - Access:
Field { obj, field },Index { array, index },ArrayLen. Cast(explicit or inserted coercion toty),Ternary,Coalesce(??),Await,EnumValue(i64)(enum member already resolved to its integer).
The analyzer inserts
Castnodes for implicit numeric widening, so MIR and the backend never guess whether a coercion is needed — theCastis simply present.
How the analyzer emits HIR¶
The analyzer builds the Hir as it type-checks (in crates/dream-sema/src/analyzer/hir_emit/):
sequenceDiagram
participant A as Analyzer
participant TC as TypeCtx
participant H as Hir builder
A->>TC: register defs (structs/unions/enums/functions)
loop each function
A->>A: type-check body (existing logic)
A->>TC: lower annotations & infer expr types → TypeId
A->>H: emit HStmt/HExpr with resolved Binding/Callee
A->>H: push MonoInstance when a generic use is discovered
end
A-->>H: Hir { functions, globals, instances }
The analyzer already computes all of this transiently during analyze_expression and overload selection; HIR emission records it on the node instead of throwing it away. Concretely:
- Where the analyzer returns a
Typefor an expression, also build the matchingHExprwithty = ctx.lower(type). - Where it resolves an identifier, emit
Var(Binding::…)with the resolved id. - Where it picks an overload, emit a
Callee { def, instance, ret }. - Where it instantiates a generic, push a
MonoInstance(dedup by(def, args)).
Because HIR carries these facts, the backend never re-infers types or re-resolves names — which is the entire reason HIR exists.
Invariants HIR guarantees to MIR¶
tyon everyHExpris a valid interned id; noError/poison survives (analysis failed otherwise).- Every
Binding/Calleeis resolved; no name lookups remain. - Implicit coercions are explicit
Castnodes. Field/Indexcarry resolved indices, not names.- Generic uses are recorded in
instances;HFunction.instanceis set for monomorphized bodies.
Iterator bodies and cleanup¶
Yield functions are analyzed through a synthesized sequence factory and an async producer
closure. The source AST stays unchanged. The producer receives a typed YieldChannel<T>;
yield value checks through emit(T) and emits an ordinary HIR await on the channel's resume
signal. yield break calls finish and returns from the producer. Argument copies inside each
producer preserve independent enumeration even when a body reassigns its parameters. Value
receivers are copied per producer; reference receivers preserve object identity. Parameter seeds
occupy a parent scope so source-body shadowing retains ordinary function semantics.
HStmt::Cleanup pairs a body with a typed synchronous cleanup expression. Iterator loops use
it to close their cursor on structured exits. MIR lowering tracks cleanup depth alongside loop
labels and emits cleanup calls on return and on jumps crossing the scope. A loop also owns an
ARC lease whose destructor closes the cursor if an async frame is abandoned during suspension.
Synchronous producers use the same frame representation but only their own queued poll is
advanced by dream_yield_run; unrelated scheduled work is untouched. Async producers use the
existing scheduler. Yield signals are passive, payload-free futures with no scheduler token.