HIR — High-level IR

The HIR pass converts expanded syntax trees into a typed intermediate representation. It resolves bindings, computes captures, and infers signal profiles.

Key types

location, and inferred Signal

lambda, let, if, begin, etc.

BindingArena (in arena.rs), which holds the per-binding metadata (BindingScope — Parameter or Local — plus is_mutated, is_captured, is_immutable, is_primitive, etc.)

propagates parameter mask (silent, yields, polymorphic, …)

A fragment is closed over its bindings

A Binding addresses a BindingArena, and the arena is not part of the term. An HIR body on its own therefore means nothing outside the unit that analyzed it: its indices name a table the reader does not hold. Carrying such a body to another unit, or to a file, means carrying the binding facts it needs — and a carrier that hoists a chosen few of them out of BindingInner is wrong the day someone adds the twelfth field.

An HirFragment (src/hir/fragment.rs) closes the body over its own table instead. Every Binding in a fragment's body is an index into that table, and each entry says what the binding is:

EntryThe bindingWhat graft does with it
Local(BindingInner)one the body introduces — a parameter, or a let bindingmints a fresh binding in the host arena carrying this metadata
Global(SymbolId)a free variable, which must be a module name (is_file_scope) or a primitiveresolves the name against the host unit, and declines if it does not resolve

HirFragment::close builds a fragment from a body, its parameters, and the defining arena. HirFragment::graft re-hosts one into any arena, minting fresh HirIds — a reused id collides in the region walk's per-id side tables. Both run over one rebuild (rebind), so close declines exactly the forms graft could not rebuild: a fragment that exists always rebuilds, and the only thing left for graft to refuse is a global the host unit cannot name.

The admitted forms are the pure-expression ones (literals, Var, Call, if, cond, begin, and/or, and a raw %-intrinsic) plus let. let is closable because its bindings are introduced in evaluation order: each value is rebuilt before its own binding enters the table, so a sequential let's later value sees the earlier binding and no value ever refers to itself. letrec is that same order defeated — its value may name its own binding — and every other binding form (loop, a match pattern, a nested lambda) introduces through a route this walk does not model. All of them decline.

Because a fragment holds a BindingInner per local rather than a summary of one, a let binding's mutability and a parameter's (numeric!) floor travel with it like any other field, and the whole fragment is plain serializable data. The stdlib disk cache stores the cross-unit inline registry as fragments (src/compiler/stdlib_cache.rs), so a cache hit compiles user code to the same bytecode a stdlib compile does.

What analysis does

