A statically typed Lisp for game development. Clojure's brackets, C's memory and value model, no garbage collector.
Flan compiles s-expressions to LLVM IR and then to a native binary. There are no object headers, so a Flan struct is exactly its C struct. There is no collector, so nothing runs between frames that you did not write. And a running program can be edited: a function recompiled in Emacs is installed into the live process at its next frame boundary, in about twenty milliseconds.
This page describes the compiler as it is, not as it is planned. Where something is designed but not built, it says so and gives the message the compiler prints. Every Flan program below was run.
A minimal Lisp for games. In one line: Odin with a Lisp frontend and a live REPL.
Types are mandatory and inference makes them feel optional; memory is manual; the
frontend is OCaml, the backend writes LLVM IR as text and hands it to
clang.
What it is not:
format, no lazy seqs, no persistent collections, no JVM.You need OCaml with dune, and a clang on PATH. Build the
compiler, then run something:
$ dune build
$ ./_build/default/bin/main.exe run calc-me.flan "1 + 2 * (3 - 0.5) / 2"
3.5
Call that binary flan. Its subcommands:
$ flan
usage: flan (read|parse|check|emit|shim) <file.flan>...
flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] [--target=wasm32-wasi]
flan run <file.flan> [args...]
flan reload <program.flan> <forms.flan> [-o out.so]
flan dev <program.flan> [-s socket]
read, parse, check, emit and
shim each stop the pipeline one stage further along and print what it
produced, which is how you find out what the compiler thinks of a form.
run builds to a temporary file and execs it.
The smallest program:
(defn main []
(print-line "hello from flan"))
The entry point is (defn main [args [string]] i32). Both the parameter
and the return type are optional: omitting args means the program ignores
argv, and omitting the return type means Unit and an exit status of 0.
There is no garbage collector and no hidden allocation. Every local is a stack slot; reading one is a load, assigning to one is a store, and a store of an aggregate is the copy. A struct is its C layout with nothing added.
Four rules carry most of the model:
(zeroed) re-zeroes something
later — a memset, not an allocation.[n T] is inline storage
and copies on assignment and on pass-by-value.[T] is ptr+len and owns nothing.
Copying a slice copies the view, never the elements.(addr x) takes the address of
any assignable place and gives (Ptr T). It does not extend anything's
lifetime, and keeping one past its frame is your contract to honour — there is no
borrow checker.That last point is what makes a heap unnecessary for a great deal of code: a value struct is shared mutably by passing its address down the call chain.
Places — the forms set accepts — are a fixed list, not an extensible
setf:
(defstruct Enemy [hp i32 name string])
(defvar spawned i32)
(defconst room-size 4)
(defvar room [room-size i32])
;; `set` takes a fixed list of forms, not an extensible setf.
(defn main []
(let [e (Enemy {:hp 10 :name "slime"})
p (addr e)]
(set spawned (+ spawned 1)) ; a local or a defvar
(set (.hp e) 7) ; a struct field
(set (.hp p) 8) ; through a (Ptr Enemy) — derefs one level
(set (at room 2) 5) ; a fixed array or slice element
(set (deref p) (Enemy {:hp 3 :name "wisp"})) ; a whole-object store
(print-i64 (i64 (.hp e))) (newline)
(print-line (.name e))
(print-i64 (i64 (at room 2))) (newline)
(print-i64 (i64 spawned)) (newline)))
3
wisp
5
1
.field and at dereference exactly one pointer level, which
is why (set (.hp p) 8) above is legal when p is a
(Ptr Enemy). Note the last two stores: the whole-object store through
p overwrote e itself, so hp reads 3 and not 8.
at and slice emit a comparison and a branch to a cold
block that names the source location and stops. A literal index out of bounds is
rejected at compile time instead. Checks are on by default and are not tied to the
optimisation level; --no-bounds-checks turns them off. Measured cost on a
50-million-iteration dependency chain over a 1024-element array: 0.11–0.12s checked
against 0.12–0.13s unchecked.
Types are annotated at function boundaries and inferred everywhere else. Every type notation reads as exactly one data item.
| Notation | Meaning | Layout |
|---|---|---|
i8 … i64, u8 … u64 | machine integers, wrapping arithmetic | the obvious one |
f32, f64 | floats | float, double |
bool | i1 | |
string | a byte slice with no NUL | ptr + len |
[T] | slice, non-owning | ptr + len |
[n T] | fixed array, a value | n inline items |
(Ptr T) | raw pointer | a pointer |
(Option T) | Some / None | tag byte + T |
| a struct | value type | fields in declaration order |
| an enum | its own type in the checker | i32 |
Unit | one value, zero size | empty |
Never | fits anywhere; nothing has it | empty |
There is no implicit widening. Both operands of a binary operator have one type, and every conversion is written as a cast:
(defn main [] i32
(let [n 40 ; i32, inferred
big (i64 n) ; every widening is written
x 1.5] ; f64
(print-i64 (+ big 2)) (newline)
(print-f64 (* x 2.5)) (newline)
(print-i64 (i64 (bit-xor (<< 1 8) 255))) (newline)
0))
42
3.75
511
An untyped integer literal is i32 and an untyped float literal is
f64, so (defconst gravity f32 0.05) names the type when
something narrower is wanted. One caveat worth knowing before it surprises you: a
whole-numbered float prints without its fraction, so 3.0 comes out as
3.
Arithmetic wraps. Shifts are bounded two ways: a literal count at or past the
operand's width is a compile error, and a computed one is masked to the width, which
is what the hardware does anyway. >> is arithmetic on a signed type
and logical on an unsigned one.
An index converts from a narrower integer and never from a wider one. A
u32 index is fine — anything above 231 truncates to a negative
i32 and the unsigned bounds check rejects it. An i64 index is
refused, because 232+5 truncates to 5 and would read the wrong element with
no trap at all.
A defstruct is a list of inline name/type pairs. A struct literal names
its fields, and omitted fields are zeroed.
(defstruct Cursor
[src [u8] ; a non-owning slice
pos i32]) ; no initialiser means zeroed
(defn peek [c (Ptr Cursor)] u8
(if (< (.pos c) (len (.src c)))
(at (.src c) (.pos c))
0))
(defn advance [c (Ptr Cursor)]
(set (.pos c) (+ (.pos c) 1))) ; field access derefs one level
(defn main []
(let [c (Cursor {:src (bytes "hi")})] ; pos omitted, so pos is 0
(print-i64 (i64 (peek (addr c)))) (newline)
(advance (addr c))
(print-i64 (i64 (peek (addr c)))) (newline)))
104
105
An enum is an i32 at run time and its own type in the checker. That is
what makes a keyword at a call site useful: :space resolves against the
parameter's enum type at compile time, and a typo is an error there rather than a wrong
number later.
(defenum Key
[space 32 escape 256 left 263 right 262])
(defn key-name [k Key] string
(cond
(= k :space) "space"
(= k :escape) "escape"
:else "an arrow"))
(defn main []
;; :space resolves against the parameter's enum at compile time.
;; A typo is an error here, not a wrong number later.
(print-line (key-name :space))
(print-line (key-name :left)))
space
an arrow
A keyword means nothing where no enum is expected. There is no keyword type to fall back on, and there is no way to name a member other than as a keyword in a position that expects that enum.
Struct and enum names share one top-level namespace with functions, globals and type aliases. A second declaration of a name is rejected whatever kind either one is.
(defn name [param Type ...] ReturnType? body ...). The parameters are
inline name/type pairs, as in let and defstruct. An omitted
return type means Unit. There is no separate declare form for
a function with a body — declare is kept only where there is none.
Top-level names are order-independent within a package, so mutually recursive functions need no forward declaration. Globals come in two kinds:
(defconst cell-size 5) ; a compile-time constant
(defconst gravity f32 0.05) ; with its type named
(defvar current-color i32) ; zeroed storage
(defconst rows 3)
(defconst cols 4)
(defvar grid [rows [cols u32]]) ; BSS, rows*cols*4 bytes
(defn main []
(print-i64 (i64 cell-size)) (newline)
(print-f64 (f64 gravity)) (newline)
(print-i64 (i64 current-color)) (newline)
(print-i64 (i64 (at grid 2 3))) (newline))
5
0.05
0
0
A defconst the checker consumed — an array length, for instance — is
part of the shape of the program. One it did not is only ever bytes in memory, which
matters for reloading; see the dev loop.
let binds name/value pairs and takes no type annotation, so a constant
whose type matters is named at the top level rather than written inline.
if, when, unless, cond,
do, and, or, not,
while, until, dotimes, return,
match. and and or short-circuit.
:else is cond's catch-all.
(defconst nums [5 i32] [1 3 8 9 10])
(defn classify [n i32] string
(cond
(< n 0) "negative"
(= n 0) "zero"
:else "positive"))
(defn countdown [n i32]
(let [i n]
(while (> i 0)
(print-i64 (i64 i))
(print-str " ")
(set i (- i 1)))
(newline)))
(defn first-even [s [i32]] (Option i32)
(dotimes [i (len s)]
(when (= 0 (% (at s i) 2))
(return (Some (at s i)))))
None)
(defn main []
(print-line (classify -3))
(countdown 4)
(unless false
(print-line "unless runs when the test is false"))
(match (first-even (slice nums 0 (len nums)))
(Some n) (do (print-i64 (i64 n)) (newline))
None (print-line "none")))
negative
4 3 2 1
unless runs when the test is false
8
dotimes evaluates its bound once into a hidden slot before the loop, so
a body that changes it cannot change the trip count, and the loop variable is not
assignable.
Loops are imperative, with while, until and
return. There is no loop/recur.
match and some(Option T) is how absence is spelled: a lookup miss, an empty
collection, the end of a stream. match works on an Option and
on nothing else today. some unwraps Some and early-returns
None from the enclosing function, which is what keeps a recursive descent
parser readable.
(defconst nums [4 i32] [4 8 15 16])
;; `some` unwraps Some and early-returns None from *this* function.
(defn doubled-first [s [i32]] (Option i32)
(Some (* 2 (some (index-of-i32 s 15)))))
(defn main []
(match (doubled-first (slice nums 0 4))
(Some i) (do (print-i64 (i64 i)) (newline)) ; 4
None (print-line "not found"))
(match (index-of-i32 (slice nums 0 4) 99)
(Some i) (do (print-i64 (i64 i)) (newline))
None (print-line "not found")))
4
not found
A defer runs at function exit, innermost first. An explicit
return runs the ones registered above it — a defer written below a return
has not executed yet and must not fire.
(defn work [n i32] i32
(defer (print-line "second"))
(defer (print-line "first")) ; innermost-first at exit
(when (< n 0)
(return 0)) ; runs both defers above it
(print-line "body")
n)
(defn main []
(print-i64 (i64 (work 3)))
(newline))
body
first
second
3
defer is function-scoped and is rejected inside a
let, a loop or a branch, rather than accepted with surprising scope. Block
scoping it is real work and is not done:
defer must be a top-level form in a function body — block-scoped defer is not implemented yet (milestone 4)
at and nth are the same operation and take any number of
indices, so (at grid r c) indexes a two-dimensional fixed array directly.
len works on a fixed array, a slice or a string.
(slice s lo hi) takes a half-open range and never copies.
(defconst rows 3)
(defconst cols 4)
;; A fixed array is a value: inline storage, copies on assignment.
(defconst palette [4 u32] [0xE6B800FF 0x3B6E8CFF 0xA83232FF 0xCC6B1FFF])
;; No initialiser means all-bytes-zero, so this is BSS and costs nothing.
(defvar grid [rows [cols i32]])
(defn main []
(set (at grid 1 2) 7)
(print-i64 (i64 (at grid 1 2))) (newline) ; 7
(print-i64 (i64 (len palette))) (newline) ; 4
;; A slice is ptr+len and non-owning: it views the array, it does not copy it.
(let [row (slice (at grid 1) 0 cols)]
(set (at row 0) 5)
(print-i64 (i64 (at grid 1 0))) (newline) ; 5 — the same storage
(print-i64 (sum-i32 row)) (newline)) ; 12
;; (zeroed) is a memset, not an allocation.
(set grid (zeroed))
(print-i64 (i64 (at grid 1 2))) (newline)) ; 0
7
4
5
12
0
A reversed range — lo greater than hi — traps, rather than
yielding a huge unsigned length.
The prelude is written in Flan and prepended to every program, so nothing in it
needs importing. Printing is deliberately not a primitive:
write-stdout is the one output primitive and everything above it is
ordinary Flan.
| Group | Names |
|---|---|
| output | print-str, print-bytes, print-i64, print-f64, print-line, newline |
slices of i32 | swap-i32!, reverse-i32!, sort-i32!, index-of-i32, min-i32, max-i32, sum-i32 |
| bytes | bytes=?, starts-with?, ends-with?, index-of-byte, index-of-bytes, trim, digit?, space? |
| parsing | parse-i64, parse-f64 |
| numbers | sign-f32, lerp, floor-f32, ceil-f32, round-f32, sqrt-f32 |
| random | rand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range |
Two deliberate choices in there. The RNG is ours, not libc's —
PCG-XSH-RR 32, written in Flan — because a grid hash is only a regression test if the
sequence is byte-identical on native and on wasm32. And the parsers are ours
too: strtoll answers 0 for "", 0 for
"abc" and 12 for "12x", which are three wrong answers a caller
cannot tell from a real 12.
There is no println. There is no overloading yet, so each printer names
its type. The names are the compiler's answer too: (println 1) is
unknown function println.
The primitives underneath are few by design, because a primitive is the only thing
that gets implemented twice per backend: argv,
write-stdout, exit, len, at,
slice, bytes, bytes->f64,
bytes->i64, f64->bytes, i64->bytes,
addr, and arithmetic, comparison and casts.
The directory is the package. Every file in a directory shares one top-level scope; files within a package do not import each other, and their order does not matter. The package declaration is optional and the name is inferred from the directory, so a loose file in a scratch directory is a package of one with no manifest and no ceremony.
;; geom/vec.flan — no package declaration: the name comes from the directory.
(defstruct V2 [x f32 y f32])
(defn add [a V2 b V2] V2
(V2 {:x (+ (.x a) (.x b)) :y (+ (.y a) (.y b))}))
;; geom/len.flan — a second file in the same directory shares one top-level
;; scope: it does not import vec.flan, and the order of the two does not matter.
(defn length [v V2] f32
(sqrt-f32 (+ (* (.x v) (.x v)) (* (.y v) (.y v)))))
;; pkg.flan — the directory is the package, and everything it declares
;; arrives qualified by the alias this import chose.
(import g "geom")
(defn main []
(let [v (g/add (g/V2 {:x 3.0 :y 0.0})
(g/V2 {:x 0.0 :y 4.0}))]
(print-f64 (f64 (g/length v)))
(newline)))
5
There is one form and one meaning: (import alias "path"), and everything
from the package is qualified alias/name. There is no unqualified-import
mode, no :refer, no ns form and no per-file namespace object.
A path with no collection prefix is relative to the importing file;
vendor: and core: are collections, resolved by walking up from
the importing file until a directory of that name is found.
Importing is a rename. Every top-level name the package declares becomes
alias/name, and every use of one — in a type, in a body, in a struct
literal, in an array length — is rewritten to match. Nothing downstream knows a package
existed.
Three more rules that are easier to know than to discover:
.flan file named outright,
for the program that is also a library. sand.flan shares a directory with
three other loose programs, so naming its directory would import all four.rl/…. A directory is keyed by its real path and read once, which is also
what ends a cycle. The same directory under two different aliases is refused.main is not exported. A package carrying one would
collide with the importer's, and main is a reachability root, so an
imported one would keep everything it calls alive. Writing sand/main is
refused at the line that wrote it.Visibility is that one rule and no more: there is no package-private marker for
anything other than main yet.
A package may carry the C it binds to. Every .c file in the directory is
compiled into the build, and a file named link lists extra linker
arguments. Whether those reach the build is decided after checking, from the program
rather than from the import list: the compiler starts at main, follows every
call, and a package none of whose externs survive contributes no C and no linker
argument. That is what lets one file import raylib and still build for wasm32.
A condition is a struct. There is no class hierarchy; matching is by type. The signalling end says here is something notable, here is the data, and an outer caller decides what to do about it — or decides nothing, in which case the signaller carries on.
(signal c) ; Unit. Handler returns -> carry on. No handler -> no-op.
(error c) ; Never. Only a transfer gets past; else the program stops.
(handler-bind [(Type [c] body ...) ...] body ...) ; match by type, no hierarchy
(restart-case BODY ; BODY and every clause have the same type = the form's
(name [] CLAUSE) ...)
(invoke-restart 'name) ; Never. Innermost frame offering the name wins.
signal has type Unit, always. That is the accumulation
case, and it is worth having on its own because it alters no control flow:
(defstruct AssetMissing [id i32])
(defvar seen i64)
(defn load-all []
(signal (AssetMissing {:id 1})) ; Unit — the caller carries on
(signal (AssetMissing {:id 2})))
(defn main []
(load-all) ; no handler: a no-op
(print-i64 seen) (newline) ; 0
;; A handler that returns normally accumulates and lets the signaller run on.
(handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
(load-all))
(print-i64 seen) (newline)) ; 3
0
3
A handler that invokes a restart instead transfers control outward to the
restart-case that offers the name, and the clause's value becomes that
form's value. Every defer between the invoke and the target runs, innermost
first, before the clause body starts.
(defstruct AssetMissing [id i32])
(defvar cleanups i64)
(defn load [n i32] i32
(signal (AssetMissing {:id n}))
100)
(defn middle [n i32] i32
(defer (set cleanups (+ cleanups 1))) ; runs on the transfer too
(+ (load n) 1))
(defn fetch [n i32] i32
(restart-case (middle n) ; its value if nothing transfers
(use-placeholder [] -1)
(retry [] 7)))
(defn main []
(print-i64 (i64 (fetch 1))) (newline) ; 101 — nothing handled it
(handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))]
(print-i64 (i64 (fetch 2))) (newline)) ; -1
(print-i64 cleanups) (newline)) ; 2 — the defer ran both times
101
-1
2
Restart lookup walks the dynamic restart stack from innermost outward and takes the
first frame offering the name, so an inner restart-case shadows an outer
one for the duration of its body. That is what makes "restarts go at the resync point"
composable.
A transfer is not platform unwinding. Every Flan signature carries a transfer channel
— one pointer appended as an out-parameter — which invoke-restart writes
and every call site checks. A callee writes the target into its caller's slot; each
frame checks, runs its defers and returns early. The disassembly is the release one plus
a guard after each call.
Three consequences to know:
restart-case, so a retry repeats the side
effects after it. Put the restart-case where re-entry is safe.find-restart to test with yet.signal cannot hand a value back. Deliberate: the
alternative forces every signal site to declare a default value and a result type.return is refused inside a handler-bind or
restart-case body, and so is invoke-restart inside a
defer. In each case a bare exit would leave frames on the stack pointing
into a function that has gone.An error nothing handles does not kill the program. It stops on the
frame that erred, with nothing unwound, so the condition and every restart between there
and the top are still live:
flan: unhandled Missing — stopped, not dead.
restart: retry
restart: use-placeholder
From there you fix the function, install it, and take a restart — and because control
never left the erring frame, retry calls through the indirection cell and
reaches the new body. Installing while stopped is allowed: the rule against swapping a
function that is on the stack is about mid-frame consistency, and there is no frame in
progress here.
The break loop lives in vendor/agent, which is an optional package. A
program that does not import it leaves the hook null and stops the old way — the message
and an exit status of 134:
(defstruct Missing [id i32])
(defn load [n i32] i32
(restart-case
(do (error (Missing {:id n})) ; Never — only a transfer gets past
0)
(use-placeholder [] -1)
(retry [] 7)))
(defn main []
(print-i64 (i64 (load 1)))
(newline))
$ flan run boom.flan
unhandled Missing
$ echo $?
134
There are two declaration forms, and they are two forms rather than one because no structural rule could tell them apart.
declare names a C symbol in a signature Flan can already spell. Nothing
is generated; a Flan string crosses as ptr+len, exactly as it is stored.
(declare cos-f64 [x f64] f64 "cos")
(defn main []
(print-f64 (cos-f64 0.0)) (newline))
1
declare-c names the C library's own function in the C library's own
signature, and the compiler writes the wrapper. This is what raylib's package is made
of — one line per binding:
(declare-c unload-texture [texture Texture2D] "UnloadTexture")
(declare-c draw-texture
[texture Texture2D x i32 y i32 tint Color]
"DrawTexture")
The reason for the wrapper is that an aggregate's calling convention is not
part of its layout. On x86-64, clang gives raylib's own prototypes
<2 x float> for a returned Vector2, i32 for
a Color argument, and { i64, i64 } for a returned
Rectangle — none of which is the struct's own LLVM type, and arm64 and
wasm32 classify differently again. Reproducing that in the backend would be three
classifiers to write and keep correct forever, and a mistake would show up as a field
full of garbage rather than as a link error.
So the boundary has one wrapper per binding, each flattening the aggregates: a struct
returns through an out-pointer, a struct argument is passed by pointer, and clang
classifies all of it, per target, for free. flan shim <file> prints
the whole generated file:
(defstruct Vector2 [x f32 y f32])
(declare-c get-mouse-position [] Vector2 "GetMousePosition")
typedef struct flan_ty_Vector2_1bebc5ae_s flan_ty_Vector2_1bebc5ae;
struct flan_ty_Vector2_1bebc5ae_s { /* Vector2 */
float x;
float y;
};
/* get-mouse-position */
extern flan_ty_Vector2_1bebc5ae GetMousePosition(void);
void flan_shim_get_mouse_position_5ad0e205(flan_ty_Vector2_1bebc5ae *out) {
*out = GetMousePosition();
}
No library header is read, deliberately, so a build needs the shared library to be
linkable and not the -devel package to be installed. What follows from that
is what the generator can and cannot promise. Guaranteed: the C typedef
and the Flan struct come from the same defstruct, so they cannot disagree,
and clang type-checks the wrapper against the generated prototype.
Trusted: that the defstruct matches the library's real
struct, and that the declare-c signature is the function's real signature.
A scalar's width now carries ABI weight — f64 where the library says
float emits double, and the library reads garbage.
Everything the boundary cannot represent is refused by name with the reason, rather
than half-supported: an Option, a union, a fixed array, a map, a returned
string, a callback, a slice parameter, an unknown type, an unrepresentable struct field,
and two Flan names for one C symbol. An aggregate in a plain declare is
refused too, so the narrow boundary cannot quietly acquire one.
One known edge: a REPL redefinition that introduces a new
declare-c cannot work, because the reload path compiles no C and the wrapper
would not exist in the running process. Editing the body of a function that calls an
existing binding is unaffected.
This is the thesis of the project: edit the code, keep the sand.
$ flan dev sand.flan
That builds the program, launches it, holds a session beside it, and listens on
.flan-dev.sock next to the source. From Emacs, C-c C-c on a
function recompiles it and installs it into the running process at that process's next
frame boundary. The window does not blink and the grid does not reset.
Four pieces, each of which can be run on its own.
The reload primitive. llc → ld -shared →
dlopen → call. Measured in this codebase:
| Step | Cost |
|---|---|
| emitting the redefinition's IR | below the timer (<0.1ms) |
llc -O2 -filetype=obj | 15–17ms |
ld -shared | 3ms |
dlopen + dlsym | 0.04ms |
About 19ms end to end. The clang driver on the same IR is 50ms, which is
why the dev path never invokes it: the driver forks a second process and re-does
argument and target resolution, and codegen is not the cost.
Indirection cells. Loading a new body is not installing it. A call
bound at link time cannot be made to notice one, so a --dev build routes
every Flan-to-Flan call through a cell — a mutable global holding the address of the
function that is current.
Here is the whole of hello.flan through
flan emit --dev, which is the shortest thing that shows it:
@"flan.cell.print-line" = global ptr @"flan.print-line"
define {} @"flan.main"(ptr %xfer) {
entry:
%t1 = load ptr, ptr @"flan.cell.print-line"
%t2 = call {} %t1(%slice { ptr @".str.36", i64 15 }, ptr %xfer)
Two things are visible there at once. The call site loads the cell rather than naming
@"flan.print-line" directly, and the signature carries ptr %xfer
— the transfer channel from conditions, which every Flan
function has, release builds included.
Redefinition is then one store, below a microsecond, which is what makes a
frame-boundary swap a non-event. Three rules fall out of it. A redefinition module
declares every global external, so globals live in the host and survive a reload
— that is what "keep the sand" means. Every other function is a declare, so
a redefined settle calls the host's move-grain rather than
freezing a private copy of it. And nothing is ever dlclosed: a cell holds an
address inside a module's text, so unloading it would leave call sites pointing at
unmapped memory. Old code is never unloaded, which is also why a thread mid-execution
finishes safely in the old version.
The agent. vendor/agent is a package like any other: a
listener thread, a single-producer ring, and three calls. This is the whole of
agent.flan — no aggregate crosses the boundary, so a plain
declare does it and there is no shim.
(declare start-raw [path string] i32 "flan_agent_start")
(declare poll-raw [] i32 "flan_agent_poll")
(declare wait-raw [ms i32] i32 "flan_agent_wait")
(defn start [path string] i32 (start-raw path))
(defn poll [] i32 (poll-raw))
(defn wait [ms i32] i32 (wait-raw ms))
(agent/start path) listens on a unix socket, once, at startup.
(agent/poll) installs whatever has arrived and returns how many.
(agent/wait ms) is the same but waits for something first, which is what a
headless test uses so that a reload is deterministic rather than a race against the
frame rate.
The split between loading and installing is the design. dlopen relocates
a module and takes the loader lock — milliseconds, unbounded — so it happens on the
listener thread. Installing is one store per function and must not land while a redefined
function is on the stack, so it happens on the game thread, at the top of the frame, when
the program asks. A game loop calls agent/poll at the top of its frame and
ignores the result.
The session and the daemon. flan dev holds the
declarations the running process was built from plus every change accepted since, and it
owns the build — which is what makes its rules describe the process that is actually
running rather than a guess about it. Re-checking the whole program on every evaluation
costs under 10ms, less than the llc that follows, and it makes an evaluation
transactional for free: a form that fails to check mutates nothing.
The protocol is one s-expression per message, length-framed by a decimal byte count.
It is not nREPL: eval there is string-in/string-out with no slot for
which form, from which file, and once the editor client is ours too there is no
CIDER to be compatible with.
Some changes are refused with a reason rather than loaded, because the alternative is a silent mismatch against memory the process has already laid out:
| Change | What it would have broken |
|---|---|
| a function's signature | a cell is a bare pointer; every call site compiled before the change still passes the old arguments through it |
| a global's type | the storage exists and has a shape — reuse reads at the wrong offsets, replacement discards the state the reload exists to preserve |
| a struct's fields | the values the process is holding have the old layout |
a defconst the checker consumed | it is in the shape of the program — (defconst rows (/ h c)) decides grid's type before anything else resolves |
a defenum member | :space is erased to an i32 literal in the caller, so it is folded there too |
A defvar's initial value is deliberately not on that list:
refusing to change it would be refusing the whole point. And a defconst the
checker never consumed can be changed, which is how a colour table gets tuned live while
an array length stays refused — a dev build emits those as mutable globals so LLVM cannot
fold a read of one.
The signature row is a stopgap and the message should not be read as the final answer. The design is versioned functions with their own trampolines, so that new callers resolve the new version while existing ones keep the old; none of the three parts exists yet, and the alternative to refusing is not the new design, it is a silent argument mismatch.
C-x C-e is a different primitive from redefining a name. There is no name to install a body into, so the expression is wrapped in a thunk with nowhere to be called from; the module says run this once, and the agent calls it after the install, on the game thread, at a frame boundary — so an expression reading the program's state sees a point the program agrees is consistent.
Nothing is marshalled back, because nothing could be: a Flan value carries no header, so no code at run time can say what it is. The compiler knows the type and renders it there, in the thunk. What comes back looks like this:
big 18446744073709551615
col :blue
(.pos b) (V {:x 1.5 :y 0})
b (Blob {:id 7 :name "sandy \"quoted\"" :pos (V {:x 1.5 :y 0}) :tags [ 0 42 0]})
(slice (.tags b) 0 3) [ 0 42 0]
(rl/get-color 0x11223344) (rl/Color {:r 17 :g 34 :b 51 :a 68})
sim/grid [ [ 0 0 0 0 0 0 0 0 ...] [ 0 ... ] ...]
A pointer is never followed — it renders as <ptr> — because it is
the only thing that could make the walk cycle, and dereferencing one a REPL was handed is
not a safe thing to do on someone's behalf. The walk is bounded at depth 4 and 8 elements,
and the output truncates at 4K. Map, function values and type variables
refuse by name.
The thunk's module is unloaded afterwards, which is the one case where that is safe: nothing points into its text once it has returned. Sixteen expression evaluations retain zero mappings, where each redefinition retains three, permanently and correctly.
emacs/flan-mode.el derives from prog-mode with
lisp-mode's syntax table, so sexp motion, paren matching and indentation are
already right. It adds Flan's brackets — [ and { are brackets,
not symbol characters, since every binding list and every type is written with them — and
the characters a Flan name may contain. emacs/flan-dev.el is the client;
there is no parser in it, which is the point of the protocol choice.
| Key | Does |
|---|---|
| C-c C-c | the top-level form at point, recompiled and installed |
| C-c C-k | the whole buffer, as one module |
| C-x C-e | the expression before point, evaluated in the running program |
| C-c C-z / C-c C-q | connect (finds .flan-dev.sock upward) / disconnect |
| C-c C-o | the running program's own output, in *flan-output* |
| C-c C-r | a prompt on the running program (*flan-repl*) |
| C-c C-b | what a stopped program is offering, and which to take |
| C-c C-d | what the running program currently defines |
| C-c C-v | help on the name at point |
| C-c C-x | rebuild, relaunch and reconnect — the way out when a reload is refused |
| M-. / M-, | where a name is written, through an xref backend |
C-c C-k sends one module rather than a form at a time on purpose: a
defvar and the function that uses it have to arrive in the same load, or the
first refers to storage that does not exist yet.
eldoc, completion and M-. all read one cached reply rather than asking per
keystroke, refreshed at the two moments the answer can have changed: on connect, and after
an evaluation the daemon accepted. The modeline says whether there is a program on the
other end, and says stopped when there is one sitting in the break loop — a
stopped program looks exactly like a running one from anywhere else in Emacs.
An error comes back with a location and the client draws an overlay there, cleared the
next time that buffer's evaluation is accepted. The repl buffer is
comint-derived and every line goes through the same request C-x C-e
uses; it is program-scoped, so in sand you write sim/settle and not
settle.
Native x86-64 is the development target. --target=wasm32-wasi produces a
module, and the headless sand acceptance program prints the same 64-bit hash under it as
natively, byte for byte, at -O2 and at -O0. That is the
milestone the RNG is written in Flan for.
Dev and release builds are deliberately different. --dev means
indirection cells and -rdynamic, which is what exports the cells for a loaded
module to bind to; release builds call directly, emit constants as constants, and get all
the folding back. Dev builds are not pruned by reachability, because what a REPL may
redefine next is not a function of what has been called so far.
Some things are refused by name rather than half-supported:
--dev with a wasm target and flan run --target=, both because a
cross-built module is not something this host can dlopen or exec.
Build time for calc-me.flan is about 110ms, of which the frontend — read,
parse, load, check, emit — is under 10ms. Every C translation unit goes through an object
cache keyed by a digest of the source text, the compiler, the optimisation level and the
target flags, so it never needs invalidating by hand.
The house rule is that anything which binds a name, alters control flow, or is not yet implemented must be recognised explicitly and rejected. So these are not missing features you discover as a strange type error — each refuses by name, with the milestone it belongs to, and the tests assert on the reason.
| You write | The compiler says |
|---|---|
(Vec T) | (Vec T) is not implemented yet — milestone 6 (see plan.org) |
(Map K V) | (Map K V) is not implemented yet — milestone 6 (see plan.org) |
(Result T E) | (Result T E) is not implemented yet — milestone 6 (see plan.org) |
(Handle T) | (Handle T) is not implemented yet — milestone 6 (see plan.org) |
(try …) | try (Result) is not implemented yet — milestone 6 (see plan.org) |
| a union type | the union type Shape is not implemented yet — milestone 6 (see plan.org) |
(Fn [T] R) | a function type is not implemented yet — milestone 5 (see plan.org) |
(fn [x i32] …) | calling something other than a named function is not implemented yet — milestone 5 (see plan.org) |
| a type variable | generic code over the type variable a is not implemented yet — milestone 5 (see plan.org) |
'sym | a quoted symbol (restart names) is not implemented yet — milestone 6 (see plan.org) |
(defmacro …) | parses, but is not expanded: running a macro means compiling it and loading it into the compiler, which is not wired up yet |
`(a ~b) | is read, but not expanded: macro expansion is not wired up yet |
handler-case | handler-case is not implemented yet |
find-restart, compute-restarts | … is not implemented yet |
Beyond that list, and just as true: there is no allocator and no
context; there is no println and no overloading; restarts take
no parameters; match works on an Option and nothing else;
defer is function-scoped; there is no package-private marker other than
main not being exported; there are no threads in the language; and the
managed class facility that plan.org describes is a plan and not a feature.
The vocabulary in spec-memory.md is normative but largely unbuilt:
clone, as-slice, push, get,
put, resolve and allocator-aware operations belong to
Vec and Map, and arrive with them.
Two notes on what is settled, because their absence reads like an oversight.
There is no interpreter and there is not going to be one: the compiled
path is the only backend. The instrumentation-based step debugger that wanted one is cut,
and compiled redefinition at ~19ms is perceptually instant for expression evaluation too.
And the macro expander is blocked on unions rather than on itself — a
macro is a function from Form to Form, which needs
Form to exist as a Flan union value first.
The repository's own documents, in the order they are worth reading:
plan.org — the design, the build sequence, and the open decisions.NEXT.md — the project's memory, and the authority on what is actually
built.spec-memory.md — ownership, containers, places, generics, function
values. Frozen.spec-conditions.md — conditions and restarts, operational semantics.
Frozen.conditions.org — a cheatsheet for driving conditions.calc-me.flan, sand.flan, test/programs/ —
real programs that run.