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/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/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 new file mode 100644 index 0000000..7811669 --- /dev/null +++ b/web/examples/check.sh @@ -0,0 +1,58 @@ +#!/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} +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 + # 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 ok "$f"; else + bad "$f" + printf '%s\n' "$got" | diff -u "${f%.flan}.out" - || true + 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 + ok "shimdemo.flan (flan shim)" +else + 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/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..8ca23cc --- /dev/null +++ b/web/examples/geom/len.flan @@ -0,0 +1,4 @@ +;; 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 new file mode 100644 index 0000000..956560c --- /dev/null +++ b/web/examples/geom/vec.flan @@ -0,0 +1,5 @@ +;; 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))})) 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/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..3a62201 --- /dev/null +++ b/web/examples/pkg.flan @@ -0,0 +1,9 @@ +;; pkg.flan — the directory is the package, and everything it declares +;; arrives qualified by the alias this import chose. +(import g "geom") + +(defn main [] + (let [v (g/add (g/V2 {:x 3.0 :y 0.0}) + (g/V2 {:x 0.0 :y 4.0}))] + (print-f64 (f64 (g/length v))) + (newline))) 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/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 diff --git a/web/examples/quotes.sh b/web/examples/quotes.sh new file mode 100644 index 0000000..dd447fc --- /dev/null +++ b/web/examples/quotes.sh @@ -0,0 +1,90 @@ +#!/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 + +# 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() { + # An empty needle would match anything — `case $x in **)` is always true — so + # every check whose text comes from a grep would go green the moment the line + # it greps for was renamed. That is the one failure this script exists to + # catch, so the empty case is a failure and not a match. + if [ -z "$2" ]; then + echo "FAIL $1" + echo " nothing to compare against — whatever this quotes has moved" + fail=1 + return + 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. +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 \ + '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))' \ + '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 "message: $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")" + +# 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/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 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..08b78ce --- /dev/null +++ b/web/index.html @@ -0,0 +1,1331 @@ + + + + + +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 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.

+ + + +

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
+usage: flan (read|parse|check|emit|shim) <file.flan>...
+       flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] [--target=wasm32-wasi]
+       flan run <file.flan> [args...]
+       flan reload <program.flan> <forms.flan> [-o out.so]
+       flan dev <program.flan> [-s socket]
+ +

read, parse, check, emit and +shim each stop the pipeline one stage further along and print what it +produced, which is how you find out what the compiler thinks of a form. +run builds to a temporary file and execs it.

+ +

The smallest program:

+ +
(defn main []
+  (print-line "hello from flan"))
+ +

The entry point is (defn main [args [string]] i32). Both the parameter +and the return type are optional: omitting args means the program ignores +argv, and omitting the return type means Unit and an exit status of 0.

+ +

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:

+ +
(defstruct Enemy [hp i32  name string])
+
+(defvar spawned i32)
+(defconst room-size 4)
+(defvar room [room-size i32])
+
+;; `set` takes a fixed list of forms, not an extensible setf.
+(defn main []
+  (let [e (Enemy {:hp 10 :name "slime"})
+        p (addr e)]
+    (set spawned (+ spawned 1))    ; a local or a defvar
+    (set (.hp e) 7)                ; a struct field
+    (set (.hp p) 8)                ; through a (Ptr Enemy) — derefs one level
+    (set (at room 2) 5)            ; a fixed array or slice element
+    (set (deref p) (Enemy {:hp 3 :name "wisp"}))   ; a whole-object store
+
+    (print-i64 (i64 (.hp e))) (newline)
+    (print-line (.name e))
+    (print-i64 (i64 (at room 2))) (newline)
+    (print-i64 (i64 spawned)) (newline)))
+ +
3
+wisp
+5
+1
+ +

.field and at dereference exactly one pointer level, which +is why (set (.hp p) 8) above is legal when p is a +(Ptr Enemy). Note the last two stores: the whole-object store through +p overwrote e itself, so hp reads 3 and not 8.

+ +

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

+ +

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

+ +

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

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

+ +
(defenum Key
+  [space 32  escape 256  left 263  right 262])
+
+(defn key-name [k Key] string
+  (cond
+    (= k :space)  "space"
+    (= k :escape) "escape"
+    :else         "an arrow"))
+
+(defn main []
+  ;; :space resolves against the parameter's enum at compile time.
+  ;; A typo is an error here, not a wrong number later.
+  (print-line (key-name :space))
+  (print-line (key-name :left)))
+ +
space
+an arrow
+ +

