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 @@ + + +
+ + +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 (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:
(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).
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 []
+ (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
+(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.
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
+(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 ...)
+
+(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.
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 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.
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.
@"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.
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})})
+(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/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 |
(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 type | the 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 variable | generic code over the type variable a is not implemented yet — milestone 5 |
'sym | a 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-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.