30 KiB
- Additional things
- flan dev blocks 4-5s before first eval when the program doesn't call agent/start
- add // for forced truncating division?
- "generic code over type variable X" is a bad error for a plain typo
- eval result ghost text position/minibuffer, both deliberate
- "unknown name u8" from (defconst grid [rows [cols u8]]) — not a real i8/u8 bug
- (when test) with no body should work like (when test (do))
- bare struct literal {.field v} can't infer its type from context (e.g. defn return type)
- struct destructuring shorthand: {.row .col} binding same-named locals
- C-c C-i should auto-inspect at point, C-u C-c C-i for the minibuffer
- inc/dec (pure) and ++/– (mutating) — doable as user macros, no compiler change
- (Cell 1 2) as a positional struct constructor
- a DEADBEEF-style sentinel-fill builtin, instead of zero
- struct field type change: got a plain type error, not the documented refusal
- compiler messages need a persistent, copyable buffer — not just Messages
- defmacro should support real parameter lists, not just one [Form] arg
- profiling: use Tracy, don't build our own
- no type-limit constants: u8-max, i32-max, f32-infinity, etc. — nothing exists
- investigate SBCL's redefinition model — warn + keep old value until callers update
- need a value-producing array constructor, usable as a defvar initializer
- revamp the flan buffers: flan-dev has no compilation-mode / jump-to-error
- implicit numeric conversions with a warning flag, instead of hard errors
- pos?/neg?/zero? don't exist, and need to be generic over numeric types
- println output goes to flan-output, not inline in the repl
- main cannot actually be redefined
- the "queued; the program is parked…" note is annoying
- (rl/with-drawing ()) fails with a confusing error
- error inside a macro-expanded body points at the macro call site, not the real line
- built-in comment
- #_ (discard) isn't syntax-highlighted, though it works correctly
- syntax highlighting: defmacro/when/etc missing, and macros aren't dynamic
Additional things
flan dev blocks 4-5s before first eval when the program doesn't call agent/start
"ready on sock" prints in [merged_setup] before [accept_loop] ever runs. [merged_serve] (lib/dev.ml:4417) waits up to 10s (await ~ms:10000, line 4427) for agent.sock to exist before starting accept_loop. The listen socket is already up, so Emacs connects fine, but nothing answers until that wait finishes/times out. So a program with no (agent/start …) looks "ready" but actually stalls the first request for up to 10s.
Maybe accept_loop should start immediately and the agent-socket wait should happen concurrently / only gate agent-specific requests?
Update: happening a lot with sand.flan, not just once. sand.flan calls agent/start (sand.flan:125) but only after rl/init-window (line 122) returns, so if window setup is slow the 10s wait in merged_serve lapses before agent/start ever runs — and since accept_loop is gated behind that wait, this isn't just a noisy log line, it's a real Emacs stall every time. Reinforces: fix should be starting accept_loop unconditionally rather than tuning the timeout.
Update: likely explains C-M-i (completion-at-point) and eldoc both breaking at once. Both read flan–defs (emacs/flan.el:1511), refreshed only on connect and after an accepted eval, always through `ignore-errors` (flan.el:592, 1743, 1841, 2333). If a refresh lands during the accept_loop stall it just silently fails, leaving flan–defs empty/stale with nothing retrying it — killing both features with no visible error. Same underlying fix (start accept_loop unconditionally) should resolve this too.
Update: also probably explains "redefined game-draw via C-c C-c but raylib never updates" — deliver (lib/dev.ml:159, called at line 764) pushes the new module over the same agent connection. A stale/half-set-up connection from the accept_loop race could report "ok" on install without the game thread's agent/poll ever actually picking it up at the next frame boundary. Quick workaround: M-x flan-restart-program for a fresh connection, then redefine again. Real fix is still the accept_loop one.
add // for forced truncating division?
Lexically free (comments are `;`, not `/`). But nothing is actually missing: `/` on two ints already truncates toward zero (sdiv/udiv, lib/emit.ml:2529), and casting a float division truncates toward zero too ((i32 (/ 7.0 2.0)) -> fptosi -> 3, lib/emit.ml:2909). So `//` as sugar for that is trivial.
Catch: Python's `//` is not toward-zero truncation, it's floor division — rounds toward -infinity. (-7 / 2) is -4 in Python, but sdiv/fptosi give -3. So "Python-like /" and "forced truncation" are two different asks.
Resolved: Joe wants the toward-zero-truncation one, for when a float division needs an int result. (int (/ a b)) already does this — no new operator needed. Not adding //.
"generic code over type variable X" is a bad error for a plain typo
Hit with `int` before defining the defalias for it. resolve_name (lib/check.ml:776-778) treats any unrecognized lowercase name as a type variable by default; near_miss (lib/check.ml:670-698, single-edit-distance only) is what rescues real typos into "unknown type X — did you mean Y?" (line 770-772). `int` -> `i32` is 2 substitutions, outside that net, so it falls into "generic code over the type variable int is not implemented yet — milestone 5" instead of a plain "unknown type int".
Common near-misses (int, float, double, str, …) all get this confusing message rather than "unknown type". Maybe near_miss should also check a short hardcoded list of common non-flan spellings (int/float/double/bool/str etc. from other languages) regardless of edit distance.
eval result ghost text position/minibuffer, both deliberate
Both things I want changed are documented decisions, not bugs:
- Position is "end of the form sent" (flan.el:1822-1825), not end of line — deliberately different from flan-watch's end-of-line ghost text so the two don't get confused (flan.el:1381-1389).
- Overlay and minibuffer echo are mutually exclusive on purpose (flan-inline-result docstring, flan.el:91-101): "saying the same number twice is how a reader learns to stop reading both."
I want it at end of line + always also in the minibuffer. Revisit — would mean changing flan–report's `at` computation and the shown/echo either-or in flan.el:1847-1848.
"unknown name u8" from (defconst grid [rows [cols u8]]) — not a real i8/u8 bug
i8/u8 etc. all exist fine (Types.ikind_of_name, lib/types.ml:72). Real issue: defconst's 2-arg form (defconst name value) has no type slot — the second form is always parsed as a VALUE via expr, never as a type (lib/parse.ml: 1341-1345). So [rows [cols u8]] became an array literal, and `u8` inside it read as a bare variable reference, which is genuinely unbound as a value -> "unknown name u8".
defconst has no "type only, no value" form; that's defvar's job. Fix: use defvar instead — (defvar grid [rows [cols u8]]) is defvar's 2-arg (name Type) form (lib/parse.ml:1333-1335), zero-initialized, and parses the bracket correctly as a type via texpr. Fits anyway since grid is mutable.
(when test) with no body should work like (when test (do))
lib/parse.ml:305-309: `when` is a hardcoded special form (not a macro yet — "until macros land at milestone 5") and requires `body <> []`, else fails "when is (when test body …)". No real reason for the restriction — empty body could just become Ast.Do [], same as (do) already means. One-line fix (drop the `body <> []` guard) whenever this file gets touched.
bare struct literal {.field v} can't infer its type from context (e.g. defn return type)
`(defn get-mouse-cell [] Cell … {.row r .col c})` fails to parse — "a bare map is not an expression; write (Type {.field v})". Root cause: this refusal is in `expr` at PARSE time (lib/parse.ml:274-276), before type-checking ever runs, so it can't see that the enclosing defn's return type is Cell. check.ml does support expected-type-driven checking elsewhere (`check ~want:ty`), but a bare struct literal never reaches it — parse rejects it first.
To make this work, the refusal would need to move from parse into check, so it can consult a `want` type (return type, let annotation, etc.) and only fail when there truly isn't one available. Real feature, not a small change.
struct destructuring shorthand: {.row .col} binding same-named locals
Only two struct-pattern forms exist: {:keys [x y]} (lib/parse.ml:729-753) and {name .field} pairs (line 759-762) — both spell the field name twice ({:keys [x y]}) or need name+dot per field. :keys makes sense for dyn maps (keys aren't statically known field names), but a struct's fields are typed and known, so a bare {.row .col} that just binds `row`/`col` locals directly would read better and is the common case.
Would be a new match arm in `dmap` (lib/parse.ml:724-778) for a lone .field symbol with no name before it. No obvious grammar collision — nothing currently matches a bare .field symbol on its own. Not implemented.
C-c C-i should auto-inspect at point, C-u C-c C-i for the minibuffer
Feature request. Today flan-inspect (emacs/flan-inspect.el:681-696) always prompts via read-string, pre-filled with the sexp before point — no current-prefix-arg handling anywhere in the file, and emacs/MANUAL.md:369 documents no C-u variant either. Not sure if I'm misremembering discussing this for flan-inspect specifically or some other command — check that too if it comes up again. Wanted: plain C-c C-i inspects the expression at point with no prompt; C-u C-c C-i opens the minibuffer to type a different one.
inc/dec (pure) and ++/– (mutating) — doable as user macros, no compiler change
No inc/dec/1+/++/– exist anywhere (prelude or special forms) — (+ x 1) is the only spelling today.
Unlike most items above, this needs no compiler change: defmacro already exists at user level (vendor/raylib's with-drawing etc.), and `place` (what `set` parses — lib/parse.ml:961-983: vars, .field, at, deref) is already general enough. Can write today:
(defmacro inc [args] `(+ ~(at args 0) 1))
(defmacro dec [args] `(- ~(at args 0) 1))
(defmacro + [args] `(set ~(at args 0) ( ~(at args 0) 1)))
(defmacro – [args] `(set ~(at args 0) (- ~(at args 0) 1)))
inc/dec are generic for free since +/- already are. ++/– reuse the place twice (read then write) — fine for a plain var, double-evaluates a place like (at arr (compute-index)) if the index has side effects. Same non-hygienic-macro tradeoff with-drawing/with-mode-2d already accept. Maybe worth putting these in the prelude itself rather than per-project.
(Cell 1 2) as a positional struct constructor
Designated initializers (C/Odin-style) already exist: {.field v …} + ZII zero-fill for omitted fields (zii_fill, lib/check.ml:3814), any order, {} fully zeroed. Not missing — just spelled with braces/dots.
Positional (Cell 1 2) genuinely doesn't exist, and can't be added as a parser special case the way {.field …} was: struct-literal recognition happens at PARSE time purely by syntax shape (Sym name applied to a {.field…}-shaped map, lib/parse.ml:593-600) — the parser has no symbol table, so (Cell 1 2) is syntactically identical to any function call (parse.ml:602-603 Ast.Call). Would need check.ml to notice an Ast.Call's head resolves to a struct type rather than a function, and reinterpret the positional args in field-declaration order there instead. Same parse/check boundary issue as the bare-struct-literal-return-type item above — recurring pattern worth keeping in mind for future struct/type ergonomics asks.
a DEADBEEF-style sentinel-fill builtin, instead of zero
Idea: a debug memset that writes a real byte pattern instead of the zero llvm.memset already uses (lib/emit.ml:934), for catching reads of uninitialized memory. Distinct from Tast.Uninit, which compiles to LLVM `poison` (lib/emit.ml:1613) — a semantic "don't care" marker, not an actual inspectable byte pattern.
Technical snag: llvm.memset only takes a single repeated i8, so a real 4-byte 0xDEADBEEF pattern needs a fill loop, not memset — more expensive. This is why MSVC/glibc-style debug allocators use a single repeated byte instead (0xCC/0xCD/0xDD/0xFD). Need to decide: cheap single-byte sentinel, or a real 4-byte pattern via a loop. Name candidates:
- single-byte (memset-cheap): 0xCC (MSVC uninit stack), 0xCD (MSVC "Clean" unwritten heap), 0xDD (MSVC "Dead" freed heap), 0xFD (MSVC fence/guard)
- 4-byte (loop, reads great in a hex dump): 0xDEADBEEF, 0xBAADF00D (Windows LocalAlloc uninit), 0xFEEEFEEE (Windows HeapFree'd), 0xDEADC0DE, 0xC0FFEE, 0x8BADF00D (Apple watchdog-timeout crash code)
Scope: should take a struct or an array (by place/pointer) and fill it with the sentinel — general over both, not just raw byte buffers. Ties into the memcpy discussion (2026-09-20): a struct/array is a first-class aggregate value in flan today (copies happen via load/store, no memcpy builtin exists), so this would be the first builtin that reaches into one of those and overwrites it as raw bytes rather than treating it as a typed value — same category of operation llvm.memset already does for zero, just exposed as a real user-facing builtin instead of only an internal codegen detail.
Not designed or implemented, just an idea.
Built, 2026-09-20, as two builtins rather than one — the author's answer to the cheap-byte-or-real-pattern question was "why not both? we need some sort of memset -1 right? and dead-beef can loop, that's fine". They are (filled BYTE) and (dead-beef), spelled the way [zeroed] is: the value of whatever type is expected of them, so (set grid (filled 0xFF)) is how a place is filled and there is no place-taking form to learn beside [set].
(dead-beef) writes the default DEADBEEF and (dead-beef 0xBAADF00D) writes the pattern given, under one byte-order rule: a pattern's ascending bytes are its big-endian bytes, which is how the hex literal reads left to right. So every candidate listed above is spellable without the compiler naming any of them, and the bare form is checked into the spelled-out default rather than being a case a backend knows about. The pattern may be computed, not only written — same rule as the byte arm, and both backends byte-reverse at run time when it is.
The snag above is why there are two and not one with a wider operand. The byte fill is one llvm.memset / one rep stosb; the 4-byte pattern is a loop on both sides, a counted dword loop in emit.ml and rep stosd in x86.ml, because the intrinsic really does only take a repeated i8.
What may be filled is numbers, and structs and fixed arrays built out of numbers — nothing that carries a tag, a length, an owning pointer or a collector descriptor. That boundary, and why each refusal is the runtime's rather than a matter of taste, is written up in FIX.org, "The two byte fills".
struct field type change: got a plain type error, not the documented refusal
Hit "expected i32, found u8" after changing Cell's row/col from u8 to i32. Turned out to be a leftover (u8 (/ my cell-size)) cast at the construction site that I hadn't updated to i32 yet — an ordinary, correct type error, not a hot-reload problem. Already fixed in the file.
Worth remembering: a struct field type change is one of exactly three redefinitions the session actually refuses outright rather than silently mis-loading (docs/BUILT.md:1213-1219: "a struct's fields | the values the process is holding have the old layout"). That refusal has its own distinct message — if I hit it for real it won't look like an ordinary type mismatch.
compiler messages need a persistent, copyable buffer — not just Messages
No dedicated log/output buffer for compiler errors exists anywhere in emacs/flan.el. Today it's only: a transient error overlay (flan–error-overlays, cleared on the next command) and `message` to Messages (append-only, awkward to copy out of, no structure). Want both the ghost text AND a persistent buffer that accumulates these, so a message can actually be copy-pasted (e.g. to hand to Claude) without hunting through Messages. Not designed or implemented.
defmacro should support real parameter lists, not just one [Form] arg
Every defmacro today takes exactly one parameter, the whole call's arg list as [Form] (lib/parse.ml comment: (defmacro m [args] body) is (defn m [args [Form]] Form body)). Porting a Clojure macro like (defmacro do-grid [[r rows c cols] & body] …) means manually picking apart `args` by hand every time — (at args 0), match on Form.Vec to unwrap a binding vector, (form-rest args 1) for the body, no arity checking, no destructuring in the signature itself. with-drawing/with-mode-2d (vendor/raylib/modes.flan) already do this by hand and it works, but it's real boilerplate for something Clojure gets for free from defmacro's own parameter list. Real design gap, not a small fix — would mean parsing macro params with the same destructuring patterns [let] already has (dvec/dmap, lib/parse.ml:698-799) plus variadic &body support, applied at the macro call site before expansion rather than left to the macro body.
profiling: use Tracy, don't build our own
No profiling infra or prior discussion exists in FIX.org/plan.org/docs. Recommendation: bind Tracy (TracyC.h, a pure C API) via declare-c — same mechanism raylib already goes through. Building a bespoke profiler means redoing a capture protocol, a viewer, flame graphs and a timeline, all of which Tracy already does well, for a domain (real-time/game loops) it was built for.
Two integration points already fit flan's own design:
- Tracy's frame mark maps directly onto flan's existing frame-boundary notion — where agent/poll is called each loop iteration.
- flan already has precedent for dev-build-only instrumentation wired in as an LLVM constructor (the allocation registry, lib/emit.ml ~4165). Automatic per-function Tracy zones in dev builds, using the same dev/cell indirection every call already goes through, could follow that pattern instead of requiring hand-instrumentation everywhere.
Alternatives considered:
- Optick — similar to Tracy but less active, historically Windows-first. No real advantage over Tracy.
- Superluminal — great UI, but commercial and Windows-only. Ruled out (Linux).
- perf/Hotspot — free, zero-instrumentation sampling, but not frame-aware, not live-viewable, Linux-only. Fine as a second opinion, not primary.
- Chrome Trace Event Format / Perfetto (or a tiny header-only exporter for it) — much smaller lift than Tracy: push timestamped begin/end onto a buffer, dump JSON, view in Perfetto/chrome://tracing after the fact. No live view, no socket server — but it's the one option with an actual story for wasm32 (plan.org's committed third target: write trace to a buffer, pull out via JS). Tracy's capture side assumes a real OS socket the viewer connects to, which doesn't really work in a wasm sandbox.
Conclusion: Tracy for native dev-loop profiling now (best tool, trivial declare-c binding, fits the frame-boundary idea already in the design). If wasm32 profiling becomes a real need later, a small Chrome-Trace-Format exporter is the justified "build our own" — not a Tracy replacement, a different target Tracy doesn't reach. Not designed or implemented.
no type-limit constants: u8-max, i32-max, f32-infinity, etc. — nothing exists
Confirmed missing entirely. No MAX/MIN constants in the prelude for any sized int type; min/max are just binary comparison builtins, not type-bound limits. No C limits.h/float.h macro import either (cimport only pulls in declared functions/structs/typedefs, not #define constants) — no path to INT_MAX/FLT_MAX that way.
Worse for infinity/NaN: there's no literal syntax at all. float_repr (lib/form.ml:87-104) prints inf/nan as words for a runtime value that happens to be one, but the reader doesn't parse those words back ("nan.0 is no improvement on a literal no reader accepts either way"). Only way to get +inf today is at runtime: (/ 1.0 0.0) (float division by zero is IEEE-754 defined, not a trap the way integer division by zero is) — no way to just write it down. Real gap, not implemented.
investigate SBCL's redefinition model — warn + keep old value until callers update
Right now a global/struct type change is a hard refusal (lib/session.ml: 304-311, docs/BUILT.md:1213-1219) — "restart to change it." I remembered a design where the OLD value/layout keeps being used until the calling code is recompiled against the new one, since code using the old shape probably won't even compile anymore anyway, so nothing unsafe reads mismatched memory. Found it — it's real, but only planned for a narrower case:
plan.org's Hot Reload section (~685-696): a signature-changing function redefinition creates a new internal function version + trampoline. Newly compiled callers use the new one; existing callers and stored function values keep the old version safely. The session warns at every tracked old caller site; recompiling one either updates it or gives a normal type error.
plan.org's dev/release table (~671-682) also lists "Structs: version word" for dev builds — implying a similar versioned-layout plan existed for structs too. But docs/BUILT.md:1223-1230 says none of this is built for functions either ("no function versions, no trampolines… the refusal stays, because the alternative to refusing is not the new design, it is a silent argument mismatch. It is a stopgap"). So today BOTH functions and globals/structs just hard-refuse; the versioned/warn-and-keep-old-value design exists on paper for functions but nowhere for structs/globals, and isn't implemented for either.
Action: look at what SBCL actually does on struct/type redefinition (it warns rather than refusing, and instances of the old layout get an "obsolete instance"-style condition on next access rather than corrupting memory) as a model for what flan's struct/global version-word plan could be, versus the current hard-refuse stopgap.
need a value-producing array constructor, usable as a defvar initializer
Wanted to fill `grid` with 255 as part of its defvar declaration, not as a separate mutation step after. Doesn't work today: dotimes returns Unit, not an array value, so it can only mutate an already-existing place — it can't be the initializer expression itself, which has to produce the whole typed value in one go. Had to declare grid zeroed then mutate it in main instead.
(array 4 rl/Vector2) (Ast.ArrayOf, "the one position with no type slot", docs/BUILT.md ~3475) already exists but only zero-fills — no way to give it a fill value or a generator. Want something like (array-fill n v) or a repeat/generate form that IS an expression (produces the array value directly, works nested for 2D), so it composes as a defvar initializer the same way a bracket literal does. Not designed or implemented.
revamp the flan buffers: flan-dev has no compilation-mode / jump-to-error
Related to the earlier "compiler messages need a persistent buffer" item but distinct — that one was about eval-time messages; this is flan-dev, the daemon's own startup/build output (emacs/flan.el:729-765, flan–start-daemon), which is where a `main` that fails to compile shows its errors. It's a plain buffer — get-buffer-create with no major mode, raw process output appended via make-process. No compilation-mode, no compilation-shell-minor-mode, so no next-error / M-g M-n / jump-to-source.
The fix is probably small: flan's own diagnostics already print in the ordinary path:line:col: message shape (every error pasted in this session has been that format) — the same shape Emacs' built-in compilation-error-regexp-alist already parses (it's the GCC/Clang shape compile-mode was built around). So this likely isn't a custom-regex job, just turning on compilation-minor-mode in flan-dev (or a dedicated flan-daemon-mode derived from compilation-mode). Umbrella task: revisit all the flan-* buffers (flan-dev, flan-output, error overlays, the eval result overlay/minibuffer item) together rather than patching each one separately. Not implemented.
implicit numeric conversions with a warning flag, instead of hard errors
Not liking the strictness; want implicit typing with an opt-in warning flag for implicit conversions instead of a hard type error.
Worth being clear-eyed first: "no implicit widening/narrowing anywhere" is not an oversight, it's a core invariant repeated all over the codebase — lib/check.ml:1774 ("this language has no implicit narrowing anywhere"), :4736, :5640, :6575-6579 ("no implicit widening, so one side has to decide it"), docs/BUILT.md:809. It's the Odin/Rust-style bet against C's decades of silent-precision-loss bugs, and it's load-bearing in how binary-op checking picks which side "decides" the type. A warn-instead-of-refuse mode isn't a flag on top of that — it's a second type-checking mode that would need threading through every one of those sites, not a small change.
That said, real middle-ground precedent exists: C/C++'s -Wconversion is exactly "implicit, but warn" bolted on after the fact. Something similar here would mean: allow widening/narrowing between numeric types silently at the type-check level, but have the checker also emit a separate warning list (surfaced same as any other diagnostic) for every site where a conversion happened that wasn't an explicit cast. Not designed, and a real philosophy question for the language, not just an implementation task.
pos?/neg?/zero? don't exist, and need to be generic over numeric types
Nothing named pos?/neg?/zero?/sign anywhere in the prelude. Trivial to write per-type ((defn pos? [x i64] bool (> x 0))), but that's the problem: we want these (and inc/dec from earlier) to work across every numeric type without writing one copy per type.
This is blocked on real generics, which don't exist yet — "generic code over the type variable %s is not implemented yet — milestone 5" (lib/check.ml: 776-778, hit earlier when `int` fell through to the type-variable path). min/max already do this at the builtin level (special-cased in the checker, not written as generic Flan functions), which is the current workaround for "needs to work over any numeric type" — but that doesn't scale to arbitrary user-defined functions like pos?/neg?/inc/dec. Two related asks logged together: (1) add pos?/neg?/zero?, (2) make milestone-5 generics real so functions like these don't need special-casing into the compiler to be generic.
println output goes to flan-output, not inline in the repl
Deliberate per emacs/flan-repl.el:40-44: "a value and the program's output are different things and arrive by different routes… showing them in one place would be convenient and wrong." Same split in flan-mode's C-x C-e.
Not sure I like it — printed output feels disconnected from the eval that produced it when it's in a separate buffer/window. Revisit.
main cannot actually be redefined
Confirmed. emit_main (lib/emit.ml:3858-3861) calls the flan-level `main` via `fname "main"` — a direct symbol call. Every other call site goes through `body_of` (lib/emit.ml:1980-1994) instead, which loads from the dev-build indirection cell so a C-c C-c redefinition takes effect. emit_main skips that, so flan_program_main (what M-x flan-rerun re-enters) always runs the body main had at the initial build, no matter what's redefined into main's cell afterward. Not documented as a known limitation anywhere. Should probably route through body_of too, or say plainly that main is special.
the "queued; the program is parked…" note is annoying
lib/dev.ml:778-790. Fires on every C-c C-c redefine while parked — and a finished program is always parked, so re-evaling main after each run always shows this long note. Message is correct, just too verbose/frequent for the common case. Maybe shorten, or only show once per park / make it toggleable?
(rl/with-drawing ()) fails with a confusing error
`()` is never a valid expression (lib/parse.ml:278). with-drawing splices its arg verbatim (vendor/raylib/modes.flan:79-84), so `(rl/with-drawing ())` expands to `(do (begin-drawing) () (end-drawing))` and the bare `()` blows up deep in the expansion. Workaround: use `(rl/with-drawing (do))` — `(do)` with zero forms is a valid no-op (used elsewhere, e.g. examples/core-input-gamepad.flan:268).
The macro does have an empty-body guard ((< (len args) 1) -> with-drawing-takes-a-body) but it only catches zero arguments ((rl/with-drawing)), not one argument that is itself (). Guard should probably also treat a single bare-() arg as "no body".
`()` is reserved as the type-position spelling of Unit (Types.to_string Unit = "()", lib/types.ml:125) and has no value-position meaning today — the unit value only comes from `(do)`, a form with no body, etc. Position already disambiguates Vec/Map literals from their type spellings (parse.ml comments near line 266-273); worth checking whether () could get the same treatment — type in type position, unit value in expression position — instead of being refused outright everywhere.
error inside a macro-expanded body points at the macro call site, not the real line
Hit with do-grid: "unknown function neg?" reported at the do-grid call line, not the actual (unless (neg? cell-val) …) line inside the spliced ~@body.
Already known/tracked, not a fresh gap: the Form wire format a macro receives and returns (lib/expand.ml:96-101) is Form.value, not Form.t — it structurally has no loc field, "a macro cannot invent a source location and the image has no room for one." So EVERY node a macro returns, including ~@body forms spliced through completely unchanged (my own original source, not compiler-generated), gets the call site's location stamped on it (lib/prelude.ml:1863-1868 says the same thing). The comment names the real fix directly: preserving locations through a macro needs "the structured-error rewrite," which doesn't exist yet — today's stamping is explicitly the stopgap "that can be had now without" it.
So: not asking for something new, just noting I hit the documented limitation and it's genuinely annoying for macros like do-grid that splice a large body through — every error in the body mislocates to the macro call.
built-in comment
No (comment …) exists. Trivial as a one-line user macro today — (defmacro comment [args] `(do)) — and it already has the useful property: macro args are raw unparsed Form, never checked as expressions, so whatever is inside never needs to type-check. Want it built in (prelude or special form) rather than something every project defines for itself.
#_ (discard) isn't syntax-highlighted, though it works correctly
Compiler side is fine and deliberate — lib/reader.ml:16-24, 202-262 reads and throws away the next form, Clojure-style, including #_#_ counting-free chaining. Purely an editor gap: flan-font-lock-keywords (emacs/flan-mode.el:126-146) has no rule for #_ at all, and flan-mode-syntax-table (line 180+, derived from lisp-mode-syntax-table) has no notion of it either since Common Lisp doesn't have this construct. So a discarded form renders as plain text, not grayed out/comment-styled.
real clojure-mode.el's approach is precedent: a syntax-propertize-function that marks a discarded form's characters with the comment syntax class, so font-lock renders it as a comment for free. Not implemented here.
syntax highlighting: defmacro/when/etc missing, and macros aren't dynamic
Two separate gaps.
Easy: flan–definers (emacs/flan-mode.el:114-118) is missing `defmacro` entirely, even though it introduces a top-level name like defn does. flan–special (120-124) is missing when/cond/and/or/break/continue/recur/ handler-bind/handler-case/restart-case/invoke-restart — all real lib/parse.ml special forms, just never added to the static lists. Plain oversights, trivial to fix.
Harder: highlighting a user-defined macro like do-grid can't be a static list — and the daemon doesn't even have the info today. A macro compiles down to an ordinary Tast.fn (defmacro m [args] body is exactly defn m [args [Form]] Form body) — macro-ness is erased by check time. Confirmed: defs (lib/dev.ml:1080-1133) reports every function with kind:"fn", no "macro" kind exists anywhere, because Tast.fn has no field recording it came from a defmacro. So even flan–defs (the live cache already driving eldoc/completion) can't tell do-grid is a macro right now.
Would need: (1) plumb a macro flag through Check/Tast so defs can report a real "macro" kind, (2) have flan-mode treat flan–defs as a dynamic font-lock source — filter macro-kind names, font-lock-add-keywords + font-lock-flush/fontify-buffer whenever the cache refreshes (same points flan-refresh-defs already runs at: connect, after an accepted eval). CIDER does exactly this for clojure-mode off a live nREPL connection — real working precedent. Not designed or implemented here.