A keyword means nothing where no enum is expected. There is no keyword type to fall +back on, and there is no way to name a member other than as a keyword in a position +that expects that enum.

+ +

Struct and enum names share one top-level namespace with functions, globals and type +aliases. A second declaration of a name is rejected whatever kind either one is.

+ +

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
+(defconst rows 3)
+(defconst cols 4)
+(defvar grid [rows [cols u32]])        ; BSS, rows*cols*4 bytes
+
+(defn main []
+  (print-i64 (i64 cell-size)) (newline)
+  (print-f64 (f64 gravity)) (newline)
+  (print-i64 (i64 current-color)) (newline)
+  (print-i64 (i64 (at grid 2 3))) (newline))
+ +
5
+0.05
+0
+0
+ +

A defconst the checker consumed — an array length, for instance — is +part of the shape of the program. One it did not is only ever bytes in memory, which +matters for reloading; see the dev loop.

+ +

let binds name/value pairs and takes no type annotation, so a constant +whose type matters is named at the top level rather than written inline.

+ +

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

+ +

(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, all but one line of it, 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, and sqrt-f32, which is the one declare in the file
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.

+ +

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.

+ +

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 — the directory is the package, and everything it declares
+;; arrives qualified by the alias this import chose.
+(import g "geom")
+
+(defn main []
+  (let [v (g/add (g/V2 {:x 3.0 :y 0.0})
+                 (g/V2 {:x 0.0 :y 4.0}))]
+    (print-f64 (f64 (g/length v)))
+    (newline)))
+ +
5
+ +

There is one form and one meaning: (import alias "path"), and everything +from the package is qualified alias/name. There is no unqualified-import +mode, no :refer, no ns form and no per-file namespace object. +A path with no collection prefix is relative to the importing file; +vendor: and core: are collections, resolved by walking up from +the importing file until a directory of that name is found.

+ +

Importing is a rename. Every top-level name the package declares becomes +alias/name, and every use of one — in a type, in a body, in a struct +literal, in an array length — is rewritten to match. Nothing downstream knows a package +existed.

+ +

Three more rules that are easier to know than to discover:

+ + + +

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 ...)     ; match by type, no hierarchy
+
+(restart-case BODY          ; BODY and every clause have the same type = the form's
+  (name [] CLAUSE) ...)
+
+(invoke-restart 'name)      ; Never. Innermost frame offering the name wins.
+ +

signal has type Unit, always. That is the accumulation +case, and it is worth having on its own because it alters no control flow:

+ +
(defstruct AssetMissing [id i32])
+
+(defvar seen i64)
+
+(defn load-all []
+  (signal (AssetMissing {:id 1}))     ; Unit — the caller carries on
+  (signal (AssetMissing {:id 2})))
+
+(defn main []
+  (load-all)                          ; no handler: a no-op
+  (print-i64 seen) (newline)          ; 0
+
+  ;; A handler that returns normally accumulates and lets the signaller run on.
+  (handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
+    (load-all))
+  (print-i64 seen) (newline))         ; 3
+ +
0
+3
+ +

A handler that invokes a restart instead transfers control outward to the +restart-case that offers the name, and the clause's value becomes that +form's value. Every defer between the invoke and the target runs, innermost +first, before the clause body starts.

+ +
(defstruct AssetMissing [id i32])
+
+(defvar cleanups i64)
+
+(defn load [n i32] i32
+  (signal (AssetMissing {:id n}))
+  100)
+
+(defn middle [n i32] i32
+  (defer (set cleanups (+ cleanups 1)))   ; runs on the transfer too
+  (+ (load n) 1))
+
+(defn fetch [n i32] i32
+  (restart-case (middle n)                ; its value if nothing transfers
+    (use-placeholder [] -1)
+    (retry           [] 7)))
+
+(defn main []
+  (print-i64 (i64 (fetch 1))) (newline)   ; 101 — nothing handled it
+
+  (handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))]
+    (print-i64 (i64 (fetch 2))) (newline)) ; -1
+
+  (print-i64 cleanups) (newline))         ; 2 — the defer ran both times
+ +
101
+-1
+2
+ +

Restart lookup walks the dynamic restart stack from innermost outward and takes the +first frame offering the name, so an inner restart-case shadows an outer +one for the duration of its body. That is what makes "restarts go at the resync point" +composable.

+ +

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

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:

