From 2ecfac75617bd786e9b647ff75ceb96da394220f Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:38:55 +0700 Subject: [PATCH 01/11] 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.

+
+ +
+ + + + From dfd64d89ea8f62ee03b48bbd29026f37a37bafce Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:40:03 +0700 Subject: [PATCH 02/11] The examples are files that run, not prose, so the page cannot drift from them Copying a snippet into HTML is where a documented language stops being the real one. Each block on the page is a program here with its recorded output beside it, and check.sh is what says the page is still true after a change. --- web/examples/arrays.flan | 23 +++++++++++++++++++++++ web/examples/arrays.out | 6 ++++++ web/examples/boom.flan | 12 ++++++++++++ web/examples/boom.out | 2 ++ web/examples/check.sh | 37 +++++++++++++++++++++++++++++++++++++ web/examples/conds.flan | 16 ++++++++++++++++ web/examples/conds.out | 3 +++ web/examples/control.flan | 30 ++++++++++++++++++++++++++++++ web/examples/control.out | 5 +++++ web/examples/defer.flan | 11 +++++++++++ web/examples/defer.out | 5 +++++ web/examples/enums.flan | 14 ++++++++++++++ web/examples/enums.out | 3 +++ web/examples/ffi.flan | 6 ++++++ web/examples/ffi.out | 2 ++ web/examples/geom/len.flan | 4 ++++ web/examples/geom/vec.flan | 5 +++++ web/examples/hello.flan | 2 ++ web/examples/hello.out | 2 ++ web/examples/numbers.flan | 8 ++++++++ web/examples/numbers.out | 4 ++++ web/examples/option.flan | 13 +++++++++++++ web/examples/option.out | 3 +++ web/examples/pkg.flan | 8 ++++++++ web/examples/pkg.out | 2 ++ web/examples/restart.flan | 24 ++++++++++++++++++++++++ web/examples/restart.out | 4 ++++ web/examples/shimdemo.flan | 7 +++++++ web/examples/structs.flan | 17 +++++++++++++++++ web/examples/structs.out | 3 +++ 30 files changed, 281 insertions(+) create mode 100644 web/examples/arrays.flan create mode 100644 web/examples/arrays.out create mode 100644 web/examples/boom.flan create mode 100644 web/examples/boom.out create mode 100644 web/examples/check.sh create mode 100644 web/examples/conds.flan create mode 100644 web/examples/conds.out create mode 100644 web/examples/control.flan create mode 100644 web/examples/control.out create mode 100644 web/examples/defer.flan create mode 100644 web/examples/defer.out create mode 100644 web/examples/enums.flan create mode 100644 web/examples/enums.out create mode 100644 web/examples/ffi.flan create mode 100644 web/examples/ffi.out create mode 100644 web/examples/geom/len.flan create mode 100644 web/examples/geom/vec.flan create mode 100644 web/examples/hello.flan create mode 100644 web/examples/hello.out create mode 100644 web/examples/numbers.flan create mode 100644 web/examples/numbers.out create mode 100644 web/examples/option.flan create mode 100644 web/examples/option.out create mode 100644 web/examples/pkg.flan create mode 100644 web/examples/pkg.out create mode 100644 web/examples/restart.flan create mode 100644 web/examples/restart.out create mode 100644 web/examples/shimdemo.flan create mode 100644 web/examples/structs.flan create mode 100644 web/examples/structs.out diff --git a/web/examples/arrays.flan b/web/examples/arrays.flan new file mode 100644 index 0000000..336972f --- /dev/null +++ b/web/examples/arrays.flan @@ -0,0 +1,23 @@ +(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 diff --git a/web/examples/arrays.out b/web/examples/arrays.out new file mode 100644 index 0000000..fbc2cae --- /dev/null +++ b/web/examples/arrays.out @@ -0,0 +1,6 @@ +7 +4 +5 +12 +0 +exit 0 diff --git a/web/examples/boom.flan b/web/examples/boom.flan new file mode 100644 index 0000000..f4dcbd8 --- /dev/null +++ b/web/examples/boom.flan @@ -0,0 +1,12 @@ +(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)) diff --git a/web/examples/boom.out b/web/examples/boom.out new file mode 100644 index 0000000..a1914b5 --- /dev/null +++ b/web/examples/boom.out @@ -0,0 +1,2 @@ +unhandled Missing +exit 134 diff --git a/web/examples/check.sh b/web/examples/check.sh new file mode 100644 index 0000000..8e78563 --- /dev/null +++ b/web/examples/check.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Every Flan program shown on index.html is in this directory, and this script +# runs all of them and compares what they print against the .out file beside +# them. An example that does not compile is worse than no example, so the page +# quotes only what this script has been green on. +# +# $ dune build && sh web/examples/check.sh +# +# FLAN overrides the compiler; the default is the one dune just built. +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +FLAN=${FLAN:-$root/_build/default/bin/main.exe} + +cd "$here" || exit 1 +fail=0 +for f in *.flan; do + # shimdemo.flan calls raylib, so it is not run. What it demonstrates is the + # C the compiler writes, which is checked below by generating that instead. + [ "$f" = shimdemo.flan ] && continue + got=$( { "$FLAN" run "$f"; echo "exit $?"; } 2>&1 ) + if [ "$got" = "$(cat "${f%.flan}.out")" ]; then + echo "ok $f" + else + echo "FAIL $f" + printf '%s\n' "$got" | diff -u "${f%.flan}.out" - || true + fail=1 + fi +done + +if "$FLAN" shim shimdemo.flan | grep -q 'GetMousePosition(void)'; then + echo "ok shimdemo.flan (flan shim)" +else + echo "FAIL shimdemo.flan (flan shim)" + fail=1 +fi + +exit $fail diff --git a/web/examples/conds.flan b/web/examples/conds.flan new file mode 100644 index 0000000..0f49b99 --- /dev/null +++ b/web/examples/conds.flan @@ -0,0 +1,16 @@ +(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 diff --git a/web/examples/conds.out b/web/examples/conds.out new file mode 100644 index 0000000..5e34bdb --- /dev/null +++ b/web/examples/conds.out @@ -0,0 +1,3 @@ +0 +3 +exit 0 diff --git a/web/examples/control.flan b/web/examples/control.flan new file mode 100644 index 0000000..56b4b6c --- /dev/null +++ b/web/examples/control.flan @@ -0,0 +1,30 @@ +(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"))) diff --git a/web/examples/control.out b/web/examples/control.out new file mode 100644 index 0000000..8650d0f --- /dev/null +++ b/web/examples/control.out @@ -0,0 +1,5 @@ +negative +4 3 2 1 +unless runs when the test is false +8 +exit 0 diff --git a/web/examples/defer.flan b/web/examples/defer.flan new file mode 100644 index 0000000..57cf9cf --- /dev/null +++ b/web/examples/defer.flan @@ -0,0 +1,11 @@ +(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)) diff --git a/web/examples/defer.out b/web/examples/defer.out new file mode 100644 index 0000000..18217b4 --- /dev/null +++ b/web/examples/defer.out @@ -0,0 +1,5 @@ +body +first +second +3 +exit 0 diff --git a/web/examples/enums.flan b/web/examples/enums.flan new file mode 100644 index 0000000..9745ea9 --- /dev/null +++ b/web/examples/enums.flan @@ -0,0 +1,14 @@ +(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))) diff --git a/web/examples/enums.out b/web/examples/enums.out new file mode 100644 index 0000000..f9cf7fe --- /dev/null +++ b/web/examples/enums.out @@ -0,0 +1,3 @@ +space +an arrow +exit 0 diff --git a/web/examples/ffi.flan b/web/examples/ffi.flan new file mode 100644 index 0000000..57c2d7a --- /dev/null +++ b/web/examples/ffi.flan @@ -0,0 +1,6 @@ +;; A plain `declare` names a C symbol in a signature Flan can already spell: +;; no aggregate crosses, so no wrapper is generated. +(declare cos-f64 [x f64] f64 "cos") + +(defn main [] + (print-f64 (cos-f64 0.0)) (newline)) diff --git a/web/examples/ffi.out b/web/examples/ffi.out new file mode 100644 index 0000000..bed8d1b --- /dev/null +++ b/web/examples/ffi.out @@ -0,0 +1,2 @@ +1 +exit 0 diff --git a/web/examples/geom/len.flan b/web/examples/geom/len.flan new file mode 100644 index 0000000..50d577e --- /dev/null +++ b/web/examples/geom/len.flan @@ -0,0 +1,4 @@ +;; A second file in the same directory shares one top-level scope: it does not +;; import vec.flan, and the order of the two files does not matter. +(defn length [v V2] f32 + (sqrt-f32 (+ (* (.x v) (.x v)) (* (.y v) (.y v))))) diff --git a/web/examples/geom/vec.flan b/web/examples/geom/vec.flan new file mode 100644 index 0000000..5f5c019 --- /dev/null +++ b/web/examples/geom/vec.flan @@ -0,0 +1,5 @@ +;; 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))})) diff --git a/web/examples/hello.flan b/web/examples/hello.flan new file mode 100644 index 0000000..118a283 --- /dev/null +++ b/web/examples/hello.flan @@ -0,0 +1,2 @@ +(defn main [] + (print-line "hello from flan")) diff --git a/web/examples/hello.out b/web/examples/hello.out new file mode 100644 index 0000000..4632956 --- /dev/null +++ b/web/examples/hello.out @@ -0,0 +1,2 @@ +hello from flan +exit 0 diff --git a/web/examples/numbers.flan b/web/examples/numbers.flan new file mode 100644 index 0000000..14c58e2 --- /dev/null +++ b/web/examples/numbers.flan @@ -0,0 +1,8 @@ +(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)) diff --git a/web/examples/numbers.out b/web/examples/numbers.out new file mode 100644 index 0000000..87b87d3 --- /dev/null +++ b/web/examples/numbers.out @@ -0,0 +1,4 @@ +42 +3.75 +511 +exit 0 diff --git a/web/examples/option.flan b/web/examples/option.flan new file mode 100644 index 0000000..b0db48f --- /dev/null +++ b/web/examples/option.flan @@ -0,0 +1,13 @@ +(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"))) diff --git a/web/examples/option.out b/web/examples/option.out new file mode 100644 index 0000000..ec95506 --- /dev/null +++ b/web/examples/option.out @@ -0,0 +1,3 @@ +4 +not found +exit 0 diff --git a/web/examples/pkg.flan b/web/examples/pkg.flan new file mode 100644 index 0000000..d2a5986 --- /dev/null +++ b/web/examples/pkg.flan @@ -0,0 +1,8 @@ +;; The directory is the package. Everything it declares arrives qualified. +(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))) diff --git a/web/examples/pkg.out b/web/examples/pkg.out new file mode 100644 index 0000000..db7e0b9 --- /dev/null +++ b/web/examples/pkg.out @@ -0,0 +1,2 @@ +5 +exit 0 diff --git a/web/examples/restart.flan b/web/examples/restart.flan new file mode 100644 index 0000000..5a54698 --- /dev/null +++ b/web/examples/restart.flan @@ -0,0 +1,24 @@ +(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 diff --git a/web/examples/restart.out b/web/examples/restart.out new file mode 100644 index 0000000..8f9fc1b --- /dev/null +++ b/web/examples/restart.out @@ -0,0 +1,4 @@ +101 +-1 +2 +exit 0 diff --git a/web/examples/shimdemo.flan b/web/examples/shimdemo.flan new file mode 100644 index 0000000..4be3119 --- /dev/null +++ b/web/examples/shimdemo.flan @@ -0,0 +1,7 @@ +(defstruct Vector2 [x f32 y f32]) + +(declare-c get-mouse-position [] Vector2 "GetMousePosition") + +(defn main [] + (print-f64 (f64 (.x (get-mouse-position)))) + (newline)) diff --git a/web/examples/structs.flan b/web/examples/structs.flan new file mode 100644 index 0000000..e24d6dd --- /dev/null +++ b/web/examples/structs.flan @@ -0,0 +1,17 @@ +(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))) diff --git a/web/examples/structs.out b/web/examples/structs.out new file mode 100644 index 0000000..5a91d35 --- /dev/null +++ b/web/examples/structs.out @@ -0,0 +1,3 @@ +104 +105 +exit 0 From 86ef55743311021f3814125689a3269894cb8877 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:43:24 +0700 Subject: [PATCH 03/11] Run the break loop rather than quote it, since the restart order is a claim NEXT.md prints the banner with the restarts in source order; the walk is innermost-first, so it is the other way round. A --dev build under timeout is enough to settle that, and settles the two place and global snippets with it. --- web/examples/breakdemo.flan | 15 +++++++++++++ web/examples/breakdemo.out | 4 ++++ web/examples/check.sh | 43 +++++++++++++++++++++++++++---------- web/examples/geom/len.flan | 4 ++-- web/examples/geom/vec.flan | 2 +- web/examples/globals.flan | 12 +++++++++++ web/examples/globals.out | 5 +++++ web/examples/pkg.flan | 3 ++- web/examples/places.flan | 20 +++++++++++++++++ web/examples/places.out | 5 +++++ 10 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 web/examples/breakdemo.flan create mode 100644 web/examples/breakdemo.out create mode 100644 web/examples/globals.flan create mode 100644 web/examples/globals.out create mode 100644 web/examples/places.flan create mode 100644 web/examples/places.out diff --git a/web/examples/breakdemo.flan b/web/examples/breakdemo.flan new file mode 100644 index 0000000..6afd53c --- /dev/null +++ b/web/examples/breakdemo.flan @@ -0,0 +1,15 @@ +(import agent "vendor:agent") + +(defstruct Missing [id i32]) + +(defn load [n i32] i32 + (restart-case + (do (error (Missing {:id n})) + 0) + (use-placeholder [] -1) + (retry [] 7))) + +(defn main [] + (agent/start "/tmp/flan-breakdemo.sock") + (print-i64 (i64 (load 1))) + (newline)) diff --git a/web/examples/breakdemo.out b/web/examples/breakdemo.out new file mode 100644 index 0000000..4383858 --- /dev/null +++ b/web/examples/breakdemo.out @@ -0,0 +1,4 @@ + +flan: unhandled Missing — stopped, not dead. + restart: retry + restart: use-placeholder diff --git a/web/examples/check.sh b/web/examples/check.sh index 8e78563..7811669 100644 --- a/web/examples/check.sh +++ b/web/examples/check.sh @@ -10,28 +10,49 @@ here=$(cd "$(dirname "$0")" && pwd) root=$(cd "$here/../.." && pwd) FLAN=${FLAN:-$root/_build/default/bin/main.exe} +tmp=${TMPDIR:-/tmp}/flan-web-check.$$ cd "$here" || exit 1 fail=0 +ok() { echo "ok $1"; } +bad() { echo "FAIL $1"; fail=1; } + for f in *.flan; do - # shimdemo.flan calls raylib, so it is not run. What it demonstrates is the - # C the compiler writes, which is checked below by generating that instead. - [ "$f" = shimdemo.flan ] && continue + # Two programs are not run by `flan run`; each is checked its own way below. + [ "$f" = shimdemo.flan ] && continue # calls raylib + [ "$f" = breakdemo.flan ] && continue # stops and waits, on purpose got=$( { "$FLAN" run "$f"; echo "exit $?"; } 2>&1 ) - if [ "$got" = "$(cat "${f%.flan}.out")" ]; then - echo "ok $f" - else - echo "FAIL $f" + if [ "$got" = "$(cat "${f%.flan}.out")" ]; then ok "$f"; else + bad "$f" printf '%s\n' "$got" | diff -u "${f%.flan}.out" - || true - fail=1 fi done +# shimdemo.flan is the declare-c example: what it demonstrates is the C the +# compiler writes, so it is checked by generating that rather than by running. if "$FLAN" shim shimdemo.flan | grep -q 'GetMousePosition(void)'; then - echo "ok shimdemo.flan (flan shim)" + ok "shimdemo.flan (flan shim)" else - echo "FAIL shimdemo.flan (flan shim)" - fail=1 + bad "shimdemo.flan (flan shim)" +fi + +# breakdemo.flan is the break loop: an unhandled error stops the program and +# waits for someone to pick a restart, so it never exits on its own. It needs +# a --dev build (the hook lives in vendor/agent) and it is killed after a few +# seconds; what is checked is the banner it printed before it stopped. +if command -v timeout >/dev/null 2>&1; then + if "$FLAN" build breakdemo.flan --dev -o "$tmp" >/dev/null 2>&1; then + got=$(timeout 5 "$tmp" 2>&1) + rm -f "$tmp" + if [ "$got" = "$(cat breakdemo.out)" ]; then ok "breakdemo.flan (break loop)"; else + bad "breakdemo.flan (break loop)" + printf '%s\n' "$got" | diff -u breakdemo.out - || true + fi + else + bad "breakdemo.flan (build --dev)" + fi +else + echo "skip breakdemo.flan (no timeout(1))" fi exit $fail diff --git a/web/examples/geom/len.flan b/web/examples/geom/len.flan index 50d577e..8ca23cc 100644 --- a/web/examples/geom/len.flan +++ b/web/examples/geom/len.flan @@ -1,4 +1,4 @@ -;; A second file in the same directory shares one top-level scope: it does not -;; import vec.flan, and the order of the two files does not matter. +;; 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))))) diff --git a/web/examples/geom/vec.flan b/web/examples/geom/vec.flan index 5f5c019..956560c 100644 --- a/web/examples/geom/vec.flan +++ b/web/examples/geom/vec.flan @@ -1,4 +1,4 @@ -;; No package declaration: the name comes from the directory. +;; 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 diff --git a/web/examples/globals.flan b/web/examples/globals.flan new file mode 100644 index 0000000..495be74 --- /dev/null +++ b/web/examples/globals.flan @@ -0,0 +1,12 @@ +(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)) diff --git a/web/examples/globals.out b/web/examples/globals.out new file mode 100644 index 0000000..7c27aec --- /dev/null +++ b/web/examples/globals.out @@ -0,0 +1,5 @@ +5 +0.05 +0 +0 +exit 0 diff --git a/web/examples/pkg.flan b/web/examples/pkg.flan index d2a5986..3a62201 100644 --- a/web/examples/pkg.flan +++ b/web/examples/pkg.flan @@ -1,4 +1,5 @@ -;; The directory is the package. Everything it declares arrives qualified. +;; pkg.flan — the directory is the package, and everything it declares +;; arrives qualified by the alias this import chose. (import g "geom") (defn main [] diff --git a/web/examples/places.flan b/web/examples/places.flan new file mode 100644 index 0000000..03f3878 --- /dev/null +++ b/web/examples/places.flan @@ -0,0 +1,20 @@ +(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))) diff --git a/web/examples/places.out b/web/examples/places.out new file mode 100644 index 0000000..4444933 --- /dev/null +++ b/web/examples/places.out @@ -0,0 +1,5 @@ +3 +wisp +5 +1 +exit 0 From a5e0c012246d08567c85e23c02a3ef1f0860008c Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:47:09 +0700 Subject: [PATCH 04/11] Check the quoted blocks too, since a paraphrase reads exactly like a quotation The blocks that are not programs were the ones that had drifted: the usage text had lost its indentation and the refusal table had trimmed "(see plan.org)" off every message, so the page was showing wording the compiler does not print. --- web/examples/quotes.sh | 62 +++++++++++++++++ web/index.html | 146 ++++++++++++++++++++++++++++++----------- 2 files changed, 168 insertions(+), 40 deletions(-) create mode 100644 web/examples/quotes.sh diff --git a/web/examples/quotes.sh b/web/examples/quotes.sh new file mode 100644 index 0000000..a9eaaa8 --- /dev/null +++ b/web/examples/quotes.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# The blocks on index.html that are not programs in this directory are quoted +# from somewhere: a repository file, or the output of a command. Each one is +# re-derived here and looked for in the page, so a quote cannot go stale +# quietly. check.sh covers the runnable blocks; this covers the rest. +# +# $ dune build && sh web/examples/quotes.sh +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +FLAN=${FLAN:-$root/_build/default/bin/main.exe} +page=$here/../index.html +fail=0 + +# Look for a literal string in the page, allowing for HTML escaping of < and >. +want() { + esc=$(printf '%s' "$2" | sed -e 's/&/\&/g' -e 's//\>/g') + if grep -qF -- "$esc" "$page"; then echo "ok $1"; else + echo "FAIL $1" + echo " not on the page: $2" + fail=1 + fi +} + +# The usage text, from the binary itself. +want "flan usage" "$("$FLAN" 2>&1 | sed -n 2p)" + +# calc-me, the first acceptance program, still answers what the page says. +want "calc-me" "$("$FLAN" run "$root/calc-me.flan" '1 + 2 * (3 - 0.5) / 2')" + +# The refusal messages quoted in "Not implemented yet" and under defer. +for pair in \ + 'vec:(defvar xs (Vec i32))' \ + 'map:(defvar m (Map string i32))' \ + 'result:(defn f [] (Result i32 i32) None)' \ + 'handle:(defvar h (Handle i32))' \ + 'fnty:(defn f [g (Fn [i32] i32)] i32 (g 1))' \ + 'quoted:(defn f [] i32 (quote a))' \ + 'deferblock:(defn f [] i32 (let [x 1] (defer (print-line "a")) x))' +do + name=${pair%%:*}; src=${pair#*:} + printf '%s\n' "$src" > "$here/.q.flan" + msg=$("$FLAN" check "$here/.q.flan" 2>&1 | sed 's/^[^ ]*: //') + want "refusal: $name" "$msg" +done +rm -f "$here/.q.flan" + +# The cell and the transfer channel, from a real --dev emit of hello.flan. +ir=$("$FLAN" emit --dev "$here/hello.flan" 2>/dev/null) +want "cell global" "$(printf '%s\n' "$ir" | grep '^@"flan.cell.print-line"')" +want "cell load" "$(printf '%s\n' "$ir" | grep 'load ptr, ptr @"flan.cell.print-line"')" +want "xfer channel" "$(printf '%s\n' "$ir" | grep 'define {} @"flan.main"')" + +# The generated C, from flan shim. +want "shim wrapper" "$("$FLAN" shim "$here/shimdemo.flan" | grep 'GetMousePosition();')" + +# Lines quoted verbatim from repository files. +want "raylib binding" "$(grep -F 'unload-texture' "$root/vendor/raylib/raylib.flan")" +want "agent declare" "$(grep -F 'flan_agent_poll' "$root/vendor/agent/agent.flan" | head -1)" +want "conditions.org" "$(grep -F 'Innermost frame offering the name wins' "$root/conditions.org")" +want "renderer" "$(grep -F ':r 17 :g 34 :b 51 :a 68' "$root/NEXT.md")" + +exit $fail diff --git a/web/index.html b/web/index.html index 62d0adf..2ecbe34 100644 --- a/web/index.html +++ b/web/index.html @@ -211,11 +211,12 @@ $ ./_build/default/bin/main.exe run calc-me.flan "1 + 2 * (3 - 0.5) / 2"

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]
+
$ 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 @@ -260,14 +261,36 @@ 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
+
(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 (.pos c) …) is legal when c is a -(Ptr Cursor).

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

