Your first compiler
Lower expressions, decisions and loops into Esper.
A frontend turns your language’s rules into a small set of operations. Start with a language that has integers, variables and functions. Keep its parser, type checker and IR generator as separate steps so each error has a clear home.
Give intermediate results names
Suppose your program says return (3 + 4) * 2. Evaluate the inner expression
first, give it a name, then use that name in the outer expression:
esper 1
fn main() i32 {
entry:
sum i32 = add 3, 4
result i32 = mul sum, 2
return result
}
This returns 14. i32 tells Esper how wide the integer is. A named result is
immutable. Your frontend can invent %0, %1 and so on instead of readable
names; those numbers are names, not positions in an instruction array.
A simple expression generator can return the name of the value it just emitted. Generating a binary expression means generating its left child, generating its right child, then emitting one operation using their two returned names.
Make a decision with blocks
A block is a sequence of instructions with one entry point. A branch chooses which block runs next. Here both arms assign a local before the return reads it:
esper 1
fn abs(value i32) i32 {
local result i32
entry:
negative i1 = lt.s value, 0
branch negative, negate, keep
negate:
result = sub 0, value
jump done
keep:
result = value
jump done
done:
return result
}
The signed comparison ends in .s. i1 is the condition type. Notice the
semicolon-free instructions, the colon after each block name, and the absence
of block parameters or PHI nodes. Esper constructs its internal SSA form for you.
Integer overflow behavior still belongs to your language: this small example
does not add a special case for the most negative signed integer.
Turn repetition into a back edge
Declare an induction variable, initialize it in the entry block, then jump back
to a block that tests it. The text walkthrough’s loop
returns 45 by summing zero through nine. An assignment updates a mutable local;
a typed snapshot i32 = copy total creates an immutable snapshot.
Never read a local before every incoming path has assigned it. A loop that can execute zero times does not initialize variables assigned only inside its body. The reader checks that rule and reports an error instead of guessing a value.
Connect functions
Use result i32 = call add(a, b) after defining fn add(a i32, b i32) i32.
Declare runtime services using imports, for example import fn puts(text ptr) i32. The interpreter needs a host binding; emitted code needs a matching symbol
at link or load time. These are two different ways of satisfying the same import.
Move from text to memory
Text makes a first frontend easy to inspect. Once the lowering rules work, use the C builder guide or C++ guide to construct units without formatting strings. Those APIs currently expose the internal SSA model, including block parameters. A public mutable-local builder is part of the next stage, not an API you can call today.
Then add one language feature at a time. The remaining guides explain storage and lowering choices for strings, structs, closures and other features. Their checked code snapshots use the compatibility format and the current builder; they are implementation references alongside the new text syntax.
Before adding an optimization, keep a tiny input program with an expected return value and printed output. Run it before and after the pass. A shorter dump is interesting; the same observable behavior is the requirement.
For separate compilation, continue with saving, loading, and linking units.