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