Bounds are checked

@@ -375,6 +398,8 @@ number later.

: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))) @@ -401,7 +426,20 @@ 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
+(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 @@ -450,7 +488,7 @@ whose type matters is named at the top level rather than written inline.

None (print-line "none")))
negative
-4 3 2 1
+4 3 2 1 
 unless runs when the test is false
 8
@@ -513,8 +551,7 @@ second 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)
+
defer must be a top-level form in a function body — block-scoped defer is not implemented yet (milestone 4)

Arrays and slices

@@ -612,7 +649,8 @@ and no ceremony.

(defn length [v V2] f32 (sqrt-f32 (+ (* (.x v) (.x v)) (* (.y v) (.y v))))) -
;; pkg.flan
+
;; pkg.flan — the directory is the package, and everything it declares
+;; arrives qualified by the alias this import chose.
 (import g "geom")
 
 (defn main []
@@ -668,12 +706,12 @@ signalling end says here is something notable, here is the data, and an
 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.
+
(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 ...)
+(handler-bind [(Type [c] body ...) ...] body ...)     ; match by type, no hierarchy
 
-(restart-case BODY          ; BODY and every clause have the same type
+(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.
@@ -841,7 +879,11 @@ is generated; a Flan string crosses as ptr+len, exactly as it is stored.

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")
+
(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 @@ -932,9 +974,20 @@ bound at link time cannot be made to notice one, so a --dev build r 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()
+

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 @@ -946,12 +999,24 @@ address inside a module's text, so unloading it would leave call sites pointing 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.

+

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.

-
(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
+
(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 @@ -1014,9 +1079,10 @@ 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})})
+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})
+(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 @@ -1102,16 +1168,16 @@ to, and the tests assert on the reason.

- - - - - - - - - - + + + + + + + + + + From 1d206518cf289df5e281f23b28f9419267e441c5 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:48:30 +0700 Subject: [PATCH 05/11] Colour the primitive type names too, since only the capitalised ones showed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule was "capitalised is a type", which leaves i32 and string looking like ordinary names in the one position — a signature — where the reader is there to see the types. --- web/index.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index 2ecbe34..df57f6a 100644 --- a/web/index.html +++ b/web/index.html @@ -1237,6 +1237,8 @@ macro is a function from Form to Form, which needs "declare declare-c import package let if when unless cond do and or not while " + "until dotimes match set return some try defer signal error handler-bind " + "restart-case invoke-restart fn quote defmacro gensym").split(" ")); + // Capitalised names are types; these are the ones that are not capitalised. + var TYPES = new Set(("i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 bool string").split(" ")); var TOKEN = /(;[^\n]*)|("(?:\\.|[^"\\])*")|(\\[A-Za-z0-9]+)|(:[A-Za-z][\w?!*<>=+-]*)|(\b0[xX][0-9a-fA-F]+\b|\b\d[\d.]*\b)|([A-Za-z][\w*?!<>=./+-]*)/g; function esc(s) { return s.replace(/&/g, "&").replace(//g, ">"); @@ -1253,7 +1255,8 @@ macro is a function from Form to Form, which needs if (num) return '' + m + ""; if (word) { if (FORMS.has(word)) return '' + m + ""; - if (/^[A-Z]/.test(word)) return '' + m + ""; + if (TYPES.has(word) || /^[A-Z]/.test(word)) + return '' + m + ""; } return m; }); From fc47489802ec66ceb4f007e248997b25b2e42f6e Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:49:41 +0700 Subject: [PATCH 06/11] Show the bounds check failing, because "checked" without a message says little The claim worth making is not that there is a check but that a failure names the line, and the only way to show that is to trip one. --- web/examples/bounds.flan | 10 ++++++++++ web/examples/bounds.out | 3 +++ web/index.html | 24 +++++++++++++++++++++--- 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 web/examples/bounds.flan create mode 100644 web/examples/bounds.out diff --git a/web/examples/bounds.flan b/web/examples/bounds.flan new file mode 100644 index 0000000..762b186 --- /dev/null +++ b/web/examples/bounds.flan @@ -0,0 +1,10 @@ +(defconst xs [3 i32] [1 2 3]) + +;; (at xs 7) with a literal index does not reach the backend at all: check.ml +;; rejects it. This one goes through a local, so it is the runtime check that +;; catches it — the same message, and the program stops where it happened. +(defn main [] + (let [i 7] + (print-line "before") + (print-i64 (i64 (at xs i))) + (print-line "unreachable"))) diff --git a/web/examples/bounds.out b/web/examples/bounds.out new file mode 100644 index 0000000..fd24439 --- /dev/null +++ b/web/examples/bounds.out @@ -0,0 +1,3 @@ +before +bounds.flan:9:28: index 7 is out of bounds for length 3 +exit 134 diff --git a/web/index.html b/web/index.html index df57f6a..82a7e9d 100644 --- a/web/index.html +++ b/web/index.html @@ -294,10 +294,28 @@ is why (set (.hp p) 8) above is legal when p is a