+ +
(declare-c unload-texture [texture Texture2D] "UnloadTexture")
+
+(declare-c draw-texture
+  [texture Texture2D  x i32  y i32  tint Color]
+  "DrawTexture")
+ +

The reason for the wrapper is that an aggregate's calling convention is not +part of its layout. On x86-64, clang gives raylib's own prototypes +<2 x float> for a returned Vector2, i32 for +a Color argument, and { i64, i64 } for a returned +Rectangle — none of which is the struct's own LLVM type, and arm64 and +wasm32 classify differently again. Reproducing that in the backend would be three +classifiers to write and keep correct forever, and a mistake would show up as a field +full of garbage rather than as a link error.

+ +

So the boundary has one wrapper per binding, each flattening the aggregates: a struct +returns through an out-pointer, a struct argument is passed by pointer, and clang +classifies all of it, per target, for free. flan shim <file> prints +the whole generated file, 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])
+
+(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.

+ +

Here is the whole of hello.flan through +flan emit --dev, which is the shortest thing that shows it:

+ +
@"flan.cell.print-line" = global ptr @"flan.print-line"
+
+define {} @"flan.main"(ptr %xfer) {
+entry:
+  %t1 = load ptr, ptr @"flan.cell.print-line"
+  %t2 = call {} %t1(%slice { ptr @".str.36", i64 15 }, ptr %xfer)
+ +

Two things are visible there at once. The call site loads the cell rather than naming +@"flan.print-line" directly, and the signature carries ptr %xfer +— the transfer channel from conditions, which every Flan +function has, release builds included.

+ +

Redefinition is then one store, below a microsecond, which is what makes a +frame-boundary swap a non-event. Three rules fall out of it. A redefinition module +declares every global external, so globals live in the host and survive a reload +— that is what "keep the sand" means. Every other function is a declare, so +a redefined settle calls the host's move-grain rather than +freezing a private copy of it. And nothing is ever dlclosed: a cell holds an +address inside a module's text, so unloading it would leave call sites pointing at +unmapped memory. Old code is never unloaded, which is also why a thread mid-execution +finishes safely in the old version.

+ +

The agent. vendor/agent is a package like any other: a +listener thread, a single-producer ring, and three calls. This is the whole of +agent.flan — no aggregate crosses the boundary, so a plain +declare does it and there is no shim.

+ +
(declare start-raw [path string] i32 "flan_agent_start")
+(declare poll-raw [] i32 "flan_agent_poll")
+(declare wait-raw [ms i32] i32 "flan_agent_wait")
+
+(defn start [path string] i32 (start-raw path))
+(defn poll [] i32 (poll-raw))
+(defn wait [ms i32] i32 (wait-raw ms))
+ +

(agent/start path) listens on a unix socket, once, at startup. +(agent/poll) installs whatever has arrived and returns how many. +(agent/wait ms) is the same but waits for something first, which is what a +headless test uses so that a reload is deterministic rather than a race against the +frame rate.

+ +

The split between loading and installing is the design. dlopen relocates +a module and takes the loader lock — milliseconds, unbounded — so it happens on the +listener thread. Installing is one store per function and must not land while a redefined +function is on the stack, so it happens on the game thread, at the top of the frame, when +the program asks. A game loop calls agent/poll at the top of its frame and +ignores the result.

+ +

The session and the daemon. flan dev holds the +declarations the running process was built from plus every change accepted since, and it +owns the build — which is what makes its rules describe the process that is actually +running rather than a guess about it. Re-checking the whole program on every evaluation +costs under 10ms, less than the llc that follows, and it makes an evaluation +transactional for free: a form that fails to check mutates nothing.

+ +

The protocol is one s-expression per message, length-framed by a decimal byte count. +It is not nREPL: eval there is string-in/string-out with no slot for +which form, from which file, and once the editor client is ours too there is no +CIDER to be compatible with.

+ +

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

A pointer is never followed — it renders as <ptr> — because it is +the only thing that could make the walk cycle, and dereferencing one a REPL was handed is +not a safe thing to do on someone's behalf. The walk is bounded at depth 4 and 8 elements, +and the output truncates at 4K. Map, function values and type variables +refuse by name.

+ +

The thunk's module is unloaded afterwards, which is the one case where that is safe: +nothing points into its text once it has returned. Sixteen expression evaluations retain +zero mappings, where each redefinition retains three, permanently and correctly.

+ +

Emacs

+ +

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

+ + + + + +
+ + + +