BUILT.md gains "The header is read now", directly under the section whose last paragraph promised that reading a header was what would convert the trusted half into a checked one and that it was not built. That sentence is replaced by a pointer to the one below it, in BUILT.md and in shim.ml's docstring both. It records the things worth not re-deriving: why the dump and not libclang (and that Zig left libclang too, which strengthens the argument rather than weakening it), why the import is bounded by the package's own defstructs, why generating defstructs would make the check circular in exactly the way a _Static_assert was rejected for, refusal-by-demotion from Zig's failDecl, the naming rule and what it must actually guarantee, and both const-vs-non-const char * and the target-varying widths. The diff and the costs are stated as measurements, with the table: 16 of 16 defstructs and 172 of 172 declare-c agree against 5.5, ten real differences against 5.1-dev, release +4ms warm, redefinition 31.0 -> 46.5ms. DISCUSS.md item 6 is rewritten rather than removed. The mechanism question is settled and is now in BUILT.md; what is left is narrower and is two decisions that are the author's — whether the header stays a build-time read or becomes a committed generator, and whether the 172 hand-written lines migrate. Both have the argument on each side written out, including what migration would lose: key-pressed? is a better name than is-key-pressed, and an enum parameter imports as i32 because nothing tells the importer the package calls KeyboardKey "Key".
317 lines
22 KiB
Markdown
317 lines
22 KiB
Markdown
# Let's discuss
|
||
|
||
Open questions, raised and deliberately not answered yet. Nothing here is a decision or a task. Each entry is the
|
||
question as asked, plus what is already known in this repo that bears on it — so the investigation starts from what
|
||
exists rather than from scratch.
|
||
|
||
Settled decisions live in `NEXT.md`. Reasons for what already exists live in `BUILT.md`.
|
||
|
||
---
|
||
|
||
## 1. `i`, the inspector, and the frame it cannot see
|
||
|
||
Two things got conflated here and they should be separated.
|
||
|
||
**What the inspector already does.** Most of what was asked for is built. `flan-inspect` opens its own buffer, lays a
|
||
value's fields one per line, `RET` walks into one, `l` comes back, `g` re-reads. The renderer bounds its walk at depth 4
|
||
and span 8, and entering a field renders *that field* from depth 0 — so the elision moves with you rather than
|
||
truncating permanently. It is CIDER's inspector adapted, and the file says what the adaptation changed.
|
||
|
||
**What is actually missing** is detail on the leaves: a number shows in decimal only, with no hex and no binary, and a
|
||
pointer does not show its address. Purely additive, small, and worth doing.
|
||
|
||
**The real problem, and it is sharper than "a bug".** The locals listing renders from each frame's own slot addresses,
|
||
so it is frame-accurate. The inspector is built on a stack of **expressions** — going into a field means sending a
|
||
different expression (`(.pos b)` where the last was `b`), and `l` works by popping back to the previous one. That design
|
||
is forced: a Flan value has no header, the thunk that rendered it is `dlclose`d as soon as it returns, and there is no
|
||
heap to retain anything in, so nothing can be held server-side the way CIDER holds a JVM object.
|
||
|
||
The consequence is that `i` evaluates a name wherever the evaluator stands, **not in the frame being looked at**. On the
|
||
innermost frame that happens to be right. On any other it may resolve to a global, to a different binding, or fail —
|
||
with nothing saying so.
|
||
|
||
**An earlier suggestion in this conversation — "root the inspector at the slot's address" — does not work**, and the
|
||
reason is worth keeping: an address is not an expression, so the first `RET` has nothing to build the next expression
|
||
from and navigation dies at step one. Recorded because it is the obvious fix and it is wrong.
|
||
|
||
So the options are genuinely three, and none is free:
|
||
|
||
1. **Teach the program to evaluate an expression relative to a frame.** The most useful and the most work: the frame's
|
||
slots would have to be in scope for a compiled thunk, which means the daemon building a thunk whose free names bind
|
||
to that frame's addresses. It would also fix `C-x C-e` while stopped, which has the same blindness.
|
||
2. **Give the inspector a second rooting mode** — an address root that can still walk, by carrying a type alongside the
|
||
address and stepping to a field's address rather than to a sub-expression. Navigation then works, but the two modes
|
||
have different capabilities and `l` has to cross between them.
|
||
3. **Refuse `i` outside the innermost frame**, honestly and by name. Cheapest, and it gives up the feature exactly where
|
||
it is most wanted, since the innermost frame is the one already fully visible.
|
||
|
||
## 2. Annotating the IR and the disassembly with the source
|
||
|
||
**The IR half is nearly free and should just be done.** `emit.ml` writes `.ll` as text, so a comment costs nothing and
|
||
cannot break anything, and every typed IR node already carries a `Loc.t`.
|
||
|
||
**The disassembly half, with the optimisation question settled.** `objdump` already interleaves source into a listing
|
||
when DWARF is present (`-S`), and the daemon already shells out to objdump — so the `-O0` case is close to a flag.
|
||
|
||
Settled in conversation: **`-O0` is expected to follow the source and gets the full annotation; `-O2` is not expected to
|
||
and gets either nothing or whatever best-effort mapping falls out.** That removes what looked like the blocking
|
||
question. `--debug` forcing `-O0` is therefore fine and does not need decoupling for this.
|
||
|
||
**How SBCL does it, since it came up.** SBCL does *not* shell out. `sb-disassem` is its own disassembler, written in
|
||
Lisp, that knows the instruction encodings directly. What that buys is annotation from the inside: it labels constants
|
||
the function references, names the functions being called, marks entry points, and shows its own calling conventions —
|
||
because it compiled the code object and still holds the metadata.
|
||
|
||
The relevant observation is that **this project is closer to SBCL's position than the objdump route suggests.** The
|
||
daemon owns the build and holds the metadata too; it simply is not feeding much of it into the listing yet. Naming the
|
||
function behind an indirection cell, or a constant by its source name, needs no instruction decoder — only the
|
||
information the daemon already has. Writing a disassembler is not the interesting part and should stay off the table;
|
||
richer annotation of objdump's output is cheap and is where SBCL's advantage actually comes from.
|
||
|
||
## 3. `def`, `defvar`, `defconst` — three roles, currently two words
|
||
|
||
There is no `def`. `defvar` is a mutable global that is always re-initialised on redefinition; `defconst` is a
|
||
compile-time constant, folded into its use sites.
|
||
|
||
The problem in practice: `C-c C-k` re-evaluates every top-level form against a *running* program, so today it wipes
|
||
state you may have spent a session accumulating.
|
||
|
||
**Settled in conversation — three roles, three words:**
|
||
|
||
- **`def`** — always re-initialised. The tweakable: some number you are adjusting and do not care about preserving.
|
||
- **`defvar`** — re-initialised *only if it would come out different*. For heavier initialisers: an N×M grid that should
|
||
recompute only when the rows or columns actually changed.
|
||
- **`defconst`** — folded, and reserved for what genuinely cannot change for the life of the program. `rows`/`cols` in
|
||
`sand.flan` are honest `defconst`s: the program does not support resizing on the fly and changing them means resetting
|
||
everything anyway.
|
||
|
||
Note this **`defvar` is not Common Lisp's**, which is "assign only if unbound". This one is value-dependent, which suits
|
||
a live-edited game better and is the harder of the two to implement.
|
||
|
||
**The open question is what "different" means**, and the grid example is what makes it sharp. If the grid is sized by
|
||
`rows` and `cols`, the initialising expression's *text* is unchanged when `rows` changes — only its value is. So:
|
||
|
||
1. **Compare the expression** (textual or AST). Cheap, and does not catch the case above.
|
||
2. **Compare the expression plus everything it depends on.** Catches it. Needs dependency tracking.
|
||
3. **Evaluate and compare the result.** Correct, and defeats the entire purpose whenever the initialiser is expensive —
|
||
which is the only case this feature exists for.
|
||
|
||
Option 2 is what is wanted.
|
||
|
||
**The reason to do it: it is the same machinery `defconst` needs.** A folded constant cannot be tuned live, because its
|
||
value is baked into every function that used it — so the values you most want to tweak while the game runs are exactly
|
||
the ones you cannot. Fixing that means knowing which functions depend on which constants, and rebuilding those. That is
|
||
the same dependency tracking `defvar`'s "if different" requires. Built once, it buys live-tunable constants *and* the
|
||
conditional re-initialisation. Neither is worth building alone; together they are clearly worth it.
|
||
|
||
## 4. Structural typing, row polymorphism, anonymous structs
|
||
|
||
The goal as stated: make this feel Clojure-like while still being typed.
|
||
|
||
**The representation is already structural.** `types.ml` does structural equality on resolved types, and a Flan struct is
|
||
exactly its C layout with no header and no tag word. What is nominal is the *checking*, not the data — so this is a
|
||
front-end question, not a data-model change.
|
||
|
||
**What it buys, in rough order of value here:**
|
||
|
||
1. **Your destructuring already looks like this, and that is the strongest argument.** Clojure's `{:keys [x y]}` works in
|
||
binding position today. Structural typing makes the same notation work in *parameter* position — the pattern you write
|
||
to pull fields out becomes the signature saying what you accept. One notation, two places, no separate declaration.
|
||
That is precisely the "Clojure-like but typed" feel being asked for, and it needs no new syntax, only a new meaning
|
||
for syntax that exists.
|
||
2. **Functions over "anything with these fields".** Magnitude over anything with an `x` and a `y` — a position, a
|
||
velocity, an enemy, a bullet. In a game this recurs constantly because everything has a position.
|
||
3. **Ad-hoc returns.** Returning a hit flag and a point without declaring a type for the pair. The Clojure habit of
|
||
returning a map, typed and free.
|
||
4. **The FFI stops needing conversions.** raylib's `Vector2` and a locally-defined vector are byte-identical and are
|
||
today two nominal types. Structurally they are one.
|
||
|
||
**Two costs, and the second is the real work:**
|
||
|
||
- **Not an "explicit over convenient" question — that framing was wrong and is recorded so it is not repeated.** The
|
||
standing rules about implicit numeric conversion and about a bare integer not becoming an enum are about *lossy*
|
||
things happening silently. A structural match is not lossy; it is a different notion of type *identity*. The two were
|
||
lumped together in conversation and the author rejected the pairing, correctly. Strong typing and nominal typing are
|
||
separate choices and this only touches the second.
|
||
- **Field order is layout, and that is the hard part.** `{x f32, y f32}` and `{y f32, x f32}` have the same fields and
|
||
different memory. So either field order becomes significant — surprising, since nothing else about a structural type
|
||
should be — or matching a differently-ordered type requires a copy, which breaks the zero-cost property the whole data
|
||
model rests on. Accepted in conversation as a limitation to respect rather than fight.
|
||
|
||
**Writability is what decides whether the copy escape exists.** Fields would be writable on the same terms as any struct:
|
||
by value you get a copy and writes are local; through a `(Ptr T)` they reach the original. But that settles the layout
|
||
question rather than sitting beside it. *Read-only* structural access has an out — the compiler could gather the
|
||
requested fields into a temporary, and then field order would not matter at all. *Writable* structural access cannot:
|
||
it has to alias the real storage, so the layout must genuinely line up. Decide writability first; the layout answer
|
||
follows from it.
|
||
|
||
**`defclass` may solve this, for the cases that can afford it.** A class has an implementation-defined representation, so
|
||
the compiler owns the layout and access goes through metadata rather than a fixed offset — field order stops being a
|
||
user-visible fact. The limit is that a class carries identity and metadata, and a `Vector2` should not pay for either.
|
||
So plain structs still need their own answer and classes only cover the cases where the overhead was already accepted.
|
||
See item 7.
|
||
|
||
Also unresolved: the shim generates a C typedef per *named* struct, and an anonymous struct has no name to generate one
|
||
from. Either anonymous structs cannot cross the FFI, or the generator learns to name them.
|
||
|
||
## 5. A JS backend — for web apps, not for games
|
||
|
||
**The goal, stated in conversation: calling JS libraries, and eventually a hiccup-style DSL for writing web apps in
|
||
Flan.** That is a different product from the wasm target and the two do not compete — games go to wasm, web apps go to
|
||
JS.
|
||
|
||
This matters because the obvious objection does not apply. "A JS backend cannot call raylib, so the whole graphics layer
|
||
would need reimplementing on canvas" is fatal for a *game* backend and irrelevant here: web apps do not use raylib.
|
||
|
||
**The design fork, and it should be decided first:**
|
||
|
||
- **Linear memory** — an `ArrayBuffer` standing in for pointers and structs, with typed-array reads and writes. Fast
|
||
arithmetic, and every value is opaque bytes, so every call into a JS library marshals both ways. This is asm.js, which
|
||
wasm exists to replace, and it is precisely wrong for an interop-motivated backend.
|
||
- **Object mapping** — a Flan struct becomes a plain JS object, a `Vec` becomes a JS array. Slower for tight numeric
|
||
loops, natural for interop, readable output. **For this goal this is clearly the right one**, and performance is not
|
||
the constraint people assume: DOM work is dominated by the browser, not by arithmetic.
|
||
|
||
**The consequence to face early: the memory model does not come along.** Object mapping means garbage collection, which
|
||
means no pointers, no manual `free`, no arena, no allocator. So this is a **dialect**, not merely a second backend —
|
||
some Flan programs will not compile to JS, and that should be named up front rather than discovered. Worth deciding
|
||
which subset is the JS-targetable one, and whether the checker enforces it per target.
|
||
|
||
**The unexpected upside: the dev loop could work on the web.** Conditions, restarts and a break loop all need the
|
||
ability to run new code in a stopped program. wasm cannot have this — `--dev` is refused there because reload is
|
||
`dlopen` and the browser has no sockets. JS has no such problem: it evaluates new code trivially. A JS target might be
|
||
the *only* place the live dev loop and the browser coexist.
|
||
|
||
**Reader conditionals, decided in conversation.** A cljc-style subset is accepted, and the author chose Clojure's
|
||
inline reader conditionals over file-level target naming. Note what that changes: **this language has no conditional
|
||
compilation today**, and that absence drove a decision earlier the same day — `barf` signals on web rather than being
|
||
compiled out, precisely because "this bit is desktop-only" is not expressible. Note also that the sand-on-web work
|
||
introduced file-level target replacement for *C* sources (`foo.web.c` replaces `foo.c`), so the repo now has a
|
||
file-level precedent that this decision deliberately does not follow. Both were put; inline was chosen.
|
||
|
||
**Also wanted, and independent of all of the above:** `#_` to discard the next form, and repeated (`#_#_`) to discard
|
||
that many following forms, as in Clojure. Reader-only, small, and useful immediately rather than whenever a JS backend
|
||
happens.
|
||
|
||
**One dependency:** hiccup is a macro, and the macro expander is blocked on `Form` being a Flan union, which is union
|
||
values. The backend can start before that; the DSL cannot.
|
||
|
||
## 6. C interop as seamless as Zig's — built, with two decisions left
|
||
|
||
**The mechanism is in** (`lib/cimport.ml`, `lib/cjson.ml`, `vendor/raylib/headers`; BUILT.md, "The header
|
||
is read now"). Settled and not worth reopening: clang's JSON AST dump over a shelled-out `clang`, never
|
||
libclang — and Zig has since abandoned linking clang too, for Aro, which strengthens the argument rather
|
||
than weakening it. The import is bounded by the package's own `defstruct`s rather than by a curated list.
|
||
Refusals are demotions in Zig's sense: the name exists, cannot be had, and says why at the use site, which
|
||
`Load.refuse_hidden` already did for `main`. Names kebab by a rule that is injective over raylib's 581, and
|
||
reversibility is by storage — the C symbol is kept verbatim — so the rule never needs an inverse. Where two
|
||
names do collide, neither takes it.
|
||
|
||
**The evidence:** against raylib 5.5, all 16 `defstruct`s and all 172 hand-written `declare-c` agree
|
||
exactly. Against the 5.1-dev header also on this machine, ten real differences. Both comparisons stop the
|
||
build, and a permuted `Texture2D` or an `f64` for a `float` is caught by name.
|
||
|
||
What is left is two decisions, and both are the author's.
|
||
|
||
### 6a. Does reading the header stay a build-time step, or become a code generator?
|
||
|
||
The cost, measured, with the wrappers pruned by `Reach` as they already were:
|
||
|
||
| | today | + 256 imported |
|
||
|---|---|---|
|
||
| release build, warm | 0.078s | 0.082s |
|
||
| **redefinition** | **31.0ms** | **46.5ms** |
|
||
| dev build, cold | 0.649s | 0.982s |
|
||
|
||
Release is nearly free and that question is answered. **The 15.5ms on redefinition is not nothing on the
|
||
branch where the dev loop is the priority** — it is a 50% increase on the number that lane exists to keep
|
||
small, and it buys a check of signatures that have not changed since the last build.
|
||
|
||
So the third option from the original discussion is now the live one: `flan import-c` already prints
|
||
`declare-c` lines, so **generate from the header, commit the result, regenerate when raylib moves** costs
|
||
nothing more to build. Explicit in the source, checked against reality, no header read at build time, and
|
||
the check becomes a thing you run rather than a thing you pay for. Against it: a committed file goes stale
|
||
silently, which is the failure the whole lane exists to prevent, and "regenerate when the library moves"
|
||
is a discipline rather than a mechanism.
|
||
|
||
A middle reading worth considering: keep the build-time check but run it only when *not* `--dev`, on the
|
||
grounds that a release build is where a wrong signature must not get through and a dev build is where
|
||
15.5ms is felt. That is the same shape as `Reach` not pruning dev builds, for a symmetric reason.
|
||
|
||
### 6b. Do the 172 hand-written lines get migrated?
|
||
|
||
The diff is clean, so nothing blocks it on correctness. What blocks it is that migration needs the header
|
||
present at *every* build, which means vendoring raylib.h into the repo or requiring `raylib-devel` — and
|
||
BUILT.md records "a build needs libraylib linkable and not raylib-devel installed" as a property that was
|
||
chosen on purpose. That is why `vendor/raylib/headers` is opt-in (`?${FLAN_RAYLIB_H}`) today and the
|
||
hand-written lines are untouched.
|
||
|
||
Worth noting what migration would actually lose, since it is small but real: the hand-written names are
|
||
better than the rule's. `IsKeyPressed` is `key-pressed?` by hand and `is-key-pressed` by rule;
|
||
`CheckCollisionRecs` is `collision-recs?`. And an enum parameter imports as `i32`, because the header says
|
||
`KeyboardKey` and nothing tells the importer the package calls that `Key` — so `(rl/key-down? :space)`
|
||
would become an integer at the call site. A migration is therefore not a deletion; it is a deletion plus a
|
||
kept list of the lines whose face is deliberately nicer than the header's.
|
||
|
||
## 7. Watching variables
|
||
|
||
Raised while designing item 1, and deliberately separated from it.
|
||
|
||
Item 1 shows globals *per frame*, chosen by what the code in that frame references. That is the right default and it
|
||
cannot cover the case where the thing you care about is not named by the frame you are standing in — stopped deep in a
|
||
helper, still wanting to see the grid.
|
||
|
||
So: a way to say "always show me this", surviving a resume and the next break.
|
||
|
||
Questions it opens:
|
||
|
||
- **What can be watched.** A global is easy — it has a name and an address that does not move. A *local* is harder: it
|
||
belongs to a frame that is gone as soon as you resume, so "watch `y`" either means a different `y` every time or means
|
||
nothing. A watched expression — `(at velocity 40 12)` — is the most useful and the most expensive, since it has to be
|
||
compiled and run in the program each time, which is what `eval-expr` already does.
|
||
- **Where the list lives.** Per project, per session, or in the file. A watch list that vanishes when Emacs restarts is
|
||
one you stop using; one in the repo is one you accidentally commit.
|
||
- **When it updates.** Only on entering a break is cheap and probably enough. Live-updating while the program runs is a
|
||
different feature — closer to a HUD than a debugger — and worth not conflating.
|
||
- **Whether it belongs in the break buffer at all**, or in its own window that is useful while the program is *running*,
|
||
which is arguably where a game developer wants it.
|
||
|
||
## 8. Indentation is wrong inside a binding vector
|
||
|
||
Reported against the `let` in `sand.flan`'s `settle`: the second and later bindings indent one column too far.
|
||
|
||
**Cause, found by reading `flan-mode.el`.** `flan-indent-function` looks at the head of the *enclosing* form and, when
|
||
it is one of Flan's body forms (`defn`, `let`, `if`, `while`, `until`, `dotimes`, `match`, `do`, `loop`, `defer`),
|
||
indents one past the open paren. Inside a `let`'s binding *vector* the enclosing open is the `[`, and the symbol after
|
||
it is the first binding's **name** — never a body form — so the check fails and it falls through to Emacs's generic
|
||
`lisp-indent-function`, which treats the vector as a function call and aligns continuation lines under the first
|
||
*argument* rather than under the first *binding*.
|
||
|
||
**What it is built on:** Emacs's built-in `lisp-indent-function`, with that one override. Not `clojure-mode`, which is
|
||
where the missing piece lives — `clojure-mode` special-cases binding vectors and aligns them as pairs.
|
||
|
||
**The fix, agreed in conversation:** add a binding-vector case to `flan-indent-function`. When the enclosing open is `[`
|
||
and the form containing it is a binding form, align continuation lines to the column of the **first binding**, not to
|
||
the first argument. Bindings are pairs, so the alignment that reads correctly is name-under-name. Check the other
|
||
bracket users in the same pass — `defn` parameter lists and `restart-case` clause parameters have the same shape and
|
||
almost certainly the same bug.
|
||
|
||
**`clojure-mode` should be the reference, not the ancestor and not a dependency.** The distinction matters and was
|
||
muddled once in conversation: the proposal is *not* `define-derived-mode` on `clojure-mode`, and not adding an external
|
||
package. It is to rewrite `flan-mode`'s indentation and font-lock as our own code, **ported from `clojure-mode`'s source
|
||
rather than from `lisp-mode`'s**.
|
||
|
||
The current mode has the wrong ancestor. It is built on Emacs's `lisp-indent-function`, and Emacs Lisp has none of the
|
||
shapes Flan actually uses: no vectors as binding forms, no keywords as map keys, no destructuring, no bracket variety.
|
||
So every rule has to be added by hand and the binding-vector bug is simply the first one hit. Clojure's rules already
|
||
cover brackets-mean-binding, pairs-align, maps, keywords, `#_`, and reader conditionals — the last two now wanted in
|
||
their own right.
|
||
|
||
Where Flan diverges it diverges deliberately rather than by discovery: `defn` carries a return type between the
|
||
parameters and the body; field access is `(.x v)`; field labels are moving from `:x` to `.x`, so `{.x 1.0}` is a struct
|
||
literal and not a map with symbol keys. Those get written on purpose.
|
||
|
||
Deriving at runtime stays rejected — it would add an external dependency to a mode that ships inside this repo and
|
||
currently needs nothing beyond stock Emacs, and that community is mid-transition to a tree-sitter mode, so the base is
|
||
moving.
|