Bounds are checked

+
(defconst xs [3 i32] [1 2 3])
+
+;; (at xs 7) with a literal index does not reach the backend at all: check.ml
+;; rejects it. This one goes through a local, so it is the runtime check that
+;; catches it — the same message, and the program stops where it happened.
+(defn main []
+  (let [i 7]
+    (print-line "before")
+    (print-i64 (i64 (at xs i)))
+    (print-line "unreachable")))
+ +
$ flan run bounds.flan
+before
+bounds.flan:9:28: index 7 is out of bounds for length 3
+$ echo $?
+134
+

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 +block that names the source location and stops. Checks are on by default and are not +tied to the optimisation level, which is what lets the acceptance table run the same +programs at -O0 and -O2 with identical checks; +--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.

From 6c34d4a66e26dd9aa52f8a7829ae34308ba1b8b2 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:50:48 +0700 Subject: [PATCH 07/11] Quote the compiler's own words for the index rule, and pin the Emacs keys The paraphrase of why a wide index is refused was shorter and said less than the message; and the keybinding table came from NEXT.md, which is two keys behind flan-mode.el, so it now reads the keymap instead. --- web/examples/quotes.sh | 31 +++++++++++++++++++++---------- web/index.html | 7 +++++-- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/web/examples/quotes.sh b/web/examples/quotes.sh index a9eaaa8..d7d26a5 100644 --- a/web/examples/quotes.sh +++ b/web/examples/quotes.sh @@ -11,14 +11,16 @@ FLAN=${FLAN:-$root/_build/default/bin/main.exe} page=$here/../index.html fail=0 -# Look for a literal string in the page, allowing for HTML escaping of < and >. +# The page is compared with its whitespace collapsed, so that a long compiler +# message may be wrapped in the HTML and still be recognised as the same text. +flat=$(sed -e 's/<//g' -e 's/&/\&/g' "$page" | tr '\n' ' ' | tr -s ' ') + want() { - esc=$(printf '%s' "$2" | sed -e 's/&/\&/g' -e 's//\>/g') - if grep -qF -- "$esc" "$page"; then echo "ok $1"; else - echo "FAIL $1" - echo " not on the page: $2" - fail=1 - fi + needle=$(printf '%s' "$2" | tr '\n' ' ' | tr -s ' ') + case $flat in + *"$needle"*) echo "ok $1" ;; + *) echo "FAIL $1"; echo " not on the page: $2"; fail=1 ;; + esac } # The usage text, from the binary itself. @@ -27,7 +29,8 @@ want "flan usage" "$("$FLAN" 2>&1 | sed -n 2p)" # calc-me, the first acceptance program, still answers what the page says. want "calc-me" "$("$FLAN" run "$root/calc-me.flan" '1 + 2 * (3 - 0.5) / 2')" -# The refusal messages quoted in "Not implemented yet" and under defer. +# The refusal and diagnostic messages quoted in the prose and in the +# "Not implemented yet" table. for pair in \ 'vec:(defvar xs (Vec i32))' \ 'map:(defvar m (Map string i32))' \ @@ -35,12 +38,13 @@ for pair in \ 'handle:(defvar h (Handle i32))' \ 'fnty:(defn f [g (Fn [i32] i32)] i32 (g 1))' \ 'quoted:(defn f [] i32 (quote a))' \ - 'deferblock:(defn f [] i32 (let [x 1] (defer (print-line "a")) x))' + 'deferblock:(defn f [] i32 (let [x 1] (defer (print-line "a")) x))' \ + 'i64index:(defconst xs [3 i32] [1 2 3]) (defn main [] i32 (let [i (i64 1)] (at xs i)))' do name=${pair%%:*}; src=${pair#*:} printf '%s\n' "$src" > "$here/.q.flan" msg=$("$FLAN" check "$here/.q.flan" 2>&1 | sed 's/^[^ ]*: //') - want "refusal: $name" "$msg" + want "message: $name" "$msg" done rm -f "$here/.q.flan" @@ -59,4 +63,11 @@ want "agent declare" "$(grep -F 'flan_agent_poll' "$root/vendor/agent/agent.fla want "conditions.org" "$(grep -F 'Innermost frame offering the name wins' "$root/conditions.org")" want "renderer" "$(grep -F ':r 17 :g 34 :b 51 :a 68' "$root/NEXT.md")" +# The Emacs bindings, from the keymap rather than from any prose about it. +for k in "C-c C-c" "C-c C-k" "C-x C-e" "C-c C-b" "C-c C-v" "C-c C-x"; do + grep -qF "(kbd \"$k\")" "$root/emacs/flan-mode.el" || { + echo "FAIL keybinding $k is not in flan-mode.el"; fail=1; continue; } + want "keybinding $k" "$k" +done + exit $fail diff --git a/web/index.html b/web/index.html index 82a7e9d..79b2343 100644 --- a/web/index.html +++ b/web/index.html @@ -372,8 +372,11 @@ 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.

