From 2ecfac75617bd786e9b647ff75ceb96da394220f Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:38:55 +0700 Subject: [PATCH] A page to point someone at, so the language is readable before it is installed Everything here is checked against the compiler rather than against plan.org: the design documents describe a language larger than the one that runs, and a page that documented the plan would mislead the first person to try it. --- web/index.html | 1198 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1198 insertions(+) create mode 100644 web/index.html diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..62d0adf --- /dev/null +++ b/web/index.html @@ -0,0 +1,1198 @@ + + + + + +Flan + + + + +
+ +
+ + flan + + +

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.

+ + + +

What Flan is

+ +

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:

+ + + +

Getting started

+ +

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 (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.

+ +

Values and memory

+ +

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:

+ + + +

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:

+ +
(set x v)              ; a local or a defvar
+(set (.field x) v)     ; x may be a struct or a (Ptr S)
+(set (at a i ...) v)   ; a fixed array or a slice element
+(set (deref p) v)      ; a whole-object store through a pointer
+ +

.field and at dereference exactly one pointer level, which +is why (set (.pos c) …) is legal when c is a +(Ptr Cursor).

+ +

Bounds are checked

+ +

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

+ +

Types are annotated at function boundaries and inferred everywhere else. Every type +notation reads as exactly one data item.

+ +
+ + + + + + + + + + + + + + +
NotationMeaningLayout
i8i64, u8u64machine integers, wrapping arithmeticthe obvious one
f32, f64floatsfloat, double
booli1
stringa byte slice with no NULptr + len
[T]slice, non-owningptr + len
[n T]fixed array, a valuen inline items
(Ptr T)raw pointera pointer
(Option T)Some / Nonetag byte + T
a structvalue typefields in declaration order
an enumits own type in the checkeri32
Unitone value, zero sizeempty
Neverfits anywhere; nothing has itempty
+
+ +

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.

+ +

Structs and enums

+ +

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 []
+  (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.

+ +

Functions

+ +

(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
+(defvar grid [rows [cols u32]])        ; BSS, rows*cols*4 bytes
+ +

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.

+ +

Control flow

+ +

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.

+ +

Option, 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
+ +

defer

+ +

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)
+ +

Arrays and slices

+ +

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

+ +

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.

+ +
+ + + + + + + + +
GroupNames
outputprint-str, print-bytes, print-i64, print-f64, print-line, newline
slices of i32swap-i32!, reverse-i32!, sort-i32!, index-of-i32, min-i32, max-i32, sum-i32
bytesbytes=?, starts-with?, ends-with?, index-of-byte, index-of-bytes, trim, digit?, space?
parsingparse-i64, parse-f64
numberssign-f32, lerp, floor-f32, ceil-f32, round-f32, sqrt-f32
randomrand-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.

+ +

Packages

+ +

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
+(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:

+ + + +

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.

+ +

Conditions and restarts

+ +

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 ...)
+
+(restart-case BODY          ; BODY and every clause have the same type
+  (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.

+ +

How it is lowered, and why that matters

+ +

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:

+ + + +

Gotchas

+ + + +

The break loop

+ +

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
+ +

The FFI

+ +

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 draw-texture [t 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.

+ +

The dev loop

+ +

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.

+ +

How it works

+ +

Four pieces, each of which can be run on its own.

+ +

The reload primitive. llcld -shared → +dlopen → call. Measured in this codebase:

+ +
+ + + + + + +
StepCost
emitting the redefinition's IRbelow the timer (<0.1ms)
llc -O2 -filetype=obj15–17ms
ld -shared3ms
dlopen + dlsym0.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.

+ +
@"flan.cell.bump" = global ptr @"flan.bump"        ; the host defines it
+%p = load ptr, ptr @"flan.cell.bump"               ; every call site
+%r = call i64 %p()
+ +

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: +three calls, a listener thread, and a single-producer ring.

+ +
(agent/start path)   ; listen on a unix socket; once, at startup
+(agent/poll)         ; install whatever has arrived; returns how many
+(agent/wait ms)      ; the same, but waits for something first
+ +

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.

+ +

What a running process cannot be told

+ +

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:

+ +
+ + + + + + + +
ChangeWhat it would have broken
a function's signaturea cell is a bare pointer; every call site compiled before the change still passes the old arguments through it
a global's typethe storage exists and has a shape — reuse reads at the wrong offsets, replacement discards the state the reload exists to preserve
a struct's fieldsthe values the process is holding have the old layout
a defconst the checker consumedit 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.

+ +

Evaluating an expression

+ +

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})})
+(slice (.tags b) 0 3)     [ 0 42 0]
+(rl/get-color 0x11223344) (rl/Color {:r 17 :g 34 :b 51 :a 68})
+ +

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

+ +

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.

+ +
+ + + + + + + + + + + + + +
KeyDoes
C-c C-cthe top-level form at point, recompiled and installed
C-c C-kthe whole buffer, as one module
C-x C-ethe expression before point, evaluated in the running program
C-c C-z / C-c C-qconnect (finds .flan-dev.sock upward) / disconnect
C-c C-othe running program's own output, in *flan-output*
C-c C-ra prompt on the running program (*flan-repl*)
C-c C-bwhat a stopped program is offering, and which to take
C-c C-dwhat the running program currently defines
C-c C-vhelp on the name at point
C-c C-xrebuild, 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.

+ +

Targets and builds

+ +

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.

+ +

Not implemented yet

+ +

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 writeThe compiler says
(Vec T)(Vec T) is not implemented yet — milestone 6
(Map K V)(Map K V) is not implemented yet — milestone 6
(Result T E)(Result T E) is not implemented yet — milestone 6
(Handle T)(Handle T) is not implemented yet — milestone 6
(try …)try (Result) is not implemented yet — milestone 6
a union typethe union type Shape is not implemented yet — milestone 6
(Fn [T] R)a function type is not implemented yet — milestone 5
(fn [x i32] …)calling something other than a named function is not implemented yet — milestone 5
a type variablegeneric code over the type variable a is not implemented yet — milestone 5
'syma quoted symbol (restart names) is not implemented yet — milestone 6
(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-casehandler-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.

+ +

Further reading

+ +

The repository's own documents, in the order they are worth reading:

+ + + +
+

Flan is a custard. This page describes the compiler on branch + dev-loop; where a document and the compiler disagree, the compiler is what + is written here.

+
+ +
+ + + +