1. Binding resolution — names → Binding arena indices, recording each binding's scope (Parameter/Local), mutation, and capture status in the BindingArena 2. Capture analysis — which free variables a closure captures, whether they are mutable. Each capture carries a CaptureKind: Local (from the parent's slot), Capture (transitive, from the parent's own captures), or Recursive (the closure captures its own enclosing letrec/def binding — a self-reference). A Recursive self-edge does not mark the binding captured, so a binding captured only by itself is cell-free and its self-reference resolves to the currently-executing closure (LoadSelf / a self-call); the lowerer reads this classified fact rather than re-deriving the self-edge. It carries no escape authority (see impl/escape.md) 3. Signal inference — interprocedural: traces call chains to determine whether a function can yield, error, or is silent 4. Tail position marking — sets is_tail on the calls in tail position, for TCO. Analysis runs it, so every AnalyzeResult carries honest flags: the linter and the call-graph builder both read is_tail off the analyzed tree, and regularize marks again after map fusion introduces new nodes 5. Special form analysis — if, let, begin, block, match, defmacro, etc. each have dedicated handlers

Signal inference

Three signal categories:

the propagates mask (e.g., (map f xs) — signals depend on f)

silence constrains a parameter to be silent at compile time. The inference propagates through call chains interprocedurally.

Dead binding elimination

src/hir/dead.rs removes a let/letrec binding when two facts hold: the binding has zero uses, and its initializer is provably effect-free. Removing the binding removes the initializer with it, so a dead call to an effect-free function never reaches LIR.

The pass runs from regularize, after dead-arm pruning and map fusion, and before functionalize. That altitude is the same one prune_typeof_match_arms uses, for the same reason: the region solver runs after the HIR transforms, so a call deleted here never mints a region and strands no release obligation.

A silent function is not a pure function

Signal::silent() says a function emits no signal bits. It does not say the function has no effect. Every %-intrinsic is registered silent, and %push-array-mut appends to its argument in place. A rule that eliminated any silent call would delete that append and change the program's output.

So the pass proves effect-freedom, not silence, and it proves it from two facts that cannot lie:

signal with every argument's signal, so a silent call node also means every argument is silent, and that a polymorphic callee got silent arguments. A yielding callback handed to an otherwise-silent higher-order function shows up here and blocks elimination.

RegionEffect::Fresh are the two declarations that state no argument is stored anywhere outliving the call, and moves_out marks the natives that remove an element from a container argument. In-place mutation is a store into an argument, so Funnel, Stores, Sends, Mixed, and PassThrough are all rejected. For a user-defined callee, the same predicate runs over its lambda body, to a fixpoint.

The fixpoint starts with nothing proven pure and grows. A self-recursive function therefore never proves pure, which keeps the pass out of the termination question: it can only delete calls that provably return.

What the pass declines to touch

expression, but the top-level names are also what (environment) reflects.

result of the enclosing body. Deleting one can change that result; deleting a let binding cannot.

records a def rather than a use, so a mutated binding can read as unused while an Assign node still names it.

else, so removing it would be sound. The pass leaves it for now; the win is small and the blast radius across the corpus is not.

Kernel and sugar (design target)

HirKind is currently a wide vocabulary: functionalize and anf_lift normalize a few constructs (while → loop/recur, assign → ssa-let/setcell, captured var → derefcell) and ANF names allocating subexpressions, but they eliminate almost nothing — the lowerer still matches all ~39 variants, so cond, match, and/or, begin, destructure reach LIR intact. The design direction is a real desugaring boundary so the lowerer consumes only the irreducible kernel:

(semantically reducible to letrec + tail-call, kept primitive because per-iteration region reuse and the ownership boundary need to name them)

intrinsic, eval

match→if+gets, destructure→intrinsic gets, begin→let-chain, while→loop, assign→ssa/setcell, do/def→let/letrec

The kernel boundary is set by what lowering needs to name (hence loop/return are kernel despite being semantically derivable), not by surface convenience.

Bodies and def

There are exactly two binding kernels: let (= let, sequential, non-recursive) and letrec (= letrec, sequential, recursive). Each surface body desugars to one of them:

ContextBody semantics
do / beginlet — sequential; no forward refs / no mutual recursion
lambda bodyletrec — strict
file / module bodyletrec — strict; the body's value is the module

def is polymorphic sugar: "extend the enclosing body with one binding." In a sequential body it desugars to a nested let ((do (def x 4) (def x (+ x 1))) → (let [x 4] (let [x (+ x 1)] x)) — shadowing is free); in a recursive body it is a letrec binding. It is a body-level, post-macro-expansion rewrite (a def may be produced by a macro and must still be collected), not a closed-form local macro.

There is no "module layer." A module is just the body's return value — per modules.md, a file "runs as a single letrec, and whatever its last expression evaluates to becomes the return value", with no export declarations and no special syntax; import-file is "essentially (eval (slurp path))". The two things that looked like a layer aren't bound to the body at all: the export projection (compute_signal_projection) is an optional compile-time signal-inference cache over the returned struct (delete it and modules still work, just with conservative cross-file signals), and (signal :kw) is an orthogonal declaration form with a compile-time effect on the signal registry, exactly like defmacro's effect on the expander. Strip both away and the file body is strictly letrec — which is what modules.md already says it is.

So analyze_file_letrec is an out-of-band driver to retire, not to rename. It implements letrec-with-redefinition as a bespoke path no eval/nested form can reach, and its deferred-binding machinery exists only to support in-file redefinition — which has no coherent meaning (forward references bind the first definition, later references the second) and should instead be a duplicate-definition error. The target is just the ordinary letrec kernel: the loader collects file forms into a letrec (via the same def-as-body sugar and gensym-expr encoding) and runs them through the normal expand→analyze→emit→eval path — which is what import-file already does. If a user-replaceable top-level wrapper is wanted (Racket #%module-begin-style), it is a macro named in valid Elle (e.g. body — not #%body, since # is the comment char and % the intrinsic prefix) whose default expansion is exactly that letrec. Two consequences fall out: the file body stops being special, and its heap literals are ordinary allocations (MaterializeConst) into the file body's own per-activation regions, freed by the termination sweep (see region/model.md — Constants lower as ordinary allocations), closing the per-eval leak.

Files

src/hir/expr.rs           Hir and HirKind definitions
src/hir/analyze/mod.rs    Main analysis entry point
src/hir/analyze/binding.rs  Binding resolution
src/hir/analyze/forms.rs  Special form handlers
src/hir/analyze/special.rs  More special forms
src/hir/tailcall.rs       Tail position marking
src/hir/dead.rs           Dead binding elimination

See also