Changelog

Mar 23, 2026

The Register VM

Loon v0.6 replaces the tree-walking interpreter with a register-based virtual machine. Programs compile to Evidence IR — a flat, typed intermediate representation — then execute on a NaN-boxed register VM. The result: 4x faster interpretation, with a Cranelift native backend that hits 370x on compute-heavy code.

Evidence IR

EIR is a register-based IR where every value lives in a virtual register. Functions lower to blocks of flat instructions — no recursion, no tree walks. The compiler resolves closures, pattern matching, and effect dispatch at compile time.

; source
[fn add [a b] [+ a b]]

; lowers to
;   r0 = param 0    (a)
;   r1 = param 1    (b)
;   r2 = add r0, r1
;   ret r2

NaN-boxing

Every value fits in 8 bytes. Floats are stored as-is. Integers, booleans, nil, and heap pointers are encoded in the NaN payload bits. No allocation for small values, no type tags to check — the IEEE 754 NaN space gives us 51 bits of payload for free.

; all of these are a single u64:
42          ; tagged integer
3.14        ; raw f64
true        ; tagged boolean
:hello      ; interned symbol index

Three backends

EIR compiles to three targets from the same IR:

  • Register VM — 4x faster than the tree-walker, portable, debuggable
  • WASM — runs in browsers and edge workers via wasm32 codegen
  • Cranelift — native x86-64/aarch64, up to 370x on numeric code
loon run fib.oo             ; VM (default)
loon run --native fib.oo    ; Cranelift
loon build --wasm fib.oo    ; WASM module

Trace recorder

The VM includes a trace recorder that captures hot loop iterations — instruction sequences, types, branch decisions. This is the foundation for a future tracing JIT: record a trace, type-specialize it, emit native code via Cranelift, guard on type assumptions.

loon run --trace fib.oo
; Trace 0: 12 instructions, 3 guards
;   loop_header -> int_add -> int_sub -> branch ...

Benchmarks

Release mode, Apple M4 Max. Shorter bars are faster.

fib(35) — exponential recursion
legacy
22.4s
VM
5.75s
native
0.06s
loop 1M — tight iteration
legacy
0.44s
VM
0.08s
native
0.006s
vec 100K — persistent vector build
legacy
0.14s
VM
0.03s

Status

All 14 sample programs pass. 465 tests. Three backends from one IR. The tree-walking interpreter remains available via --legacy.

Feb 25, 2026

Tail Call Optimization

Loon v0.5 adds three tiers of tail call optimization. Recursive patterns — the only way to loop in a functional language — now run in constant stack space. The interpreter used to overflow at ~200 recursive calls. Now it handles millions.

loop/recur

The new loop form binds locals and iterates. recur jumps back to the nearest loop with new values. Zero overhead — no function object, no closure, no stack frame.

[loop [i 0 sum 0]
  [if [>= i 1000000] sum
    [recur [+ i 1] [+ sum i]]]]

recur in fn

recur also works inside function bodies, targeting the enclosing fn. This gives you self-recursion without naming the function or growing the stack.

[fn factorial [n acc]
  [if [<= n 1] acc
    [recur [- n 1] [* acc n]]]]

[factorial 1000000 1]  ; constant stack space

General tail calls

Any function call in tail position — the last expression in if, match, do, when, or try — reuses the current stack frame via a trampoline. This means mutual recursion works at scale too.

[fn is-even [n]
  [if [= n 0] true [is-odd [- n 1]]]]

[fn is-odd [n]
  [if [= n 0] false [is-even [- n 1]]]]

[is-even 100000]  ; true, no stack overflow

Collections at scale

With TCO, the persistent data structures from v0.4.23 are finally unlocked. The benchmark suite now runs at 100,000 elements — 500x larger than before. Building a 100K-element vector via loop/recur takes under a second in release mode.

