From cargo init to a Running Compiler

· 6 min read
Building Hew: Part 2 of 16

At some point you have to stop writing specifications and start writing code. That point came at 1:57 AM — cargo init, a blank lexer file, and a self-imposed constraint: get Hew source to produce a running binary before doing anything else. No optimizations, no standard library, no actor runtime. Just the skeleton.

Building the Pipeline

Lexer — 1:57 AM

The lexer came in at 1,243 lines of Rust. Most of it is the usual work — scanning characters, emitting tokens, handling string escapes — but the token set tells you something about where this language is headed:

pub enum TokenType {
    // Standard keywords
    Let, Var, Const, Fn, If, Else, Match, Loop, For, While,
    // Actor-specific
    Actor, Supervisor, Child, Restart, Budget, Strategy,
    Permanent, Transient, Temporary,
    OneForOne, OneForAll, RestForOne,
    Scope, Spawn, Await, Receive, Init,
    // ...
}

Those actor-specific keywords — Supervisor, Restart, Budget, Strategy, OneForOne — aren’t library constructs or macros. They’re first-class syntax, reserved from day one. Whether that commitment pays off is a question for later. The lexer doesn’t care — it just turns source text into tokens and hands them off.

Parser — 3:23 AM

Recursive descent handles the structure — function declarations, let bindings, if expressions — while Pratt parsing handles expression precedence. Pratt parsing is one of those techniques that’s simpler to implement than to explain: each operator carries a binding power, and the parser uses that to decide how tightly it grabs its operands. No separate precedence table, no grammar ambiguity. Just a loop consuming tokens left to right.

The resulting AST has explicit nodes for everything, no desugaring at this stage. Keeping parsing separate from lowering makes each pass easier to test independently. (A small discipline that pays for itself almost immediately when something goes wrong.)

Making It Safe

Type checker — 3:27 AM

815 lines. Most of it is plumbing — walking the AST, unifying types, reporting errors with spans. But buried in there is the seed of the capability system.

The idea is straightforward. Types crossing actor boundaries must be Send, meaning no mutable references to shared state. Types inside a frozen actor must be Frozen — deeply immutable, no interior mutability, nothing that could change after construction. Violate either constraint and the compiler rejects the program. An entire class of data races becomes a compile-time error instead of something you discover at 3 AM under production load.

At this stage the checker only handles primitives, function types, and basic inference. Generics and actor types aren’t wired up. Just enough to type-check arithmetic and function calls.

Lowering to IR and C

IR — 3:35 AM

The intermediate representation uses SSA form, where every value gets assigned to a numbered register exactly once:

// SSA Register representation
pub struct Register(pub u32);
// Display as %0, %1, %2, etc.
// Instructions like: %3 = add %1, %2
//                    %1 = const i32 42

SSA makes future optimization passes cleaner — dead code elimination, constant propagation, register allocation all benefit from the guarantee that each register is written once. None of that matters yet. But having the IR in SSA form from the start means those passes can slot in later without restructuring the pipeline.

Code generation — 3:44 AM

Two codegen paths emerged: one that walks the IR, and one that translates the AST directly to C. The IR-based path is more principled. The direct AST-to-C path is more practical. For bootstrapping, practical won.

The direct path walks the AST and emits C, introducing temporaries for every subexpression. Mechanical and predictable — no register allocation, no instruction scheduling. The generated code isn’t pretty, but it compiles with gcc and produces correct binaries. (That’s exactly what a first code generator needs to do.)

55

3:48 AM

Every compiler needs a first real program. Fibonacci is the classic choice because it quietly exercises everything — function calls, recursion, conditionals, arithmetic — so a correct result shakes out bugs across every stage simultaneously.

fn fibonacci(n: i32) -> i32 {
    if n <= 1 {
        n
    } else {
        fibonacci(n - 1) + fibonacci(n - 2)
    }
}

fn main() -> i32 {
    let result = fibonacci(10);
    println(result);
    0
}

The compiler turned that into this C:

int32_t fibonacci(int32_t n) {
    int temp_0 = n <= 1;
    if (temp_0) {
        return n;
    } else {
        int temp_1 = n - 1;
        int temp_2 = fibonacci(temp_1);
        int temp_3 = n - 2;
        int temp_4 = fibonacci(temp_3);
        int temp_5 = temp_2 + temp_4;
        return temp_5;
    }
    return 0;
}

Six temporaries for what a human would write as one expression. Every subexpression pulled into its own variable. Verbose, yes — but correct, readable, and trivial to debug. And gcc -O2 cleans up the redundancy anyway.

Pipe the C through gcc. Run the binary.

55.

The tenth Fibonacci number. Every stage — lexer, parser, type checker, IR, code generator — wired together and producing the right answer. Two hours from cargo init to a 16KB binary that does what it’s supposed to.

There’s a long list of things this compiler can’t do yet: actors, generics, closures, pattern matching, the standard library, error recovery, useful diagnostics. The generated C is mechanical, though it maps cleanly back to the source. But Hew source goes in and a running binary comes out.