From 4e89987a83e2c139f499be4030cc9ac695a6e174 Mon Sep 17 00:00:00 2001
From: Joseph Ferano 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.
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:
@@ -317,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.
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
@@ -472,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])
@@ -531,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.
@@ -599,8 +594,7 @@ above.
(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])
@@ -643,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)
@@ -709,28 +702,26 @@ ordinary Flan.
-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,
@@ -779,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:
- A package may be a single
.flan file named outright,
@@ -787,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.
+ 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.
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
@@ -803,7 +794,7 @@ 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.
+argument, so a file that imports raylib still builds for wasm32.
Conditions and restarts
@@ -822,8 +813,8 @@ carries on.
(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:
signal has type Unit, always. A handler that returns
+normally leaves the signaller to carry on — the accumulation case:
(defstruct AssetMissing [id i32])
@@ -881,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 restart-case shadows an outer
-one for the duration of its body. That is what makes "restarts go at the resync point"
-composable.
+one for the duration of its body. An inner parser's skip-form is 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-restart writes
@@ -892,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
@@ -938,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, 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.
From there you fix the function, install it, and take a restart. Control never left
+the erring frame, so retry calls 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 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
@@ -993,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 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
@@ -1026,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 -devel package to be installed. What follows from that
-is what the generator can and cannot promise. Guaranteed: the C typedef
+linkable and not the -devel package to be installed.
+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
@@ -1048,7 +1036,7 @@ existing binding is unaffected.
This is the thesis of the project: edit the code, keep the sand.
+Edit the code, keep the sand.
$ flan dev sand.flan
@@ -1059,7 +1047,7 @@ frame boundary. The window does not blink and the grid does not reset.
Four pieces, each of which can be run on its own.
+Four pieces, each runnable on its own.
The reload primitive. llc → ld -shared →
dlopen → call. Measured in this codebase:
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
@@ -1084,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"
@@ -1093,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
@@ -1123,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
@@ -1136,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.
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
@@ -1163,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.
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 @@ -1193,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.
[ 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.