[fn build-vec [n]
  [loop [v #[] i 0]
    [if [>= i n] v
      [recur [conj v i] [+ i 1]]]]]

[build-vec 100000]  ; 0.7s release

What changed

One file changed: the interpreter's eval/call path. eval is completely untouched. A new eval_tail function mirrors eval but returns a Trampoline (Done, TailCall, or Recur) instead of a Value. call_fn runs a trampoline loop. loop/recur are new special forms. All 355 existing tests pass unchanged.

Feb 25, 2026

Persistent Data Structures

Loon's collections are now backed by persistent data structures via the imbl crate. Cloning a 10,000-element vector or map is now O(1) instead of O(n), and map lookup is O(log₃₂ n) instead of O(n). Every conj, assoc, filter, and map operation benefits from structural sharing.

What changed

Vec is now an RRB-tree vector (imbl::Vector), Map is a hash array mapped trie (imbl::HashMap), and Set is imbl::HashSet. The API is identical — existing Loon code runs without changes.

; These are now O(1) clone + O(log n) insert:
[let v [conj big-vector 42]]
[let m [assoc big-map :key val]]

; Map lookup is now O(1) instead of O(n):
[get big-map :key]

Performance improvements

The asymptotic wins are dramatic:

  • Clone: O(n) — O(1) (structural sharing)
  • Vec append/prepend: O(n) — O(log n)
  • Map lookup: O(n) — O(log₃₂ n) ≈ O(1)
  • Map insert/remove: O(n) — O(log₃₂ n)
  • Set membership: O(n) — O(log₃₂ n) ≈ O(1)

Hash and Eq for Value

Value now implements Hash and Eq, required for HashMap/HashSet keys. Floats hash via to_bits(), collections use commutative XOR for order-independent hashing, and non-hashable types (Fn, Builtin) use a sentinel. This also means sets and maps can contain any value type as keys.

Other fixes

sort now works correctly on keywords (previously keywords compared as equal). Tuple stays as a plain Vec since it's fixed-size and doesn't benefit from persistence.

Feb 25, 2026

Physics Type System

Loon now has compile-time dimensional analysis. The type checker rejects code that violates physical law, with zero runtime overhead. Just as Loon has no null, Loon now has no silent escape from the physics type world.

Literal unit suffixes

Write numbers with unit suffixes. They desugar to calls to the unit builtin at parse time.

[let d 100.0m]              ; Length
[let t 9.58s]               ; Time
[let v [/ d t]]             ; Velocity
[let f [* 80.0kg [/ d [* t t]]]]  ; Force

No dimensionless

When you divide meters by meters, you get Scalar — not Float. To get a raw Float, you must explicitly call magnitude. This is the same philosophy as no-null: once in the physics world, you stay until you explicitly leave.

[/ 10.0m 5.0m]        ; Scalar (NOT Float)
[magnitude 10.0m]     ; 10.0 : Float (explicit exit)
[scalar 2.0]          ; Scalar (explicit entry)

Physics-aware errors

Dimension mismatches produce E0208 errors with named quantities and operation hints.

error[E0208]: cannot add Length and Time
  1 | [+ distance duration]
    = hint: did you mean Velocity? try [/ a b]

Physics as an algebraic effect

Physical constants and material properties are effects — swap them by changing handlers. The new Physics effect provides gravity, yield-strength, elastic-modulus, density, temperature, and thermal-conductivity. The Sim effect provides stress, deflection, natural-freq, and thermal-field.

[handle [design-beam 10.0kN 5.0m]
  [Physics.yield-strength] [resume 250.0MPa]
  [Physics.gravity] [resume [unit 9.81 :m]]]

Dimensional polymorphism

Functions that multiply by a scalar are automatically polymorphic over dimensions.

[fn double [x] [* 2.0 x]]
[double 5.0m]   ; 10.0m  (Length)
[double 3.0N]   ; 6.0N   (Force)

21 named quantities recognized (Velocity, Force, Energy, Power, Pressure, etc.), 30+ units with SI prefixes, and 5 physics constants (Const.c, Const.G, Const.h, Const.k-B, Const.e-charge).

Feb 24, 2026

JSON & Interpolation

Four fixes that make Loon usable for real applications touching JSON data.

Dot access on string-keyed maps

map.field now tries string keys after keyword keys. Combined with IO.parse-json returning keyword keys by default, dot access works on parsed JSON without workarounds.

[let m [IO.parse-json "\{\"name\":\"cam\"\}"]]
m.name  ; — "cam"

Fix \n inside string interpolation

Escape sequences like \n inside {..} interpolation blocks were being converted too early, corrupting inner strings during re-parse. The unescape pass is now interpolation-aware: it converts \" to " for inner string delimiters but preserves \n, \t, and \\ verbatim for the inner parse.

[let items #["a" "b"]]
[str "list:\n{[join \"\n\" items]}"]  ; — "list:\na\nb"

keyword and keywordize-keys builtins

keyword converts a string to a keyword (the inverse of name). keywordize-keys converts all string keys in a map to keywords.

[keyword "hello"]       ; — :hello
[keywordize-keys m]     ; string keys — keyword keys

IO.parse-json returns keyword keys

JSON object keys are now keywords instead of strings, matching Loon's map literal convention {:key val}. The get builtin's fuzzy matching means [get m "key"] still works.

Feb 21, 2026

.oo

Loon source files now use the .oo extension. Shorter, distinctive, from the middle of l-oo-n. All tooling, samples, and new project scaffolding default to .oo. Existing .loon files continue to work everywhere as a fallback.

; create a new project
loon new my-app

; creates:
;   my-app/pkg.oo
;   my-app/src/main.oo

Module resolution, manifest loading (pkg.oo), and lockfiles (lock.oo) all try .oo first, then fall back to .loon. The tree-sitter grammar recognizes both extensions. No migration required — rename your files when you feel like it.

Feb 21, 2026

Transparent Wildcards

Match expressions with a wildcard arm now emit a compiler warning listing exactly which constructors the wildcard catches. This is W0100, the first warning code in Loon.

[type Shape [Circle f64] [Rect f64 f64] Point]

[match s
  [Circle r] "circle"
  _ "other"]

The compiler tells you:

W0100: _ catches 2 constructors of Shape: Rect, Point
  why: adding a variant to `Shape` will silently fall into this arm
  fix: add explicit arms for each constructor, or keep _ if intentional

The motivation: in most languages, a wildcard suppresses exhaustiveness checking entirely. If you add a new variant to an ADT, the compiler stays silent and your wildcard quietly swallows the new case. This has caused real bugs in production systems.

Loon's approach: the wildcard still works, but the compiler makes it transparent. You see exactly what it catches. When a new variant appears, the warning updates to include it, so you can decide whether the fallback is still correct.

The warning fires only on known ADTs. Matching on strings, ints, or polymorphic types produces no warning. Variable bindings (not just _) trigger the same check.

The LSP surfaces W0100 as a proper warning severity, so your editor shows it in yellow rather than red.

Feb 20, 2026

The Syntax Refresh

Loon v0.4.1 ships nine changes that make the language smaller and more expressive. Three remove syntax, six add features. Every change was motivated by real patterns in the Loon web app and sample programs.

Killing =>

The fat arrow was the only infix operator in a prefix language. Match arms are now positional pairs — pattern, then body. No separator.

; before
[match shape
  [Circle r] => [* 3.14 r r]
  [Rect w h] => [* w h]]

; after
[match shape
  [Circle r] [* 3.14 r r]
  [Rect w h] [* w h]]

Same for handle blocks. Every source file in the codebase got cleaner.

Killing /

Effect annotations used a / separator between params and effect set. Now the set literal sits directly after the params — no separator needed.

; before
[fn load [path] / #{IO Fail}
  [IO.read-file path]]

; after
[fn load [path] #{IO Fail}
  [IO.read-file path]]

Universal string interpolation

Every string now supports {expr} interpolation. No wrapper function needed. Use backslash-brace to write a literal brace.

; before
[str "Hello, " name "!"]

; after
"Hello, {name}!"

This eliminated roughly 40% of str calls across the codebase.

Type methods

The centerpiece feature. Define methods directly inside type variants. Loon generates dispatch functions automatically.

[type Shape
  [Circle f64
    [fn area [r] [* 3.14159 r r]]
    [fn describe [r] "circle r={r}"]]
  [Rect f64 f64
    [fn area [w h] [* w h]]
    [fn describe [w h] "{w}x{h}"]]
  Point
    [fn area [] 0.0]
    [fn describe [] "point"]]

[area [Circle 5.0]]        ; 78.539...
[describe [Rect 3.0 4.0]]  ; "3x4"

Types are open by default — a second [type Shape ...] adds new variants and extends the dispatch functions. This solves the expression problem with zero new keywords.

Record types and dot access

ADT variants can have named fields. Dot syntax reads them.

[type Route
  [Route :path String :handler Fn]]

[let r [Route {path: "/tour", handler: tour-page}]]
[r.path]     ; "/tour"
[r.handler]  ; tour-page

Match guards

A lowercase function call in pattern position evaluates as a boolean guard. This turns nested if-chains into flat match tables.

[match true
  [= w ""]                   ""
  [contains? special w]       "keyword"
  [contains? builtins w]      "builtin"
  _                           ""]

Map destructuring with defaults

Function params can destructure maps with default values. This eliminates five lines of boilerplate per component.

; before
[fn card [first & rest]
  [let props [if [map? first] first {}]]
  [let children [if [map? first] rest [cons first rest]]]
  [let accent [get props :accent false]]
  ...]

; after
[fn card [{accent false, class ""} & children]
  [div {:class [cx "card" class]} children]]

And the rest

The dom/ namespace is now dom. for consistency. Implicit do was already working — we just verified it. Every sample program, the entire web app, and all 306 tests pass with the new syntax.

Feb 1, 2026

Hello, World — Introducing Loon

Today we're sharing Loon with the world. It started as a question: what if a LISP had invisible types, Rust-style ownership without annotations, and algebraic effects instead of exceptions?

Loon is the answer. It's a v0.1 — the interpreter works, type inference works, the effect system works. There's a long road to v1.0, but the foundations are solid.

And this website? It's written entirely in Loon. The Rust interpreter compiles to WebAssembly, evaluates Loon source code in your browser, and manipulates the DOM through a thin bridge. It's Loon all the way down.

What works today

  • Tail call optimization (loop/recur, trampoline)
  • Persistent data structures (imbl)
  • Full interpreter with 50+ builtins
  • Hindley-Milner type inference
  • Ownership checking (inferred)
  • Algebraic effects with handle/resume
  • Pattern matching with guards
  • Pipe operator with thread-last semantics
  • Module system with selective imports
  • REPL with time travel and forking
  • Basic WASM codegen

What's next

Traits, a more complete effect system, and better error messages are the immediate priorities.