+refused, and the message is worth reading because it is the shape of most of them:

+ +
an index is an i32, and i64 is wider — write (i32 …), because a value that does
+not fit truncates to one that does and would read the wrong element without
+tripping the bounds check

Structs and enums

From 23601f382d54e83403f0d7e8c3d7b74f3d988bc5 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:51:53 +0700 Subject: [PATCH 08/11] Say that break and continue do not exist, since a loop section implies them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan.org settled the loop story as "while/for with break/continue and return", so a reader will reach for them; they are not implemented and, unlike the rest, not refused by name either — they come back as unknown function. --- web/index.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index 79b2343..5a2682c 100644 --- a/web/index.html +++ b/web/index.html @@ -404,6 +404,12 @@ its fields, and omitted fields are zeroed.

104
 105
+

Those are the bytes h and i. There is no character type — a +byte is a u8 — but there is a byte literal, so \h is 104 and +\space is 32, and the prelude's digit? reads as +(and (>= b \0) (<= b \9)). To see a byte as a letter rather than as a +number, print a slice of them with print-bytes.

+

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 @@ -518,7 +524,10 @@ a body that changes it cannot change the trip count, and the loop variable is no assignable.

Loops are imperative, with while, until and -return. There is no loop/recur.

+return. There is no loop/recur, and there is no +break or continue either — both are planned and neither +exists, so today they report as unknown function break. An early exit out +of a loop is return, as first-even does above.

