diff --git a/BUILT.md b/BUILT.md index 6007777..7f9b654 100644 --- a/BUILT.md +++ b/BUILT.md @@ -227,7 +227,7 @@ form, no new `decl_kind`, no reader or parser change. A C symbol the package already binds by hand is left alone, so `declare-c` stays the escape hatch and stays the thing that wins. -A leading `?` makes a line optional, and `vendor/raylib` uses it. "No raylib +A leading `?` makes a line optional, and `vendor/raylib` used it. "No raylib headers are needed" is a real property — a build needs libraylib linkable, not raylib-devel installed — and requiring a header would take it from everyone in order to give the check to whoever has one. Unset `FLAN_RAYLIB_H` and the build @@ -235,6 +235,12 @@ is exactly what it was; set it and every signature is checked. A path that is *set and wrong* is an error naming it, because silently behaving as though nobody had opted in is the difference between an opt-in and a trap. +*(Superseded. The `?` marker is still what it says here, but `vendor/raylib` no +longer uses it: the header is committed at `vendor/raylib/raylib-5.5.h`, the +line names it directly, and the check runs on every build. See "The header is +committed too" below, which is where the argument in this paragraph is +answered.)* + #### What the diff found The evidence the whole lane exists for. Against raylib **5.5** — the version @@ -489,6 +495,34 @@ nothing and the read is purely the check. It is a *better* check than before: 425 declarations rather than 172, because `generated.flan` is a package file like any other and is checked like one. +#### The header is committed too, and the check is no longer opt-in + +The paragraph above is superseded: there is no `FLAN_RAYLIB_H`, and the header +read is not conditional on anything. The header is committed at +`vendor/raylib/raylib-5.5.h` and `headers` names that path directly, so the +check runs on **every** build. + +**What dissolved the opt-in argument is the commit, not a change of mind about +the property.** "A build needs libraylib linkable and not raylib-devel +installed" is still true and still the reason the opt-in existed — but nobody +needs raylib-devel to have a file that ships with the repository, so requiring +the header costs nobody anything. The trade the `?` was paying for stopped +existing. + +**What being optional actually cost was found the hard way**, and the story is in +`vendor/raylib/headers` rather than repeated here. The short of it: the header a +tree happened to have lived under a gitignored directory, so several parallel +lanes were checking against nothing and were not told. **A check that silently +does not run is worse than no check** — the failure mode of an opt-in is not +"the check does not run", it is "the check does not run and the output looks the +same". + +There is no environment variable for the path any more. To check against a +different header, edit the line or replace the file. The check still runs over +every declaration in the package, hand-written and generated alike, and only the +hand-written half can actually disagree — for the reason the section above gives, +which is unchanged. + **`bindings`, beside `headers`, is what survives regeneration.** A committed generated file cannot be hand-corrected — the next run overwrites it and the edit is destroyed without anybody being told, which is the worst shape an edit @@ -4678,9 +4712,11 @@ and how big, and a 16px tile drawn at 4x is a dest four times the source. `draw- scale; `draw-texture-ex` has the scale and no source. Neither half draws a tile. It *was* reachable — with `FLAN_RAYLIB_H` exported the importer brings it in with 250-odd others — and that is the -finding worth keeping. `vendor/raylib/headers` keeps the import opt-in on purpose, so a build needs libraylib linkable +finding worth keeping. `vendor/raylib/headers` kept the import opt-in on purpose, so a build needs libraylib linkable and not raylib-devel installed. That property is worth keeping and it means **the default build had no draw call for a -grid-based game**. The rule that follows: *a raylib function on a game's per-frame path is hand-written in +grid-based game**. (The opt-in is gone — the header and the generated bindings are both committed now, so the default +build has every declaration. The rule below is unaffected: it is about where a per-frame call is *written*, not about +what the import happens to reach.) The rule that follows: *a raylib function on a game's per-frame path is hand-written in `raylib.flan` and checked against the header; it is not left to the import.* The import widens the surface; it must not be load-bearing. diff --git a/DISCUSS.md b/DISCUSS.md index cb37870..62152c3 100644 --- a/DISCUSS.md +++ b/DISCUSS.md @@ -227,7 +227,11 @@ would become an integer at the call site. A migration is therefore not a deletio kept list of the lines whose face is deliberately nicer than the header's. **Answered: no, and the blocker is not the one above.** Vendoring stopped being the question once the *output* was -committed rather than the header — no header is needed at any build. The count was also smaller than feared: 136 of +committed rather than the header — no header is needed at any build. (Vendoring then happened anyway, and for the +other reason: `vendor/raylib/raylib-5.5.h` is committed, `headers` names it with no `${...}` in front of it, and the +signature check runs on every build. What that settled was not "can we build without a header" — the committed +bindings had already settled that — but "does the check actually run", and behind an opt-in the answer in several +worktrees was no.) The count was also smaller than feared: 136 of the 172 are exactly what the rule produces, and the other 36 are expressible as `name` overrides in `bindings`. What actually decides it is that migration would gut the check. Everything the generator emits agrees with the header diff --git a/PORTING.md b/PORTING.md index 132eacc..5ee7c6d 100644 --- a/PORTING.md +++ b/PORTING.md @@ -58,10 +58,13 @@ draw, the palette, and the tile riding the cursor are all of the drawing there i `Rectangle`, `Vector2` and `Color`, all four of which `raylib.flan` already describes, so it is a `declare-c` beside `draw-texture-rec` and nothing else. -**Why it did not already land.** It is importable — with `FLAN_RAYLIB_H` exported it -comes in with the other 256. But `vendor/raylib/headers` says the header is opt-in on +**Why it did not already land.** It was importable — with `FLAN_RAYLIB_H` exported it +came in with the other 256. But `vendor/raylib/headers` made the header opt-in on purpose, so that a build needs libraylib linkable and *not* raylib-devel installed. That -property is worth keeping, and it means the default build of this game has no renderer. +property is worth keeping, and it meant the default build of this game had no renderer. +*(Both halves of that changed after this was written: the generated bindings and the +header are committed, so the default build has every declaration and the header check is +not opt-in. The rule below survives it — see the note at the end of §1.)* The general rule this exposes is worth more than the one line: **a function the game calls every frame should be hand-written and checked against the header, not left to the import.** The import widens the surface; it should not be load-bearing. @@ -112,10 +115,20 @@ carve reads out of a 2×1 image and the case goes red. **And the rule Tier 0 item 4 asked for, written down:** *a raylib function on a game's per-frame path is hand-written in `raylib.flan` and checked against the header; it is not left to the opt-in import.* The import widens the surface and is worth having, but a -build that does not have `FLAN_RAYLIB_H` set is the default build, and the default build +build that did not have `FLAN_RAYLIB_H` set was the default build, and the default build has to be able to draw. The test that goes with the rule is a link check, which is cheap and is all a GL-context call can have. +**Update — the justification moved, the rule did not.** There is no `FLAN_RAYLIB_H` any +more. `vendor/raylib/generated.flan` holds every declaration the importer produced and is +committed; `vendor/raylib/raylib-5.5.h` is committed beside it and `headers` names it +directly, so the signature check runs on every build rather than on the builds that had a +variable exported. The original argument — "the default build has no header, so it has no +`DrawTexturePro`" — is therefore dead. The rule outlives it for a different reason: a +per-frame call hand-written in `raylib.flan` is one a person transcribed, and the +hand-written lines are the only declarations in the package a header can actually +contradict. Everything generated agrees with the header by construction. + --- ## 2. What looks like a gap and is not @@ -254,8 +267,11 @@ project has queued generics for. `Rectangle`s. Twenty elements, once, at load. - `game.clj` `init-game`'s `mapv` over four `[keyword path]` pairs to textures. Four elements, once. -- The prelude is monomorphic per element type (`sort-i32!`, `map-f32!`, `reduce-i32`), so - anything over `[r c]` pairs has no helper at all. +- The prelude was monomorphic per element type (`sort-i32!`, `map-f32!`, `reduce-i32`), so + anything over `[r c]` pairs had no helper at all. *(Generics landed after this was + written: those families are one `sort!`, `map!` and `reduce` now, each over a type + variable, so a helper does exist for a slice of pairs. It does not change the count + below — three loops, five lines each, at load time.)* All three are `dotimes` with a `push`. Five lines each, at load time, run once. @@ -355,7 +371,7 @@ and Flan rejects it at compile time. Part of that rollback machinery is answerin dynamic-language problem and is not a gap. The out-of-bounds path is the part that survives the port, and it is the part that matters. -### The watch buffer has no equivalent +### The watch buffer has no equivalent — *closed, Tier 1 item 5* `watch.clj` is a pull-based watch: the game drops a snapshot of labelled values into an atom once per frame, and Emacs polls it on its own timer, so the watch rate is decoupled @@ -377,6 +393,15 @@ shows you a number while you drag the mouse. Whether `flan dev`'s `eval` against a running program could serve it — it does not gate on `Running` the way `globals_op` does — I did not test, and should not be assumed. +**Closed.** Both halves are built. `M-x flan-watch` is the buffer, fed by a table the +*program* writes and Emacs reads as memory — pushed rather than polled, so the values are +as fresh as the last frame and are still there while the program is stopped. +`watch-num-i64` / `watch-num-f64` are the `spy-num` half, keeping count/min/max/last/mean +per slot with no formatting on the write path. `M-x flan-watch-ghost-mode` shows the same +values inline at the call that wrote each one. The finding above stands as the reason the +inspector was not the answer: it is stopped-only, and this is a different tool. See Tier 1 +item 5 and `emacs/MANUAL.md`. + ### Offline image tooling `sprite_atlas.clj` `auto-select-tiles` carves the atlas into 16x16 subimages and keeps the @@ -509,8 +534,10 @@ not compete for the same slot. 4. ~~**Decide the rule the first item exposes.**~~ Decided and written into §1: *a raylib function on a game's per-frame path is hand-written in `raylib.flan` and checked - against the header, not left to the opt-in import.* The default build has no - `FLAN_RAYLIB_H` and the default build has to be able to draw. + against the header, not left to the opt-in import.* The default build had no + `FLAN_RAYLIB_H` and the default build has to be able to draw. (That justification is + obsolete — the bindings and the header are both committed now — but the rule stands on + the other leg: see the update at the end of §1.) ### Tier 1 — language and tooling, in the order that unblocks the most of this game diff --git a/REFERENCES.md b/REFERENCES.md index 9fc17d5..6beb029 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -17,7 +17,7 @@ its newest release does. | `zig` | 2026-09-12 | Comptime, build system, error handling, C interop. | | `jank` | 2026-09-11 | A Clojure with native interop and a REPL into a running process. | | `janet` | 2025-09-29 | A small embeddable Lisp. `varfn` and its reload story. | -| `raylib` | 2026-07-16 | The C library the FFI binds. **Note:** `vendor/raylib/web/raylib-5.5/` in *this* repo is the vendored 5.5 the bindings are checked against; the clone may be a different version. | +| `raylib` | 2026-07-16 | The C library the FFI binds. **Note:** `vendor/raylib/raylib-5.5.h` in *this* repo is the committed header the bindings are checked against on every build; the clone may be a different version. It used to be whatever `vendor/raylib/web/` happened to hold, which is gitignored and therefore absent from a fresh worktree — the reason the header is tracked now. | ### Odin, specifically @@ -25,7 +25,9 @@ Most of what this repository copies. Places already cited in the notes: - `base/runtime/core.odin:369` — `Map_Info`. The type-erased container runtime: `rawptr` keys, cell info for size and alignment, hash and equality as function pointers. This is why `Vec` and `Map` - here need no generics, and it is the thing **not** to convert when generics lands. + here need no generics, and it is the thing that was **not** converted when generics + landed — `Vec` and `Map` are still type-erased, and a `$t` only reaches them as an + element or key type. - `base/runtime/dynamic_map_internal.odin` — the map algorithm `(Map K V)` follows. - `base/runtime/dynamic_array_internal.odin` — the same for `Vec`. - `src/check_expr.cpp:470-510` — `find_or_generate_polymorphic_procedure`. The instantiation diff --git a/conditions.org b/conditions.org index b0fa0a2..81de9c3 100644 --- a/conditions.org +++ b/conditions.org @@ -45,7 +45,8 @@ stack and neither end can see the other. #+end_src ~defer~ between the invoke and the target runs, innermost first, before the -clause body. ~errdefer~ does not. +clause body. (~errdefer~ would not, and is in *Not yet* below — it is refused by +name today.) * The break loop @@ -64,8 +65,8 @@ above it, or ~abort~. * Not yet -~handler-case~ · ~find-restart~ · ~compute-restarts~ · a clause's report -string. Each refused by name with its reason. +~handler-case~ · ~find-restart~ · ~compute-restarts~ · ~errdefer~ · a clause's +report string. Each refused by name with its reason. A restart *with parameters* cannot be taken from the break loop: it aims at a frame by position and has nothing to fill the parameters in with, so the clause @@ -91,6 +92,10 @@ the agent's socket instead. frame re-runs everything, mutations included; one placed after the mutations re-runs only what follows. Put the restart before anything mutates, make the retried section idempotent, or snapshot what will be re-applied. + ~test/programs/frame-rollback.flan~ is the worked example of the snapshot, + including the ordering that matters: restore *in the restart clause*, not in a + ~defer~ — a defer runs on the ordinary return path too, so that version silently + rolls back the frames that succeeded. This bites harder here than in most Lisps because the point is a *game loop* — skip the frame, carry on, don't die. Since a bad index signals diff --git a/emacs/MANUAL.md b/emacs/MANUAL.md index 6cd04f1..695a990 100644 --- a/emacs/MANUAL.md +++ b/emacs/MANUAL.md @@ -355,6 +355,31 @@ never appeared would send you looking for a bug in the program. A name is truncated at 31 bytes and a rendered value at 192, with an ellipsis where a value was clipped. +**A number sampled thousands of times a frame — `watch-num-i64`, +`watch-num-f64`.** The scalar entry points keep one value per name, and from a +hot inner loop that is nearly useless: you see whichever of the 91,200 cells ran +last. These two are the other half. A slot keeps **count, min, max, last and +mean**, and the buffer renders them as one line: + +```flan +(declare-c watch-num-i64 [name string x i64] i32 "flan_dev_watch_num_i64") +(declare-c watch-num-f64 [name string x f64] i32 "flan_dev_watch_num_f64") + +(watch-num-i64 "cell" (at grid i)) +``` + +The slot renders as `n=… min=… max=… last=… mean=…`, one line per name. + +Two entry points rather than one so a program need not cast at the call site. +The write path does **no formatting** — a sample is a load, five compares and +the slot's seqlock — and the listener thread renders once per editor tick, which +is the whole reason this exists rather than a second `watch-i64`. **The window is +since the editor's last tick**, not since the program started: a min and a max +over a whole session reach the session's extremes within seconds and then never +move again, so the two most useful of the five would go dead exactly when you +start playing. A whole number prints as one, because a spy on an array index +reading `66.0000` sends you looking for a rounding bug that is not there. + **Scalars only, so far.** `i64`, `u64`, `f64` and `string` have entry points; a struct or a slice does not. That is not an oversight in the runtime — a Flan value carries no header, so rendering one is a walk over its *type* at compile diff --git a/plan.org b/plan.org index 1837d33..895457f 100644 --- a/plan.org +++ b/plan.org @@ -205,14 +205,21 @@ and on a managed ~class~ instance. An ordinary ~struct~ never carries one. the parser guessed between a return type and a body form from a table of type names; the guess was silently wrong twice, so the slot is mandatory. - Every type notation reads as exactly one data item: ~[f32]~, ~[4 f32]~, - ~(Vec f32)~, ~{string i32}~, ~(Ptr World)~, ~(Fn [f32] bool)~, ~(Option a)~, - ~(Handle a)~. + ~(Vec f32)~, ~(Map string i32)~, ~(Ptr World)~, ~(Fn [f32] bool)~, + ~(Option $t)~, ~(Handle $t)~. The map spelling was ~{string i32}~ once and is + not any more: braces in type position are refused by name. The ~where~ clause + below is a brace form at the head of a body, so keeping the brace type would + have put ~(defn f [...] {string i32} {:where ...} body)~ in the language — two + braces in a row meaning different things. - ~i8..i64~, ~u8..u64~, ~f32~, ~f64~ as real machine types; wrapping arithmetic. - Vector width 128-bit. Fixed arrays with component-wise ops and swizzles. - Parametric polymorphism by monomorphisation (Odin's model, no type classes, no - HKTs). A type variable is written ~$t~ at its binding site in a signature and - bare ~t~ at a use. This is what makes ~map~/~filter~/~reduce~ and the monomorphic - containers work. + HKTs). A type variable is written ~$t~ wherever a *type* goes — parameter, + return type, or nested as ~[$t]~ or ~(Vec $t)~ — and bare ~t~ wherever a type's + *name* is an argument in expression position: ~(vec-new t)~, ~(map-new t i32)~, + ~(pool-new t)~, and the cast ~(t x)~. This is what makes + ~map!~/~filter~/~reduce~ and the monomorphic containers work; it collapsed the + prelude's per-type families into one function each. A generic body is checked *abstractly*, with nothing substituted, so ~=~, ~<~, ~+~ and ~hash~ over an unconstrained variable are rejected at the definition rather than at whichever call site first instantiates it. **This is not Odin's @@ -220,11 +227,28 @@ and on a managed ~class~ instance. An ordinary ~struct~ never carries one. decisions are independent even though this line once bundled them. What makes the rejection liveable is a ~where~ clause over compile-time type predicates, which is Odin's (~core/slice/slice.odin:289~, - ~where intrinsics.type_is_ordered(T)~). ~(sort! xs)~ declares ~ordered?~ of its + ~where intrinsics.type_is_ordered(T)~). It is written as a Clojure-style map at + the head of the body — ~{:where (ordered? $t)}~, or a vector for more than one, + ~{:where [(copyable? $t) (copyable? $u)]}~ — on the precedent of Clojure's + ~{:pre ... :post ...}~, and because a bare ~{}~ in expression position is + already refused so nothing else it could be. ~sort!~ declares ~ordered?~ of its variable, the abstract pass then allows ~<~ in the body, and each instantiation checks the concrete type satisfies the predicate and refuses the call site if it - does not. Roughly four predicates are wanted here — ~ordered?~, ~equal?~, - ~hashable?~, ~numeric?~ — against Odin's forty-one. + does not. There are *five* predicates — ~ordered?~, ~equal?~, ~hashable?~, + ~numeric?~, ~copyable?~ — against Odin's forty-one, and they entail one another + in one direction, so one clause usually does: ~numeric?~ gives ~ordered?~, + ~ordered?~ gives ~equal?~, and any of the four gives ~copyable?~. + ~copyable?~ has no Odin counterpart, because Odin has no move semantics and a + ~$T~ there never has to answer the question. *A type variable is move-only by + default* and ~copyable?~ is the opt-out: whether a variable is move-only is not + decidable abstractly — ~i32~ at one instantiation, ~(Vec i32)~ at the next — so + the checker takes the stricter rule, which can only refuse a program that would + have been fine and never admit one that double-frees. The prior art is Rust's + ~T: Copy~, differing in that the compiler answers the question rather than a + user implementing a trait. + ~hashable?~ is what lets a variable *key a map*: without it the type + ~(Map $t i32)~ is refused where it is written, and with it the refusal moves to + the call site that names an unhashable key. This is not a type class and the difference is worth keeping straight: a type class carries *implementations* selected per instance and extensible by anyone, and needs dictionaries and coherence rules. A predicate carries nothing — it @@ -232,11 +256,15 @@ and on a managed ~class~ instance. An ordinary ~struct~ never carries one. a user-defined ~<~; every operation the prelude and the containers need is a primitive, so it does not bind. Compile-time interfaces, if they are ever wanted, come after the base checker is stable. - ~println~ is a compiler-provided exception and needs an explicit allow-list of + ~println~ is a compiler-provided exception and is on an explicit allow-list of forms the abstract pass defers to instantiation: whether a printer exists for a - variable is only decidable once it is substituted. Keep the list short — every - member moves a refusal from the definition to a call site, which is what the - abstract rule exists to avoid. + variable is only decidable once it is substituted. The ~Map~ operations over a + variable key are the other member, for the same reason — the hash and the + equality are concrete symbols chosen from the concrete key type — and that one + is paid for by ~hashable?~ being written in the signature, so the refusal it + moves still lands against a requirement somebody wrote down. Keep the list + short: every member moves a refusal from the definition to a call site, which + is what the abstract rule exists to avoid. - Function values split three ways (spec-memory.md): ~(Fn [T1 T2] R)~ is a plain pointer with no environment — the only kind that crosses FFI or sits in a reload cell; a *non-escaping* ~fn~ captures enclosing locals by value into a stack @@ -850,8 +878,12 @@ marked. - Dev backend → interpreter for milestone 2 certainly. Whether it /survives/ milestone 3 is open, not settled — see Compilation. - ~set~ on places → a fixed list of assignable forms, not ~setf~. -- Loop story → imperative ~while~/~for~ with ~break~/~continue~ and ~return~; - ~loop~/~recur~ only if it later earns its place. sand.flan is ported. +- Loop story → imperative ~while~/~until~/~dotimes~ with ~break~/~continue~ and + ~return~; ~loop~/~recur~ only if it later earns its place. It did: both are + built. ~break~ and ~continue~ take a label for the loop that is not the + innermost, and ~loop~ is an *expression* whose value is its body's, with + ~recur~ rebinding every name at once and jumping rather than calling. + sand.flan is ported. - Generic parameters → inferred at call sites, no explicit instantiation. No type classes. An operator over a variable with no ~where~ clause asserting it is rejected at the definition; a ~where~ predicate is what admits it. diff --git a/web/index.html b/web/index.html index 9180a1b..9416cea 100644 --- a/web/index.html +++ b/web/index.html @@ -229,6 +229,7 @@ footer { margin-top: 3.5rem; padding-top: 1.5rem; border-top: 1px solid var(--ru
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.
clang. There is a second one, off by default, that emits x86-64 by hand —
+see targets and builds.
What it is not:
@@ -326,7 +328,9 @@ $ ./_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] [--debug] [--target=wasm32-wasi]
+ flan import-c <header.h> [package.flan...] [clang flags...]
+ flan generate-c <package-dir>
+ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] [--debug] [--sanitize] [--x86] [--target=wasm32-wasi|web]
flan run <file.flan> [args...]
flan reload <program.flan> <forms.flan> [-o out.so]
flan dev <program.flan> [-s socket]
@@ -444,9 +448,17 @@ notation reads as exactly one data item.
string[T][n T](Vec T)(Map K V)(Pool T)(Handle T)i64(Ptr T)(Option T)Some / None(Fn [T ...] R)Allocator$tdefunion, matched by casei32()Neverif, when, unless, cond,
do, and, or, not,
-while, until, dotimes, return,
+while, until, dotimes, loop/recur,
+break, continue, return,
match. and and or short-circuit.
:else is cond's catch-all.
Loops are imperative, with while, until and
-return. There is no loop/recur. There is no
-break or continue yet either; both refuse by name:
break and continue leave or restart the innermost loop, and
+take a label when that is not the one meant. loop and
+recur are the functional shape beside them: loop is an
+expression, its value is the value of its body, and recur rebinds
+every name at once and jumps rather than calls — ten million iterations do not grow the
+stack.
break is not implemented yet (see the build sequence in plan.org)
+(defn gcd [a i32 b i32] i32
+ (loop [x a y b] ; recur rebinds every name at once
+ (if (= y 0) x (recur y (% x y)))))
-An early exit out of a loop is return, as first-even does
-above.
+(defn main [] ()
+ (println (gcd 84 36))
+
+ ;; loop is an expression: its value is the value of the body.
+ (println (loop [i 0 acc 0]
+ (if (= i 5) acc (recur (+ i 1) (+ acc i)))))
+
+ ;; continue in a dotimes advances the counter on the skipped iteration too.
+ (let [sum 0]
+ (dotimes [k 5]
+ (when (= k 2) (continue))
+ (set sum (+ sum k)))
+ (println sum))
+
+ ;; A label says which loop. Unlabelled, break leaves the innermost.
+ (dotimes :outer [a 3]
+ (dotimes [b 3]
+ (when (= b 2) (break :outer))
+ (println b))))
+
+12
+10
+8
+0
+1
+
+return is still the way out of a function from inside a loop,
+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.
defunion, and on nothing else. some unwraps
+Some and early-returns None from the enclosing function.
(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)))))
+ (Some (* 2 (some (index-of s 15)))))
(defn main [] ()
(match (doubled-first (slice nums 0 4))
(Some i) (do (print i) (println "")) ; 4
None (println "not found"))
- (match (index-of-i32 (slice nums 0 4) 99)
+ (match (index-of (slice nums 0 4) 99)
(Some i) (do (print i) (println ""))
None (println "not found")))
@@ -697,10 +741,19 @@ first
second
3
-defer is function-scoped and is rejected inside a
-let, a loop or a branch. Block scoping it is not done:
defer is function-scoped: it is copied into every exit path of the
+function, so it always registers and always runs at function exit. It is therefore
+rejected inside a loop or a branch, where "always registers" would be a
+lie. A let is fine, and that is not an exception — a let is not
+a frame here, its bindings are function slots like any other and nothing is released at
+scope exit, so a defer written in one has exactly the function's extent.
+That is the shape the permission exists for: acquire, defer the release beside it, then
+use it.
defer must be a top-level form in a function body — block-scoped defer is not implemented yet (milestone 4)
+defer is not allowed inside a branch — a defer is copied into every exit path of
+the function, so it always registers and always runs at function exit. Write it at
+the top level of the function body, or in a let that is (a let has the function's
+extent, because nothing is released at scope exit)
(set (at grid r c) v) and (addr (at grid r c))
A reversed range — lo greater than hi — traps, rather than
yielding a huge unsigned length.
+Generics
+
+Parametric polymorphism by monomorphisation: one body is written, and every call
+site gets a copy compiled at the types it passed. There are no type classes, no
+dictionaries and nothing decided at run time.
+
+A type variable is written $t where a type goes — a
+parameter's type, the return type, or nested inside a type constructor such as
+[$t] or (Vec $t). The sigil is not a binding-site-only
+spelling: every type position writes it. Bare t is the same
+variable in expression position, where a builtin takes the name of a
+type as an argument — (vec-new t), (map-new t i32),
+(pool-new t), and the cast (t x).
+
+(defn ident [x $t] $t x) ; needs nothing declared
+
+(defn twice [x $t] $t ; + - * / % need numeric?
+ {:where (numeric? $t)}
+ (+ x x))
+
+(defn clamp-to [x $t lo $t hi $t] $t ; < <= > >= min max need ordered?
+ {:where (ordered? $t)}
+ (min (max x lo) hi))
+
+(defn first-or [s [$t] d $t] $t ; the variable inside a slice type
+ {:where (copyable? $t)}
+ (if (= (len s) 0) d (at s 0)))
+
+(defn one-of [x $t] (Vec $t) ; bare t is the type-name argument
+ {:where (copyable? $t)}
+ (let [v (vec-new t)]
+ (push v x)
+ v))
+
+(defn main [] ()
+ (println (ident 3))
+ (println (ident "text"))
+ (println (twice 1.5))
+ (println (clamp-to 12 0 10))
+ (let [ns [5 3 9 1]
+ one (one-of 4.5)]
+ (println (first-or (slice ns 0 4) -1))
+ (println (at (as-slice one) 0))
+ (free one)))
+
+3
+text
+3
+10
+5
+4.5
+
+The body is checked once, abstractly
+
+A generic body is checked with nothing substituted, so an operator
+the variable is not declared to support is refused at the definition rather than at
+whichever call site first reaches a type that happens to work. That is deliberately
+not Odin's rule, which checks a polymorphic body per instantiation:
+
++ over the type variable t is refused: a type variable supports only what it is
+declared to support, and nothing here says t is numeric?. Write {:where (numeric?
+$t)} at the head of the body, or take the operation as a parameter — a
+(Fn [t t] ...) — and call it here
+
+What makes that liveable is a where clause, written as a Clojure-style
+map at the head of the body — {:where (ordered? $t)}, or a vector when
+there is more than one: {:where [(copyable? $t) (copyable? $u)]}. There
+are five predicates, and each gates builtins the compiler already has:
+
+
+
+Predicate What it admits
+numeric?+ - * / %, and a cast (t x)
+ordered?< <= > >= min max
+equal?= and !=
+hashable?the variable as a Map key — (map-new t V), get, put, has-key?
+copyable?reading the value more than once; Pool and Vec element positions
+
+
+
+They entail each other in one direction, so one clause usually does:
+numeric? gives ordered?, ordered? gives
+equal?, and any of the four gives copyable?. A
+sort! that compares its elements and reads them twice declares
+ordered? and nothing else.
+
+A type variable is move-only by default, and
+copyable? is the opt-out. Types.is_move_only of a variable is
+not decidable abstractly — the same variable is i32 at one instantiation
+and (Vec i32) at the next — so the checker assumes the stricter rule,
+which can only refuse a program that would have been fine and never admit one that
+double-frees. It is Rust's T: Copy, with the difference that the compiler
+answers the question rather than a user implementing a trait. So
+(defn twice [x $t] $t (+ x x)) does not merely want
+numeric?; reading x a second time is a use after move:
+
+x was moved at twice.flan:1:26 and cannot be used again — t is move-only, so
+binding, passing or returning one transfers ownership and the source binding is
+dead afterwards (spec-memory.md). That rule is what makes a double free
+unrepresentable; (clone x) if you wanted a second one
+
+Each instantiation then checks the concrete type against what the signature declared,
+and refuses the call site when it does not answer:
+
+this call instantiates twice at $t = bool, and bool does not answer numeric? —
+which twice requires, being written {:where (numeric? $t)}. The requirement is the
+signature's, so the refusal is here, at the call that asked for the type: pass one
+the predicate admits
+
+Two forms are deferred to the instantiation rather than settled abstractly, because
+their legality is only decidable after substituting: println over a
+variable, which selects the structural printer per copy, and the Map
+operations over a variable key, whose hash and equality are concrete symbols chosen from
+the concrete key type. The Map half is what hashable? buys —
+without the clause, the type (Map $t i32) is refused where it is
+written, and with it the refusal moves to the call site that names an
+unhashable key.
+
+This is not a type class and the difference is worth keeping straight. A type class
+carries implementations, selected per instance and extensible by anyone, and
+needs dictionaries and coherence rules. A predicate carries nothing — it gates a builtin
+that already exists. The ceiling is that nobody can supply a < of their
+own; every operation the prelude and the containers need is a primitive, so it does not
+bind. test/programs/generics.flan exercises the whole of it.
+
Printing
println prints a value and a newline; print is the same
@@ -775,7 +953,11 @@ no newline: true
The walk covers every integer and float type, bool, (),
string, [u8], enums, Ptr, Option,
-structs, fixed arrays and slices. An enum member comes back as its name: the value is
+structs, unions, fixed arrays and slices. An owning container has no printer for its
+contents and comes back as a marker instead — <vec>,
+<pool>, <allocator> — while a Handle
+shows its index and generation, and a Map has no printer at all.
+An enum member comes back as its name: the value is
an i32 by the time the backend sees it, so the name is recovered here from
the checker's table, and a value outside the declared members falls through to the
number, which is what you would want to see. A Ptr prints as
@@ -800,7 +982,7 @@ takes the value as it is and prints the number it holds.
The prelude is written in Flan, all but one line of it, and prepended to every +
The prelude is written in Flan, all but five lines of it, and prepended to every
program, so nothing in it needs importing. It holds no printing of its own:
print and println are the compiler's, and
write-stdout — the one output primitive — is what they are written
@@ -809,16 +991,27 @@ over.
| Group | Names |
|---|---|
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? |
| slice algorithms, over one type variable | swap!, reverse!, sort!, sort-by!, index-of, min-of, max-of, map!, reduce, filter |
| the per-type layer that stays | sum-i32, sum-f32 — the element and the accumulator are different types, which one variable cannot say |
| bytes | bytes=?, bytes<?, bytes-ci=?, starts-with?, ends-with?, index-of-bytes, trim, digit?, space?, sort-bytes! |
| parsing | parse-i64, parse-f64 |
| text | split-on-byte, split-next!, lower-ascii, upper-ascii, bytes-ci=? |
| text | split-on-byte, split-next!, split, lower-ascii, upper-ascii, to-lower, to-upper |
| building bytes | append!, append-i64!, append-f64!, concat, join, repeat-bytes, replace-bytes, slices-new, format-f64 |
| 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 |
| numbers | sign-f32, lerp, clamp, floor-f32, ceil-f32, round-f32, and the five declares: sqrt-f32, sin-f32, cos-f32, atan2-f32, pow-f32 |
| random | rand-seed, rand-u32, rand-f32, rand-i32-range, rand-f32-range |
| forms, for macros | form-nil, form-cons, form-append, form-rest, form-items, form-pair, form-sym?, form-is-sym?, gensym, and unless and into, which are macros written here rather than special forms |
| the rest | pause, which signals the Pause condition the break loop stops on, and embed-find |
One family, not one per type. The slice algorithms used to be
+sort-i32! beside sort-f32! beside sort-bytes!, and
+generics collapsed them: sort! is written once and instantiated at whatever
+element type the call passes. sum-i32 and sum-f32 are what did
+not collapse, and they are the honest exception — each widens its element into a
+different accumulator, which one variable cannot express.
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:
@@ -826,16 +1019,18 @@ native and on wasm32. The parsers are ours too:
"abc" and 12 for "12x", which are three wrong answers a caller
cannot tell from a real 12.
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. Every link carries -lm.
There is no println. There is no overloading yet, so each printer names
-its type. (println 1) is unknown function println.
Five functions in the file are not Flan, and they are libm's:
+(declare sqrt-f32 [x f32] f32 "sqrtf") and the same line for
+sinf, cosf, atan2f and powf. 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 other four are not: IEEE-754 requires
+nothing of sinf, cosf, atan2f or
+powf, and glibc, musl and wasi-libc do differ in the last bit — so the
+byte-identical-hash property the RNG exists for does not survive a hash routed through
+any of them. Every link carries -lm.
The primitives underneath are few — a primitive is the only thing implemented
twice per backend: argv,
@@ -930,9 +1125,9 @@ carries on.
signal has type (), always. A handler that returns
normally leaves the signaller to carry on — the accumulation case:
A clause may take parameters, which is how the answer comes from outside the +frame that offers the restart:
+ +(defn supplied [n i32] i32
+ (restart-case (middle n)
+ (use-value [v i32] (* v 2)) ; the handler supplies v
+ (retry [] 7)))
+
+(defn main [] ()
+ (handler-bind [(AssetMissing [c] (invoke-restart 'use-value 21))]
+ (println (supplied 7)))) ; 42
+
+42
+
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. An inner parser's skip-form is found
@@ -1032,7 +1241,11 @@ a guard after each call.
So the author chooses where the retry boundary is. A restart-case at the
top of a frame re-runs everything including mutations already applied; one placed after
the mutations re-runs only what follows. Put the restart before anything mutates, make
- the retried section idempotent, or snapshot what will be re-applied.
test/programs/frame-rollback.flan is the worked example of the snapshot, and
+the ordering in it is the part worth reading: restore in the restart clause and
+not in a defer, because a defer runs on the ordinary return path too and
+that version silently rolls back the frames that succeeded.
This matters more here than in most Lisps because the intended use is a
game loop, where the plan is to skip a frame and carry on rather than die. Now
that a bad index signals BoundsError instead of ending the process,
@@ -1047,7 +1260,14 @@ a guard after each call.
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.
- (use-value [s string] …) shadows an outer
+ (use-value [v i32] …) and (invoke-restart 'use-value 21)
+ stops the program even though the outer clause would have taken it. The clause's
+ parameters are slots of the function that wrote it, and the invoker fills a buffer that
+ function owns — by the time a clause runs, the invoking frame has gone.No library header is read during a build, deliberately, so a build needs the shared
-library to be linkable and not the -devel package to be installed.
+
A build needs the shared library to be linkable and not the
+-devel package to be installed: every declaration a package uses is written
+down in the package itself, so nothing has to go looking for a system header.
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.
@@ -1172,24 +1393,38 @@ directory may carry a headers file naming the library's own C heade
the same declare-c line a person would have written, and writes them to
generated.flan in the package — which is committed.
$ export FLAN_RAYLIB_H=/path/to/raylib-5.5/src/raylib.h
-$ flan generate-c vendor/raylib
-wrote vendor/raylib/generated.flan: 253 declarations, 156 refused, of 581 functions.
-Every defstruct and every hand-written declare-c agrees with it.
+$ flan generate-c vendor/raylib
+wrote vendor/raylib/generated.flan: 269 declarations, 117 refused, of 581 functions
+in vendor/raylib/raylib-5.5.h.
+Every defstruct, every hand-written declare-c and every mapped
+constant agrees with it.
-Committing the output rather than generating at build time is what keeps the -no-header property honest: the declarations are in the repository, so every build gets -all of them, they are greppable, and they show up in a diff when the library moves. The -argument is not caching — the clang dump is already cached on disk and in memory.
+Committing the output is what makes the declarations a dependency of the repository +rather than of the machine: every build gets all of them, they are greppable, and they +show up in a diff when the library moves. The argument is not caching — the clang dump +is already cached on disk and in memory.
-Regeneration is the check. The cost of committing the output is that
-nothing compares the bindings against reality on every build any more, so the one
-function that writes the file compares first and refuses to write when the
-package and the header disagree: every defstruct against the header's
-record, and every hand-written declare-c against the header's signature.
-Pointed at a raylib 5.1-dev header while the package is written for 5.5, it reports ten
-real differences and writes nothing — which is exactly the silent version skew a
-generated file would otherwise bake in and make look reviewed.
The header is committed too, at
+vendor/raylib/raylib-5.5.h, and headers names it by path with
+no environment variable in front of it. That line used to be
+?${FLAN_RAYLIB_H} — optional, on the argument that requiring a header would
+cost everyone the no--devel property in order to give the check to whoever
+had one. Committing the header dissolved that argument, because nobody needs
+raylib-devel to have a file that ships with the repository. So the check now
+runs on every build, and delete the header and the build says so by
+name rather than going quiet. What being optional actually cost was found the hard way:
+a gitignored web directory meant several working trees were checking against nothing and
+were not told, and a check that silently does not run is worse than no check.
Regeneration is the stronger check. An ordinary build re-reads every
+declaration against the header, but only the hand-written ones can disagree — the
+generated half came out of that header and agrees with it by construction. The one
+function that writes the file therefore compares first and refuses to
+write when the package and the header disagree: every defstruct
+against the header's record, and every hand-written declare-c against the
+header's signature. Pointed at a raylib 5.1-dev header while the package is written for
+5.5, it reports ten real differences and writes nothing — which is exactly the silent
+version skew a generated file would otherwise bake in and make look reviewed.
This is also why the hand-written bindings are kept rather than replaced by generated ones. Everything the generator emits agrees with the header by construction, so diffing @@ -1387,8 +1622,12 @@ sim/grid [ [ 0 0 0 0 0 0 0 0 ...] [ 0 ... ] ...]
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.
<vec>,
+<pool>, <allocator>, <handle 3:1>
+— and a function value renders as its signature, because the inspector reaches every local
+of a stopped frame and a frame holding one has to render rather than refuse. A
+Map is what still refuses by name.
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 @@ -1430,8 +1669,10 @@ something surprising.
| Key | Does |
|---|---|
| C-c C-c | the top-level form at point, recompiled and installed |
| C-u C-c C-c | …and mark it, so the program stops at the form point is inside (C-u C-u: on entry) |
| C-c C-k | the whole buffer, as one module |
| C-x C-e | the expression before point, evaluated in the running program |
| C-u C-x C-e | …and stop at it instead of printing its value |
| 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*) |
Some commands have no key. M-x flan-dev starts a program and
+M-x flan-dev-quit stops it. M-x flan-watch opens the watch
+buffer, which shows values while the program runs rather than while it is
+stopped, and M-x flan-watch-ghost-mode shows the same values inline at the
+call that wrote each one. M-x flan-inspect-address roots an inspection at a
+raw address rather than at an expression. M-x flan-allocations and
+M-x flan-leaks read the allocation registry: every block it recorded grouped
+by type, and the same walk with the dead left out.
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.
There is a second backend, and it is off by default.
+flan build --x86 lowers the checked program to x86-64 by hand —
+lib/x86.ml, writing an assembly file directly — instead of going through
+LLVM. It is the dev backend: LLVM stays the default and stays the release path,
+and the two never meet in one process, which is what lets the hand-written one pick its
+own internal calling convention (every aggregate by pointer, no eightbyte rule, no
+classifier) and match SysV only at the C boundary, where the shim has already flattened
+every struct.
It covers a subset of the IR and refuses the rest by name, so a +build that succeeds is one it really compiled rather than one it half-compiled. +Conditions are the visible gap — anything reaching the transfer channel is refused:
+ +$ flan build test/programs/algorithms.flan --x86
+Fatal error: exception Flan.X86.Unsupported("restart-case needs the transfer
+channel, which this backend does not emit a guard for")
+
--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
@@ -1604,41 +1871,42 @@ 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 |
'sym as a value | a quoted symbol (restart names) is not implemented yet — milestone 6 (see plan.org) |
| a bare lowercase type name | generic code over the type variable a is not implemented yet — milestone 5 (see plan.org) |
errdefer | errdefer is not implemented yet (see the build sequence in plan.org) |
await | await is not implemented yet (see the build sequence in plan.org) |
handler-case | handler-case is not implemented yet (see the build sequence in plan.org) |
find-restart, compute-restarts | … is not implemented yet (see the build sequence in plan.org) |
a union as a declare parameter | a parameter of g is U, which cannot cross to C directly — pass (Ptr U) and let the shim read it |
| a user-written allocator | a user-written allocator is not implemented yet, and a defn's name in that position … |
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.
Two of those rows want reading carefully. A quoted symbol works where a
+restart is named — (invoke-restart 'use-value 21) is the ordinary
+spelling — and is refused only as a value in its own right, because there is no symbol
+type to give it. And the type-variable row is about the sigil: bare
+a in type position is not a type variable and never became one;
+$t is, and generics is where it is written down.
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.
Beyond that list, and just as true: there is no overloading; defer is
+refused inside a loop or a branch (a let is fine — it has the function's
+extent); find-restart and compute-restarts are blocked on a
+Restart type rather than on effort; a restart with parameters cannot be
+taken from the break loop, which aims at a frame by position and has nothing to fill them
+with; 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.
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
-Form to exist as a Flan union value first.
One of these is settled rather than pending. There is no interpreter
+and there is not going to be one: compiling is the only way a form is ever run. The
+instrumentation-based step debugger that wanted one is cut, and compiled redefinition at
+~19ms is perceptually instant for expression evaluation too. That is also how macros run
+— a defmacro is compiled into a shared object and dlopened into the
+compiler before the file that calls it is expanded, so there is no second evaluator to
+disagree with the first.