RFC-0051 — Structured handler-body lowering for transforming backends
- Status: Draft
- Author: Mark Truluck
- Scope: Replace the Erlang handler-body text-reparse pipeline (brace→
case→ SSAData-threading → gen_statem tuple injection → early-exit nesting → separator joining, each re-scanning the previous stage’s emitted output) with a single structural lowering over a control-flow IR assembled from the scanned handler body. Erlang-first; the same IR generalizes to any backend that must transform native control flow (Lua). No effect on passthrough backends. - Companions: RFC-0042 (
@@fsm— already has theIfAst/Blockshape this reuses), RFC-0046 (@@:self.field), RFC-0050 (statement syntax). - Supersedes: a deleted earlier RFC-0051 draft that wrongly proposed parsing
if/elseinto the front-end as a Frame statement. That framing is rejected here (§3);if/elsestays native. - Tracking: framec#123 (text-oracle elimination), framec#119, framec#125.
1. Purpose
The Erlang backend lowers a handler body through a pipeline of text passes that reparse one another’s output:
spliced body
→ erlang_lower_native_if (native `if … ->` → brace `if`)
→ erlang_transform_blocks (brace `{ }` → `case … of`) [now an FSM]
→ erlang_nest_early_exits (re-scan `; false -> ok` to nest trailing code)
→ body_processor (re-scan lines to thread `Data` SSA + wrap cases)
→ case_arms (re-scan arms to inject `{keep_state,…}` tuples)
→ erlang_smart_join (re-scan to place `,`/`;`/newline)
Stages 3–6 recover control flow and data flow from emitted text. This is the largest instance of the anti-pattern framec#123 exists to remove, and it is incomplete by construction: every stage enumerates a fixed set of textual shapes, and an unenumerated shape is silently mishandled. The bugs found this arc are all the same defect wearing different masks:
- framec#119 — a nested fall-through arm returned bare
okinstead of a gen_statem tuple (bad_return_from_state_function), because tuple injection only descended one text level. - framec#125 — an
else ifarm that mutates plus trailing code that mutates:Datanot threaded through the non-terminalcase(; false ->,, wrongDataN). - The nested early-exit crash —
nest_early_exitsmis-placed an outer trailing statement into the innercaseas a duplicate; false ->arm. - The over-folding bug — the early-exit nesting folds every no-else
if+ trailing into the false arm, even when the body does not exit; the trailing must run unconditionally. (Pre-existing innest_early_exits; faithfully reproduced when it was ported into the parser.) - 37_aggregator — sequential non-terminal mutating
ifs: theData-wrap emitted a spurious__ReturnVal = case …/; false -> ok,.
Each fix shifted the defect to the next stage (patching the fold exposed
body_processor; a “does this body exit?” heuristic that sniffs
self.result for "frame_transition__" is itself the disease). No sequence of
text patches converges, because no stage has the structure it is reasoning
about.
This RFC replaces stages 1–6 with: assemble a control-flow IR from the already-
scanned handler body, then emit gen_statem Erlang from it in one structural
pass. Early-exit, Data SSA threading, and tuple injection become properties
of the structure, not pattern matches on text.
2. Why only Erlang (and Lua) deviate at all
The seven Frame statements are universal — -> $S, => $^, push$, pop$,
$.x =/@@:self.x =, @@:return, @@:(e) — and a handler body is everywhere
“the seven, plus native code.” What differs is how a backend expands them:
-
The 16 imperative/passthrough backends expand in place. They are mutable and statement-based:
@@:self.x = 5→self.x = 5;-> $S→…; return;; nativeif c { … }is already valid target syntax and passes through untouched. The segment scanner finds the seven statements, expands each where it sits, and the surrounding native code is the user’s own valid code. -
Erlang cannot expand in place. It is immutable and expression-based, so the same seven statements reshape the surrounding code:
@@:self.x = 5becomes a new bindingData1 = Data#data{x = 5}that every later read must thread;-> $Smust become the clause’s tail expression{next_state,…}, not a mid-body jump; and nativeif c { … }has no Erlang form — it must becomecase … of. A transition emitsreturnon every imperative backend (frame_expansion/transition.rs), soif c { -> $S } trailingis naturally correct there; Erlang has no earlyreturnand must restructure.
So two distinct deviations are tangled together, and this RFC separates them:
- Intrinsic and unavoidable — Erlang reshapes code around the seven statements. Forced by the target. Not a defect.
- Accreted and fixable — that reshaping is done by re-parsing emitted text across six stages. This is what makes Erlang look unlike every other backend, and it is the entire bug surface.
Lua is the same kind of backend — it transforms the same native braces
({ } → do/then…end) and already parses them (OutputBlockParserFsm). So
“backends that must rewrite native control flow” = {Erlang, Lua}; “backends that
pass it through” = the rest. Only the first set needs the structure this RFC
defines.
3. What this is — and explicitly is not
- It is NOT
if/elsebecoming the eighth Frame statement. Frame does not own its semantics, does not validate it, and makes no portability guarantee about it. The condition and the leaf statements remain native text, emitted verbatim into whatever control-flow form the target uses. The seven statements are special precisely because they have no native corollary;if/elsehas one and stays native. - It is NOT front-end parsing of native control flow. The front-end /
segmenter continues to emit
if/elseas opaqueNativeCode. Nothing about@@systemparsing changes, so no passthrough backend is affected. - It is NOT a full native AST. Conditions and expressions are opaque text
leaves. The only
@@:self.field/$.xaccesses inside them are already Frame constructs (RFC-0046) that framec recognizes; the structural walk rewrites them to the liveDataN#data.fieldat their position. - It IS a control-flow skeleton —
If/Blocknodes wrapping the seven Frame statements (as typed nodes) and native text leaves — assembled at codegen for the backends that must transform control flow, so they reshape from structure instead of from their own emitted text.
The distinction is exactly the one the segmenter already lives by: framec parses
the shape of a program (where @@system blocks are, where the seven statements
are) without interpreting native code. The control-flow shape (if/else
nesting) is the same kind of shape — needed only because some targets cannot take
the native shape verbatim.
4. The IR
HandlerIR = Block
Block = Vec<Node>
Node =
| Frame(FrameStmt) // one of the seven — a typed node, NOT text
| If { cond: Text, then: Block, els: Option<Block> }
| Native(Text) // opaque native leaf: expression / call / the
// RHS of an assignment, with @@:self.field /
// $.x accesses marked for position-aware rewrite
FrameStmtis the existing structured form of the seven (Transition,Forward,StackPush,StackPop, the assign/return/context nodes). Critically, a transition is a node, so “does this branch exit?” ismatches!(last, Frame(Transition|Forward))— a structural fact, never a substring scan.If.condis text (emitted verbatim ascase (<cond>) of),then/elsare nested blocks.else ifisels: Some([If{…}])(right-nested), the shapeIfAstalready uses for@@fsm.- No
Loopnode: RFC-0050 §2.5 admits no Frame-levelwhile/for/loop.
This is deliberately the same shape as IfAst/Block already built for
@@fsm (RFC-0042). §5 reuses scanning, not the front-end grammar; §10 notes a
later unification.
5. Production — assemble from the scan, never reparse output
The IR is built from artifacts that already exist, with no second pass over generated code:
HandlerEntry.body_statements: Vec<Statement>already holds the seven Frame statements as typed nodes interleaved withNativeCode(String)chunks, in source order (e.g.NativeCode("if c {"),Transition,NativeCode("} more")).OutputBlockLexerFsm(the shared, string/comment-safe brace tokenizer Lua and Erlang already use vialex_blocks) tokenizes theNativeCodetext intoIF/ELSE/LBRACE/RBRACE/….
The builder walks body_statements once, maintaining a stack of open Blocks:
- a
NativeCodechunk is lexed;if <cond> {pushes anIf(cond = the text betweenIFand the block{),} else {/} else if {switch/extend the arm,}pops; any non-structural text becomes aNativeleaf; - a Frame statement is appended as a
Framenode to the current block.
The result is the If/Block tree with Frame statements as nodes and native
text as leaves. The brace discipline already proven for #122 (a {/} is a
block delimiter only at a line-structural position; tuple/map braces are leaves)
carries over directly — it is the same lexer.
This is a single scan-driven parse, the textbook front half of a compiler: lexer (FSM) → parser → IR. There is no inspection of emitted Erlang anywhere.
6. The Erlang structural emitter
One recursive walk of the IR emits gen_statem Erlang, threading two pieces of
context functionally: the live Data SSA name and the live return value.
Block— emit nodes in order, threadingData. The block’s tail position determines whether its last node must yield a gen_statem return tuple.Frame(VarAssign{field, rhs})—Data{n+1} = Data{n}#data{field = rhs'}, whererhs'has@@:self.frewritten toData{n}#data.f(the live binding). Advances theDataname.Frame(Transition)—frame_transition__(target, Data{n}, …, From, Ret)in tail position (it already returns{next_state,…}).Native(text)— emit verbatim, with@@:self.frewrites; never a return.-
If { cond, then, els }— the core, and the three behaviors that were three buggy text stages become three structural cases:- Terminal
if(early exit).thenunconditionally exits — i.e. its last node is aFrame(Transition|Forward), a structural test. With noelsand trailing nodes after theIfin the parentBlock, the trailing is the false arm:case (cond) of true -> <then> ; false -> <trailing> end. The over-folding bug is impossible: a non-exitingthendoes not satisfy the predicate, so its trailing runs unconditionally. - Non-terminal
ifthat mutates.then/elsbindDatabut do not exit, and code follows. TheIfmust yield the mergedData:Data{n+1} = case (cond) of true -> <thenData>; false -> <elseData/Data{n}> end, and the walk continues fromData{n+1}. #125 and 37_aggregator are impossible: the merge is explicit, not a text wrap. - Pure control
ifin tail position — both arms end in returns; thecaseis itself the clause’s tail.
- Terminal
- Return-tuple completeness. Because the walk knows tail position, every
leaf on every path emits exactly one status tuple (
frame_transition__/{keep_state, Data{n}, [{reply, From, Ret}]}). #119 is impossible by construction.
frame_transition__/7, the #data{} record, expression_to_string (Erlang
arm), and the __ReturnVal convention are reused unchanged — only their
placement moves from “recovered from text” to “known from structure.”
7. Correctness is structural, not enumerated
Each bug this arc found maps to a property the IR makes true by construction:
| Bug | Text-pipeline cause | Structural guarantee |
|---|---|---|
#119 fall-through ok |
tuple injection descends one text level | every leaf path yields a tuple (§6) |
| over-folding | folds any no-else if+trailing |
fold only when then is a transition node (§6.1) |
| nested early-exit crash | ; false -> ok re-scan mis-nests |
nesting is the tree, not a re-scan |
| #125 / 37_aggregator | Data not threaded through non-terminal case |
explicit SSA merge at If joins (§6.2) |
There is no enumeration of arm shapes to be incomplete, and no self.result
sniffing. The same read_after / nested / 37_aggregator inputs that the text
pipeline gets wrong are correct here because the lowering reasons about the
program, not its printout.
8. What is deleted
erlang_system/blocks.rs: the early-exit fold insideErlangBlockParserFsm,erlang_nest_early_exits(already gone), anderlang_lower_native_if(nativeifbecomes anIfnode directly — §5 lexesif … ->as readily asif … {).erlang_system/body_processor.rs(~1,746 lines): the line classifier and SSADatatext-threading; theCaseFramewrap. The SSA logic becomes the §6 walk.erlang_system/case_arms.rs(~430 lines):analyze_case_arms,rewrite_mixed_case_arms,erlang_inject_orphan_reply_tuples. Tuple placement becomes §6.erlang_smart_joinshrinks to formatting only; the bracket core stays the RFC-0035 paren FSM.
Net: ~2,100 lines of text-reparse → one structural emitter, with the scanning
already shared (OutputBlockLexerFsm).
9. Migration — phased, behind a seam, TDD-gated
The single substitution point is erlang_system.rs where the spliced body is
handed to the text pipeline; the IR path slots in there.
- Phase 0 — contract. Lock the behavior as executable tests before code:
the bug repros (
read_after,nested,else-if + trailing/#125,37_aggregator, basic/exiting early-exit) as behavioral fixtures asserting runtime results, plus the 322 existing Erlang fixtures as the regression net. These define “done.” - Phase 1 — IR + builder. §4 types + the §5 scan-driven builder. Unit-test the IR shape on the Phase-0 inputs (assert tree structure, not text).
- Phase 2 — emitter behind a fallback seam. §6 emitter; route a handler through it only when its body is fully representable, else fall back to the text pipeline. Land with zero regression; grow coverage handler-shape by handler-shape (transitions, forward, push/pop, persist, async, HSM cascades).
- Phase 3 — cut over + delete. Make the IR path the only path; delete §8.
- Phase 4 — Lua. Re-point Lua’s
do/then…endtransform at the same IR (it transforms the same braces). Optional, later.
10. Risks and mitigations
| Risk | Mitigation |
|---|---|
gen_statem surface is large (transitions, forward, push$/pop$, persist, async, HSM enter/exit cascades, self-call guards) — the emitter must cover all the text pipeline does |
Phase-2 fallback seam: unsupported shapes use the old path until the emitter covers them; never a regression. The 322-fixture matrix is the coverage oracle. |
Building the IR from interleaved body_statements + lexed NativeCode mis-aligns spans |
The statements are already source-ordered; the builder is a single linear walk, unit-tested on the Phase-0 inputs. |
Re-deriving an If shape duplicates @@fsm’s IfAst |
Reuse the IfAst/Block types; §5 only adds a scan-driven builder for @@system bodies. A later RFC may unify the producers. |
| Behavioral drift on the 322 fixtures | Per-handler cutover + before/after generated-Erlang diff where behavior is unchanged; full matrix 17/17 each phase. |
11. Validation
- Phase-0 behavioral fixtures green (the bug repros — currently failing or latently wrong under the text pipeline).
- Erlang matrix (322 + the new fixtures) and full 17-language matrix 17/17.
- Unit tests on the IR builder (tree shape) and the emitter (
DataSSA names, tuple completeness, early-exit placement, non-terminalcasemerge). cargo test+ clippy + fmt clean; generated-Erlang byte-diff reviewed where behavior is unchanged.
12. Out of scope
- The passthrough backends — unchanged; they never see the IR.
- Loops — none (RFC-0050 §2.5).
- Front-end grammar changes for
@@system— none;if/elsestays native, parsed for shape at codegen only.
13. Definition of done
- The Erlang handler-body lowering is one structural pass over the §4 IR;
body_processor.rs,case_arms.rs, and theblocks.rsearly-exit/native-if logic are deleted. - The Phase-0 repros (
read_after,nested, #125,37_aggregator, early-exit) pass behaviorally; #119 and #125 close. - Erlang matrix green; full matrix 17/17; unit tests + clippy + fmt clean.
- No code path inspects emitted Erlang to recover structure.