The AST is a map. The code generator turns it into directions the VM can follow, one instruction at a time.
By the time source code reaches the code generator, it has already survived three gauntlets. The lexer broke it into tokens. The parser assembled those tokens into a tree. The type checker verified that the tree makes sense. What remains is the final translation: converting that abstract, hierarchical representation into a flat stream of bytecode instructions that the FLIN Virtual Machine can execute.
This is the story of Session 009 -- the session where FLIN gained the ability to compile. In roughly thirty minutes of implementation, we built a code generator that walks the AST, emits opcodes, manages a constant pool, and produces bytecode for every language construct FLIN supports: variables, arithmetic, control flow, entity operations, view rendering, temporal access, and AI-powered queries. 1,700 lines of Rust. 26 new tests. And at the end, a counter application that compiled into 26 bytes of bytecode.
The Architecture of the Code Generator
The code generator has three layers: the Chunk (the bytecode container), the Emitter (the tree-walking engine), and the CodeGenerator facade (the public API). Each layer owns a single responsibility.
The Chunk is the raw output format -- a byte array for instructions, a constant pool for values, and a line table for debugging:
pub struct Chunk {
pub constants: Vec<Constant>,
pub code: Vec<u8>,
pub lines: Vec<u32>,
}impl Chunk { pub fn add_constant(&mut self, constant: Constant) -> u16 { // Deduplication: if this constant already exists, return its index for (i, existing) in self.constants.iter().enumerate() { if existing == &constant { return i as u16; } } let index = self.constants.len() as u16; self.constants.push(constant); index }
pub fn write_opcode(&mut self, op: OpCode, line: u32) { self.code.push(op as u8); self.lines.push(line); }
pub fn write_u16(&mut self, value: u16) { self.code.push((value & 0xFF) as u8); self.code.push((value >> 8) as u8); } } ```
Two design decisions in this structure deserve attention. First, constant deduplication. When the code generator encounters the string "count" three times in a program, the constant pool stores it once and returns the same index each time. This keeps the bytecode compact -- critical when the constant pool index is encoded as a two-byte operand. Second, little-endian encoding for multi-byte values. Every u16 operand is stored low byte first, matching the format specified in the bytecode specification and the native byte order of most modern hardware.
The Emitter is where the real work happens. It carries the compilation state:
pub struct Emitter {
chunk: Chunk,
locals: Vec<Local>,
scope_depth: usize,
loop_starts: Vec<usize>,
loop_exits: Vec<Vec<usize>>,
entities: HashMap<String, EntitySchema>,
globals: HashMap<String, u16>,
}Each field serves a specific purpose. locals tracks variables in the current scope, mapping names to stack slots. scope_depth tells the emitter whether a variable declaration should produce a StoreLocal or StoreGlobal instruction. loop_starts and loop_exits manage the jump targets for for loops -- the start address for continue and the exit addresses for break, which need to be patched after the loop body is emitted. entities stores the schemas of declared entities so that save and entity construction know which fields to expect. globals maps global variable names to their constant pool indices.
The public API is deliberately simple:
pub struct CodeGenerator {
emitter: Emitter,
}impl CodeGenerator {
pub fn generate(self, program: &Program) -> Result
Two methods. One compiles and returns bytecode. The other compiles, returns bytecode, and also returns a human-readable disassembly string for debugging. That is the entire public surface.
Walking the AST
Code generation is a depth-first tree walk. For each AST node, the emitter calls the appropriate emit_* method, which emits opcodes and recursively processes child nodes. The dispatch is straightforward:
For statements, emit_stmt() pattern-matches on the statement type:
VarDecl-- emit the initializer expression, thenStoreGlobalorStoreLocalEntityDecl-- register the schema in the entities map (no runtime code emitted)Save-- emit the entity expression, then theSaveopcodeDelete-- emit the entity expression, then theDeleteopcodeIf-- emit the condition, emitJumpIfFalseto the else branch, emit the then branch, emitJumppast the else branch, patch the jump targetsFor-- emit the iterable, set up the iterator, emit the loop body withNextFor/EndForView-- delegate to the view emission subsystem
For expressions, emit_expr() does the same:
Integer(0)-- emitLoadInt0(a single-byte instruction, no operand needed)Integer(1)-- emitLoadInt1Integer(n)where -128 <= n <= 127 -- emitLoadSmallIntwith a single-byte operandInteger(n)otherwise -- add to constant pool, emitLoadConstBinary { left, op, right }-- emit left, emit right, emit the operator opcodePostfix { operand, Increment }-- load, duplicate, increment, store backAsk(query)-- load the query string, emitAskTemporal { expr, time }-- emit the expression, emitAtVersionorAtTime
This recursive structure means that arbitrarily complex expressions compile naturally. a + b * c becomes: emit a, emit b, emit c, emit Mul, emit Add. The tree structure already encodes operator precedence -- the code generator does not need to think about it.
The Constant Pool
Every value that cannot be encoded directly into an instruction operand goes into the constant pool: strings, large integers, floating-point numbers, identifiers (used as keys for global variable lookup), entity names, and function references.
The constant pool uses a tagged format:
pub enum Constant {
Null,
Bool(bool),
Int(i64),
Float(f64),
String(String),
Identifier(String),
EntityName(String),
Function { arity: u8, address: u16, name_idx: u16 },
Time(i64),
Money { amount: i64, currency: u8 },
}The distinction between String and Identifier is important. A String constant is a runtime string value -- the result of "hello" in source code. An Identifier is a name used for variable lookup -- the count in count = 0 or LoadGlobal $count. They occupy the same constant pool, but their semantic roles differ. The VM uses identifiers to look up globals by name; it uses strings as data values pushed onto the stack.
Deduplication keeps the pool compact. Consider a FLIN program that references count in five different places: once in the declaration, twice in event handlers, once in a text binding, once in a conditional. Without deduplication, the constant pool would contain five copies of the identifier "count". With deduplication, it contains one, and all five instructions reference the same index.
The constant pool index is encoded as u16, giving a maximum of 65,536 constants per compilation unit. For most FLIN applications -- which are single-file, UI-focused programs -- this is more than sufficient.
Jump Patching
Control flow is where the flat nature of bytecode collides with the hierarchical nature of the AST. An if statement has a then-branch and an optional else-branch. In the AST, these are nested children. In bytecode, they are regions of a flat instruction stream connected by jump instructions.
The challenge: when the emitter encounters an if statement, it needs to emit a JumpIfFalse instruction that jumps to the else branch. But it does not yet know the address of the else branch -- it has not emitted the then-branch yet.
The solution is jump patching. The emitter writes a placeholder address, records the offset where the placeholder lives, emits the then-branch, and then goes back and overwrites the placeholder with the correct address:
fn emit_if(&mut self, condition: &Expr, then_branch: &[Stmt],
else_branch: &Option<Vec<Stmt>>) -> Result<(), CodeGenError> {
// Emit condition
self.emit_expr(condition)?;// Emit JumpIfFalse with placeholder let else_jump = self.emit_jump(OpCode::JumpIfFalse);
// Emit then-branch for stmt in then_branch { self.emit_stmt(stmt)?; }
if let Some(else_stmts) = else_branch { // Jump past else (from end of then-branch) let end_jump = self.emit_jump(OpCode::Jump);
// Patch the else_jump to point here self.patch_jump(else_jump);
// Emit else-branch for stmt in else_stmts { self.emit_stmt(stmt)?; }
// Patch the end_jump to point here self.patch_jump(end_jump); } else { self.patch_jump(else_jump); }
Ok(()) } ```
The emit_jump method writes the opcode and a two-byte placeholder (0x0000), then returns the offset of the placeholder. The patch_jump method calculates the distance from the placeholder to the current code position and writes it back using Chunk::patch_u16. This pattern is used for every conditional and loop in the language.
For loops, the patching works in both directions. The start of the loop records a loop_start address for backward jumps (the loop iteration). The loop_exits vector collects all forward jump offsets that need to be patched when the loop ends -- break statements, the loop termination condition, and the EndFor instruction.
View Code Generation
FLIN's view system compiles HTML-like templates into bytecode. This is one of the most distinctive aspects of the code generator -- most language compilers do not need to handle DOM element creation as a first-class operation.
The view emission follows the nesting structure of the template. For a simple counter:
count = 0
<button click={count++}>{count}</button>The code generator produces:
0000: LoadInt0 ; push 0
0001: StoreGlobal $count ; count = 0
0004: CreateElement $button ; <button>
0007: CreateHandler $click ; start click handler
000A: LoadGlobal $count ; load count
000D: Dup ; duplicate for postfix
000E: Incr ; increment
000F: StoreGlobal $count ; store back
0012: TriggerUpdate ; notify reactivity system
0013: EndHandler ; end click handler
0014: BindHandler ; attach handler to element
0015: LoadGlobal $count ; load count for display
0018: BindText ; bind as reactive text
0019: CloseElement ; </button>
001A: Halt ; endSeveral details are worth noting. CreateElement pushes an element onto the VM's element stack. CloseElement pops it. Everything emitted between them -- handlers, text bindings, attributes -- attaches to the current element. This stack-based approach mirrors the nesting of HTML without requiring the bytecode format to be hierarchical.
Event handlers are delimited by CreateHandler/EndHandler pairs. The instructions between them are not executed immediately -- they form a closure that the VM stores and invokes when the event fires. TriggerUpdate inside the handler tells the reactivity system that the UI needs to re-render after the handler completes.
BindText creates a reactive binding. When the VM encounters BindText, it does not just set the text content once -- it records the dependency so that when count changes (via TriggerUpdate), the text is automatically updated. This is the bytecode-level implementation of FLIN's reactivity model: the code generator emits the dependency graph, and the VM maintains it at runtime.
View conditionals and loops use similar jump-patching mechanics to control-flow statements, but with view-specific opcodes: StartIf/EndIf for conditional rendering and StartFor/NextFor/EndFor for list rendering.
Optimized Literal Emission
Small optimizations in code generation compound across an entire program. The most impactful optimization in the emitter is specialized literal instructions.
Loading the integer 0 could be done with LoadConst -- add 0 to the constant pool and emit a three-byte instruction. But 0 is by far the most common integer literal in any program (initialization, comparisons, loop counters). So the instruction set provides LoadInt0, a single-byte instruction with no operand. Same for LoadInt1 and LoadIntN1.
For integers in the range -128 to 127, LoadSmallInt uses a one-byte signed operand instead of a constant pool reference. This covers loop bounds, array indices, and most counter operations.
The emitter applies these optimizations transparently:
fn emit_literal(&mut self, value: &Expr, line: u32) -> Result<(), CodeGenError> {
match value {
Expr::Integer(0) => {
self.chunk.write_opcode(OpCode::LoadInt0, line);
}
Expr::Integer(1) => {
self.chunk.write_opcode(OpCode::LoadInt1, line);
}
Expr::Integer(-1) => {
self.chunk.write_opcode(OpCode::LoadIntN1, line);
}
Expr::Integer(n) if *n >= -128 && *n <= 127 => {
self.chunk.write_opcode(OpCode::LoadSmallInt, line);
self.chunk.code.push(*n as u8);
}
Expr::Integer(n) => {
let idx = self.chunk.add_constant(Constant::Int(*n));
self.chunk.write_opcode(OpCode::LoadConst, line);
self.chunk.write_u16(idx);
}
Expr::Bool(true) => {
self.chunk.write_opcode(OpCode::LoadTrue, line);
}
Expr::Bool(false) => {
self.chunk.write_opcode(OpCode::LoadFalse, line);
}
Expr::None => {
self.chunk.write_opcode(OpCode::LoadNone, line);
}
// ...
}
Ok(())
}Booleans and none get the same treatment. LoadTrue, LoadFalse, and LoadNone are single-byte instructions. No constant pool entry, no operand decoding at runtime. These optimizations are invisible to the programmer but reduce bytecode size by 20-30% in typical programs.
Short-Circuit Evaluation
Boolean operators require special handling. In most languages, a && b does not evaluate b if a is false. Similarly, a || b does not evaluate b if a is true. This is not just an optimization -- it is a semantic guarantee. Code like user != none && user.name == "Juste" relies on short-circuit evaluation to avoid a null pointer error.
The emitter implements short-circuit evaluation using conditional jumps:
For &&: emit a, emit JumpIfFalse past the b evaluation, emit b, emit And. If a is false, the jump skips b entirely and the false value remains on the stack.
For ||: emit a, emit JumpIfTrue past the b evaluation, emit b, emit Or. If a is true, the jump skips b entirely and the true value remains on the stack.
This is one of the cases where the code generator must reason about evaluation semantics, not just mechanical translation. A naive implementation that always emits both operands would produce incorrect programs.
Entity Method Detection
FLIN entities have built-in methods: .all, .find(id), .where(condition), .first, .count, .order(field). These look like regular method calls in the source code, but they compile to specialized opcodes that the VM maps directly to FlinDB operations.
The emitter detects entity method calls by checking whether the callee is a known entity name with a recognized method:
For User.all, it emits QueryAll with the entity type index. For User.find(id), it emits the id expression followed by QueryFind. For User.where(active == true), it emits the predicate followed by QueryWhere.
This detection happens at code generation time, not runtime. The VM does not need to dispatch method calls dynamically -- it receives a specific query opcode and executes the corresponding FlinDB operation directly.
The Disassembler
The code generator includes a disassembler that produces human-readable output from bytecode. This was not an afterthought -- it was built alongside the emitter and used to verify every test:
== counter ==
Constants:
[0000] 0
[0001] $count
[0002] $button
[0003] $clickCode: 0000 1 LoadInt0 0001 | StoreGlobal 0001 ; $count 0004 | CreateElement 0002 ; $button 0007 | CreateHandler 0003 ; $click ... ```
The disassembler resolves constant pool references inline (the ; $count comments), shows bytecode offsets in hexadecimal, and uses | to indicate instructions on the same source line as the previous instruction. This output format became the primary debugging tool for the entire compiler -- when a test fails, the disassembly immediately shows whether the wrong opcode was emitted, the wrong constant was referenced, or the wrong jump target was patched.
26 Tests, Zero Surprises
Session 009 added 26 new tests, bringing the total to 219. The tests covered three layers:
Bytecode definition tests verified that opcodes roundtrip correctly (write a byte, read it back, get the same opcode), that operand sizes match the specification, that constant tags are unique, and that the chunk data structure handles patching correctly.
Emitter tests verified individual compilation patterns: integer literals, variable declarations, binary addition, string literals, boolean literals, lists, the counter example, if-else branching, save/delete statements, entity construction, ask expressions, unary negation, and comparisons.
Integration tests compiled complete programs through the CodeGenerator facade and verified the output against expected bytecode sequences.
The counter example test was the capstone -- a complete FLIN program that exercises variable declaration, view element creation, event handling, postfix increment, reactive text binding, and the Halt instruction. When that test passed, we knew the code generator could handle real programs.
What This Phase Proved
Building a code generator in a single session -- roughly 30 minutes of wall-clock time -- seems implausible until you consider the preparation. The AST was clean and well-typed. The bytecode specification (PRD 08) defined every opcode, every operand format, and every instruction behavior before a single line of codegen was written. The type checker had already validated that every expression had a known type.
The code generator did not need to make decisions. It translated. Each AST node had exactly one correct bytecode sequence. Each opcode had exactly one encoding. The specification did the thinking; the implementation just followed the map.
This is the payoff of specification-driven development. When you invest time in writing a precise bytecode spec and a precise AST definition, the code generator writes itself. The alternatives -- discovering the bytecode format during implementation, or designing the AST and the codegen simultaneously -- produce working code eventually, but at the cost of false starts, redesigns, and subtle bugs from impedance mismatches between the tree and the instructions.
---
This is Part 16 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO built a programming language compiler in sessions measured in minutes, not months.
Next in the series: The bytecode format itself -- the .flinc binary, the magic bytes, the instruction encoding, and the design decisions that make FLIN's bytecode both compact and debuggable.