diff --git a/web/examples/quotes.sh b/web/examples/quotes.sh index dd447fc..b0f50b3 100644 --- a/web/examples/quotes.sh +++ b/web/examples/quotes.sh @@ -56,7 +56,8 @@ for pair in \ '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)))' + 'i64index:(defconst xs [3 i32] [1 2 3]) (defn main [] i32 (let [i (i64 1)] (at xs i)))' \ + 'break:(defn main [] (let [i 0] (while (< i 3) (break))))' do name=${pair%%:*}; src=${pair#*:} printf '%s\n' "$src" > "$here/.q.flan" diff --git a/web/index.html b/web/index.html index 08b78ce..24477f1 100644 --- a/web/index.html +++ b/web/index.html @@ -67,6 +67,7 @@ body { color: var(--text); font: 17px/1.65 "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; } +main { display: block; } .wrap { max-width: 44rem; margin: 0 auto; padding: 0 1.25rem 6rem; } header { padding: 3.5rem 0 2rem; } @@ -117,12 +118,63 @@ th { font-weight: 600; color: var(--muted); } .lede { font-size: 1.1rem; } .note { color: var(--muted); font-size: .94rem; } -nav.toc { background: var(--panel); border-radius: 5px; padding: 1rem 1.25rem; margin: 2rem 0 0; } -nav.toc ul { list-style: none; padding: 0; margin: 0; columns: 2; column-gap: 2rem; } -nav.toc li { margin: .18rem 0; font-size: .93rem; break-inside: avoid; } + +/* The table of contents: a bar with a toggle when the window is narrow, a + fixed column beside the text when there is room for one. The toggle is a + checkbox so that no script is needed to open it. */ +.toc-toggle { position: absolute; width: 1px; height: 1px; opacity: 0; } +.toc-button { + display: block; cursor: pointer; user-select: none; + position: sticky; top: 0; z-index: 2; + padding: .6rem 1.25rem; + background: var(--panel); border-bottom: 1px solid var(--rule); + color: var(--muted); font-size: .92rem; letter-spacing: .02em; +} +.toc-button::before { content: "\2261\00a0\00a0"; } +.toc-toggle:focus-visible + .toc-button { outline: 2px solid var(--accent); outline-offset: -2px; } +nav.toc { + display: none; + background: var(--panel); border-bottom: 1px solid var(--rule); + padding: .75rem 1.25rem 1.1rem; +} +.toc-toggle:checked ~ nav.toc { display: block; } +.toc-title { + display: none; /* the toggle already says "Contents" */ + margin: 0 0 .5rem; font-size: .78rem; letter-spacing: .09em; + text-transform: uppercase; color: var(--muted); +} +nav.toc ul { list-style: none; padding: 0; margin: 0; } +nav.toc li { margin: 0; font-size: .93rem; } +nav.toc a { + display: block; padding: .2rem 0; color: var(--muted); + text-decoration: none; border-left: 2px solid transparent; padding-left: .6rem; +} +nav.toc a:hover { color: var(--link); border-left-color: var(--accent); } +h2, h3 { scroll-margin-top: 3.5rem; } + +/* Nineteen entries one to a line fills a phone screen, so the opened panel + goes to two columns as soon as there is room for them. */ +@media (min-width: 30rem) and (max-width: 65.99rem) { + nav.toc ul { columns: 2; column-gap: 1.5rem; } + nav.toc li { break-inside: avoid; } +} footer { margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid var(--rule); color: var(--muted); font-size: .92rem; } -@media (max-width: 34rem) { nav.toc ul { columns: 1; } } +/* Wide enough for both: the contents become a column of their own, scrolling + independently of the page, and the text moves over to sit beside it. */ +@media (min-width: 66rem) { + .toc-button { display: none; } + nav.toc { + display: block; + position: fixed; top: 0; left: 0; bottom: 0; + width: 15rem; overflow-y: auto; overscroll-behavior: contain; + background: none; border-bottom: 0; border-right: 1px solid var(--rule); + padding: 3.6rem 1rem 2rem 1.5rem; + } + main { margin-left: 15rem; } + .toc-title { display: block; } + h2, h3 { scroll-margin-top: 1rem; } +} .c { color: var(--c-comment); font-style: italic; } .s { color: var(--c-string); } @@ -133,34 +185,11 @@ footer { margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ru -
- -
- - 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.

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

+

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.

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

@@ -291,10 +347,10 @@ 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.

+

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

@@ -364,19 +419,18 @@ have one type, and every conversion is written as a cast:

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.

+something narrower is wanted. One caveat: 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.

+operand's width is a compile error, and a computed one is masked to the width. +>> 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:

+refused:

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 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 +textsplit-on-byte, split-next!, lower-ascii, upper-ascii, bytes-ci=? +UTF-8decode-rune, rune-at, rune-count, rune-size, rune-start?, valid-utf8?, encode-rune! 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 +

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.

+its type. (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.

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:

+

Three more rules:

-

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.

+

About 19ms end to end. The clang driver on the same IR is 50ms, so the +dev path never invokes it. The driver forks a second process and re-does argument and +target resolution; 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 @@ -1021,7 +1072,7 @@ every Flan-to-Flan call through a cell — a mutable global holding the address function that is current.

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

+flan emit --dev:

@"flan.cell.print-line" = global ptr @"flan.print-line"
 
@@ -1030,20 +1081,19 @@ 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.

+

The call site loads the cell rather than naming @"flan.print-line" +directly. The signature carries ptr %xfer — the transfer channel from +conditions, on every Flan function, 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.

+

Redefinition is then one store, below a microsecond. Three rules follow. A +redefinition module declares every global external, so globals live in the host +and survive a reload. 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, so 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 @@ -1060,11 +1110,10 @@ listener thread, a single-producer ring, and three calls. This is the whole of

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

+(agent/wait ms) is the same but waits for something first. A headless test +uses it 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 +

Loading and installing are separate. 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 @@ -1073,10 +1122,10 @@ 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.

+owns the build, so its rules describe the process that is actually running. Re-checking +the whole program on every evaluation costs under 10ms, less than the llc +that follows. An evaluation is transactional: 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 @@ -1100,23 +1149,23 @@ a silent mismatch against memory the process has already laid out:

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 +its storage holds state the program moved past long ago. And a defconst +the checker never consumed can be changed, so a colour table can be 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.

+

The signature row is a stopgap. 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 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.

+from. The module says run this once, and the agent calls it after the install, on +the game thread, at a frame boundary. An expression reading the program's state therefore +sees a consistent one.

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 @@ -1130,15 +1179,14 @@ b (Blob {:id 7 :name "sandy \"quoted\"" :pos (V {:x 1.5 (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.

+

A pointer is never followed; it renders as <ptr>. Following one +would make the walk cycle, and dereferencing a pointer a REPL was handed is not safe. +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.

+

The thunk's module is unloaded afterwards. Nothing points into its text once it has +returned. Sixteen expression evaluations retain zero mappings, where each redefinition +retains three, permanently and correctly.

Emacs

@@ -1147,7 +1195,7 @@ zero mappings, where each redefinition retains three, permanently and correctly. 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.

+there is no parser in it.

@@ -1189,22 +1237,28 @@ module, and the headless sand acceptance program prints the same 64-bit hash und it does natively:

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

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 +

The RNG is written in Flan rather than called from libc for that number: 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.

+indirection cells and -rdynamic, which exports those 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: what a REPL may redefine +next is not a function of what has been called so far.

+ +

--debug is a third flag beside --dev and the optimisation +level. --dev asks whether you can redefine the program while it runs; +--debug asks whether you can stop it and read it. It emits DWARF, sets +-O0, and is refused by name for wasm32. lldb needs no plugin to read a +Flan struct: the struct is its C struct.

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

@@ -1224,10 +1278,9 @@ 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.

+

The house rule is that anything which binds a name, alters control flow, or is not +yet implemented must be recognised explicitly and rejected. Each of these refuses by +name, with the milestone it belongs to, and the tests assert on the reason.

@@ -1261,9 +1314,8 @@ managed class facility that plan.org describes is a plan and not a 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, +

Two of these are settled rather than pending. 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 @@ -1293,6 +1345,7 @@ macro is a function from Form to Form, which needs +