Option, match and some

From 4d1a0c7807a8ce77eab1f8a338db8795b53f2a85 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:53:38 +0700 Subject: [PATCH 09/11] Build sand for wasm32 and compare the hash, rather than repeat the number It is the project's headline cross-target claim and the page was asserting it second-hand. Both targets print 2256461126764447066 on this machine, so the transcript is now what the page shows. --- web/examples/quotes.sh | 7 +++++++ web/index.html | 33 +++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/web/examples/quotes.sh b/web/examples/quotes.sh index d7d26a5..44f6465 100644 --- a/web/examples/quotes.sh +++ b/web/examples/quotes.sh @@ -29,6 +29,13 @@ want "flan usage" "$("$FLAN" 2>&1 | sed -n 2p)" # calc-me, the first acceptance program, still answers what the page says. want "calc-me" "$("$FLAN" run "$root/calc-me.flan" '1 + 2 * (3 - 0.5) / 2')" +# The sand hash. The page shows it twice — native and wasm32 — and the claim is +# that the two agree; only the native half is cheap enough to check here. +want "sand hash" "$("$FLAN" run "$root/test/programs/sand-headless.flan")" + +# The two cross-target refusals, in the compiler's own words. +want "run --target" "$("$FLAN" run "$here/hello.flan" --target=wasm32-wasi 2>&1)" + # The refusal and diagnostic messages quoted in the prose and in the # "Not implemented yet" table. for pair in \ diff --git a/web/index.html b/web/index.html index 5a2682c..55889f1 100644 --- a/web/index.html +++ b/web/index.html @@ -905,6 +905,8 @@ is generated; a Flan string crosses as ptr+len, exactly as it is stored.

