A bytecode compiler and stack-based VM for a custom programming language, written in Rust from scratch — no parser generators, no existing VMs, no GC libraries.
fun make_counter() -> Fun() -> Int {
var count: Int = 0;
return fun() -> Int {
count = count + 1;
return count;
};
}
fun main() -> Unit {
val c = make_counter();
print("{c()}"); // 1
print("{c()}"); // 2
print("{c()}"); // 3
}
source text
│
▼
Lexer token stream, interpolated strings resolved
│
▼
Pratt Parser AST with line/col error spans
│
▼
Compiler bytecode + constant pool, upvalue descriptors
│
▼
Stack VM call frames, open→closed upvalues, mark-sweep GC
- Lexical scoping with
val(immutable) andvar(mutable) - First-class functions and closures with correct upvalue capture
- Coroutines —
coro/yield/resumewith O(1) stack-swap context switching - Modules —
module/import/exportwith compile-time visibility enforcement - Tail-call optimisation — explicit
return f(...)in tail position, no stack growth - String interpolation —
"hello {name}, you are {age} years old" - Mark-sweep GC with stress mode (collect on every allocation)
cargo build --releaseRun a program:
./target/release/pyre examples/hello.pyreFlags:
| Flag | Effect |
|---|---|
--dis |
Print disassembled bytecode and exit |
--trace |
Print each instruction + stack to stderr before executing |
--gc-stress |
Collect garbage before every allocation |
REPL (no file argument):
./target/release/pyrebash tests/run_tests.shExpected output is embedded as comments in each test file — no separate fixture files:
// expected output:
// Hello, Pyre!
fun main() -> Unit {
print("Hello, Pyre!");
}
The interpreter compiles to WebAssembly via wasm-pack:
wasm-pack build --target web --out-dir playground/pkg --features wasm
cd playground && python -m http.server 8080Open http://localhost:8080. Six built-in examples, Ctrl+Enter to run.
// math.pyre
module math;
export fun square(n: Int) -> Int { return n * n; }
export fun abs(n: Int) -> Int { return n * sign(n); }
fun sign(n: Int) -> Int { // private — compile error if accessed from outside
if n > 0 { return 1; }
if n < 0 { return -1; }
return 0;
}
// main.pyre
import math;
fun main() -> Unit {
print("{math.square(5)}"); // 25
print("{math.abs(-3)}"); // 3
// math.sign(-1) // compile error: 'sign' is private to module 'math'
}
fun make_range(lo: Int, hi: Int) -> Coro() -> Int {
return coro() -> Int {
var i: Int = lo;
while i < hi { yield i; i = i + 1; }
};
}
fun main() -> Unit {
val r = make_range(0, 3);
print("{resume r}"); // 0
print("{resume r}"); // 1
print("{resume r}"); // 2
}
Context switching is O(1) — resume and yield swap stack ownership via std::mem::swap, no copying.
| Benchmark | CPython | Pyre | |
|---|---|---|---|
fib(30) × 100k |
55ms | 370ms | 6.7× slower |
sum(1..1000) × 10k |
193ms | 521ms | 2.7× slower |
| closure counter × 500k | 38ms | 90ms | 2.4× slower |
Pyre is slower because both are interpreters — the gap is CPython's adaptive specialization (integer ops become direct C after ~8 executions) vs Pyre's generic opcode dispatch. Closing it would require specialized opcodes or a JIT.
Pratt parser over grammar tables — operator precedence and associativity fall out of binding-power numbers assigned per token. Adding a new operator is one line.
Stack VM over register VM — simpler code generation: every expression result goes on the stack, no register allocation needed. Costs more instructions per expression but keeps the compiler straightforward.
Open → closed upvalues — captured variables start as stack slots (open). When the enclosing scope exits, they are promoted to heap-allocated cells (closed). Multiple closures sharing one variable all point to the same cell.
std::mem::swap coroutines — each coroutine owns its stack and frames in a GcCoroutine struct. resume swaps the VM's active context with the coroutine's saved context in O(1). No copying, no OS threads, no async executor.
Compile-time module visibility — the compiler builds an export table per module and rejects private-function access before any bytecode is emitted, not at runtime.