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.
What Flan is
A minimal Lisp for games. In one line: Odin with a Lisp frontend and a live REPL. @@ -217,15 +274,14 @@ $ ./_build/default/bin/main.exe run calc-me.flan "1 + 2 * (3 - 0.5) / 2"
$ flan
usage: flan (read|parse|check|emit|shim) <file.flan>...
- flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] [--target=wasm32-wasi]
+ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] [--debug] [--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.
run builds to a temporary file and execs it.
The smallest program:
@@ -259,8 +315,8 @@ slot; reading one is a load, assigning to one is a store, and a store of an aggr 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.
+A value struct is shared mutably by passing its address down the call chain. No +heap is involved.
Places — the forms set accepts — are a fixed list, not an extensible
setf:
.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.
.field and at dereference exactly one pointer level, so
+(set (.hp p) 8) above is legal when p is a
+(Ptr Enemy). The whole-object store through p overwrote
+e itself, so hp reads 3 and not 8.
Bounds are checked
@@ -317,9 +373,8 @@ $ echo $?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
+tied to the optimisation level. --no-bounds-checks turns them off.
+Measured cost on a
50-million-iteration dependency chain over a 1024-element array: 0.11–0.12s checked
against 0.12–0.13s unchecked.
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.
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.
>> 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
@@ -414,10 +468,9 @@ byte is a u8 — but there is a byte literal, so \h is
(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.
+An enum is an i32 at run time and its own type in the checker. A
+keyword at a call site resolves against the parameter's enum type at compile time, so a
+typo is an error there rather than a wrong number later.
(defenum Key
[space 32 escape 256 left 263 right 262])
@@ -473,8 +526,8 @@ functions need no forward declaration. Globals come in two kinds:
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.
+part of the shape of the program. One it did not is only ever bytes in memory. The
+two reload differently; 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.
@@ -528,18 +581,20 @@ a body that changes it cannot change the trip count, and the loop variable is no
assignable.
Loops are imperative, with while, until and
-return. There is no loop/recur, 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.
+return. There is no loop/recur. There is no
+break or continue yet either; both refuse by name:
+
+break is not implemented yet (see the build sequence in plan.org)
+
+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.
+None from the enclosing function.
(defconst nums [4 i32] [4 8 15 16])
@@ -582,8 +637,7 @@ 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:
+let, a loop or a branch. Block scoping it is not done:
defer must be a top-level form in a function body — block-scoped defer is not implemented yet (milestone 4)
@@ -641,33 +695,33 @@ ordinary Flan.
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
+text split-on-byte, split-next!, lower-ascii, upper-ascii, bytes-ci=?
+UTF-8 decode-rune, rune-at, rune-count, rune-size, rune-start?, valid-utf8?, encode-rune!
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
+
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. 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
+
sqrt-f32 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.
sqrt correctly rounded, so libm gives the same bit pattern on both
+targets anyway. 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.
(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,
+
The primitives underneath are few — a primitive is the only thing implemented
+twice per backend: argv,
write-stdout, exit, len, at,
slice, bytes, bytes->f64,
bytes->i64, f64->bytes, i64->bytes,
@@ -716,7 +770,7 @@ the importing file until a directory of that name is found.
Three more rules that are easier to know than to discover:
+Three more rules:
- A package may be a single
.flanfile named outright, @@ -724,8 +778,8 @@ existed. three other loose programs, so naming its directory would import all four. - A package may import a package, and the qualification flattens to
the inner alias: raylib imported by a package that is itself imported is still
-
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.
+ mainis not exported. A package carrying one would collide with the importer's, andmainis a reachability root, so an imported one would keep everything it calls alive. Writingsand/mainis @@ -740,7 +794,7 @@ compiled into the build, and a file namedlinklists extra linker arguments. Whether those reach the build is decided after checking, from the program rather than from the import list: the compiler starts atmain, 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. +argument, so a file that imports raylib still builds for wasm32.Conditions and restarts
@@ -759,8 +813,8 @@ carries on. (invoke-restart 'name) ; Never. Innermost frame offering the name wins. -
+signalhas typeUnit, always. That is the accumulation -case, and it is worth having on its own because it alters no control flow:signalhas typeUnit, always. A handler that returns +normally leaves the signaller to carry on — the accumulation case:(defstruct AssetMissing [id i32]) @@ -818,10 +872,10 @@ first, before the clause body starts.Restart lookup walks the dynamic restart stack from innermost outward and takes the first frame offering the name, so an inner
+one for the duration of its body. An inner parser'srestart-caseshadows an outer -one for the duration of its body. That is what makes "restarts go at the resync point" -composable.skip-formis found +before an outer one's. -How it is lowered, and why that matters
+How a transfer is lowered
A transfer is not platform unwinding. Every Flan signature carries a transfer channel — one pointer appended as an out-parameter — which
-invoke-restartwrites @@ -829,12 +883,11 @@ and every call site checks. A callee writes the target into its caller's slot; e frame checks, runs its defers and returns early. The disassembly is the release one plus a guard after each call.Three consequences to know:
+Three consequences:
- wasm32 works with no exception proposal, and native and wasm - builds of the same program agree, which is the property the acceptance table exists to - check. + builds of the same program agree.
- Every function carries the channel, release builds included. A
hot-reload cell holds a bare pointer, so the honest answer to "what can this call?" is
"anything". A later optimisation may stop a function checking the channel; it may not
@@ -875,11 +928,9 @@ and the top are still live:
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,
+retrycalls 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.From there you fix the function, install it, and take a restart. Control never left +the erring frame, so
retrycalls through the indirection cell and reaches +the new body. Installing while stopped is allowed; there is no frame in progress.The break loop lives in
[texture Texture2D x i32 y i32 tint Color] "DrawTexture") -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 @@ -930,8 +981,8 @@ of — one line per binding: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 +
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 returnedVector2,i32for aColorargument, and{ i64, i64 }for a returnedRectangle— none of which is the struct's own LLVM type, and arm64 and @@ -963,8 +1014,8 @@ void flan_shim_get_mouse_position_5ad0e205(flan_ty_Vector2_1bebc5ae *out) { }No library header is read, deliberately, so a build needs the shared library to be -linkable and not the
-develpackage to be installed. What follows from that -is what the generator can and cannot promise. Guaranteed: the C typedef +linkable and not the-develpackage to be installed. +Guaranteed: the C typedef and the Flan struct come from the samedefstruct, so they cannot disagree, and clang type-checks the wrapper against the generated prototype. Trusted: that thedefstructmatches the library's real @@ -985,7 +1036,7 @@ existing binding is unaffected.The dev loop
-This is the thesis of the project: edit the code, keep the sand.
+Edit the code, keep the sand.
@@ -996,7 +1047,7 @@ frame boundary. The window does not blink and the grid does not reset.$ flan dev sand.flanHow it works
-Four pieces, each of which can be run on its own.
+Four pieces, each runnable on its own.
The reload primitive.
@@ -1011,9 +1062,9 @@ frame boundary. The window does not blink and the grid does not reset.llc→ld -shared→dlopen→ call. Measured in this codebase:
rl/…. A directory is keyed by its real path and read once, so a cycle
+ ends there. The same directory under two different aliases is refused.