11 KiB
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. Inspecting globals, separately, in the break buffer
Locals are readable now; globals are not shown anywhere. They are arguably the more useful half in this language today,
because a Flan game keeps most of its state in top-level defvars — sand.flan holds its entire grid that way.
What already exists: the daemon's describe op returns the program's globals by name, and Session.render walks a
concrete type to a printed value. The eval-expr path already renders a global by name inside the running program. So
the machinery is there and the question is mostly one of presentation — its own section in the break buffer, folded like
the stack, or a separate buffer.
The real question is which globals. A program has hundreds and a listing of all of them is the backtrace problem again: the thing you want is buried by the thing you don't.
Direction: one Globals section, scoped to the stack. Per-frame was considered and dropped. The reason is that a global is not part of a frame — it is program state the frame happened to touch — so nesting it under one implies an ownership that is not there, and the same name then appears once per frame that reads it.
So: a section of its own, whose contents are the union of the globals every frame on the current stack references. That keeps the compiler doing the choosing (the reference set per function is already known) without repeating anything, and without falling back to listing all of a program's globals, which is the backtrace problem again.
Two refinements that came out of the same conversation:
- Annotate each entry with which frames touch it. This recovers what per-frame would have told you — "the whole chain is reading this" reads differently from "only the innermost one is" — at no cost in duplication.
- Order by the innermost frame that touches it. A deep stack makes the union large again, and proximity to the error is the ordering that puts the likely culprit on top.
What this deliberately does not solve: a global that nothing on the stack names but that you still want visible — the sand grid while stopped in a helper that never mentions it. That is watching, and it is item 10 rather than part of this.
2. The break buffer should appear by itself
Today a condition stops the program and the buffer only opens when C-c C-b is typed. It should pop-to-buffer the
moment the program stops.
What already exists: flan-dev--absorb inspects every reply for :stopped, and there is a poll for the case where no
reply is pending — the client already knows the moment it stops, and already updates the mode line from it. So this is
a hook at a point that exists, not new plumbing.
To decide: whether it steals focus or only displays; whether it should also fire for a (pause), which is a deliberate
stop and arguably always wants the window; and what happens when it stops while point is mid-edit in another buffer.
3. What is i supposed to inspect in the stack section?
Raised as a question about intent, and it deserves one, because the current answer may be wrong rather than merely undocumented.
i on a local passes that local's name to the inspector, which evaluates it as an expression inside the running
program. That is correct for the innermost frame, where the name is in scope. It is not obviously correct for any
other frame — evaluating y while looking at frame 3 evaluates y wherever the evaluator stands, not in frame 3.
It may resolve to a global, to a different binding, or fail.
The locals listing does not have this problem: it renders from the frame's own slot addresses. So the display is
frame-accurate and the inspector may not be. Options: root the inspector at the slot's address the way the listing is
rooted; refuse i outside the innermost frame; or make the inspector frame-aware. Worth settling before anyone relies
on it.
4. Annotating the IR and the disassembly with the source
The compiler should interleave the originating Flan expression into both the emitted LLVM IR and the disassembly listing.
What already exists: emit.ml writes .ll as text, so a comment costs nothing and cannot break anything. Every typed IR
node carries a Loc.t. DWARF is emitted under --debug, with a line table naming the .flan file, and the disassembly
listing already rebases addresses and annotates cells, calls and branch targets. So both halves have the information
already; what is missing is the interleaving.
The IR half is nearly free. The disassembly half is more interesting and more valuable — it is what would let you see what one line of Flan actually costs, including the indirection cell a dev build puts on every cross-function call.
5. def, defvar, defconst
There is no def. There is defvar (mutable global) and defconst (compile-time constant, folded).
The proposal: a def that is mutable, where defvar only rewrites the value if the variable is new — Common Lisp's
actual defvar semantics, where re-evaluating a defvar deliberately does not clobber a value you have been building up
at runtime. That distinction matters much more here than in most languages, because C-c C-k on a whole buffer
re-evaluates every top-level form against a running program, and today that resets state you may have spent a session
accumulating.
defconst under redefinition is the unclear one, as noted — it is folded into its use sites, so changing one is closer to
a recompile than to an assignment.
6. Structural typing, row polymorphism, anonymous structs
Worth noting before this is scoped: types.ml already does structural equality on resolved types, and a Flan struct
is exactly its C layout with no header or tag word. So the representation is already structural; what is nominal is the
checking, not the data.
The questions this opens: whether a function can take "any struct with an x and a y"; whether anonymous structs get a
spelling; how this interacts with the FFI, where the shim generates a C typedef per named struct; and how it interacts
with the planned managed classes, which are explicitly the opposite direction — identity and metadata rather than plain
layout.
7. A performant JS transpiler
Asked as a feasibility question. Bear in mind the web target already exists and ships real machine code via wasm, so this is not the only route to a browser and the case for it needs stating: smaller artifacts, no wasm toolchain, debuggability in browser devtools, or something else.
The hard parts are the ones the wasm target got for free from clang: the memory model (Flan is pointers and explicit layout; JS is not), the FFI, and the fact that the whole raylib layer is C. A JS backend that cannot run raylib is a different product from the one that can.
8. C interop as seamless as Zig's
Today: declare-c names one C function per line, and the compiler generates the wrapper, the typedefs and the flattened
declaration. 175 of them for raylib. No header is ever read, deliberately — which means nothing can check that a
declaration matches the real signature, and that is written down as trusted rather than guaranteed.
The proposal is to read the header, prefix a namespace, and get rl/InitWindow for free — plus possibly automatic
kebab-casing to rl/init-window.
This is a large change in kind, not just in size: it means a C parser or a libclang dependency in the build, and it
trades an explicit, checkable list for an implicit surface. Worth weighing against what declare-c already buys, which
is that a binding is one line and the wrapper is generated. The auto-kebab-case question is separable and much smaller —
and note the FFI currently keeps C's own spelling on purpose, so the mapping would need to be reversible.
9. Where defclass stands
Specified in plan.org and deliberately not started — its own last line says nothing happens until ordinary
struct, Handle and reload semantics work. Those have largely landed since that was written, so the gate may be closer
than the document assumes.
What it is meant to add: identity, runtime shape metadata, an implementation-defined representation, generic-function dispatch, and live schema change with an explicit migration at a frame boundary — which is the answer to the one thing redefinition still cannot do, changing a struct's layout while instances exist.
Three findings from an earlier review, recorded in NEXT.md and not yet in plan.org:
- A generic function is a cell. Adding a method from a later module is the same problem indirection cells already solve, so the expensive half is built and tested.
- The pool is not one storage option among three.
migrate-instanceshas to enumerate live instances, which a pool behind a generationalHandlegives by construction and the other two options do not. - Every layout version must stay resolvable for as long as any instance holds it — the same rule as "nothing is ever
dlclosed".
Also open and related: whether a condition can be a class, which decides whether handler matching has one path or two.
NEXT.md records the argument; decision 4 there took the cheaper parent-link route for now and explicitly left real
inheritance possible later.
10. 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 differentyevery 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 whateval-expralready 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.