Ori Compiler Design
What This Document Covers
This is the design documentation for the Ori compiler — a multi-crate Rust codebase that compiles a statically-typed, expression-based language with Hindley-Milner type inference, automatic reference counting, and capability-based effects. The compiler features a dual backend: a tree-walking interpreter for rapid development and a full LLVM pipeline for native binaries and WebAssembly.
These documents serve the same purpose as a compiler textbook’s case study chapters: they explain not just what each component does, but why it was designed that way, what alternatives were considered, and how the pieces fit together. Each section opens with the general compiler concept — what problem it solves, what the classical approaches are — then shows how Ori applies and adapts those ideas.
The Anatomy of a Compiler
A compiler is a program that translates source code from one language to another — typically from a high-level language humans write to a low-level form machines execute. Every compiler, regardless of language, must solve the same fundamental problems:
- Lexical analysis — Breaking source text into tokens (words, operators, punctuation)
- Parsing — Organizing tokens into a tree structure that reflects the program’s grammar
- Semantic analysis — Checking that the program makes sense (types match, variables are defined, etc.)
- Optimization — Transforming the program to run faster or use less memory without changing its meaning
- Code generation — Producing the target output (machine code, bytecode, another language)
What makes each compiler interesting is the specific tradeoffs it makes at each stage. A compiler for a dynamically-typed language might skip semantic analysis entirely. A JIT compiler might skip optimization in favor of startup speed. A research compiler might explore novel type systems at the cost of compilation time.
Ori’s compiler makes several distinctive choices across these stages that are worth understanding before diving into the details.
What Makes Ori’s Compiler Distinctive
Dual Backend with Shared Canonical IR
Most compilers have one backend. Ori has two: a tree-walking interpreter for ori run (instant feedback during development) and a full LLVM pipeline for ori build (native performance for production). Both consume the same canonical IR — a sugar-free, type-annotated intermediate representation produced by a single canonicalization pass. This means desugaring, pattern compilation, and constant folding happen exactly once, regardless of which backend executes the result.
Incremental Everything via Salsa
The compiler is built on the Salsa framework, which provides automatic incremental computation. Every phase — lexing, parsing, type checking, evaluation — is a Salsa query whose result is memoized. When a source file changes, only the affected queries re-execute. This matters for IDE integration: editing one function doesn’t re-type-check the entire module.
ARC Memory Management (Not GC, Not Borrow Checking)
Ori uses automatic reference counting with compile-time optimizations inspired by Perceus (Reinking et al., 2021) and Lean 4. The ARC system performs borrow inference, liveness analysis, RC insertion/elimination, and reset/reuse — all as compiler passes over a dedicated ARC IR. This sits between the simplicity of garbage collection (no borrow checker for users to fight) and the determinism of manual memory management (no GC pauses).
Expression-Based with No Return
Ori is expression-based in the ML/Rust tradition: every construct produces a value, and the last expression in a block is that block’s value. There is no return keyword — the language deliberately omits it, recognizing return only in the lexer to produce a helpful error for users coming from other languages.
| Construct | Value |
|---|---|
| Function body | Last expression is the return value |
if...then...else | Each branch is an expression |
match arms | Each arm is an expression |
{ ... } block | Last expression (without ;) is the value |
for...yield | Collected values form a list |
Early exit is handled through ? (propagate errors), break (exit loops), and panic (diverge with Never type).
Lean Core, Rich Libraries
The compiler implements only constructs that require special syntax or static analysis. Everything else — data transformation, string utilities, collection operations — belongs in the standard library as regular method calls.
| In Compiler | In Stdlib |
|---|---|
{ } blocks, try { }, match (bindings, early return) | map, filter, fold, find (collection methods) |
recurse (self-referential self()) | retry, validate (library functions) |
parallel, spawn, timeout (concurrency) | |
cache, with (capability-aware resources) |
The test is simple: “Does this need special syntax or static analysis?” If not, it’s a library function. This keeps the compiler focused and allows the stdlib to evolve without compiler changes.
Capability-Based Effects
Side effects are tracked through a capability system. Functions declare what they need (uses Http, FileSystem), and callers must provide those capabilities (with Http = MockHttp in expr). This enables compile-time effect tracking and trivial mocking in tests — no dependency injection frameworks required.
Compilation Pipeline
flowchart TB
A["Source File
Salsa input"]
B["Token List
tokens() query"]
C["Parse Result
Module + ExprArena"]
D["Typed Module
expr_types + errors"]
E["Canonical IR
CanArena + DecisionTrees"]
F["Eval Result
Value + EvalOutput"]
G["ARC IR
borrow + liveness + RC"]
H["LLVM IR
Native Binary"]
A --> B --> C --> D --> E
E --> F
E --> G --> H
classDef frontend fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
classDef canon fill:#3b1f6e,stroke:#a78bfa,color:#e9d5ff
classDef interpreter fill:#1a4731,stroke:#34d399,color:#d1fae5
classDef native fill:#5c3a1e,stroke:#f59e0b,color:#fef3c7
class A,B,C,D frontend
class E canon
class F interpreter
class G,H native
Each step is a Salsa query with automatic memoization. After canonicalization, the pipeline forks: the interpreter consumes canonical IR directly, while the ARC system lowers it to a basic-block SSA IR with explicit reference counting before LLVM codegen.
Compiler Architecture
The compiler is organized as a multi-crate Rust workspace. Dependencies flow strictly downward — later phases depend on earlier ones, never the reverse.
| Crate | Purpose |
|---|---|
ori_ir | Core IR types (AST, arena, interning, derives) — no dependencies |
ori_diagnostic | Error reporting, DiagnosticQueue, suggestions, emitters |
ori_lexer_core | Raw scanner, source buffer, token tags |
ori_lexer | Tokenization (cooking layer over core) |
ori_parse | Recursive descent Pratt parser |
ori_types | Type system: Pool, InferEngine, unification, registries |
ori_patterns | Pattern system, Value types, EvalError |
ori_canon | Canonical IR lowering (desugaring, pattern compilation, constant folding) |
ori_eval | Tree-walking interpreter components |
ori_arc | ARC analysis (borrow inference, RC insertion/elimination, reset/reuse) |
ori_llvm | LLVM backend for JIT and AOT compilation |
ori_rt | Runtime library for AOT binaries (C ABI, zero compiler deps) |
ori_fmt | Source code formatter (5-layer architecture) |
oric | CLI orchestrator, Salsa queries, reporting |
Documentation Sections
Pipeline Stages (in compilation order)
Architecture (Section 01)
- Architecture Overview - High-level compiler structure
- Compilation Pipeline - Query-based pipeline design
- Salsa Integration - Incremental compilation framework
- Data Flow - How data moves through the compiler
Intermediate Representation (Section 02)
- IR Overview - Data structures for compilation
- Flat AST - Arena-based expression storage
- Arena Allocation - Memory management strategy
- String Interning - Identifier deduplication
- Type Representation - Runtime type encoding
Lexer (Section 03)
- Lexer Overview - Tokenization design
- Token Design - Token types and structure
Parser (Section 04)
- Parser Overview - Parsing architecture
- Pratt Parser - Binding power table and operator precedence
- Error Recovery - ParseOutcome, TokenSet, synchronization
- Grammar Modules - Module organization and naming
- Incremental Parsing - IDE reuse of unchanged declarations
Type System (Section 05)
- Type System Overview - Type checking architecture
- Pool Architecture - SoA storage, interning, type construction
- Type Inference - Hindley-Milner inference
- Unification - Union-find, rank system, occurs check
- Type Environment - Scope-based type tracking
- Type Registry - User-defined types, traits, methods
Pattern System (Section 06)
- Pattern System Overview - Pattern architecture
- Pattern Trait - PatternDefinition interface
- Pattern Registry - Pattern lookup system
- Pattern Fusion - Optimization passes
- Adding Patterns - How to add new patterns
Canonicalization (Section 07)
- Canonicalization Overview - Canonical IR lowering architecture
- Desugaring - Syntactic sugar elimination
- Pattern Compilation - Decision tree construction
- Constant Folding - Compile-time evaluation
Evaluator (Section 08)
- Evaluator Overview - Interpretation architecture
- Tree Walking - Execution strategy
- Environment - Variable scoping
- Value System - Runtime value representation
- Module Loading - Import resolution
ARC System (Section 09)
- ARC System Overview - ARC pipeline overview, module structure
- ARC IR - IR definitions, type classification
- Lowering - CanExpr → ARC IR
- Borrow Inference - Global ownership inference
- Liveness - Backward dataflow liveness analysis
- RC Insertion - Perceus algorithm RC placement
- Reset/Reuse - In-place constructor reuse
- RC Elimination - Redundant RC pair removal
- Drop Descriptors - Per-type drop generation
- Decision Trees - Pattern compilation in ARC IR
LLVM Backend (Section 10)
- LLVM Backend Overview - JIT and AOT code generation architecture
- AOT Compilation - Native executable and WebAssembly generation
- Closures - Closure representation and calling conventions
- User-Defined Types - Struct types, impl blocks, method dispatch
- ARC Emitter - ARC IR → LLVM IR translation
- Builtins Codegen - Built-in function LLVM generation
- Codegen Verification - Audit pipeline and RC balance checking
Subsystem Sections
Runtime (Section 11)
- Runtime Overview -
ori_rtruntime library overview - Reference Counting - RC header layout and tracing
- Collections & COW - Copy-on-write collection semantics
- String SSO - Small string optimization
- Data Structures - List, map, set memory layouts
Formatter (Section 12)
- Formatter Overview - 5-layer formatting architecture
- Spacing - O(1) token spacing lookup
- Packing - Container single-line vs multi-line decisions
- Rules - Breaking rules and priority
Cross-Cutting Sections
Diagnostics (Section 13)
- Diagnostics Overview - Error reporting system
- Problem Types - Error categorization
- Code Fixes - Automatic fix suggestions
- Emitters - Output format handlers
Testing (Section 14)
- Testing Overview - Test system architecture
- Test Discovery - Finding test functions
- Test Runner - Parallel test execution
Platform Targets (Section 15)
- Platform Targets Overview - Native vs WASM compilation
- Conditional Compilation - Platform-specific code patterns
- WASM Target - WebAssembly considerations
- Recursion Limits - Stack safety implementation
Appendices
- Salsa Patterns - Common Salsa usage patterns
- Memory Management - Compiler-internal allocation strategies
- Error Codes - Complete error code reference
- Debugging - Debug flags and tracing
- Coding Guidelines - Code style, testing, best practices