Skip to content

Hello! 👋 My name is Nathan Weir. This is a fun personal project for using AI to build a bespoke, domain-specific programming language. It is not a serious, professional project. This site and the language itself are largely generated via Claude Code. If you find yourself programming with Weir, have fun - but use at your own risk!

Memory Model

Weir uses a hybrid memory model: tracing garbage collection for most code, with opt-in arena allocation for performance-critical paths.

Most code allocates freely with no memory management burden — preserving the Lisp feel. The GC is designed for game development from the start.

;; Normal code — GC managed, no memory thinking
(defn load-level ((path : String)) : Level
(let ((data (read-file path))
(entities (parse-entities data)))
(Level entities)))

The current implementation uses mark-and-sweep, stop-the-world collection. Future improvements planned:

FeatureStatusPurpose
Mark-and-sweepImplementedBasic tracing collection
Shadow stackImplementedRoot discovery for JIT-compiled code
Incremental/concurrentFutureReduce pause times
GenerationalFutureOptimize for short-lived allocations
SuppressibleFuturePrevent GC during critical sections
Manually triggerableFutureFull GC during loading screens
Game loop awareFutureCollect between frames

The JIT codegen uses a shadow stack to track GC roots. At function entry and let bindings, heap-pointer values are stored into dedicated stack slots whose addresses are pushed onto the shadow stack. The GC reads these slot addresses to find live objects.

For performance-critical code, the developer explicitly opts into arena allocation via with-arena blocks. Arenas use bump allocation (a pointer increment — near-zero cost) and free all contents at once when the block ends.

;; Hot path — arena allocated, no GC pauses
(defn update-physics ((world : World)) : Unit
(with-arena frame
(let ((contacts (detect-collisions world))
(impulses (solve-constraints contacts)))
(apply-impulses world impulses))))
;; everything allocated in the arena is freed instantly at block end

Arena-allocated values cannot escape their with-arena block. The compiler enforces this via lexical escape analysis:

This is a purely syntactic check — not a full lifetime system like Rust:

  • Cannot return an arena-allocated value from the with-arena block
  • Cannot assign an arena value to an outer mutable variable via set!
  • Can pass arena values to functions called within the block
  • Can read from arena values and use them in computations

Several permanent design properties of Weir make the lexical analysis sufficient:

  • No mutable references — function arguments are passed by value (copying the pointer). The callee cannot modify the caller’s bindings.
  • No global mutable state — no top-level mutable variables to stash arena pointers in.
  • Conservative call tagging — any function call returning a heap type inside an arena block gets arena provenance, preventing escape even if it allocated from the GC.
ConcernHow Addressed
Lisp feelGC by default — 90%+ of code just allocates freely
Game dev performanceArenas for hot paths — zero GC pauses where it matters
SafetyGC prevents use-after-free; arena escapes caught at compile time
Live reloadingGC handles stale instances naturally; arenas are transient and unaffected
Guard railsSafe by default (GC), explicit opt-in for performance (arenas)

The type system tracks arena provenance to enforce escape prevention:

  • Arena-scoped values carry implicit provenance annotations (no developer-visible region types)
  • Generic functions work over both GC and arena-allocated values transparently
  • A closure that captures an arena value must itself be arena-scoped

Heap objects (structs, closures, ADT payloads, vectors) are GC-managed:

  • Allocated via weir_gc_alloc(shape, size)
  • Fields stored as consecutive i64 slots (uniform slot size)
  • Each object has a ShapeDesc with a pointer_mask marking which slots contain heap pointers
  • The GC traces through pointer slots to find live references