1
+

That is 1.0, printed by the same rule as before.

+

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:

@@ -927,7 +929,8 @@ 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:

+the whole generated file, of which this is the end — the rest is the typedefs +and a comment saying not to edit it:

(defstruct Vector2 [x f32  y f32])
 
@@ -1170,8 +1173,19 @@ uses; it is program-scoped, so in sand you write sim/settle and not
 
 

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.

+it does natively:

+ +
$ flan run test/programs/sand-headless.flan
+2256461126764447066
+$ flan build test/programs/sand-headless.flan --target=wasm32-wasi -o sand.wasm
+$ node --no-warnings test/wasm-run.mjs sand.wasm
+2256461126764447066
+ +

That number is the whole point of writing the RNG in Flan rather than calling libc's: +a grid hash is only a regression test if the sequence is byte-identical on both targets. +It holds at -O2 and at -O0. The wasm side needs a +wasm32 builtins archive — from wasi-sdk, or emscripten's substituting for +it — and the compiler names every path it looked in when it cannot find one.

Dev and release builds are deliberately different. --dev means indirection cells and -rdynamic, which is what exports the cells for a loaded @@ -1179,9 +1193,16 @@ module to bind to; release builds call directly, emit constants as constants, an 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.

+

Some things are refused by name rather than half-supported, and both cross-target +refusals say why:

