Alloy Compiler

A rewrite of the Alloy front end around staged passes, so that type information stops leaking into code generation.

Period
Nov 2025 – Jun 2026
Role
Solo
Status
archived
commits
20
LOC
3,981
Open source C++20MSVC

Highlights

  • A 1,534-line recursive-descent parser over an arena of AST nodes.
  • Symbol resolution with overload-aware tables: overloads are permitted for functions and externs, and rejected on any collision with a type or variable.
  • Stages are dependency-gated rather than fail-fast, so one bad file no longer aborts the whole batch.
  • A bump allocator that spills into a fresh block when full, rather than asserting the way the previous one did.

Scoped deliberately: this entry covers the hand-written front end only. Later stages of the compiler were AI-assisted and are not presented as personal work.

The problem

This one exists because the previous compiler failed for a reason I could name, and this was the attempt to fix it structurally rather than patch around it. It is worth reading as evidence of how I handle a codebase that has gone wrong: diagnose the actual cause, decide whether it can be repaired in place, and rebuild the part that has to change. It is also scoped honestly. Only the hand-written front end is presented here, and the AI-assisted work that came later is excluded from this page entirely.

AlloyLang stopped because type checking was never a phase. It was a scatter of comparisons living inside the code generator, which meant the only place the compiler looked at types was the place where the types had already been lowered to LLVM. Two distinct named types that both lower to i32 are indistinguishable there, so the central rule of the type system I had designed could not be enforced at all.

This project is the answer to that specific diagnosis. It is a rewrite of the Alloy front end around staged passes, where each stage owns one thing and hands a finished artifact to the next, so that type information stops leaking into code generation because there is a place for it to live instead.

One thing to be clear about before anything else: this page covers the hand-written front end only. There is a separate branch in the repository where later stages were AI-assisted, and none of that work is presented here as mine. What follows is the twenty commits and roughly 4,000 lines on master.

The stages

Per source file the compiler tokenizes, parses, then declares, which builds a symbol table of that module's top-level definitions. Only once every file has been through all three does a second pass run resolution across modules, where each module's imports are satisfied from the symbol tables the declare pass produced.

Splitting declare from resolve is what makes cross-module compilation work without an ordering constraint. If resolution ran immediately after parsing each file, a module importing something defined in a file not yet read would fail, and the compiler would need either a dependency-ordered build or multiple passes over the whole set. Every file gets its declarations out first, and then everything is available to everything.

The driver ends with a TODO for type interning and type checking, which is where the work stopped. The stage boundary the whole rewrite exists to create is in place; the thing that goes in it is not written.

Errors do not stop the batch

Each stage returns a status and each source file gets its own logger with its own error flag. The driver deliberately does not check those statuses between stages. A file that failed to parse still goes through declare, and a module whose declare pass logged errors still contributes whatever symbols it did manage to record.

What gates downstream work is not a pass-or-fail flag but the concrete artifact: when a module resolves its imports, the question asked is whether the imported module's symbols are there, not whether that module compiled cleanly. So one broken file produces errors for that file, and everything unaffected by it still gets compiled and still reports its own problems.

The alternative is fail-fast, where the first error returns out of the whole compilation. That is easier to write and it is what the previous compiler did, and it means a project with three unrelated mistakes takes three compile-and-fix cycles to find them all.

Overloads are a property of the symbol table

The symbol table maps a name to a list of declarations rather than to a single one, and the rule about which names may hold more than one is enforced when a name is added. Functions and externs may overload, so adding a second function under an existing function name appends to the list. Anything else may not: a second type or variable under an existing name is an error, and so is a function whose name collides with a type or a variable already there.

Every name resolves to a list, and names that cannot overload simply resolve to a list of one. That means the code consuming resolution results has one shape to handle rather than two, and overload resolution becomes a matter of narrowing a set rather than a special case bolted onto a single-symbol lookup.

The parser and its arena

The parser is hand-written recursive descent, about 1,500 lines, and every AST node is allocated out of an arena that the parse result carries with it. Nodes are never individually freed and the whole tree for a module goes away when its arena does.

The arena is a list of fixed 16 KB blocks with a bump pointer. When an allocation does not fit in the current block, the block reports that rather than growing, and the arena appends a fresh block and retries. This matters because the previous compiler's AST allocator was a single fixed-size buffer whose size was a magic number, and running past it was not a recoverable condition.

Where it stopped

Twenty commits in, with the front end built and the type checker not yet started, work on this compiler ended in June 2026. The formal specification and the reference implementation in Zig began the same month.