diff --git a/TODO.org b/TODO.org index 117fd7cc..8c55c4ba 100644 --- a/TODO.org +++ b/TODO.org @@ -1,5 +1,5 @@ #+TITLE: Flan -#+TODO: TODO WAIT | DONE CANCELLED +#+TODO: TODO NEXT WAIT | DONE CANCELLED #+ARCHIVE: ::* Archive Every decision, open question and known gap, one =**= heading each under the @@ -137,7 +137,8 @@ big-endian bytes, which is how the hex literal reads. Two and not one with a wider operand because the intrinsic takes a single repeated byte: the byte fill is one instruction and the four-byte pattern is a loop on both backends. -** TODO There is no literal for an infinity or a NaN +** NEXT There is no literal for an infinity or a NaN +Decided 2026-09-25: four constants the compiler supplies, =f64-inf=, =f64-nan=, =f32-inf=, =f32-nan=, beside =f64-max= and the rest. Negative infinity is =(- f64-inf)=. Rules out Clojure's =##Inf= reader literal. =lib/reader.ml= has no literal for either, and =float_repr= prints =inf= and =nan= as words the reader will not read back. =(/ 1.0 0.0)= is the only route to an infinity, and the constant folder is integers only, so it cannot be a =defconst=. @@ -328,6 +329,13 @@ a =defer= in one always registers. A loop body and a branch are still refused by name: =defer= is a compile-time construct with the cleanup copied into every exit path, so "maybe registered" is not expressible. +** TODO A return runs its defers before it computes its value +=(return v)= is lowered as =Do (defers @ [Return v])= (=lib/check.ml= near 3474), +so a defer that changes what =v= reads changes the answer, and =(return x)= and +falling off the end with =x= disagree. All backends agree with each other. The +value is computed first and the defers run after, the order Odin, Go and Zig +use. + ** DONE edn reads into a struct and answers a dynamic value CLOSED: [2026-09-17] Two projects, not one, and the dynamic half goes through an allocator rather than @@ -362,17 +370,17 @@ expressible and a build-time refusal would be unusable. =barf= on web signals no-op — which is how a save file disappears with nothing said — and the build-time refusal. -** TODO Conditions get a parent link, not class inheritance +** NEXT Conditions get a parent link, not class inheritance +Decided 2026-09-25: build it, with a root =Error= every built-in error descends from, so one handler catches any error. A catch-all handler gets the condition's name and the runtime's sentence, not its fields. =(pause)= and warnings are not under =Error=. A condition type may name a parent where it is declared, and handler matching walks that static chain. It buys the hierarchy conditions most lack — a catch-all "any file error" handler — at compile-time cost only. Rules out the class answer: a class condition allocates at the signal site, inverts the lifetime rule, and lets a layout change under a standing handler frame. Not built. -** TODO Can a condition be a class? -Unanswered, and the shape that probably wins is both — a struct condition stays -what it is, a class condition allocates and matches by walking its class chain. -Three costs, one serious: signalling would allocate on the failure path. +** CANCELLED Can a condition be a class? +CLOSED: [2026-09-25] +A class condition allocates on the failure path. The parent link above gives the hierarchy without it. ** DONE handler-case CLOSED: [2026-09-19] @@ -437,13 +445,15 @@ it. The alternative weighed — a per-binding declaration naming which argument carries the count — cannot reach a count that is a sibling field. No marker on the name; =ptr= is the marker, it owns nothing, and =free= refuses it. -** TODO A string cannot be returned from C +** NEXT A string cannot be returned from C +Decided 2026-09-25: copy the returned text into the context allocator at the boundary. =TextFormat= stays unbound, being variadic. A string crosses as a parameter only — a C function that returns one returns something Flan has no owner for. It is what makes =GetGamepadName= unbindable, and the same rule refuses =TextFormat=, which is variadic and so has no honest signature either. -** TODO Model, Mesh and FilePathList want a defstruct, and a callback wants the other direction +** NEXT Model, Mesh and FilePathList want a defstruct, and a callback wants the other direction +Decided 2026-09-25: =FilePathList= crosses by the same copy-at-the-boundary rule, in the lane with =Model= and =Mesh=. Callbacks wait until a program needs one. =Ray= and =BoundingBox= have their defstructs now. =FilePathList= is a =char**= and blocks the drop-files example; =Model= and =Mesh= are ordinary widening. Function-pointer parameters — =SetTraceLogCallback=, the audio stream processors — @@ -479,7 +489,8 @@ an ordinary =defn= declares no environment and is byte-for-byte what it was. The static side does not pay for the dynamic side. Rejected names: Closure, Proc, Fun, Func, Fnptr. -** TODO Escaping closures, allocated on the GC side +** NEXT Escaping closures, allocated on the GC side +Decided 2026-09-25: start it. It must work on wasm32. The second half of "do both". What changes is where the environment points — a frame slot today, a collector allocation then — and the escape check goes away with it, along with the refusals on returning, storing, pointing at and pushing a @@ -584,7 +595,8 @@ back to the call site. Costs about 2µs a call. Rules out putting a =loc= field on the wire, and rules out structural matching of the expansion against the arguments, which can pick the wrong one of two equal subtrees. -** TODO A declared name may carry the $ sigil +** NEXT A declared name may carry the $ sigil +Decided 2026-09-25: refuse =$= at the start of any declared name; the refusal says =$= marks a type variable. =(defn $foo [x i32] i32 ...)= is accepted and =($foo 3)= calls it; so is =(defstruct $S [a i32])=, whose type can then be written nowhere. The character is reserved in every type position and in no name. Refusing it in a declared name @@ -629,7 +641,8 @@ A machine-type target needs =numeric?=; an enum target needs =integer?=; by what it claims, not by the set it happens to denote this week — which is why =ordered?= is refused even though every type it admits today converts. -** TODO There is now no generic enum to integer conversion +** NEXT There is now no generic enum to integer conversion +Decided 2026-09-25: build =enum?= as described. Recorded as a loss. The one spelling that worked did so by not asking about the operand at all, so removing it was still right. =enum?= is the eventual answer — it would entail =ordered?= and =equal?= and not =numeric?=, so the cast rule @@ -695,7 +708,8 @@ depth it gave up at. The bare depth number is a backstop that also prints the chain. Before any of it, the compiler hung rather than failed, which wedges =C-c C-c= with nothing to show. -** TODO Generic types +** NEXT Generic types +Decided 2026-09-25: the freeze is lifted for this; build both type and length parameters. =(defstruct Pair [a $t b $t])= cannot be spelled, and neither can a length parameter. =Types.Named= is a bare string with no room for parameters; giving it some changes the type, the layout calculator, both backends, the renderer and the @@ -704,7 +718,8 @@ not started — it is a language feature under a freeze, and it was stopped once already for that reason. The motivating case is Odin's =Small_Array=: a fixed-capacity array with a count and no allocation. -** TODO A value predicate over a length parameter +** WAIT A value predicate over a length parameter +Decided 2026-09-25: waits until a program wants one. Odin's =where N >= 0= is a predicate over a value, not a type, and a =where= clause here admits nothing but type predicates. Whether it should take value predicates over a length parameter deserves answering deliberately rather than @@ -797,7 +812,8 @@ CLOSED: [2026-09-20] typed conditions stay strict =bool=. =and= and =or= hand back the operand that decided them, Clojure's rule, through a desugaring that evaluates each test once. -** TODO A bool arm and a dyn arm joining as dyn +** NEXT A bool arm and a dyn arm joining as dyn +Decided 2026-09-25: they join as =dyn=, the =bool= boxed — Clojure's rule, so =(or false (box "s"))= answers ="s"=. With both arms of a desugared =and=/=or= holding real values, a non-bool =dyn= on the losing side meets the strict =bool= boundary and traps — =(or false (box "s"))= is the case. Whether a =bool= arm and a =dyn= arm should @@ -867,7 +883,8 @@ ordinary expressions and the builtin reads the type back out of one type an expression cannot hold, such as =(Fn [i32] ())=, is parsed as =Ast.TypeArg=. Rules out a type expression anywhere else in expression position. -** TODO An array literal cannot say it is [f32] +** NEXT An array literal cannot say it is [f32] +Decided 2026-09-25: the first element's type carries to the rest, so =[(f32 1.0) 2.5]= is an =[f32]=; that is refused today and is a bug. No =1.0f= suffix for now. A float literal defaults to =f64=, an array literal has no context, and a =let= has no annotation. Same shape as =(vec-new [u8])= and probably the same fix. Not the same fix: a bracket literal has no argument to put a type in. Decision: @@ -882,13 +899,15 @@ blocker it was, since =(array 4 T)= answers the case that raised it. plan.org's rule is "annotate function signatures, infer locals", so a general annotation is a deliberate absence. -** TODO A read-only slice type +** NEXT A read-only slice type +Decided 2026-09-25: =[const u8]=, Zig's spelling in Flan's brackets. =bytes-view= answers one and a =set= through it is a compile error; a =[T]= converts to =[const T]= and not back, and the prelude's read-only functions take it. =const= is reserved as a name, since =[n T]= accepts a constant's name for =n=. =bytes-view= is read-only by convention only — the type system cannot say a =[u8]= may not be stored through, so a trap on read-only memory is the enforcement. A read-only slice type, or provenance, is what would move that refusal to compile time. -** TODO Writing through a string literal +** NEXT Writing through a string literal +Decided 2026-09-25: closed by the read-only slice type above. =(let [s (bytes-view "Hi")] (set (at s 0) \h))= stores into read-only memory at =-O0= and is deleted as undefined at =-O2= — same source, and which way it fails depends on a flag. Narrowed when =(bytes s)= started copying, so the common @@ -966,7 +985,8 @@ at all, which is what the diagnosis predicted. A =map= that *changes* the elemen type is the one shape that did not come with them: one copy per ordered pair of types rather than per type. -** TODO CFn in a struct or a fixed array +** NEXT CFn in a struct or a fixed array +Decided 2026-09-25: allowed. A call through a null =CFn= is a named runtime condition on both backends, and parks in a dev build. A zeroed function value is a null pointer, so a function value is refused in any position zero-initialisation would conjure one — =CFn= included. An =(Option (CFn ...))= field is already legal. A table of function pointers is exactly what @@ -1030,11 +1050,9 @@ compared as a constant rather than as a lowering. That is how the float =%= gap survived. Two things would close it: an =-O0= pass of the sweep, and something that walks the two backends' primitive match arms mechanically. Neither is queued. -** TODO (uninit) and unreachable differ between the backends, and the language has not said what they mean -=(uninit)= is stable garbage — whatever the stack slot held — rather than -=poison=, and an exhausted match is =ud2=, a defined SIGILL, rather than undefined -behaviour. Both are deliberate and both are now written down, but the *language* -still has not defined what reading an uninitialised value means, which is the item. +** DONE Reading (uninit) before writing it is undefined behaviour +CLOSED: [2026-09-25] +Reading an =(uninit)= value before writing it is undefined behaviour, and the backends may differ on it. An exhausted match stays =ud2= on x86. ** DONE f64 to i64 out of range, and INT64_MIN / -1 CLOSED: [2026-09-14] @@ -1067,6 +1085,10 @@ build included, reaches =emit_globals_init= as =Emit.startup_plan='s straight into their symbol are =Tast.const_init= ones, which neither transfer nor read anything. Nothing to change. +** TODO A u64 converted to f64 is signed on x86 +=(f64 (u64 18446744073709551615))= prints =1.84467e+19= under LLVM and =-1= under +=--x86=, with a literal or a run-time =u64= alike. + ** DONE An aggregate built in place never reads its own destination CLOSED: [2026-09-25] =assign= builds in place only when the right-hand side settles *and* reads no @@ -1093,7 +1115,8 @@ so a =declare-c= wrapper leaning on the courtesy is already backend-dependent as well as slice-dependent. The contract is pointer and length, and nothing promised otherwise. -** TODO Frame descriptions are gated on --debug +** NEXT Frame descriptions are gated on --debug +Decided 2026-09-25: emit the x86 backend's =.cfi= directives in every build. No runtime cost; the lane measures the =.eh_frame= size it adds. They are correct in every build and free at runtime, and a release build is where a crash would most want them. One =if= in three places. @@ -1103,11 +1126,9 @@ outer one. The disambiguating suffix makes both visible, which is not the same a making the answer right. It needs block structure the typed IR does not carry, and the variable declarations moved out of the entry block. -** TODO UBSan sees no Flan code, and no flag changes that -UBSan's checks are branches clang's C frontend emits inline, not a pass, so shift -undefined behaviour, alignment and a NaN float-to-int cast are unreached. Either -the emitter grows those checks behind the flag — a compiler feature of the same -shape as the bounds checks — or they belong to the checker. Not decided. +** CANCELLED UBSan sees no Flan code, and no flag changes that +CLOSED: [2026-09-25] +The language defines the cases UBSan would catch itself: a computed shift count is masked, and a float-to-int cast out of range or of a NaN signals =ArithError=. Flan code produces no misaligned access. ** DONE A JS backend is a dialect, not a second machine CLOSED: [2026-09-17] @@ -1374,7 +1395,8 @@ Hiding it means a new field in the frame layout written out in both backends and the runtime. Choosing it is refused loudly rather than answered wrongly, so this is cosmetic. -** TODO A formatted number does not outlive its frame +** NEXT A formatted number does not outlive its frame +Decided 2026-09-25: the conversion's bytes are always copied into the context allocator, so the string outlives the frame. Rules out refusing the escape, which needs flow tracking. The conversion buffer is one frame slot per call site, so returning a string built from it returns a view of storage the return has just released, and pushing one pushes an element aliasing that slot. Neither shape is refused. Copy the bytes for @@ -1392,7 +1414,8 @@ The opposite of what the escaping-alloca argument predicts, and the measurement that first said otherwise was comparing a 40-frame binary with a 600-frame one. That is why every number in =docs/BUILT.md= is a minimum of nine runs. -** TODO runtime/flan_dyn_stub.c is dead +** NEXT runtime/flan_dyn_stub.c is dead +Decided 2026-09-25: delete it, as part of a sweep for dead code across the repository, each removal checked unused first. No dune rule mentions it, no module refers to it, no test links it, and it does not compile — two conflicting-type errors against its own header. It is maintained by accident: one lane added a function to it, which is duplicity on the same side @@ -1536,7 +1559,8 @@ only by an explicit call. A sentence about the shape of the gate rather than an observed problem: only the daemon sets the variable and it never runs release builds. The fix, if it is ever felt, is a narrower gate. -** TODO The daemon's "has not called (agent/start ...)" note is unreachable +** NEXT The daemon's "has not called (agent/start ...)" note is unreachable +Decided 2026-09-25: retire the note in the same dead-code sweep. Unreachable, not merely unexercised: the one state it was true of is closed by the constructor. Retiring it is the author's call over a lane that merged days ago, so it is left in place saying a true thing about a state nothing can be in. @@ -1598,14 +1622,14 @@ Settled for conditions and for structs, because =Load= qualifies every declarati at import. Still open for locals, where the debug information gives a bare name and nothing qualifies it. -** TODO The render-thunk-per-inspection design +** NEXT The render-thunk-per-inspection design +Decided 2026-09-25: the inspector reads a value through the type layouts the compiler records, with no compile per inspection, which lets it hold a value. An inspection still compiles a thunk per request. A redesign rather than a deletion, and its own lane: it is what unblocks the inspector retaining a value. -** TODO The watch design is reopened -Push was chosen partly because polling costs a compile, and that premise weakened -when the processes merged. The table-and-read design stands; whether it should stay -pushed is open. +** DONE The watch table stays pushed +CLOSED: [2026-09-25] +The watch table stays pushed, and shares the push channel program output moves to. ** DONE A watch over a struct or a slice CLOSED: [2026-09-25] @@ -1630,7 +1654,8 @@ Only instantiations reach the function list, so a generic name used to report th it had installed nothing at all. The session expands to instantiations before reporting. -** TODO Signature generations and stale-caller warnings +** NEXT Signature generations and stale-caller warnings +Decided 2026-09-25: SBCL's behaviour: the new definition always installs, the stale callers are listed by file and line, and a stale call stops in the break buffer naming its site. The check is a signature word the cell carries, compared at each dev-build call site; a release build has neither. Per-block control of runtime checks (Zig's =@setRuntimeSafety=, Odin's =#no_bounds_check=) is a separate question, not built. The biggest hole in "you never restart the program". A changed signature is refused outright today and that is a placeholder, not the design. It needs function versions, a trampoline per version, and caller tracking good enough to name the @@ -1814,7 +1839,8 @@ specification's own branch — the flag is the command and the printed shape is error pattern, so anyone who wants one has the four lines, and the manual carries them. -** TODO defclass slots take types, checked on write +** NEXT defclass slots take types, checked on write +Decided 2026-09-25: slots are name/type pairs checked on write; an untyped slot stays legal and holds any =dyn=. A migration keeps a stored value that no longer fits the new type, warns once, and the next write is checked. One lane with the =set= entry below. =(defclass State [pause bool step bool])= reads as four untyped slots and reports a duplicate =bool=. Wanted: the slot list is name/type pairs, as CLOS does it. The type is a declaration about the values and not a layout — an @@ -1826,14 +1852,16 @@ Open: what migration does with a stored value that no longer fits a changed slot type, and whether an untyped slot stays legal (it should — =dyn= is a type and writing nothing should mean it). -** TODO println takes up to a second to appear +** NEXT println takes up to a second to appear +Decided 2026-09-25: the daemon pushes program output on the editor's connection as it is written. Rules out a faster poll. Output is drained by =flan--poll= at =flan-poll-interval=, 1.0s (=emacs/flan.el:541=). A reply carries whatever was buffered when it was composed, so anything the program prints after that waits for the next tick. Polling faster costs a request a second for nothing most of the time; the daemon pushing on its own connection is the other shape. Decide which. -** TODO set writes a class slot; put is for maps +** NEXT set writes a class slot; put is for maps +Decided 2026-09-25: as written; one lane with typed slots. =put= exists because an absent map key has no location to store into, which is why =(get m k)= is refused as a place (=lib/parse.ml:1159=). A class instance is not in that situation: its slots are fixed by the =defclass=, so a declared slot @@ -1842,7 +1870,8 @@ always exists and =(set (get state :pause) true)= is a field store like insertion is real. Writing an undeclared slot through =set= is then a refusal naming the class. -** TODO update: change a place by applying a function to it +** NEXT update: change a place by applying a function to it +Decided 2026-09-25: every place evaluates each of its subexpressions once, C's compound-assignment rule, which also fixes =++= and =--=; =update= is built on that. Rules out refusing side effects in a place. =(set (.velocity g) (inc (.velocity g)))= names the place twice. Clojure's =update= would be a macro over the same two steps, for a struct field and a class slot alike. @@ -1880,7 +1909,14 @@ motion states in that mode; every other key, including what =special-mode-map= binds, stays Evil's. The other special-mode buffers (inspect, watch, doc, disassembly, diagnostics, lower) have the same exposure and are not changed. -** TODO Eval in the frame, from the break loop +** NEXT Evil takes the keys in the other Flan buffers +The inspect, watch, doc, disassembly, diagnostics and lower buffers get the break +buffer's treatment: the keys each binds itself go to Evil's normal and motion +states, and every other key stays Evil's. Bindings stay as close as possible +between Evil and Emacs states. + +** NEXT Eval in the frame, from the break loop +Decided 2026-09-25: SLIME's eval-in-frame, as described. An expression is evaluated at a frame boundary, so it sees globals and not the stopped frame's locals — which are the values anyone stopped there wants. Wants SLIME's eval-in-frame: pick a frame, and the expression is checked and run with @@ -1896,7 +1932,8 @@ because =locals= and the inspector are asked by it. The innermost frame is shown even when it is the prelude's, unless the stop is =(pause)=, because it is where the program stopped. Rules out renumbering the visible frames. -** TODO There is no stepper +** NEXT There is no stepper +Decided 2026-09-25: stepping happens inside a stopped frame, so the game loop and its clock are frozen, as under =(pause)=. =(pause)= stops and offers restarts, frames, locals and the inspector, but nothing advances a form at a time. CIDER instruments a form and steps the instrumented copy; the equivalent here is a dev-build-only instrumented @@ -1948,7 +1985,8 @@ daemon asks for the sink off (=lib/loc.ml:185=) and gets one exception, so a function with three bad expressions takes three round trips. The sink is per-phase; making it per-form would need a resync point inside a body. -** TODO A session should start before a program compiles +** NEXT A session should start before a program compiles +Decided 2026-09-25: SBCL/nREPL order: an empty host session, then a load-file op. Re-running with no =main= says there is none; a half-loaded file keeps what compiled and lists the errors. =C-c C-k= is load-file and the inspector moves to =C-c M-i=, CIDER's key. =flan dev= builds the program first, so a =main= that does not compile gives no session. Want SBCL/nREPL order: empty image, then load a file into it. Needs a host built with no user program, and a load-file op — the reload machinery