+ +
$ flan run hello.flan --target=wasm32-wasi
+flan run: --target is refused — a cross-built module is not something this host
+can exec. Use flan build --target=... and a wasm runtime.
+
+$ flan build hello.flan --dev --target=wasm32-wasi
+wasm32: --dev is native only — the reload path is dlopen, which wasm32 has no
+equivalent of

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 From bbda5e4cd79c524aaa0efb9bd4e020f2ea04e043 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 03:55:06 +0700 Subject: [PATCH 10/11] Point the reader at the two scripts, so the page's claim about itself is testable "Every program below was run" is the kind of assurance nobody can act on. Naming check.sh and quotes.sh turns it into something a reader can re-run, and says plainly that a disagreement makes one of them go red. --- web/index.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/index.html b/web/index.html index 55889f1..c5846e9 100644 --- a/web/index.html +++ b/web/index.html @@ -154,7 +154,11 @@ 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.

+prints for it. Every Flan program on this page is a file in web/examples/ +with its output recorded beside it; sh web/examples/check.sh runs them all +and compares, and quotes.sh re-derives the blocks that are transcripts +rather than programs. If the page and the compiler disagree, one of those two goes +red.

- +
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
(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 typethe 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 variablegeneric code over the type variable a is not implemented yet — milestone 5 (see plan.org)
'syma 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-casehandler-case is not implemented yet
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
numberssign-f32, lerp, floor-f32, ceil-f32, round-f32, and sqrt-f32, which is the one declare in the file
randomrand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range
@@ -653,6 +653,15 @@ 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.

+

sqrt-f32 goes the other way, and is the one function in the file that +is not Flan: (declare sqrt-f32 [x f32] f32 "sqrtf"). Every other number +here is reachable from the four operations and a cast; a square root is not, and the +usual trick of seeding Newton's method from the exponent bits needs a bit-cast between +f32 and u32 that the language does not have. IEEE-754 makes +sqrt correctly rounded, so libm gives the same bit pattern on both targets +anyway — the very property that keeps the RNG in Flan is, for this one, the argument +for going out to C. It is also why every link carries -lm.

+

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.