Compare commits

...
1524 Commits
Author SHA1 Message Date
JosephFerano ca20a13f99 TODO's enum item reads as a sentence. 2026-10-02 05:20:16 +07:00
JosephFerano 7581439c04 TODO's enum item reads as a sentence. 2026-10-02 05:18:35 +07:00
JosephFerano 656daeacda TODO records the gaps the final reviews and the heavy suites left open. 2026-10-02 05:18:29 +07:00
JosephFerano d2d4061a4b The x86 survey masks allocator addresses and agent socket paths before comparing, the page's recorded outputs match the current error positions and printing, and its usage text lists --defs. 2026-10-02 04:48:22 +07:00
JosephFerano 37155bba6f The valgrind sweep compares output with the allocator address in a StorageExhausted payload masked, since it differs on every run. 2026-10-02 04:18:00 +07:00
JosephFerano 99e613a73c The pin pruner no longer passes null pointers to memmove for an empty run, and the unchecked-bounds sanitizer control expects a clamped slice to read nothing. 2026-10-02 04:07:34 +07:00
JosephFerano 4c372cf345 A dev build stops for an interrupt at loop heads and function entries, a struct redefinition names the frames still on the old layout, and tests keep their own cache directory. 2026-10-02 03:50:11 +07:00
JosephFerano 92d662702b The safepoint costs a dev binary with no agent 0.73s to 0.96s over two billion loop turns with a three-instruction body on LLVM, 0.31s to 0.34s in fib(38), and 8.1s to 8.2s on the x86 loop, and a release build's output is byte for byte unchanged. 2026-10-02 03:47:44 +07:00
JosephFerano b55a3ac612 TODO records the safepoint interrupt and that a load names the frames running an old struct layout. 2026-10-02 03:47:39 +07:00
JosephFerano cd1a9ef387 The struct-live test redefines Pt by a same-size field change a running frame survives, and the declaration refusal test uses a defconst. 2026-10-02 03:34:56 +07:00
JosephFerano ed6ade38b6 The prompt test keeps its refusal of a declaration with one that is not a once. 2026-10-02 03:01:12 +07:00
JosephFerano 0bb030c40b A name pattern binds only over numbers, strings and a dyn, so an enum member written without its colon is still refused by name. 2026-10-02 03:00:22 +07:00
JosephFerano e2389ce16f A once typed at the prompt defines a session global as a let does. 2026-10-02 02:58:54 +07:00
JosephFerano a96b748c10 A closed connection is not stopped, the watch regexp docstring fits 80 columns, and the pop, put, remove and clear docs say what they take. 2026-10-02 02:57:38 +07:00
JosephFerano 4cb39c4ae3 The syntax spec says a lowercase name in a literal match binds the value. 2026-10-02 02:56:32 +07:00
JosephFerano c02710ad46 A lowercase name in a match arm binds the value and always fits, so a guard on it is the arm taken when the test holds, and an interrupt stop has no condition fields to fetch. 2026-10-02 02:56:21 +07:00
JosephFerano a0a2406c8c Every test binary keeps its object and image cache in its own temporary directory, and the C import cache honours FLAN_CACHE_DIR. 2026-10-02 02:54:06 +07:00
JosephFerano 3023a3ba96 A refused enum is reported at its own line in the text sent, and the refusal says why it stays. 2026-10-02 02:53:25 +07:00
JosephFerano dbdb6cdea2 A test checks the definitions list has no closure environment. 2026-10-02 02:52:36 +07:00
JosephFerano fafec47907 Compiler-made closure environments are not in the definitions list, and a number never resolves to a definition in the editor. 2026-10-02 02:49:02 +07:00
JosephFerano 15c53f5a52 Loading a changed struct names the functions still running with its old layout, from a stopped program's stack or, for a running one, main. 2026-10-02 02:47:59 +07:00
JosephFerano f4c8b3e6dc Assigning a once local steps once, because a symbol standing for a plain name needs no temporary for its place. 2026-10-02 02:46:00 +07:00
JosephFerano 889b22c309 The manual says an interrupt stops at the next loop turn or function entry and offers abandon-evaluation inside an expression. 2026-10-02 02:44:26 +07:00
JosephFerano 343fd1dc3d An interrupt stops a dev program at the next loop turn or function entry in any module, so a program in a system call, a C call or a runaway expression stops within a second, and no table of text ranges is kept. 2026-10-02 02:44:15 +07:00
JosephFerano 128b94ba4a A stepped range ends at the type's limit, a large array or struct moves in one copy, and test_reload pins a size-changed global starting over. 2026-10-02 02:35:24 +07:00
JosephFerano 7fc8007f06 A global retyped across a reload starts over with zeroed storage and a line on stderr, and the reload test asserts that instead of a dead process. 2026-10-02 02:30:47 +07:00
JosephFerano f07befbc0c A build with a flag before the file says the file comes first, and an array literal index that overflows or divides by zero says so. 2026-10-02 02:29:40 +07:00
JosephFerano a05be7dbcf TODO records what is left of big arrays on LLVM: returns, parameters and a computed global's startup store. 2026-10-02 02:27:18 +07:00
JosephFerano 2cd1e380e3 A write through a typed class slot is checked against the type at that place and made as the dyn it crosses into, and a BitSet slot keeps its members in ascending bit order. 2026-10-02 02:22:41 +07:00
JosephFerano 9e14ca50a9 A big array or struct is copied from an element, field, dereference or global with one memmove, and a big literal is written part by part where it lies. 2026-10-02 02:22:18 +07:00
JosephFerano 72eca05d6e A Vec slice with an inclusive bound at the largest i32 ends at the Vec's length without adding 1 to it. 2026-10-02 02:08:39 +07:00
JosephFerano 19164384f7 A range with a step ends at the limit of its integer type instead of wrapping or trapping when the next step does not fit. 2026-10-02 02:07:54 +07:00
JosephFerano ebdbf5587e The syntax spec says a slice clamps and a single index traps, and that enum, Map, Set and BitSet slots are held. 2026-10-02 02:04:58 +07:00
JosephFerano 96da2eb9f1 print ends the line and prin does not, both read a format literal first, and println is refused with a message that says to write print. 2026-10-02 01:50:25 +07:00
JosephFerano 6d28823307 A stopped frame lists and evaluates once locals and symbols, each link of an optional chain steps at its own place, and three stale test rows are corrected. 2026-10-02 01:02:27 +07:00
JosephFerano b855cc4815 A stopped frame lists a function's once locals under their written names and an expression sent to it reads them and the symbols in scope at the stop. 2026-10-02 00:57:45 +07:00
JosephFerano 662233f317 Each link of an optional chain has its own location, so a step moves along a?.b?.c. 2026-10-02 00:47:26 +07:00
JosephFerano 9ab1229993 A comparison guarding a call in an and or an or statement is not warned about as an unused result. 2026-10-02 00:45:37 +07:00
JosephFerano c47150632e A class slot takes a struct, an array, a Vec, a Map, a Set, a BitSet, an enum, text or a char, and enums, Maps, Sets and Vecs cross into dyn and back. 2026-10-02 00:43:13 +07:00
JosephFerano 62025c7d33 A negative inspector step is out of range, and the REPL and rolled-back-import tests expect the .fln spelling and the .fln message. 2026-10-02 00:42:45 +07:00
JosephFerano fb8432a90a The interrupt, stepping-from-a-pause and locals-in-scope work joins master. 2026-10-02 00:12:37 +07:00
JosephFerano da6fa71ca0 A key interrupts the running image, stepping starts from a pause, and a frame lists only the locals in scope. 2026-10-02 00:12:19 +07:00
JosephFerano 4bb622c81f Integer overflow and an out-of-range shift count trap in dev and debug builds, and +% -% *% wrap in every build. 2026-10-02 00:09:44 +07:00
JosephFerano f866999767 A u64 constant product may exceed the largest i64 and a dyn overflow row is added to the acceptance table. 2026-10-01 23:59:35 +07:00
JosephFerano 6b4240a673 A load reports what flan check reports and installs nothing until clean, and a struct may be redefined in a running image. 2026-10-01 23:55:34 +07:00
JosephFerano 9b13c10d67 A global whose layout changes size takes a fresh slot each time, the registry never ends the session over a size mismatch, a reload module carries the renderers of the conditions it signals, and rows pin shrink, repeated size changes and a container load on both backends. 2026-10-01 23:55:07 +07:00
JosephFerano 7489d4d87e Typed maps and sets hash with a fixed seed, a dev build poisons removed items and quarantines freed blocks, and a release stack overflow names the function. 2026-10-01 23:54:55 +07:00
JosephFerano 364bc3f571 The quarantine's statics are compiled out under AddressSanitizer, and the release freed-block row asserts only that nothing is poisoned. 2026-10-01 23:54:30 +07:00
JosephFerano 16e29a720c A shift count is range-checked at its own width, a dyn overflow signals the same ArithError a typed one does, constants that overflow are refused, parse-i64 never overflows, and programs that meant to wrap use the % operators. 2026-10-01 23:51:42 +07:00
JosephFerano 1aeabc94ed TODO records the data-type slot as waiting on a dyn form, and the dev and acceptance rows cover Map slots and the data-type refusal. 2026-10-01 23:44:03 +07:00
JosephFerano 9cdd7a2d3f A class slot's refusal spells its type as the file writes it, and the acceptance program covers enum, Map, Set and BitSet slots. 2026-10-01 23:43:26 +07:00
JosephFerano 9fdc262b76 An array literal takes designated indexes and ranges, copy reads a dyn vector into a typed destination, and an array or Vec passes its address at a C import's Ptr(const T). 2026-10-01 23:42:34 +07:00
JosephFerano cbfbc9ce67 An interrupt stops the program only in Flan code and otherwise stays pending, abort ends a wedged process, and frame locals and eval-in-frame share one scope, including in lambdas, with step-here reaching generics and lambdas. 2026-10-01 23:41:41 +07:00
JosephFerano 9d0f10727d A typed Map, Set or BitSet crosses into dyn as a copy and back checked, an enum is a descriptor code, and Map, Set, BitSet and enum-holding slots are checked on write. 2026-10-01 23:40:48 +07:00
JosephFerano 974c4ccff5 The prelude's text functions take and give str, a String passes where a str is wanted, and split cuts at a character. 2026-10-01 23:37:36 +07:00
JosephFerano 3d7a9aee79 A String passes where a str is wanted as a view of its text, split takes a char separator, getenv gives None for a value that is not UTF-8 beside getenv-bytes, and the refusals for bytes at str, str at bytes and sort over text say what to write. 2026-10-01 23:36:59 +07:00
JosephFerano 1ccb5e14e1 An empty image's executable is cached, flan copies lists a generic's copies, and the inspector takes a value's type from the daemon. 2026-10-01 23:35:34 +07:00
JosephFerano 65fbc06993 An enum crosses into dyn as the keyword of its member's name, and an enum slot stores that keyword and refuses a non-member. 2026-10-01 23:35:11 +07:00
JosephFerano e0bc389c6b A class slot takes a struct, a fixed array, a Vec or a char, checked on every write and held as the dyn it crosses into; an enum, Map, Set, BitSet or data type is refused by type. 2026-10-01 23:30:52 +07:00
JosephFerano 8361ca3d13 The image cache checks an entry against its recorded size and digest, relinks over a bad one, keeps the four newest, and its test uses a cache of its own; a [u8] slice is a hex dump at any length and a cut reply is paged. 2026-10-01 23:30:30 +07:00
JosephFerano cdf281f094 An array, a Vec or a slice at an imported C function's Ptr(const T) parameter passes its first address, or null when empty. 2026-10-01 23:29:05 +07:00
JosephFerano a8845b0d93 copy from a dyn vector crosses nil into a T? item, and its traps name the destination as written. 2026-10-01 23:27:18 +07:00
JosephFerano 278a877597 A designated array literal builds in a zeroed slot moved with one memmove at any size, a range key prints as written, and a const over 65536 items is refused with the form to write. 2026-10-01 23:27:18 +07:00
JosephFerano 0f3df9dcbf A wide literal written inside a quasiquote keeps its width. 2026-10-01 23:25:21 +07:00
JosephFerano a1cf3282d0 A struct may be redefined in a running image: its users are recompiled, a global that no longer fits gets a fresh slot, and a once retyped through a let keeps its value. 2026-10-01 23:15:55 +07:00
JosephFerano 3b58e0d0f7 A load checks the whole file as one program, lists every real error without blaming callers of a function that failed, and installs nothing until the file is clean. 2026-10-01 23:15:55 +07:00
JosephFerano b2d3c15c59 The reply-type check in test_dev expects (+ 1 1) to be an i32. 2026-10-01 23:12:56 +07:00
JosephFerano 11633b8971 Comments on the typed map's seed say it is fixed. 2026-10-01 23:10:59 +07:00
JosephFerano 398af8e967 once inside a function is a static local, and symbol NAME = EXPR stands for a place in scope. 2026-10-01 23:10:41 +07:00
JosephFerano 3786081160 A static local is retried after a failed first reach and never named by its hidden global, and a symbol is worked out once in a compound assignment, reported at its use, warned about when a binding captures it, reinstalled when redefined and refused under a taken name. 2026-10-01 23:09:48 +07:00
JosephFerano 720350d59b The prelude's text functions take and give str or String, bounds.flan prints its byte slices as text, and text-functions.fln pins every result. 2026-10-01 23:08:56 +07:00
JosephFerano 66a5b7be3e A wide literal written inside a quasiquote keeps its width, so the expansion reads as the hand-written literal does. 2026-10-01 23:08:26 +07:00
JosephFerano be93be194f The manual says the empty image is cached, a generic's copies need no session, and bytes are known by type; the held-value item records why it waits. 2026-10-01 23:04:24 +07:00
JosephFerano 856ab8afce An expression's reply names its type, so the inspector knows a page of bytes is bytes without a printable value in it. 2026-10-01 23:04:01 +07:00
JosephFerano a9dc89954b A release stack overflow names the function, through dladdr on a link that now exports the program's symbols. 2026-10-01 23:03:21 +07:00
JosephFerano 65027341f9 A dev build holds freed heap blocks, filled with the dead-beef pattern, in a bounded FIFO before really freeing them. 2026-10-01 22:58:30 +07:00
JosephFerano ac848db7ab flan copies <file> <generic> names the copies a file makes, and the lowering view uses it when no session is attached. 2026-10-01 22:58:15 +07:00
JosephFerano 937e65f340 The frame-escape check follows a slice of a slice, and a local bound to a slice, to the local array it points into. 2026-10-01 22:57:08 +07:00
JosephFerano 97b81938bc A new empty image copies its executable from the cache keyed by its link inputs, so a start to a bound socket takes 0.42-0.51s instead of 0.69-0.74s. 2026-10-01 22:56:15 +07:00
JosephFerano 750c1eed3d pop, pop-front, remove-last, remove-at and clear fill what they remove with the dead-beef pattern in a dev build. 2026-10-01 22:55:33 +07:00
JosephFerano 6fa476089d A typed Map and Set hash with a fixed seed, so their order is the same on every run and on both backends. 2026-10-01 22:54:04 +07:00
JosephFerano cd0623bb59 TODO queues decision 198: a wide literal keeps its width through a quasiquote. 2026-10-01 22:53:43 +07:00
JosephFerano 6730f88cee TODO queues decision 197, class slots of any crossing type, and cancels the retyped once carry-over. 2026-10-01 22:51:34 +07:00
JosephFerano 69a151bd33 BitSet is a set of enum members or a range in one integer, and BitArray is a growable bit array; both take the common verbs. 2026-10-01 22:51:02 +07:00
JosephFerano e30c5bf0ee Review follow-ups: trap and refusal wording, the unused-comparison warning's reach, {} over bytes, a dyn char into str, array length checks and top-level let commas. 2026-10-01 22:49:23 +07:00
JosephFerano f4570cb7c9 TODO settles decision 182: a redefined struct recompiles its users and migrates nothing. 2026-10-01 22:46:16 +07:00
JosephFerano c3ed92ff40 A BitSet's place is evaluated once, a literal on either side takes the set's type, a BitSet literal may be a const, and BitArray reads take the array by value and survive growth in a walk. 2026-10-01 22:46:02 +07:00
JosephFerano 3b5a1dfa85 Typed and dyn integer + - * and a computed shift count stop on overflow in dev and debug builds and wrap or mask in release, and +% -% *% wrap in every build. 2026-10-01 22:42:51 +07:00
JosephFerano 8f63e2b3f0 A frame's locals list only the slots in scope at its stop under their written names, and s at a pause installs the function for stepping so its next call steps. 2026-10-01 22:41:19 +07:00
JosephFerano a7b5d09c95 Eldoc shows signatures in .fln spelling with parameter names and the argument being typed. 2026-10-01 22:40:17 +07:00
JosephFerano eb02bf01c4 Eldoc counts a piped call's left side, skips escaped commas, shows operators and optional arguments as written, and a dev expression reaches a user's verb-named definition. 2026-10-01 22:39:56 +07:00
JosephFerano e2aeb9b9de TODO drops the dyn vec slice item: a dyn vector is a value, and its slice is one too. 2026-10-01 22:34:37 +07:00
JosephFerano 3599b741ca TODO queues decision 196: dev poison on pop, clear and remove-at, and the slice-of-slice escape. 2026-10-01 22:34:21 +07:00
JosephFerano 6ed8b454f8 The comparison table resets with each check, and the spec says a top-level let takes commas. 2026-10-01 22:32:50 +07:00
JosephFerano 56c514a980 The array-length and top-level let rows in the checker and reader tests state the new behaviour. 2026-10-01 22:30:04 +07:00
JosephFerano 65d37aca7e TODO queues decision 195: typed maps and sets hash with a fixed seed. 2026-10-01 22:29:49 +07:00
JosephFerano e60844ea0d TODO queues decision 194: a load reports what flan check reports. 2026-10-01 22:26:31 +07:00
JosephFerano 6ca11f142d put, get, remove, length, clear, in and for work on a BitSet and a BitArray, and the BitSet and BitArray names are highlighted. 2026-10-01 22:26:11 +07:00
JosephFerano b177649815 A once in a function body is a static local, and symbol NAME = EXPR makes a name stand for an expression, read and assigned, wherever it is in scope. 2026-10-01 22:24:08 +07:00
JosephFerano 93af417732 The agent's poll leaves temp memory alone, the stepper stops at each link of an optional chain, and an evaluated let keeps its name for the session. 2026-10-01 22:23:56 +07:00
JosephFerano 1b9b95ec12 An evaluated let shows under its written name in every reply, a call of one is refused in that name, a loop's index is named as one when assigned, and a poll test runs under a daemon. 2026-10-01 22:23:38 +07:00
JosephFerano 94f19a7a26 The grown-parameter warning, the slice-escape fix and parse.ml's not-assignable, get-place and read-only-slice messages print in .fln spelling for a .fln file. 2026-10-01 22:21:54 +07:00
JosephFerano 332cb3ddd9 A BitSet has a #{...} literal, in, the operators | & - ^ and the comparisons, all on its integer. 2026-10-01 22:19:50 +07:00
JosephFerano a4cf382887 A top-level let binds a global for each name on its line, as the local form does. 2026-10-01 22:19:41 +07:00
JosephFerano 64fd9aae39 copy takes a dyn vector as its source into an array, a slice or a Vec, each item crossing into the destination's item type. 2026-10-01 22:19:12 +07:00
JosephFerano 77377c1203 A nested array type with arithmetic lengths reads without parentheses, and a length below 0 or above 2147483647 is refused with its value. 2026-10-01 22:18:47 +07:00
JosephFerano de93e4e190 C-c C-M-i interrupts the running image where it is, stopping it on an Interrupt break whose continue restart returns to it. 2026-10-01 22:17:40 +07:00
JosephFerano 8b9c1bf3e8 TODO drops the slice-bound refusal that clamping made obsolete. 2026-10-01 22:17:26 +07:00
JosephFerano 0b1feda713 An enum trap counts the members it leaves out, a where clause is said to go on the signature line, a nested Option slot and a vendor import print as written, a dropped comparison or and/or is warned about wherever it is dropped, {} prints a [u8] slice as numbers, and str reads a dyn char. 2026-10-01 22:17:21 +07:00
JosephFerano a2e259510e A typed array literal's items may name their index or range, [0x10 = 5, 0x20..=0x2F = 1], and the rest are zero. 2026-10-01 22:17:14 +07:00
JosephFerano 03df3bce0e BitSet(Flag) and BitSet(0..64) are types stored in the narrowest unsigned integer, and print as their members. 2026-10-01 22:16:45 +07:00
JosephFerano 2044220688 Eldoc shows signatures in .fln spelling with parameter names and highlights the argument being typed. 2026-10-01 22:13:58 +07:00
JosephFerano e14450e504 The BitSet TODO says what it needs. 2026-10-01 22:10:47 +07:00
JosephFerano 2642bab5f4 A growable BitArray lives in the prelude with bits-put, bits-get, bits-remove, bits-length, bits-clear and a cursor over the set bits. 2026-10-01 22:10:43 +07:00
JosephFerano a5278a3e9d A match arm takes a guard, and switch is a match that need not be exhaustive. 2026-10-01 22:05:44 +07:00
JosephFerano c3aaa736dc A guarded match prints in any position, a guard takes as like an if, and switch and a repeated _ are refused in their own words. 2026-10-01 22:05:27 +07:00
JosephFerano 6b2ab59de5 The inspector and the conditions buffer refresh at every stop, expand values in place, and show arrays in rows with hex. 2026-10-01 21:49:06 +07:00
JosephFerano ae625214d7 Stop functions fail one at a time, only a plain place is read again at a stop, a byte array is a hex dump for an untyped root, and three docstrings fit 80 columns. 2026-10-01 21:48:48 +07:00
JosephFerano bde8e744de pop, pop-front, remove-last, clear, copy and fill work on the built-in containers, and a slice clamps its bounds. 2026-10-01 21:47:48 +07:00
JosephFerano d9209cb956 A byte slice and a Vec(u8) print as numbers, and a struct in dyn prints as Name{.x 1}. 2026-10-01 21:47:36 +07:00
JosephFerano 81461bb951 The range-slices trap row names the line the u32 bound is now on. 2026-10-01 21:47:26 +07:00
JosephFerano adcebeb076 copy and fill refuse a destination that is a part of a dyn vector, and a TODO records copy from dyn into a typed destination. 2026-10-01 21:44:23 +07:00
JosephFerano 5d2a00cad9 A slice end of the smallest i32 clamps to the front instead of reading as the end of a Vec or text. 2026-10-01 21:43:21 +07:00
JosephFerano ff81858beb The reader takes <<=, >>= and %=, several lets on one line, skips comment: blocks, and names the glued Name{ and a line-ending and. 2026-10-01 21:39:11 +07:00
JosephFerano 7d85390b52 The and or or line-ending message comes from the checker, so a dyn program that takes an if on the next line stays legal. 2026-10-01 21:38:50 +07:00
JosephFerano 70ebfa4e0e The stepper's keys live in the stopped source buffer, indentation handles comments, regions and repeats, and a load shows only its own diagnostics. 2026-10-01 21:38:11 +07:00
JosephFerano d17998e7f9 Package refusals name the missing name and offer the nearest, struct and class-slot refusals spell .fln, and an unused comparison is warned about. 2026-10-01 21:37:57 +07:00
JosephFerano cf54e37513 The stepper's keys go when any route ends the stop, a region indent leaves operator-led continuation lines in brackets alone, and flan-fln-mode.el byte-compiles clean. 2026-10-01 21:37:53 +07:00
JosephFerano e36cd70736 A line inside a multi-line string is never blanked as a comment block. 2026-10-01 21:36:40 +07:00
JosephFerano d235900dd2 A slice clamps each bound into the length and a reversed range is empty, on every container. 2026-10-01 21:34:46 +07:00
JosephFerano fdbb22d4b3 A match arm takes a guard, pattern if test -> body, and switch is a match that need not be exhaustive. 2026-10-01 21:34:14 +07:00
JosephFerano f6c33e95b4 Evaluating in a stopped frame refuses assignment to a loop index or pattern binding, and an evaluated let keeps its name for later evaluations. 2026-10-01 21:30:46 +07:00
JosephFerano 1c231c9e2f TODO records what blocks listing only the locals in scope. 2026-10-01 21:30:32 +07:00
JosephFerano 7129ecf72b An open conditions buffer and inspector redraw at every stop, values open in place, and arrays draw in rows of N and are read whole by slices. 2026-10-01 21:30:25 +07:00
JosephFerano a2fa4636a4 TODO records how wide the print and prin rename is. 2026-10-01 21:30:23 +07:00
JosephFerano e13185064f A [u8] slice and a Vec(u8) print as numbers, a struct crossing into dyn prints as Name{.x 1}, and only str and String print as text. 2026-10-01 21:30:08 +07:00
JosephFerano e3586771c8 Refusals name what a package lacks, spell struct and class fixes in .fln, point a bare let at its own line, and warn about an unused comparison. 2026-10-01 21:27:58 +07:00
JosephFerano f056a6eccc The parse tests say an array length may be arithmetic and a shift's count any integer type. 2026-10-01 21:27:06 +07:00
JosephFerano 2d4c5b24b7 C-c C-k shows only its own run's diagnostics, a load keeps its warnings' underlines beside its errors, a region evaluates every statement, the REPL reads .fln, and a command-line image's stale socket is swept. 2026-10-01 21:26:12 +07:00
JosephFerano bb77b73cb8 A let in a function binds several names on one line, split by commas at the let's own level. 2026-10-01 21:22:36 +07:00
JosephFerano 604d66dd74 A file that uses agent/ without importing it gets the vendor agent package imported. 2026-10-01 21:21:31 +07:00
JosephFerano 028680ee06 The agent's poll leaves the temp allocator alone. 2026-10-01 21:21:12 +07:00
JosephFerano c12e6c60dd The stepper stops before each link of an optional chain. 2026-10-01 21:21:12 +07:00
JosephFerano e57469327f TODO.org records that a bounded generic boxes beside a dyn and an unbounded one is refused with the bound to write. 2026-10-01 21:20:40 +07:00
JosephFerano a0c7db1f3d An and or or that ends a line and takes a statement on the next as its right side says so, naming both lines. 2026-10-01 21:20:34 +07:00
JosephFerano 729f213404 A dyn keyword passed where an enum is wanted becomes the member it names, or traps naming the members. 2026-10-01 21:19:54 +07:00
JosephFerano 3feef61b41 copy and fill work on arrays, slices, Vecs and dyn vectors. 2026-10-01 21:18:56 +07:00
JosephFerano c473d3d5a6 A space between a struct's name and its { is refused with the glued Name{.field value} spelling. 2026-10-01 21:18:37 +07:00
JosephFerano c2e8fb1ee2 An array type written [_ T] takes its length from the bracket literal it is set to. 2026-10-01 21:18:32 +07:00
JosephFerano 02531f809b The stepper's keys are edebug's, on in the stopped source buffer only and listed in its header line, and the form to run has a quiet themed background. 2026-10-01 21:18:03 +07:00
JosephFerano b7fe3bc2a6 An array length may be + - * over integers and constants, written [(W * H) u8] or [W * H u8]. 2026-10-01 21:17:30 +07:00
JosephFerano 3c41da12a8 A comment: block is blanked before lexing, so it may hold text that is not .fln. 2026-10-01 21:17:16 +07:00
JosephFerano 85746bfa8e TODO.org drops the compound-assignment and test_syntax items, both done. 2026-10-01 21:15:48 +07:00
JosephFerano 3a268ce227 <<=, >>= and %= assign as the other compound operators do. 2026-10-01 21:15:48 +07:00
JosephFerano 6c21964582 pop, pop-front, remove-last and clear work on every built-in container, typed and dyn. 2026-10-01 21:15:36 +07:00
JosephFerano cde0955e60 RET under a comment keeps the comment's column, a region indent aligns bracketed lines, and a repeated == steps through the valid columns. 2026-10-01 21:15:16 +07:00
JosephFerano 07c47ccd00 str over a char gives that character's UTF-8 text. 2026-10-01 21:15:12 +07:00
JosephFerano d9638a62c8 The inspector reads a u8's character, 97 (\a), as part of the number, so a struct holding one parses as a struct. 2026-10-01 21:14:51 +07:00
JosephFerano bc7aa46e21 A dyn value indexes a typed array, read as an i32 when the program runs, so the counter of a for over a dyn vector works. 2026-10-01 21:13:36 +07:00
JosephFerano b512fe2618 A shift's count may be any integer type and the result takes the left side's type. 2026-10-01 21:12:51 +07:00
JosephFerano 7964c15cfd TODO queues pop and pop-front for vectors. 2026-10-01 19:00:50 +07:00
JosephFerano 787c4cf40c TODO queues eldoc signatures in fln syntax. 2026-10-01 18:59:42 +07:00
JosephFerano f224fd0e4c TODO queues slices that clamp their bounds. 2026-10-01 18:48:28 +07:00
JosephFerano 733a3a9862 TODO widens clear to every container. 2026-10-01 18:46:04 +07:00
JosephFerano 9633c141d4 TODO queues clear for vectors. 2026-10-01 18:45:09 +07:00
JosephFerano 7c3110ed29 Review subagents cover up to five related lanes at once, and the dispatching session never reviews. 2026-10-01 15:40:59 +07:00
JosephFerano 1db1a2fbf3 Dispatch groups related TODOs into one Sonnet lane, and trivial lanes merge without review. 2026-10-01 14:48:14 +07:00
JosephFerano 4b70385b57 TODO queues decision 193: overflow traps in debug builds and +% wraps. 2026-10-01 14:14:51 +07:00
JosephFerano da6a61862b TODO queues decision 192: a shift's count may be any integer type. 2026-10-01 13:12:28 +07:00
JosephFerano 069549797e TODO queues a warning for a comparison used as a statement. 2026-10-01 06:10:59 +07:00
JosephFerano 013792d8a0 TODO queues an interrupt key. 2026-10-01 06:03:30 +07:00
JosephFerano c2a96f00d3 TODO queues decision 191: print ends the line and prin does not. 2026-10-01 05:59:36 +07:00
JosephFerano 798f4bfca7 TODO queues the inspector refreshing at every stop. 2026-10-01 05:54:59 +07:00
JosephFerano c8e5dfa006 TODO queues a watch-window conditions buffer, live refresh while stepping, and arrays in rows of N. 2026-10-01 05:51:46 +07:00
JosephFerano 4c2dd69411 TODO records the inspector misreading a struct. 2026-10-01 05:48:25 +07:00
JosephFerano f846ca94c2 TODO records out-of-scope and temporary locals in the frame listing. 2026-10-01 05:46:29 +07:00
JosephFerano 8b8b2cf151 TODO records the stepper menu blocking other keys. 2026-10-01 05:44:28 +07:00
JosephFerano c623fcbb6b TODO queues a nicer stepper highlight. 2026-10-01 05:41:44 +07:00
JosephFerano 240c079f50 TODO queues stepping onward from a pause. 2026-10-01 05:39:27 +07:00
JosephFerano 8f5b3f2497 TODO queues decision 189: switch, a match that need not be exhaustive. 2026-09-30 21:34:59 +07:00
JosephFerano 3550117ebb TODO records the unhelpful error for a line ending in an operator. 2026-09-30 20:43:58 +07:00
JosephFerano 0da1e2b3f3 TODO records RET inside a comment indenting the new line. 2026-09-30 10:39:03 +07:00
JosephFerano 753283e949 TODO restores its Checker heading and rules out ? narrowing a field or a global. 2026-09-30 10:30:11 +07:00
JosephFerano 6e227d60d1 raylib's Key enum names every key raylib.h does, key-equal and the function keys among them. 2026-09-29 19:25:23 +07:00
JosephFerano 0c53666cd1 TODO records the raylib Key enum's missing entries. 2026-09-29 19:23:53 +07:00
JosephFerano 7122fe5a7c TODO queues decision 187: several let bindings on one line. 2026-09-29 18:43:02 +07:00
JosephFerano a3e439872b TODO records an Emacs freeze with a long-running image. 2026-09-29 17:58:29 +07:00
JosephFerano bf5afd7c9a tformat, format and bformat build text from a checked format string with Rust-style specifiers. 2026-09-29 11:52:16 +07:00
JosephFerano 72d86c9a29 tformat, format and bformat compile a literal Rust-style format string into Vec(u8) appends, on both backends. 2026-09-29 11:51:47 +07:00
JosephFerano 86ff0047ee TODO queues decisions 185 and 186: a format family and static locals. 2026-09-29 10:42:00 +07:00
JosephFerano 735545e9cb TODO queues decision 184: the poll leaves the temp allocator alone. 2026-09-29 10:28:17 +07:00
JosephFerano 3f6a84c45c TODO notes that formatting must not allocate per frame. 2026-09-29 10:24:16 +07:00
JosephFerano 789221a48a TODO records that formatting a label takes concat and a digit table. 2026-09-29 10:16:34 +07:00
JosephFerano 5172b84964 TODO records that loading chip-8.fln into an image takes seconds. 2026-09-29 09:51:18 +07:00
JosephFerano a069b88138 TODO queues stopping a load at the first failing function and diagnostics that show only the last run. 2026-09-29 09:49:53 +07:00
JosephFerano 97614ee62f TODO records the missing shift and remainder compound assignments. 2026-09-28 21:24:19 +07:00
JosephFerano 9e2fb44e78 TODO queues indent cycling through valid depths. 2026-09-28 21:23:42 +07:00
JosephFerano 152c13039e TODO records the type-variable message for an unknown package type. 2026-09-28 11:19:03 +07:00
JosephFerano 0a6851c42b TODO records the misleading message for a name a package lacks. 2026-09-28 11:17:05 +07:00
JosephFerano 13eabbfc8d TODO queues parameter names in eldoc. 2026-09-28 11:16:19 +07:00
JosephFerano f50485a383 TODO queues decision 183: BitSet and a growable bit array. 2026-09-28 10:36:25 +07:00
JosephFerano f0a2c5a550 TODO marks decision 182 for discussion. 2026-09-28 10:29:56 +07:00
JosephFerano 8ac3c02705 TODO queues decision 182: a struct may be redefined in a running image. 2026-09-28 10:26:22 +07:00
JosephFerano d5acd10af1 TODO queues decision 181: match guards. 2026-09-28 10:04:53 +07:00
JosephFerano ebfe1476eb TODO queues decision 180: symbol macros. 2026-09-28 10:02:04 +07:00
JosephFerano dbf7a9e1e2 TODO records that struct-literal fields inside a call do not indent. 2026-09-28 09:56:13 +07:00
JosephFerano 9da6e558ad TODO records the paused 174 lane and test_syntax's failures on master. 2026-09-28 09:43:15 +07:00
JosephFerano be55983b10 TODO queues decision 179: a comment block is not read. 2026-09-28 09:43:07 +07:00
JosephFerano a246cddec1 TODO records that a spaced struct literal gets the comma message. 2026-09-28 08:56:34 +07:00
JosephFerano 0cdc7e3589 TODO queues decision 178: an evaluated let binds its name for the session. 2026-09-28 07:29:38 +07:00
JosephFerano 406d9e9041 TODO folds the memory view into the inspector. 2026-09-28 07:25:23 +07:00
JosephFerano 3e450e2531 TODO queues region evaluation of expressions. 2026-09-28 07:23:08 +07:00
JosephFerano 9e01d23f5d TODO records that a byte slice and a Vec(u8) print as text. 2026-09-28 07:17:57 +07:00
JosephFerano 29bb944406 TODO queues the REPL reading .fln. 2026-09-28 07:16:00 +07:00
JosephFerano 6edd1c2ee7 TODO queues a hex-dump memory view in Emacs. 2026-09-28 07:14:03 +07:00
JosephFerano 581bcf80fd TODO queues decision 177: copy and fill. 2026-09-28 07:10:18 +07:00
JosephFerano dbb8269cb5 TODO rules out byte-order read helpers. 2026-09-28 06:46:26 +07:00
JosephFerano 171a8dea35 TODO records that a char cannot become a str and a dyn keyword cannot cross into an enum. 2026-09-27 20:59:01 +07:00
JosephFerano 757bbdb4d2 Eldoc shows the signature of the .fln call point is inside, not only of the name under point. 2026-09-27 20:52:22 +07:00
JosephFerano 9ba1b0bf86 TODO records that a for loop's index over a dyn vector is typed dyn. 2026-09-27 20:47:15 +07:00
JosephFerano c9f1cf83c5 TODO queues decision 176: designated array initializers. 2026-09-27 20:36:39 +07:00
JosephFerano 988c8b69f3 TODO records the positional-struct message's paren spelling and a let refusal's late position. 2026-09-27 20:35:39 +07:00
JosephFerano 0a6b9c06a2 TODO queues decision 173 and records the class-slot message's paren spelling. 2026-09-27 20:31:39 +07:00
JosephFerano 7d1bc00e1b A call through a qualifier nothing imports says so and shows the import to write. 2026-09-27 20:29:53 +07:00
JosephFerano 40916ad9a9 TODO queues decision 172: pop answers an Option and remove-last traps. 2026-09-27 20:26:07 +07:00
JosephFerano aced491634 TODO queues decision 171: [_ T] takes its length from the literal. 2026-09-27 20:21:02 +07:00
JosephFerano cd55700806 TODO records that an array length computed from constants is refused. 2026-09-27 18:04:12 +07:00
JosephFerano fd396dd38c A negative index or slice bound signals BoundsError, and last(c) reads the final item. 2026-09-27 16:02:44 +07:00
JosephFerano 33da181792 A negative index or slice bound signals BoundsError instead of counting from the end, and last(c) reaches the final element. 2026-09-27 16:01:51 +07:00
JosephFerano 787e1cdda9 In .fln the bit operators are & | ^ with prefix ^ for bit-not, and a rest marker is ...name. 2026-09-27 15:34:13 +07:00
JosephFerano b53a121563 The .fln rest marker is ...name, glued to the name everywhere, and a bare & in a pattern or parameter list is refused (decision 167). 2026-09-27 15:33:18 +07:00
JosephFerano d226e52702 The .fln bit operators are one character each: & | ^ and prefix ^, with && || ^^ refused by name (decision 166). 2026-09-27 15:01:25 +07:00
JosephFerano 81542dfa33 A vector literal builds a Vec at a Vec(T) binding, field or argument, and a slice at a slice parameter. 2026-09-27 14:00:18 +07:00
JosephFerano 2d90a2f26a The literal-to-slice reading applies only at a call's own argument, so a struct field or a return keeps refusing a bracket literal at a slice type 2026-09-27 14:00:09 +07:00
JosephFerano c55d157b3e The prelude's declare, quote, let-splice and if forms are written in .fln. 2026-09-27 13:56:27 +07:00
JosephFerano 00a391f006 A let or once global may change type in a running image: its readers and writers are recompiled against a new slot, and a retyped once starts over with a warning. 2026-09-27 13:56:27 +07:00
JosephFerano 6d31c456ef A retyped global's writers are republished too, not only its readers, and the fixture for decision 161 is written in .fln. 2026-09-27 13:56:07 +07:00
JosephFerano 66759b1b85 Merge branch 'local-master-ref' into worktree-agent-fln-forms
# Conflicts:
#	TODO.org
2026-09-27 13:47:55 +07:00
JosephFerano 3a2cabeff1 A bracket literal at a Vec(T) binding, field or argument builds a Vec, and one at a [const $t] or [$t] parameter builds the array and passes it as a slice 2026-09-27 13:45:43 +07:00
JosephFerano bb39a3a693 Merge branch 'local-master-ref' into worktree-agent-fln-forms
# Conflicts:
#	TODO.org
#	lib/prelude.fln
2026-09-27 13:43:35 +07:00
JosephFerano 69df331bb4 A let global may change type in a running image, with a new slot, its initialiser rerun, and every reader and writer republished, and a defonce global that changes type resets with a warning instead of being refused. 2026-09-27 09:47:56 +07:00
JosephFerano 243a8f9435 TODO records that a vector literal at a Vec(T) binding is refused. 2026-09-27 09:38:32 +07:00
JosephFerano 994e4c4fa4 Values print in .fln spelling: Pt{.x 1}, Some(10), None, and typed Vec and Map by their elements. 2026-09-27 09:38:13 +07:00
JosephFerano d8bc2e79e9 Merges master (decision 154 drops OBJ_VIEW), fixes the typed Vec printer's end-of-slice sentinel to INT32_MIN, and spells KeyError, Point and the layout-query's generic head as .fln literals in the tests. 2026-09-27 09:37:21 +07:00
JosephFerano f741444d75 Text counts characters on dyn, str and String alike, with byte-length, byte-at and byte-slice for bytes and length refused on text. 2026-09-27 08:50:25 +07:00
JosephFerano c599dce220 A once or a class introduced by loading into a running or empty image gets its initialiser and its migrate generic's body. 2026-09-27 08:49:14 +07:00
JosephFerano 53bcfd5f49 Printed values, dev-loop rendering and Emacs's inspector all spell structs, data cases, Options, Vecs and Maps as .fln literals instead of parens. 2026-09-27 08:42:48 +07:00
JosephFerano 4a64fd3bf6 A once or a first-touched migrated class introduced by a load or an eval into a running or empty image installs its initialiser and its migrate generic's body, not just the flag that would have guarded a rerun. 2026-09-27 08:38:54 +07:00
JosephFerano dbf50385be char-count counts the characters of [const u8] too, and rune-count is gone, refused with char-count as the fix. 2026-09-27 08:30:06 +07:00
JosephFerano 26cf46a399 The .fln reader writes declare, a mid-expression quote, and a spliced let binding list, with the else-less if's one spelling documented as the fallback. 2026-09-27 08:28:15 +07:00
JosephFerano cbc69f6d9a Text counts characters in dyn, str and String alike through s[i], slices, get and for, with char-count and byte-length as its two counts, byte-at and byte-slice as its byte forms, and length on text refused. 2026-09-27 08:23:54 +07:00
JosephFerano a6b03c33b5 A dyn set is a value: binding, passing or storing it copies on the first write, as a dyn vector does. 2026-09-27 08:15:44 +07:00
JosephFerano 60000bffb2 A dyn set shares a dyn vector's sticky bit and copy, so put and remove leave every other name's set alone and a shared set walked by a for loop that mutates it still traps. 2026-09-27 08:11:18 +07:00
JosephFerano 0e7ebd2623 Collections share a few verbs, put, get, remove, remove-at and insert, and out-of-range signals BoundsError on every container. 2026-09-27 01:13:21 +07:00
JosephFerano 84a3286480 An unhandled dyn v[i] or v[i] = x out of range stops as BoundsError, in the break loop as well, with the dyn side's sentence. 2026-09-27 01:03:47 +07:00
JosephFerano 145a2e417d A retired verb's suggestion compiles as pasted, a dyn v[i] or v[i] = x out of range signals BoundsError before its DynRange trap, remove-at on a String answers a char, and remove or in over a vector of a type with no = names the operation. 2026-09-27 00:52:34 +07:00
JosephFerano 573367e4d1 Stepping shows the next form and the last value in the source, with a transient menu of step keys that stays open between steps. 2026-09-27 00:32:52 +07:00
JosephFerano 80d682141f A step stop never takes a per-image conditions buffer's window. 2026-09-27 00:32:48 +07:00
JosephFerano 39538c9450 Merge branch 'master' into worktree-agent-a3455f2505057efe8 2026-09-27 00:32:08 +07:00
JosephFerano f3b1c62005 Every collection verb's index counts from the end when negative and signals BoundsError on typed and dyn containers alike, and get on a String answers the char at a character position. 2026-09-27 00:19:18 +07:00
JosephFerano 31ee4fb182 Merge branch 'master' into worktree-agent-a7e355a907475c193 2026-09-27 00:11:58 +07:00
JosephFerano 50098ed8f1 Master's images are merged in, the stepper acts on the image that stopped, a let line's first step shows the value it bound, and the poll skips an image that does not answer within flan-poll-timeout instead of freezing Emacs. 2026-09-27 00:11:58 +07:00
JosephFerano e8075ab76c A str taken from dyn text that provably dies before its crossing runs again is lent without a pin, so round-trip loops stay flat. 2026-09-27 00:09:28 +07:00
JosephFerano c66934f629 Merge branch 'master' into worktree-agent-a3455f2505057efe8 2026-09-27 00:07:49 +07:00
JosephFerano f3b62bfad8 put, get, remove, remove-at, insert and in are the collection verbs on every built-in container, and push, add, has, has-key, map-remove and append on text are refused with the call to write. 2026-09-27 00:07:21 +07:00
JosephFerano e8a0d10364 A str a parameter carries to a global through any chain of loop assignments stays pinned, and a lent crossing left by a trap or a transfer drops its texts. 2026-09-27 00:02:33 +07:00
JosephFerano a04d8b3f5b Stepping into a function arms only that function's next call, re-stepping an edited defn makes q restore the edit, a let line's first step shows the value it bound, and q with nothing stepping says so. 2026-09-27 00:01:12 +07:00
JosephFerano 3f2e590be0 TODO.org records that a silent daemon freezes Emacs's poll. 2026-09-26 23:58:59 +07:00
JosephFerano bba7d55533 Emacs runs several live images with one current, M-x flan starts an empty one whose REPL pops up when ready, and every image's stops are seen. 2026-09-26 23:58:46 +07:00
JosephFerano bd324700c2 Merge master. 2026-09-26 23:54:00 +07:00
JosephFerano 564a549225 Every image is polled and its own conditions buffer acts on it, C-c C-x says how to rebuild, and flan dev removes its socket on SIGINT or SIGTERM and sweeps image sockets whose Emacs has gone. 2026-09-26 23:43:08 +07:00
JosephFerano deca12f0cb A str taken from dyn text is lent rather than pinned when the compiler proves it is dropped before its crossing runs again, so a round-trip loop without free-temp keeps a flat heap. 2026-09-26 23:34:37 +07:00
JosephFerano 2ad55044a6 flan-step.el says why it closes the menu through a transient internal. 2026-09-26 23:33:39 +07:00
JosephFerano b6196f0e1e Merge branch 'master' into worktree-agent-a7e355a907475c193 2026-09-26 23:26:44 +07:00
JosephFerano 2e8e5135b3 A step stop shows in the source with the form about to run highlighted and the value of the one that just ran after it, driven from a transient menu whose n, i, o, c, e, l and q step, step into, step out, continue, evaluate, list locals and quit, on both backends. 2026-09-26 23:26:40 +07:00
JosephFerano 36f4876e03 Merge branch 'master' into worktree-agent-aab0b85605bab2e1c 2026-09-26 23:22:46 +07:00
JosephFerano 395d5545bf Ranges are spelled a..b and a..=b, sequences slice with v[a..b] and index from the end with negatives, a vector or text times n repeats it, and ||= &&= ^^= assign. 2026-09-26 23:21:29 +07:00
JosephFerano c0d1be7b98 Master's new dyn programs write their ranges with .., since ..< is refused. 2026-09-26 23:21:18 +07:00
JosephFerano 2396dc8bb5 Merge branch 'master' into worktree-agent-aaed12a7cb56adbfd
# Conflicts:
#	runtime/flan_dyn.c
2026-09-26 23:13:24 +07:00
JosephFerano 256b6ca874 Every new stop shows the conditions buffer: replies number the stop, a stop at the same condition as the last is still new, and a stop that arrives while a request is in flight waits for it rather than being dropped. 2026-09-26 23:05:09 +07:00
JosephFerano a2ca40d596 TODO.org retires the view entries for decision 154 and records the text-pin growth. 2026-09-26 23:03:13 +07:00
JosephFerano 6ac258dadc A typed array, slice, Vec or struct crossing into dyn is copied, and every element and field is checked when it crosses back. 2026-09-26 23:03:02 +07:00
JosephFerano c9d56b2d4a A repetition of an empty vector or text is empty at once, whatever its count. 2026-09-26 23:00:28 +07:00
JosephFerano 8573290e1b An image's REPL answers for the image that opened it last, stopping every image at exit walks a copy of the list, and the C a process holds only grows. 2026-09-26 22:58:50 +07:00
JosephFerano ac5f7c22da Repetition chains, a u32 index no i32 holds and a repetition too long to build trap by name, slice messages echo the range in .. form, and .fln diagnostics spell types and suggested fixes as .fln writes them. 2026-09-26 22:55:19 +07:00
JosephFerano 062085aa13 flan-repl.el's header names the image's daemon buffer. 2026-09-26 22:53:27 +07:00
JosephFerano a01e12a58f The manual, BUILT.md and TODO.org describe images: M-x flan, C-c C-k, flan-run, switching and listing, and no C-c C-x. 2026-09-26 22:53:09 +07:00
JosephFerano bfa920ac52 Emacs keeps a list of images with one current: M-x flan starts an empty one and pops its REPL when ready, flan-switch-image and flan-list-images pick one, killing an image's buffer stops only that image, and C-c C-k loads a file into the current image. 2026-09-26 22:51:32 +07:00
JosephFerano 2082774c96 test_dev starts an empty image, loads two projects' files and a file bringing C into it, and runs and evaluates what it loaded. 2026-09-26 22:51:32 +07:00
JosephFerano 035531f136 The dyn-descriptor refusals name a dyn vector as the fix instead of a retired feature. 2026-09-26 22:48:41 +07:00
JosephFerano 1acc8e9999 A dyn function that sorts a typed value's copy hands the sorted copy back, since the typed original is no longer written through. 2026-09-26 22:47:32 +07:00
JosephFerano bcd11d3848 A typed array, slice, Vec or struct crossing into dyn is copied, so typed and dyn never share storage and every type is checked when the value crosses back. 2026-09-26 22:42:57 +07:00
JosephFerano 7aa9c705db flan dev with no file starts an empty image: the prelude and nothing else, with its socket under XDG_RUNTIME_DIR, taking files with C-c C-k and running their main on flan-run. 2026-09-26 22:36:07 +07:00
JosephFerano d67b5b9f47 A file loaded into a running dev session brings its packages and its C: the session learns their aliases, and C the process was not linked with is built into a shared object and loaded before the module that calls it. 2026-09-26 22:34:28 +07:00
JosephFerano 9f15469e13 Merge branch 'master' into worktree-agent-aaed12a7cb56adbfd
# Conflicts:
#	runtime/flan_dyn.c
2026-09-26 22:34:05 +07:00
JosephFerano ff2570809e flan-fln-mode highlights int and float as types. 2026-09-26 22:32:45 +07:00
JosephFerano fb37e3bbe3 A dyn vector is a value: binding, passing, capturing or storing it copies on the first write, and a loop over it traps on any structural change. 2026-09-26 22:12:31 +07:00
JosephFerano 1d07f6c449 A dyn for loop's watch on its variable ends with the frame that owns it, dropped wherever the root stack shrinks, so a loop left by a return or an unwind leaves nothing a later frame's copy can land in. 2026-09-26 22:05:15 +07:00
JosephFerano 9188a60f3e The lane holds master's dyn inspector, where a negative step counts from the end of a vec as an index does. 2026-09-26 22:03:34 +07:00
JosephFerano 3e5248b0f9 A vector times an int repeats its elements, typed or dyn and either way round, and text times an int repeats the text. 2026-09-26 21:59:42 +07:00
JosephFerano 366def24e6 Merge branch 'master' into worktree-agent-afea8cf0236460a11 2026-09-26 21:58:47 +07:00
JosephFerano ec988b5f6c v[a..b], v[a..], v[..b] and v[a..=b] slice dyn vectors and text, typed arrays, slices, Vecs and strs, with negative bounds counting from the end, and a parenthesised bound reads as a bound. 2026-09-26 21:52:58 +07:00
JosephFerano b1626b3281 A dyn for loop follows only the copy a write stores into the variable it walks, so a reassignment to any other vector, a sibling copy included, traps. 2026-09-26 21:51:49 +07:00
JosephFerano bb0a604654 TODO.org records that a vector literal at a [const $t] parameter is refused. 2026-09-26 21:46:18 +07:00
JosephFerano a467376bc8 After a let's binding line, RET goes to the let's column and TAB offers the name column. 2026-09-26 21:45:15 +07:00
JosephFerano 0cf7f6f2c2 In .fln, the line after a let's binding line indents to the let's column first and its name column on the next TAB. 2026-09-26 21:45:01 +07:00
JosephFerano fc6129bff2 Merge branch 'master' into worktree-agent-afea8cf0236460a11 2026-09-26 21:44:21 +07:00
JosephFerano 11e182bdf9 x ||= y, x &&= y and x ^^= y assign as += does, over integers, typed and dyn sets and places. 2026-09-26 21:42:25 +07:00
JosephFerano c3273bb82a TODO.org queues decision 156: a few collection verbs work on every built-in container. 2026-09-26 21:38:26 +07:00
JosephFerano 26c5149845 The dev-loop inspector steps into dyn vectors, maps and sets, lists their elements and writes them back. 2026-09-26 21:37:59 +07:00
JosephFerano 2258f61adc A negative index counts from the end on every typed and dyn vector, array, Vec, slice and text, in at, get and the inspector's steps. 2026-09-26 21:37:50 +07:00
JosephFerano 17a6e0be2b The inspector refuses a store anywhere inside a set's element, will not open a dyn value for editing unless all of it was listed, and refuses a field name with a newline in it. 2026-09-26 21:37:45 +07:00
JosephFerano 2cd735fbdd A dyn for loop takes its vector without marking it, sees an element written in its body, follows the copy a write makes of a vector shared before the loop, and traps only on a change of length or count or a different vector. 2026-09-26 21:36:44 +07:00
JosephFerano 181a2c38ef TODO.org records decision 155: typed containers copy as handles, as in Odin. 2026-09-26 21:32:38 +07:00
JosephFerano 56fb094280 Merge branch 'master' into worktree-agent-afea8cf0236460a11 2026-09-26 21:29:41 +07:00
JosephFerano 82c1e7b8c6 A dyn for loop over a variable re-reads it each trip and traps when it no longer holds the value the walk began with, so a push that copied a shared vector, a reassignment, a global and a captured vector are all caught. 2026-09-26 21:22:10 +07:00
JosephFerano ed40a1d338 TODO.org records that typed maps stay in hash order like Rust's, and dyn maps keep insertion order. 2026-09-26 21:20:54 +07:00
JosephFerano 851f8e638d The inspector steps into a dyn map by the entry's position, so keys that print alike stay apart, and lists a dyn set's elements and a typed set's. 2026-09-26 21:17:26 +07:00
JosephFerano ce17f51bd3 Ranges use Rust's spelling, a..b, a..=b, a.. and ..b, and ..< is refused with .. as its fix. 2026-09-26 21:16:24 +07:00
JosephFerano 0c0fbaf793 Merge branch 'master' into worktree-agent-afea8cf0236460a11 2026-09-26 21:16:02 +07:00
JosephFerano 99546948a0 TODO.org queues decision 154: a typed array or struct crossing into dyn is copied. 2026-09-26 21:11:54 +07:00
JosephFerano a5811a9325 A typed struct or array holding a dyn is marked when copied from any place or call, a local a defer names is copied rather than moved at a return, and a dyn for loop sees a push to the vector it walks whether or not that vector is shared. 2026-09-26 21:06:37 +07:00
JosephFerano ba8a6ce838 Merge branch 'master' into worktree-agent-ae0097873d010280d 2026-09-26 21:04:53 +07:00
JosephFerano 6fd669c396 The inspector steps into dyn vectors by index, dyn maps and struct views by key or field, lists each element with its dyn tag, and writes an element back, on both backends and in the Emacs buffer. 2026-09-26 21:01:55 +07:00
JosephFerano ec7fabfd45 TODO.org records that a typed Vec prints as <vec>. 2026-09-26 20:50:47 +07:00
JosephFerano 44c8f81e61 Flan has sets with #{…} and Set(T), set operators and an in test, and for loops read Odin-style over ranges, collections and reverse. 2026-09-26 20:47:27 +07:00
JosephFerano d3aa1e7b08 A range's counter takes the join of its bounds' types with each bound checked once, and a macro's for takes range(a, b, step). 2026-09-26 20:47:10 +07:00
JosephFerano 991c6bcc47 A store into a dyn vector, or one inside a dyn map, runs its indices and keys in the order written, then the value, then reaches the element; m.k writes through the entry and a map's values answer marked vectors. 2026-09-26 20:37:41 +07:00
JosephFerano 1c31fcdc19 The dev-loop tests annotate the literals they need typed, so test_dev and test_session pass under decision 132. 2026-09-26 20:37:40 +07:00
JosephFerano 5dfc3cd1af test_dev and test_session annotate the literals their claims need typed, and TODO.org records that an inspector path cannot step into a dyn vector. 2026-09-26 20:37:25 +07:00
JosephFerano 681efbdc6a The lane carries master's typed map stores through m[k], with master's new range(n) loops written with ..<. 2026-09-26 20:32:34 +07:00
JosephFerano 3a8f9e5f4f Merge branch 'master' into worktree-agent-afea8cf0236460a11 2026-09-26 20:29:41 +07:00
JosephFerano 8be506fce8 A dyn vector is a value: a copy shares its storage until either side writes, which copies first, on both backends and in the dev loop. 2026-09-26 20:26:46 +07:00
JosephFerano 135462027e A function whose body fails to check no longer adds a second error to main or to calls through it. 2026-09-26 20:26:28 +07:00
JosephFerano b7fcccc663 A main or a function value whose inferred body failed to check reports only that body's errors. 2026-09-26 20:26:10 +07:00
JosephFerano 603a37b5da A typed map indexes and writes through m[k] like a dyn map, m[k] traps on a missing key while get answers optionally, and arrays pass to read-only slice parameters. 2026-09-26 20:24:21 +07:00
JosephFerano 5cd83d2595 push, reserve, free, put, map-remove, append, insert and remove through a typed map's m[k] change the stored entry, found after the arguments that could put into the map. 2026-09-26 20:19:56 +07:00
JosephFerano 525e3d32a0 An inclusive or reversed range counts to its limit without overflow in any integer type, a macro's for takes a range, and a for loop traps on any add or remove through a change counter. 2026-09-26 20:18:48 +07:00
JosephFerano 036149db37 The lane carries master's warnings in eval and load replies and its self-call warning. 2026-09-26 20:17:06 +07:00
JosephFerano 6af0485084 A store through a typed map's entry is refused when the map is reached read-only, and m[k] reads a typed map of dyn values. 2026-09-26 20:13:45 +07:00
JosephFerano ddfc062530 A store through a typed map's m[k] writes into the entry after its value is computed, each KeyError keeps its own copy of the key, and a dyn map's missing key traps in plain words. 2026-09-26 20:06:17 +07:00
JosephFerano 1bd2360e77 A vector, map or text literal is dyn unless its binding is annotated, it stands at a typed parameter, or an element's type or a pattern over it fixes its type. 2026-09-26 19:57:26 +07:00
JosephFerano 6e85682cf8 A trap names a literal's binding only when every literal that binding can hold has one type, a comparison names it too, no trap's site carries the note, the over a let names that let's binding, and a nested map literal gets one --no-gc error. 2026-09-26 19:57:12 +07:00
JosephFerano 40cbb42bc6 A runaway recursion or an oversized stack frame is reported as a stack overflow naming the function, and the dev loop stops on a StackOverflow condition. 2026-09-26 19:54:58 +07:00
JosephFerano 1bef6677e1 Merge branch 'master' into worktree-agent-a9a460b58327292d6 2026-09-26 19:50:04 +07:00
JosephFerano 82df0fa262 A stack overflow names the function that is recursing rather than the helper it ran out in, and one frame too big for the stack is reported as an overflow too. 2026-09-26 19:47:43 +07:00
JosephFerano c623b93949 CLAUDE.md says a test executable's deps are built before it runs. 2026-09-26 19:45:57 +07:00
JosephFerano 5089663e40 An annotation over an if, do, let or match types the literals it gives, text literal operands of unary minus, min and max are refused at compile time, dyn arithmetic on a literal's binding names its annotation, and --no-gc names one fix for a map literal or (the dyn ...). 2026-09-26 19:45:28 +07:00
JosephFerano bb9a9db699 A dyn m.k traps on a missing key as m[k] does, KeyError names the key's value, a Fn value's [const T] parameter views an array or a Vec, a program's fn keys is warned about once, and test_acceptance compiles FLAN_TEST_JOBS programs at once. 2026-09-26 19:42:21 +07:00
JosephFerano a082d4c1fc TODO.org queues decision 148, insertion-ordered typed maps, and records 149. 2026-09-26 19:37:03 +07:00
JosephFerano 1d8bfd9089 Merge branch 'master' into worktree-agent-a633fcba5eee385ed 2026-09-26 19:32:44 +07:00
JosephFerano ec2b836060 Sets, in and the Odin for have acceptance programs on every backend, a dyn walk traps on a change in its last trip, and the spec and TODO.org record decisions 143 to 145. 2026-09-26 19:28:56 +07:00
JosephFerano d5181eead1 TODO.org records that a failed load-file drops warning underlines. 2026-09-26 19:28:40 +07:00
JosephFerano fd63b56a96 A function that cannot return without calling itself gets a warning, and a dev eval shows warnings for the forms it sent in Emacs. 2026-09-26 19:28:28 +07:00
JosephFerano 9beff10914 m[k] over a map is the value, and a missing key signals KeyError on a typed map and traps as DynKey on a dyn one; get is the read that answers None or nil. 2026-09-26 19:26:57 +07:00
JosephFerano 128cb8c172 A set prints as its literal, typed or dyn, in println and the inspector, and the printer writes sets, in and for as a .fln file reads them. 2026-09-26 19:22:47 +07:00
JosephFerano 6c2f7c34c7 An eval or load reply carries the compiler's warnings, which Emacs marks at their sites and counts in the echo line, and a load says each warning once. 2026-09-26 19:20:40 +07:00
JosephFerano 82cbc8e63a TODO.org records that values still print in paren spelling. 2026-09-26 19:15:42 +07:00
JosephFerano 49f8035c55 A fixed array or a Vec passes to a [const T] parameter as a view of itself, and a dyn vec at a [const $t] opens once another argument names $t. 2026-09-26 19:15:10 +07:00
JosephFerano 024ec3b128 A .fln file reads #{a, b} as a set, x in c as membership, and for over a..<b, a..=b, reverse(...) and any collection, and every range(...) loop in the tree is written with ..<. 2026-09-26 19:13:00 +07:00
JosephFerano fae322955e A map inside a collection is refused with the chained spelling, and a gone view traps before keys roots anything. 2026-09-26 19:09:45 +07:00
JosephFerano 487c981799 A set is a dyn kind and a typed Set(T), a map with no values, with add, remove, has, the algebra and its operators. 2026-09-26 19:07:31 +07:00
JosephFerano e5a9a5a99b A runaway recursion is reported as a stack overflow, naming the function and depth in a dev build and stopping there on a StackOverflow condition. 2026-09-26 19:05:04 +07:00
JosephFerano 69ea773ce4 A typed map is indexed, assigned and built from an annotated literal as a dyn map is, and both kinds answer keys and values. 2026-09-26 19:02:37 +07:00
JosephFerano bbaca242e5 A dev eval prints the compiler's warnings for the forms it sent and not again for forms sent earlier. 2026-09-26 18:59:34 +07:00
JosephFerano 08ecba92f6 The checker warns at a call through which a function calls itself on every path, so it never returns. 2026-09-26 18:55:49 +07:00
JosephFerano b84412d997 A text or bracket literal is typed only where a type is wanted as it is written, a generic parameter and an arithmetic operand included, and every refusal or --no-gc error names the annotation the user can write. 2026-09-26 18:51:16 +07:00
JosephFerano c82a3b79d6 CLAUDE.md caps every compiled program's memory and time. 2026-09-26 18:50:13 +07:00
JosephFerano 5274f83f88 One fn form holds all of a name's arities, and evaluating it replaces them all. 2026-09-26 18:30:11 +07:00
JosephFerano ae41ec8454 An install reply's :fns and flan reload's summary name each fn once, as it is written. 2026-09-26 18:29:54 +07:00
JosephFerano 1215db7d26 Merge master into the arity lane. 2026-09-26 18:29:54 +07:00
JosephFerano c3f1adb0c3 TODO.org queues decision 141: a dyn vector is a value, copied on first write. 2026-09-26 18:25:08 +07:00
JosephFerano 2010655d60 The vendor libraries, examples and sand are written in .fln. 2026-09-26 18:24:54 +07:00
JosephFerano 5a8e7fa87b TODO.org records that eval-in-frame can assign loop indices and pattern bindings. 2026-09-26 18:21:09 +07:00
JosephFerano 8d198f88c9 A condition can bind what it tests with as, inside and chains too, and the bound name is read-only in the program and in the debugger. 2026-09-26 18:21:00 +07:00
JosephFerano cfdc6e29fd A T value flows into a T? where one is wanted, and a kept else-less form whose arm is already T? gives T?, not T??. 2026-09-26 18:16:34 +07:00
JosephFerano d6c33928f5 A name an as bound is refused as itself when assigned, in the program and in a stopped frame alike. 2026-09-26 18:16:13 +07:00
JosephFerano c1018d87c5 Every reference to the removed .flan files is updated, generated.fln's stale banner line is regenerated, and the ported .fln files read like handwritten Flan. 2026-09-26 18:13:56 +07:00
JosephFerano 7d2da19c0c A fn form's arities are one definition by an id the form is given, and a caller of a removed arity stops on StaleCall and is compiled again whenever its call checks. 2026-09-26 18:08:41 +07:00
JosephFerano fd6510e0b5 Merge branch 'master' into worktree-agent-afbb9bcfd4d47bb1b
# Conflicts:
#	lib/check.ml
2026-09-26 18:06:56 +07:00
JosephFerano 8333ba017d A literal local read as a typed array's element takes that element's type, and the Option str refusal test names str. 2026-09-26 18:06:29 +07:00
JosephFerano b814aca187 A kept when or else-less chain over an Option arm is that Option, flattened one level, unless an Option of it is wanted. 2026-09-26 18:02:43 +07:00
JosephFerano 83bb65957d Each name an as binds is a slot of its own under its name, so the break loop's locals show it with what the test found. 2026-09-26 17:59:59 +07:00
JosephFerano 09ffc27883 Master merges into the as-chain lane. 2026-09-26 17:52:20 +07:00
JosephFerano e4d0e9c60e A pause mark on any column of a condition keeps what it binds, an as in parentheses gets its own refusal, and what an as finds is held in one slot. 2026-09-26 17:52:06 +07:00
JosephFerano 44812855c7 A u64 past the largest dyn int traps at the crossing, a dyn anywhere in an operation makes it dyn, and dyn unbox traps name their site. 2026-09-26 17:51:31 +07:00
JosephFerano b8e68a48f7 A literal local read at T? takes T, None may come first in an if, an Option around an array of Options takes a literal, and a literal refused at T? names the Option. 2026-09-26 17:45:44 +07:00
JosephFerano d14cffa23a (the dyn <literal>) beside nil traps at run time, and a dyn opened at f64 or bool traps at its site. 2026-09-26 17:44:27 +07:00
JosephFerano c4547adb25 Master merges into the as-chain lane. 2026-09-26 17:42:08 +07:00
JosephFerano 488a6a3295 Merge master into the arity lane. 2026-09-26 17:35:18 +07:00
JosephFerano dd719766ee A fn's arities are one form, fn f with a (params) -> R line per arity, and evaluating it replaces every arity at once. 2026-09-26 17:35:18 +07:00
JosephFerano c60cc33b95 A dyn operand in a fold's first pair keeps the form dyn at a typed want, so only the answer is opened and every position gives the same result. 2026-09-26 17:30:44 +07:00
JosephFerano e85ca2c431 Master is merged into the dyn crossing lane again. 2026-09-26 17:30:44 +07:00
JosephFerano 3afb7e13af Master merges into the as-chain lane, and as names what a pipe answers. 2026-09-26 17:22:07 +07:00
JosephFerano 8f46a43e7a Merge branch 'master' into worktree-agent-afbb9bcfd4d47bb1b 2026-09-26 17:21:11 +07:00
JosephFerano 89d54a47f4 A None arm written first in a match meets the other arms at their Option. 2026-09-26 17:21:06 +07:00
JosephFerano 70be2a57e6 Master is merged into the dyn crossing lane again. 2026-09-26 17:18:36 +07:00
JosephFerano f25f69c0be The comment on the prelude's macro forms says the prelude is read once. 2026-09-26 17:18:34 +07:00
JosephFerano 4d85cefbe2 A bare nil in a typed fold is refused in every position while a dyn holding nil traps, a fold operand asked on its own terms records no literal-local use, and a wide literal at dyn says it has no dyn value. 2026-09-26 17:18:29 +07:00
JosephFerano 3f59c18257 Merge master into the arity versions lane. 2026-09-26 17:18:24 +07:00
JosephFerano 2af5dee0d0 The prelude is written in .fln and read once per process. 2026-09-26 17:18:15 +07:00
JosephFerano 09f1e4369f An Option plus a literal is refused at the operator, the literal having been wrapped. 2026-09-26 17:16:16 +07:00
JosephFerano 87709618f9 An as chain's refusals quote the whole condition, and narrowing after an as is tested. 2026-09-26 17:15:24 +07:00
JosephFerano 21c2e78808 .fln has a pipe, x |> f(a), that passes x as the first argument. 2026-09-26 17:15:09 +07:00
JosephFerano 0c7a53f526 A pipe into a language word is refused, and a pipe the next operator or an as swallows is refused with the bracketed form as the fix. 2026-09-26 17:11:15 +07:00
JosephFerano cb60df1dc0 A T is wrapped in Some wherever a T? is wanted, one level at a time and never unwrapped back. 2026-09-26 17:11:12 +07:00
JosephFerano 29494c7978 Merge master into the arity versions lane. 2026-09-26 17:08:19 +07:00
JosephFerano 179156b105 A function name has one version per arity, and a call picks the version by its number of arguments. 2026-09-26 17:08:15 +07:00
JosephFerano e219e90446 Merge branch 'master' into worktree-agent-abfc2bd050afe177a 2026-09-26 17:07:07 +07:00
JosephFerano 0598949fcb Master merges into the as-chain lane. 2026-09-26 17:07:04 +07:00
JosephFerano 5d5018011e Vendor packages, examples/*, and sand.flan are ported from .flan to .fln, and flan generate-c writes generated.fln. 2026-09-26 17:07:04 +07:00
JosephFerano c1916ed819 e as g is any test of a condition's and chain, binding g for the rest of the chain and the block. 2026-09-26 17:07:01 +07:00
JosephFerano 5a3d480edf In .fln, x |> f(a) reads as f(x, a), below or, with the Emacs mode indenting and marking pipe lines. 2026-09-26 16:58:53 +07:00
JosephFerano f484f16190 Master is merged into the dyn crossing lane. 2026-09-26 16:48:32 +07:00
JosephFerano 7dcef05df3 A u64 past the largest i64 traps where it crosses into dyn, a dyn nil past a fold's first pair traps at run time, and an is-numeric $t boxes beside a dyn. 2026-09-26 16:47:28 +07:00
JosephFerano 1763157ef2 Forms .fln cannot yet write are recorded. 2026-09-26 16:40:59 +07:00
JosephFerano edd2955383 The hand-written corpus holds the author's substring-search and sort practice file. 2026-09-26 16:37:18 +07:00
JosephFerano d8a70fea04 Merge master into the prelude .fln lane. 2026-09-26 16:37:09 +07:00
JosephFerano 66fce31dbd The prelude is lib/prelude.fln, embedded at build time and read by the indented reader. 2026-09-26 16:37:02 +07:00
JosephFerano b49c6ad9cd The hand-written corpus gets the author's substring-search and sort practice file, updated to current .fln syntax with a main that runs and prints each function. 2026-09-26 16:36:35 +07:00
JosephFerano 15ff547e52 rand-int stays u64. 2026-09-26 16:29:38 +07:00
JosephFerano 3a2f3327f6 Three dyn-crossing gaps found beside the fold fix are recorded. 2026-09-26 16:28:15 +07:00
JosephFerano f4d79b0147 A dyn operand anywhere in a fold makes the rest of it dyn, and min and max take dyn numbers and chars. 2026-09-26 16:28:15 +07:00
JosephFerano 181f7da037 Merge master into the dyn fold lane.
# Conflicts:
#	lib/check.ml
2026-09-26 16:16:25 +07:00
JosephFerano 97e89c8d40 min and max take a dyn in any position, picking numbers by value and chars by code point in the dyn runtime and trapping at the form on anything else. 2026-09-26 16:15:52 +07:00
JosephFerano ac752d1405 .fln names carry no ? or !, predicates are is- and has-, and optionals are T?, x ??, x!, x? with narrowing, as, and ?. chains. 2026-09-26 16:13:32 +07:00
JosephFerano bd08d6524c Merge master into the .fln optionals lane. 2026-09-26 16:08:16 +07:00
JosephFerano adfab2d482 A narrowed name keeps its assignability, a local whose address is taken or that a fn assigns is not narrowed, a trailing ? tests a whole chain, and the optional refusals name the test to write. 2026-09-26 16:08:16 +07:00
JosephFerano d138155eae A dyn operand anywhere in an arithmetic, bitwise or comparison fold makes the fold dyn from there on, and min or max refuses one past its first pair as it does in it. 2026-09-26 16:08:08 +07:00
JosephFerano 2243b55259 Intermittent dev-session tests wait until the features land. 2026-09-26 15:53:50 +07:00
JosephFerano 8afa054349 Merge master into the .fln optionals lane. 2026-09-26 15:52:00 +07:00
JosephFerano d252eee2b2 In .fln x? tests that a value is present and narrows a local Option to its payload in the block it guards, and e? as g names what a test found. 2026-09-26 15:48:36 +07:00
JosephFerano 6c6b5a60cb ?? continues a line, a chain or an unwrap is no place to assign, nested marks and a ? on a value say what to write, a lowercase type takes ?, and a predicate reads as a word with its name beside it. 2026-09-26 15:36:49 +07:00
JosephFerano e3f431ea79 A literal's reading is fixed where it is bound, and a dyn past the second operand of + is recorded as converted wrongly. 2026-09-26 15:31:58 +07:00
JosephFerano 85482956ea char is a typed code point that stays a char in dyn, a char plus an integer is a char, and a char minus a char is their distance. 2026-09-26 15:31:34 +07:00
JosephFerano 570d2d88fb A char plus or minus an integer is checked for overflow, so a trap names the true sum or the side of the range it left. 2026-09-26 15:27:07 +07:00
JosephFerano 7361830938 A char minus a char takes the integer width its site wants, a chain of + or - folds left step by step, and a char plus a u64 is checked unsigned. 2026-09-26 15:23:34 +07:00
JosephFerano 33203bd3c5 Merge master into the typed char lane
# Conflicts:
#	TODO.org
#	lib/check.ml
2026-09-26 15:04:23 +07:00
JosephFerano c9f33feb6c Merge master into the .fln optionals lane. 2026-09-26 14:55:03 +07:00
JosephFerano 37c4f2ee10 A char plus or minus an integer is a char checked to be a scalar value, a char minus a char is their distance, and dyn chars do the same. 2026-09-26 14:55:00 +07:00
JosephFerano 178c129c97 A .fln name carries no ? or !, and .fln has T?, x ?? d, x!, if let over a plain name and a?.b chaining. 2026-09-26 14:54:58 +07:00
JosephFerano ce19ee615c Performance work waits while design comes first. 2026-09-26 14:43:28 +07:00
JosephFerano 7db1a81d1f Builtins, prelude, vendor and raylib predicates are named with is- or has- and no name carries a ? or !. 2026-09-26 14:34:36 +07:00
JosephFerano 2ace3bfe2b (char n) on a u64 past 2^63 traps naming the u64 it was given. 2026-09-26 14:30:17 +07:00
JosephFerano 3cc8ca9cbb A test's flan dev daemon ends with its harness, a daemon whose socket is gone exits, agent sockets are removed with their program, and killing *flan* asks first. 2026-09-26 14:15:25 +07:00
JosephFerano ed5b56f212 A daemon under a test harness ends within a second or two of the harness going even while a client holds its connection. 2026-09-26 14:05:29 +07:00
JosephFerano 398bd7b851 .fln is queued to become the only source language, with Swift optionals and no ? or ! in names. 2026-09-26 14:03:10 +07:00
JosephFerano 3134246511 Merge branch 'master' into worktree-agent-a5e429a5bd331edd4 2026-09-26 14:00:12 +07:00
JosephFerano 92fbeb4fc9 println prints a char as the character itself, and a char inside a value, in the inspector or at the REPL prints as its literal. 2026-09-26 13:59:32 +07:00
JosephFerano ce0068c0f7 A cheap front for dyn sequences waits on a program that needs it. 2026-09-26 13:53:24 +07:00
JosephFerano 322a675f1b A kept when or else-less chain gives an Option, get is a checked lookup, and .fln has if let and elif let. 2026-09-26 13:52:44 +07:00
JosephFerano fd8620516c A char literal is a typed char unless typed code wants a number, and a char compares, hashes, converts with (i32 c) and a checked (char n), and crosses into dyn as a dyn char. 2026-09-26 13:49:02 +07:00
JosephFerano e122782577 The Emacs test script consumes its own arguments, so batch Emacs never opens the compiler binary after the tests. 2026-09-26 13:47:55 +07:00
JosephFerano 38078cada3 A kept if-let chain takes its type from what its arm answered, so an arm that returns stays Never and one with no value makes the chain a statement. 2026-09-26 13:47:24 +07:00
JosephFerano 717f07f985 test-flan.el clears the arguments it reads, so a batch Emacs no longer stops to ask whether to visit the compiler binary once it passes 10 MB. 2026-09-26 13:45:17 +07:00
JosephFerano 5038c5954a get on a String is refused as at is, a dyn get's trap names get, a kept else-less form beside a number is blamed at itself in arithmetic and comparisons, and a kept if-let chain with no final else gives an Option. 2026-09-26 13:40:16 +07:00
JosephFerano b545584de8 Master is merged into the when, get and if let lane, and dyn get over text counts characters through at's own body. 2026-09-26 13:17:57 +07:00
JosephFerano 6170f50061 The agent's comment on socket removal describes how a flan dev session removes it now. 2026-09-26 13:17:05 +07:00
JosephFerano aa6a007c7e Merge branch 'master' into worktree-agent-a5e429a5bd331edd4 2026-09-26 13:16:24 +07:00
JosephFerano 2752d57655 A dev daemon started by a test dies with it, a daemon whose socket is gone ends cleanly, the Emacs daemon buffer asks before it is killed and closes the session, and a program's agent socket lives under TMPDIR and goes on a trap or fatal signal. 2026-09-26 13:13:39 +07:00
JosephFerano 9f4a50538e Dyn has a char that prints as its literal, dyn text counts characters, and a dyn unboxes into every integer width with a range check. 2026-09-26 13:09:44 +07:00
JosephFerano c24b19e7b1 Merge master into the dyn char lane 2026-09-26 13:09:28 +07:00
JosephFerano 1b588df6b5 Master is merged into the when, get and if let lane after the review fixes. 2026-09-26 13:09:24 +07:00
JosephFerano 79430aca77 A lambda's last form is checked at the return its position wants, a call's arguments and a kept cond with no else keep a when as an Option, a when used as an arithmetic operand is blamed itself, elif let reads and prints as a nested if-let, and dyn get over text or a vec runs at's own runtime body. 2026-09-26 13:09:21 +07:00
JosephFerano 764c2d10af String is owned, growable, always-valid UTF-8 text, and the prelude's text builders return it. 2026-09-26 13:05:56 +07:00
JosephFerano 1243504ac6 .fln has let groups, grid[r c] indices and a not prefix, and a binding lines up under the let's first name. 2026-09-26 13:01:34 +07:00
JosephFerano d980251632 A text's ASCII check is its char count against its length, so pinning a text never changes how it counts, and a char writes through a dyn view where its code point fits. 2026-09-26 13:01:33 +07:00
JosephFerano 4a7df449aa bytes->string copies the Vec's bytes into a new String, a String's character walk stops at its position and never reads past its length, and a builder's block is a String in the leak report. 2026-09-26 13:00:13 +07:00
JosephFerano e5fd44920e Merge branch 'master' into worktree-agent-a26dff0627201f77b 2026-09-26 12:55:06 +07:00
JosephFerano 5d41a2d424 A dyn vec or map moved into typed storage takes a char where its code point fits, and a whole float in a trap's sentence reads as 2.0. 2026-09-26 12:54:08 +07:00
JosephFerano a3899f3ecb Master is merged into the when, get and if let lane for the review fixes. 2026-09-26 12:53:34 +07:00
JosephFerano f35a2b240a Merge master into the dyn char lane 2026-09-26 12:51:41 +07:00
JosephFerano 473a9528b9 A lane runs only the tests its change touches, and one tester runs the full suite after a batch of merges. 2026-09-26 12:47:34 +07:00
JosephFerano 28d4c0f8dc A number literal takes its type from its uses inside the function, an unconstrained float literal is an f64, and a dyn use asks for the dyn width. 2026-09-26 12:41:54 +07:00
JosephFerano d415d5da15 .fln comparison chains may mix operators pointing one way, such as 0 <= r < rows, and evaluate every operand once in order. 2026-09-26 12:35:30 +07:00
JosephFerano 56b254d81c A typed char is queued behind the dyn char and literal inference lanes. 2026-09-26 12:33:00 +07:00
JosephFerano 659f4d8c6c Merge branch 'master' into worktree-agent-a26dff0627201f77b 2026-09-26 12:29:59 +07:00
JosephFerano ef87bdb3b3 A prelude text builder checks its own String as a backstop, and a direct call checks the text it passes first, at the caller's line. 2026-09-26 12:29:59 +07:00
JosephFerano 10a2a2b376 Master is merged into the when, get and if let lane a third time. 2026-09-26 12:28:10 +07:00
JosephFerano 9c47d3bacc A dyn goes into every integer width range-checked, a char where its code point fits, and the trap names the width and what it found. 2026-09-26 12:27:40 +07:00
JosephFerano 0b56818569 Dev bookkeeping per temp allocation grows without free-temp, recorded. 2026-09-26 12:23:17 +07:00
JosephFerano 36655025c0 Master is merged into the when, get and if let lane again. 2026-09-26 12:23:17 +07:00
JosephFerano 83fa52a9c3 A dyn value crosses into typed code wanting a str, a slice, a fixed array or a struct, checked at the crossing, and a writable slice is never a copy. 2026-09-26 12:23:05 +07:00
JosephFerano bfdd971baa A String compares equal to a String or a str by its bytes, and a prelude builder's String is checked at the caller's line. 2026-09-26 12:22:17 +07:00
JosephFerano b1cba196b9 A .fln comparison chain may mix < with <=, or > with >=, reading as the and of its tests with every operand evaluated once, left to right, as a < b < c is, and the printers write that and back as the chain. 2026-09-26 12:19:03 +07:00
JosephFerano 8d08e82b7c The paren printer breaks an if-let as it breaks an if, and a one-key dyn get takes no slot. 2026-09-26 12:17:47 +07:00
JosephFerano f7781dbb78 Emacs offers no binding column after a binding's else block, and offers one after a first binding whose bracketed lambda has closed 2026-09-26 12:15:04 +07:00
JosephFerano d5437c3f22 A non-ASCII char literal is refused where a byte is wanted, a dyn int or char goes into an i32 range-checked, a cast on a dyn char gives its code point, and C1 controls print as \uXXXX. 2026-09-26 12:15:04 +07:00
JosephFerano 62fd7b19bc Master is merged into the when, get and if let lane. 2026-09-26 12:13:55 +07:00
JosephFerano 5ee433a38f A kept when answers an Option, get is a checked lookup over arrays, slices, Vecs and dyn, and if let reads as a two-arm match in both syntaxes. 2026-09-26 12:12:48 +07:00
JosephFerano 349e2d7e72 Merge master 2026-09-26 12:09:56 +07:00
JosephFerano 78e25dfa48 A str from dyn text is pinned once per temp stamp and is a poisoned temp copy in a dev build, a float past f32's range and a map key a struct lacks trap, and a struct's refusal names what the value is. 2026-09-26 12:09:25 +07:00
JosephFerano 5cc661d6ab Merge branch 'master' into worktree-agent-a26dff0627201f77b 2026-09-26 12:07:11 +07:00
JosephFerano db67ab19b4 String is owned, growable, always-valid UTF-8 text over a (Vec u8), and the prelude's text builders return it. 2026-09-26 12:07:11 +07:00
JosephFerano 66ea0b3abe Merge branch 'master' into worktree-agent-ab88d16e8e6fbfe15 2026-09-26 12:05:38 +07:00
JosephFerano 4fb9d3b3fe A wide fold of let operands checks slowly, recorded. 2026-09-26 12:05:30 +07:00
JosephFerano d742be75bd .fln has bit operators &&, ||, ^^ and ~~ above comparisons, and bit-not, popcount, the zero counts and rotations work on typed and dyn integers. 2026-09-26 12:05:16 +07:00
JosephFerano 8a698076eb Merge master into the dyn char lane 2026-09-26 12:04:12 +07:00
JosephFerano 0ed3ce93e2 Every char prints as a spelling that reads back as it, with a name or Clojure's \uXXXX for each control character. 2026-09-26 11:54:03 +07:00
JosephFerano 24b346d927 Merge branch 'master' into worktree-agent-ab88d16e8e6fbfe15 2026-09-26 11:52:13 +07:00
JosephFerano ca9e28860a A dyn value into a written type has a checker row that compiles. 2026-09-26 11:52:13 +07:00
JosephFerano 6d8ced2ba3 A dyn text becomes a str as its own bytes, a dyn view becomes its own storage, and a dyn vec or map becomes a checked copy in a [const T], a fixed array or a struct, while a writable [T] is never a copy. 2026-09-26 11:48:01 +07:00
JosephFerano bcdb6250a9 A bit operator's operand of any shape that answers a bool gets the and, or or not hint, found by a check that is thrown away. 2026-09-26 11:46:32 +07:00
JosephFerano e24395a737 Merge branch 'master' into worktree-agent-a65403bb057c203d7 2026-09-26 11:42:43 +07:00
JosephFerano 68fddf90a1 if let is queued, with Rust's spelling. 2026-09-26 11:21:25 +07:00
JosephFerano dad9c02dc6 A used when gives an Option and get is a checked lookup, both queued. 2026-09-26 11:19:19 +07:00
JosephFerano c7fb0e5422 Literal locals fed through do, let and if arms share one type without extra rounds, a chain past the rounds names the local to annotate, a restart's conversion fix shows the argument as written, and a name's lookup is answered from the last one under the same scope 2026-09-26 11:14:15 +07:00
JosephFerano 1d09f2fd3d Merge master into the dyn char lane 2026-09-26 11:11:42 +07:00
JosephFerano 71f2b0b9b6 Dyn has a char that prints as its literal, dyn text counts characters, and chars and text convert between a text and a vec of chars. 2026-09-26 11:11:42 +07:00
JosephFerano 17ff7cb348 A binding ends its let's bindings only when its value ends in an open block, the message names the let's own line, and Emacs offers no binding column after such a value 2026-09-26 11:11:32 +07:00
JosephFerano 3d5caf25ae Merge master 2026-09-26 11:06:05 +07:00
JosephFerano 5712d4bdc0 A bool operand of a bit operator is refused with its and, or or not hint in a whole-file check, whichever operand it is. 2026-09-26 11:04:32 +07:00
JosephFerano c4452fc500 Merge master into the bit operators lane 2026-09-26 11:04:32 +07:00
JosephFerano 75fe42d310 A dev build's views are checked in constant time per lookup, the dev registry grows instead of dropping notes, and a release crossing into dyn carries no dev record. 2026-09-26 10:57:35 +07:00
JosephFerano a546bde112 Merge master 2026-09-26 10:56:30 +07:00
JosephFerano 4188c47b76 A dev build's self-equality scan starts by bumping a stamp rather than clearing its table, so one large scan leaves later ones as cheap as their own size. 2026-09-26 10:52:59 +07:00
JosephFerano 7123d4d3f0 .fln has no loop or recur, and flan convert refuses a file that uses them. 2026-09-26 10:50:12 +07:00
JosephFerano 473bf0a2b7 The bit operators are && || ^^ ~~ in .fln at Python's precedence, take integers only on typed and dyn values, and popcount, leading-zeros, trailing-zeros and the rotations exist on both backends. 2026-09-26 10:49:54 +07:00
JosephFerano 23663c4773 An unconstrained float literal is an f64, literal locals settle by merging the locals that feed each other, and a restart refused for a number type writes the conversion 2026-09-26 10:45:46 +07:00
JosephFerano 17a7afb297 The .fln reader takes loop and recur as names wherever the Lisp loop's spellings are not written, and the convert refusal lists its lines in a sentence of their own. 2026-09-26 10:43:10 +07:00
JosephFerano 9644b8a616 A dev build's self-equality scan walks each container once, a free finds its block by the keyed probe, a registry listing waits out a growth, and a view's crossing and scalar read take fewer instructions. 2026-09-26 10:41:12 +07:00
JosephFerano 63dc853121 A binding whose value is a block ends its let's bindings, a tab between let and its name is refused, and only a binding-shaped line under a let gets the alignment advice 2026-09-26 10:39:05 +07:00
JosephFerano b5380b4750 Merge master 2026-09-26 10:33:04 +07:00
JosephFerano fc463cf320 The .fln reader refuses loop and recur, flan convert refuses a .flan file that uses them, and every loop outside recur.flan is a while or until. 2026-09-26 10:31:53 +07:00
JosephFerano 0a05d83b3b A release build's dyn view carries no dev record, the dev registry grows instead of dropping notes, a container compared with itself still traps on a stale view inside it, has-key? traps at its site, and a trap clears the walk site it leaves. 2026-09-26 10:13:01 +07:00
JosephFerano 207f5d7bad Merge master 2026-09-26 09:59:00 +07:00
JosephFerano ee6fa5297a A view of any typed value crosses into dyn, reads and writes through .field and [:k], and traps under --dev once its owner is gone. 2026-09-26 09:58:33 +07:00
JosephFerano 0e9d647aca A let's binding lines must line up with its first name, may name an operator word in parentheses, are refused clearly after a block value, and at the top level may be a typed name alone 2026-09-26 09:56:54 +07:00
JosephFerano 497c9f470f A dyn view's record is collected with it, a dev build ties a view of a stack address to its owning frame and finds a heap block through an address index, and a view's traps name the field, the operation and the site. 2026-09-26 09:54:45 +07:00
JosephFerano 5f1b9b9e7e An unconstrained float literal is an f32, and an integer literal meeting a float literal meets it at the float 2026-09-26 09:49:51 +07:00
JosephFerano 163c224d0d Merge master 2026-09-26 09:49:41 +07:00
JosephFerano 7991e00e76 A struct's dyn view takes .field and [:k], read and assigned, and a get on it traps at its own site. 2026-09-26 09:32:49 +07:00
JosephFerano 697c016231 Merge master 2026-09-26 09:29:40 +07:00
JosephFerano d519ca81dd Merge master into the views lane 2026-09-26 09:29:15 +07:00
JosephFerano 36156c9de9 A dyn value takes .field and [:key] as get and put, and a class instance refuses a slot it does not have 2026-09-26 09:28:25 +07:00
JosephFerano 25e7db944f In a .fln buffer a let's binding lines belong to its statement, TAB offers the column under its first name, and C-c C-e sends the group 2026-09-26 06:32:09 +07:00
JosephFerano 12cd4feff0 A .fln let takes more bindings on the lines indented under it, a top-level one more globals, and flan convert writes runs of short bindings that way 2026-09-26 06:26:36 +07:00
JosephFerano 0a1f5a3483 The literal-conflict fix spells a whole float with its point, and the measuring switch says what it is for 2026-09-26 06:26:24 +07:00
JosephFerano 749b4aeb84 A float element of a dyn view takes an int it holds exactly and traps on one it does not. 2026-09-26 06:22:26 +07:00
JosephFerano 5177586848 A number or character literal bound by let or loop takes its type from its uses in the function 2026-09-26 06:22:25 +07:00
JosephFerano f39612bc7c Code a request sends without :syntax is read in its file's syntax, or the program's when it names none, and a dotted dyn name points to its accessor. 2026-09-26 06:21:07 +07:00
JosephFerano 04b29f283d In a .fln buffer a closer-led line inside open brackets goes to the bracket's continuation column, save the closer of a lambda's block 2026-09-26 06:20:55 +07:00
JosephFerano f91ed8ad27 Indices in .fln separate like a vector's elements, by spaces between single values or by commas 2026-09-26 06:19:59 +07:00
JosephFerano e79d9c0d07 Merge master into the views lane 2026-09-26 06:18:31 +07:00
JosephFerano 6e006fce27 not is a prefix word in .fln, looser than comparisons and tighter than and, with not(x) still a call 2026-09-26 06:18:11 +07:00
JosephFerano 26fc9d82a6 docs/BUILT.md states how a dev build decides a dyn view is stale. 2026-09-26 06:17:59 +07:00
JosephFerano d5d9dc1538 A view describes a struct from its own program's table, and an Option's payload is bound to a slot before it is viewed. 2026-09-26 06:17:59 +07:00
JosephFerano 8291e62d7a TODO.org records the view decisions: str read-only, aggregates written through their own view, no int into a float element, and no check in a release build. 2026-09-26 06:10:51 +07:00
JosephFerano 06eaf738b9 .fln lambdas are written with =>, and one with a block can sit last inside a call's brackets 2026-09-26 06:10:12 +07:00
JosephFerano 7953376e4e Any typed container of numbers, bools, str, structs, arrays, slices or Vecs crosses into dyn as a view from any storage, and a dev build traps on a view whose frame returned or whose block was released. 2026-09-26 06:10:11 +07:00
JosephFerano b1404727b6 A lambda with a block prints wherever it is last in its brackets, an element after a block lambda is refused as not last, and TAB offers no column of a block whose brackets have closed 2026-09-26 06:09:47 +07:00
JosephFerano 2dbc8539e5 Merge branch 'master' into worktree-agent-af3c12884ed5f2255 2026-09-26 06:09:13 +07:00
JosephFerano 4eefd25df9 A class instance refuses a key its class does not declare, read or written, through get, put, .field and [:key]. 2026-09-26 06:02:51 +07:00
JosephFerano 65be0bf774 The closer placement after RET in an open call is recorded 2026-09-26 06:01:56 +07:00
JosephFerano d8fd89e31c Calls without parentheses are held 2026-09-26 06:00:48 +07:00
JosephFerano e0652f2970 not as a prefix word is queued 2026-09-26 05:58:26 +07:00
JosephFerano 1330f2cbc6 .fln has struct, class, method, generic, multi, macro, loop and type headers, one-line structs, and a top-level let is a global 2026-09-26 05:56:48 +07:00
JosephFerano 444d2384ce Indices separate like vector elements, queued 2026-09-26 05:56:31 +07:00
JosephFerano 1022b7c7f9 Merge branch 'master' into worktree-agent-af3c12884ed5f2255 2026-09-26 05:54:17 +07:00
JosephFerano 5a4ed94b65 Merge branch 'master' into worktree-agent-ae8e466a60e7cf35a
# Conflicts:
#	TODO.org
2026-09-26 05:54:09 +07:00
JosephFerano 6f6e01db06 A bare def is answered with the let to write, and a generic missing its arrow is refused at the end of its line. 2026-09-26 05:54:01 +07:00
JosephFerano 605eb97e75 A dyn value's .field and [:key] read as get and assign as put. 2026-09-26 05:52:56 +07:00
JosephFerano 8e8015cb2d Primitive literals are typed by local inference, and container and text literals are dyn unless annotated 2026-09-26 05:52:20 +07:00
JosephFerano 7c197e3bb0 A let takes several bindings on indented lines, queued 2026-09-26 05:46:52 +07:00
JosephFerano 895828992e Merge branch 'master' into worktree-agent-ae8e466a60e7cf35a 2026-09-26 05:44:39 +07:00
JosephFerano a8f98c86f1 A let in a form cut from inside a body stays local when the editor sends it, and the printer forgets one file's class names before the next. 2026-09-26 05:44:03 +07:00
JosephFerano f9dadce5d0 The typed read-only text type is str, and a variable may be named after a type when a type follows it 2026-09-26 05:40:11 +07:00
JosephFerano 702ebdc9c5 In a .fln file a top-level let is a global and def is refused, while a let in a block or an evaluated expression stays local. 2026-09-26 05:39:59 +07:00
JosephFerano cde4f2ab1b A .fln file writes classes and methods as class, generic, multi and method headers, and flan convert prints them so. 2026-09-26 05:39:59 +07:00
JosephFerano 905a4c53be A struct or union fits on one line, struct Pt(x: i32, y: i32), and flan convert writes that form when it fits. 2026-09-26 05:39:59 +07:00
JosephFerano de0c8791f3 A variable named like a header or clause word, data = 3 or on += 1, is read and printed as an assignment. 2026-09-26 05:39:59 +07:00
JosephFerano 3f4e5a930f A defining form written as the fallback call in a .fln buffer draws its head as a keyword and its name as a definition, and imenu lists it. 2026-09-26 05:39:59 +07:00
JosephFerano 72c73117d6 A .fln file writes a type alias as type Row = Vec(i32), and flan check prints nothing for a file that checks unless given --defs. 2026-09-26 05:39:59 +07:00
JosephFerano 1a97dfff77 A .fln file writes a condition's parent as struct DiskFull :parent IoError, a macro as macro repeat(i, n, & body) and a loop as loop x = a, y = b, and flan convert prints them so. 2026-09-26 05:39:59 +07:00
JosephFerano aefd2b7887 Merge branch 'master' into worktree-agent-a13e26f81443d4165 2026-09-26 05:39:56 +07:00
JosephFerano 49e6ac6512 Comments and the README example spell the text type str. 2026-09-26 05:39:56 +07:00
JosephFerano 34ef99d1f2 A block lambda in code cut mid-line reads against its line's start, an arm or else value ending in => takes its block, and a missing comma between indices shows the indexed spelling 2026-09-26 05:37:22 +07:00
JosephFerano 69e5e7fe4a A dyn value takes .field and [:key], queued 2026-09-26 05:37:20 +07:00
JosephFerano 6e56fce7fa flan-fln-mode reads a lambda's block inside brackets as statements, and the spec and manual write lambdas with => 2026-09-26 05:27:28 +07:00
JosephFerano 7e5ea096a5 A top-level let is a .fln global 2026-09-26 05:27:27 +07:00
JosephFerano b92e91bcaa A parameter, local or field may be named str or any other type name when a type follows it, and a type-named parameter with no type after it is refused with both fixes. 2026-09-26 05:27:23 +07:00
JosephFerano 3520e2d73e Classes and methods get .fln syntax 2026-09-26 05:23:57 +07:00
JosephFerano 267db2cd91 The typed read-only text type is spelled str, and string is refused with that fix. 2026-09-26 05:22:24 +07:00
JosephFerano 8d24b024a2 One-line structs are queued 2026-09-26 05:19:51 +07:00
JosephFerano 3c962197f4 A .fln lambda's body follows =>, and a lambda's block may sit inside a call's brackets as its last argument 2026-09-26 05:17:31 +07:00
JosephFerano ada798f875 type aliases in .fln are queued, and a noisy flan check is recorded 2026-09-26 05:05:05 +07:00
JosephFerano 7d898beb91 Lambdas are written with => 2026-09-26 04:57:45 +07:00
JosephFerano 78e0091e3e Dyn unless annotated follows the plain rule 2026-09-26 04:56:02 +07:00
JosephFerano 1ef3022599 Parent struct, macro and loop sugar in .fln are queued 2026-09-26 04:52:54 +07:00
JosephFerano 15fc197f9d flan-fln-mode handles else after a one-line if, typed lambdas, restart report text and flat lets 2026-09-26 01:05:16 +07:00
JosephFerano ba3ad6b1bc In a .fln buffer ik takes the value on a one-line else or elif line, a wrapped fn header's return type is coloured, and C-u C-c C-c on a let the let above merges in stops at its value 2026-09-26 01:05:00 +07:00
JosephFerano 02fcb1725e The .fln mode knows else under a one-line if, one-line else, flat lets whose value goes on under them, typed lambda headers, restart reports and Dir.north arms, in its bounds, TAB, pause targets and colours 2026-09-26 00:52:55 +07:00
JosephFerano 3d97c73c32 Hand-written .fln programs cover the syntax, and .fln gains else after a one-line if, typed lambdas, a restart's report on its header, Dir.north, and messages that spell types in the file's syntax 2026-09-26 00:36:49 +07:00
JosephFerano 6c32383539 Typed lambdas bind a generic's CFn variables, a local shadows an enum of its name, the lambda-in-brackets fix is the typed let that compiles, an else deeper than a one-line if or after else-if is refused by name, a block if takes a one-line else, and messages spell every type in the code's syntax 2026-09-26 00:36:21 +07:00
JosephFerano fff1c0b9c3 Merge master: return-type inference beside the syntax-following type spellings 2026-09-26 00:27:59 +07:00
JosephFerano 9befc558a0 A function's return type can be left to inference, with _ in .flan or no arrow in .fln, and if, match and return paths join their types the same way in either order 2026-09-26 00:10:18 +07:00
JosephFerano 40eb57a5b6 A dyn opened in any value position of an operand or an arm makes it meet as a dyn, found by the one walk of value positions the frame-escape check also uses 2026-09-26 00:09:42 +07:00
JosephFerano fd1a7f2b36 A one-line if takes its else on the next line, lambdas state their types, a restart's report sits on its header, Dir.north names an enum member, messages spell types in the code's syntax, adjacent globals convert adjacent, and a bad map key is one error 2026-09-26 00:07:28 +07:00
JosephFerano dbddc61b30 defvar in a .fln file suggests once and def in its spelling, and x++ on a global suggests ++(x) 2026-09-25 23:48:59 +07:00
JosephFerano dca29a5605 A .fln file's common mistakes are answered in .fln terms, a bare () in a body slot does nothing, and the syntax gaps found writing by hand are listed in TODO.org 2026-09-25 23:44:04 +07:00
JosephFerano 263214e639 A dyn opened by an expectation is marked where it is opened, so a bool or an Option beside a dyn stays a dyn, and an operand refused at a type is not asked again, so nested sums and matches check in linear time 2026-09-25 23:43:14 +07:00
JosephFerano 7e48f1eaf5 Eight hand-written .fln programs run the same on both backends and through a conversion to parens and back 2026-09-25 23:34:16 +07:00
JosephFerano 2135f6d37f Merge branch 'master' into worktree-agent-a46233a32d1912c78 2026-09-25 23:27:55 +07:00
JosephFerano 8fe8062a0c Hand-written .fln programs exist, flan convert writes corpus-shaped paren text, and several .fln diagnostics suggest .fln spellings 2026-09-25 23:26:53 +07:00
JosephFerano 3e504deb6f An arm is checked at the other arm's type first and meets it at the join only when refused there, so every program master accepts keeps its type and value while arms still meet in either order 2026-09-25 23:10:16 +07:00
JosephFerano c1beb602a7 A .fln let is always flat, and flan convert writes flat lets, renaming a shadowed name instead of nesting 2026-09-25 23:01:11 +07:00
JosephFerano 0e26af2107 A macro body counts as run in order only when nothing outside its templates depends on how the body splits into arguments, a let whose longer scope would reach a macro naming it stays in a do: block, and converted programs with shadowed names and macro bodies print what their paren originals print 2026-09-25 23:00:27 +07:00
JosephFerano 93e614e239 A frame that prints shows its globals, live prelude shadowing matches a rebuild, flan run cleans up and dies with flan, and a long socket path works from Emacs 2026-09-25 22:59:50 +07:00
JosephFerano bdda16a461 flan run's program dies with flan, and a program killed by a signal ends flan run with 128 plus its number 2026-09-25 22:57:02 +07:00
JosephFerano 69ac194ba7 An editor socket path too long to connect to is linked at a short path the Emacs client computes the same way 2026-09-25 22:54:44 +07:00
JosephFerano 6583392504 A prelude body moved by one live shadowing is compiled again when a later shadowing moves a call inside it 2026-09-25 22:51:02 +07:00
JosephFerano e18b28be3e Master is merged into the flat-let lane 2026-09-25 22:49:39 +07:00
JosephFerano 319a3c78c4 Merge branch 'master' into worktree-agent-a46233a32d1912c78 2026-09-25 22:40:48 +07:00
JosephFerano eb08f1300d dev-parity keeps its pointer global named beside the prints, since a pointer prints without reading it 2026-09-25 22:40:04 +07:00
JosephFerano 55ab1a98ae A macro whose definition splices its body into a do, and comment, count as bodies run in order where the .fln printer writes a let flat, and a struct-pattern let renames and flattens like a plain one 2026-09-25 22:39:50 +07:00
JosephFerano a14e633a21 A pointer's element type can be cast and a slice made from a pointer and a count with slice-from 2026-09-25 22:35:34 +07:00
JosephFerano a39d96149d slice-from-ptr is refused with the slice-from call to write, and the pointer cast's const rule is documented as covering the outer pointer only 2026-09-25 22:35:24 +07:00
JosephFerano ccdadeeb6e A mark or a step spliced into a program that defines its own pause or step-point calls the prelude's 2026-09-25 22:34:28 +07:00
JosephFerano 70b9040512 type-of answers a dyn value's kind as a keyword, and a class cannot be named after a built-in kind 2026-09-25 22:29:26 +07:00
JosephFerano d07c7b4e2c A prelude function shadowed live keeps the prelude's own calls on the prelude's body, as a rebuild does 2026-09-25 22:28:58 +07:00
JosephFerano 41273c9d22 Dyn and typed containers print without a space after the bracket, and a value that cannot cross into dyn gets a fix that compiles 2026-09-25 22:27:53 +07:00
JosephFerano 35aa5a8c0f A refused dyn view of a global names its own defining form as the fix, and every dyn fix is spelled in the syntax the code was written in 2026-09-25 22:27:36 +07:00
JosephFerano 24761dcf54 rl/update-texture-colors remains, as a one-line defn over the pointer cast 2026-09-25 22:27:21 +07:00
JosephFerano c41d27ec45 A frame whose slots are all the compiler's own is compared against the slot count its record carries, so a frame that prints keeps its globals and locals 2026-09-25 22:26:11 +07:00
JosephFerano 4b1e722854 slice-from makes a slice from a pointer and any integer count, and ((Ptr U) p) casts a pointer's element type without dropping const 2026-09-25 22:24:11 +07:00
JosephFerano 285bb1047f The half-write test reports a session that closes under a question as a failure instead of ending test_dev 2026-09-25 22:24:08 +07:00
JosephFerano 0b80c1956f A let in .fln is always flat and a line indented under one is refused, the printer renames a let's name a later statement means otherwise or puts the let in a do: block, and a one-argument and/or prints as its argument 2026-09-25 22:23:37 +07:00
JosephFerano e0f71bba9c A unix socket path too long for sun_path is bound and reached through its directory, so flan dev runs under any TMPDIR and -s path 2026-09-25 22:18:52 +07:00
JosephFerano c1b21edaf4 The break loop's typed-value walk prints arrays and slices as [1 2 3], matching evaluation 2026-09-25 22:18:45 +07:00
JosephFerano e80411d283 type-of answers a value's kind as a keyword or an instance's class name, and a class named like a kind is refused. 2026-09-25 22:18:36 +07:00
JosephFerano 47cc213b42 Return types read off the body and the converter's latest fixtures live together 2026-09-25 22:14:32 +07:00
JosephFerano ff0679cbb1 An if's else arm is checked once and brought to the join, an abandoned check undoes only what it wrote, and an arm refused at the join says why at its value 2026-09-25 22:14:06 +07:00
JosephFerano a1e5e39dba Master's changes are merged into the dyn printing lane, with its converter fixture 2026-09-25 22:11:04 +07:00
JosephFerano 2b35085358 flan run removes its executable and work directory when it is sent SIGTERM or SIGHUP, and the one-shot commands' empty work directory is gone from TODO.org since the exit handler already removes it 2026-09-25 22:10:07 +07:00
JosephFerano d16fe79b79 A refused dyn view of a parameter names its declaration as the fix 2026-09-25 22:09:44 +07:00
JosephFerano 8aaf33589e type-of is queued 2026-09-25 22:04:28 +07:00
JosephFerano 5adabd9f3c Dyn text is immutable and counts characters, str counts bytes 2026-09-25 22:03:26 +07:00
JosephFerano 85283c2e87 Any typed container crosses into dyn as a view 2026-09-25 22:02:04 +07:00
JosephFerano c3d36b5108 The inspector reads memory directly, a trapped read shows as trapped without a break, and requests take effect in the order the client sent them 2026-09-25 22:01:53 +07:00
JosephFerano 4f060bb648 Dyn and typed containers print as [1 2 3] and {:a 1}, a refused dyn view names the value and a dyn fix in the file's own syntax, and a dyn function whose body gives no value says so by name 2026-09-25 22:01:40 +07:00
JosephFerano cf8fea9b3c docs/BUILT.md says a restart is taken after the jobs queued before it. 2026-09-25 22:01:15 +07:00
JosephFerano c9b556a539 A restart is taken only after every job queued before it was accepted has run, and a choice waiting for an outer break survives a nested one, so a fix loaded just before retry is the body the retry runs. 2026-09-25 22:01:08 +07:00
JosephFerano af6e00bc2a = compares bools, a match over a bool takes true and false, and a match over a dyn takes keyword arms 2026-09-25 22:00:51 +07:00
JosephFerano 1cfe97548a test/syntax/algorithms.fln is the converter's fixture again 2026-09-25 22:00:44 +07:00
JosephFerano cecbec6c71 A slice from a C pointer and a pointer cast are queued 2026-09-25 22:00:13 +07:00
JosephFerano cc45b8a55d Dyn has a character type 2026-09-25 21:57:37 +07:00
JosephFerano 2dec18b87f The typed text types are str and String 2026-09-25 21:54:26 +07:00
JosephFerano d8ece682d3 Merge master 2026-09-25 21:51:08 +07:00
JosephFerano e81191d770 A bool returned from C reads as its low bit on --x86 as on LLVM, and the where-and fix and the x-1 hint follow the code's own syntax rather than its file name 2026-09-25 21:50:56 +07:00
JosephFerano bb800903d8 Return types read off the body and frame-escape refusals live together 2026-09-25 21:49:14 +07:00
JosephFerano 7bc04cc434 Flan is dyn unless annotated 2026-09-25 21:43:21 +07:00
JosephFerano c3a05d7447 The site says a match over a bool needs no _ arm 2026-09-25 21:37:44 +07:00
JosephFerano 18581a452c = and != compare bools, a match over a bool takes true and false arms, a match over a dyn takes keyword arms, and where predicates joined with and are refused with the comma or vector form as the fix 2026-09-25 21:35:31 +07:00
JosephFerano 94134da25a Dyn containers print without a space after the bracket, queued 2026-09-25 21:32:46 +07:00
JosephFerano d991e1986c Flat lets and one-argument and/or in the .fln printer are queued 2026-09-25 21:27:50 +07:00
JosephFerano d77d541360 A value that traps or faults while the inspector reads it shows as #<trapped: NAME> with no break pushed, a restart already accepted is taken before queued reads run, and a fault in the inspector's read names the read. 2026-09-25 21:27:24 +07:00
JosephFerano ed7258c80f An abandoned check puts back everything it wrote into the environment, an else arm refused on its own terms is not blamed for the then arm's type, and a match's arms meet at the same join as an if's 2026-09-25 21:26:56 +07:00
JosephFerano d574031609 Keyword arms over a dyn and = on bool are queued 2026-09-25 21:23:50 +07:00
JosephFerano d4f24adbc8 A function cannot return or store in a global the address of its own frame, and a dev build poisons every arena's bytes on free-all 2026-09-25 21:19:13 +07:00
JosephFerano ab5fe36a48 A frame address stored into a global's field, array or Vec element, or pushed or put into a global container is refused with a fix that names no wrong type, and the sanitizer sweep builds at -O0 so a read of a returned frame reports 2026-09-25 21:18:48 +07:00
JosephFerano bd70dfa913 Merge branch 'master' into worktree-agent-a9ad2e8b56d1fbd10 2026-09-25 21:18:18 +07:00
JosephFerano 4fa11f6360 Merge master 2026-09-25 21:11:31 +07:00
JosephFerano 0fc258c434 Merge branch 'master' into worktree-agent-a46233a32d1912c78 2026-09-25 21:11:17 +07:00
JosephFerano 3001aadff3 .fln files open in flan-fln-mode, which evaluates, moves over, indents and edits their statements, clauses and top-level forms 2026-09-25 21:07:16 +07:00
JosephFerano 5c044a7b40 id and ad on a comment that belongs to no form find no object 2026-09-25 21:05:02 +07:00
JosephFerano f76d0d6e9f An if's arms and a _ function's exits meet at one join in any order, a return inside the last form counts where it is written, and a constant computed by a _ function is refused as with a written type 2026-09-25 21:02:26 +07:00
JosephFerano 095701b29b A negated name counts as the name an arm's pattern binds, and a comment belongs to the form or block whose column it sits at 2026-09-25 21:02:19 +07:00
JosephFerano fcd32c21c5 A match arm can be a number, char or string literal 2026-09-25 21:01:40 +07:00
JosephFerano 76b2ab3113 A duplicate literal arm is one whose value equals an earlier arm's at the scrutinee's type, and a literal no dyn holds is refused with a pattern fix 2026-09-25 21:01:15 +07:00
JosephFerano 9e09375d7b Merge branch 'master' into worktree-agent-a6088ba602da36c5e
# Conflicts:
#	TODO.org
2026-09-25 20:58:42 +07:00
JosephFerano 4d3688a519 Merge master into the .fln mode lane; a .fln arm sends its match whenever its value uses any name the pattern has, C-c C-s steps a .fln top-level form, and a comment block directly above a form is part of its text objects 2026-09-25 20:53:48 +07:00
JosephFerano 69eb11ce9b Return types read off the body and generic structs live together 2026-09-25 20:51:38 +07:00
JosephFerano 111ee66a4b A match arm can be a number, char or string literal compared with =, over a number, a string or a dyn, with a _ arm required 2026-09-25 20:51:24 +07:00
JosephFerano a6ccce0e67 Every store of a frame address into a global outside main is refused, fn and handler bodies and restart clauses are checked as frames of their own, and the suggested clone keeps the slice's bounds 2026-09-25 20:50:17 +07:00
JosephFerano c6a3259a62 A function that returns or stores into a global an address or slice of its own frame is refused, a dev free-all poisons any arena's released bytes and ASan sees them released, and @sanitize checks stack use after return 2026-09-25 20:40:21 +07:00
JosephFerano df7078d586 A _ function's exits combine exactly as an if's arms do, a refused _ body or loop is collected with every other error, and the placeholder type never leaves the checker 2026-09-25 20:37:50 +07:00
JosephFerano f1bdcc8840 Editor code cut out mid-line carries :indent, the column its statement starts at, so the reader accepts its wrapped lines as the file does and reports every location where it is in the buffer 2026-09-25 20:35:33 +07:00
JosephFerano 36c2d14c28 The inspector's reader prints a generic struct instance as (Pair i32 {…}) through Render.head, as the compiled renderer does. 2026-09-25 20:33:31 +07:00
JosephFerano db7f703c0e ML-style patterns are held as a future direction 2026-09-25 20:33:26 +07:00
JosephFerano 25911d7c9d Nested ML-style patterns are wanted 2026-09-25 20:31:57 +07:00
JosephFerano e7d82cb640 A match arm can match a number or string literal, queued 2026-09-25 20:30:31 +07:00
JosephFerano c3ba4c9e08 A wrapped condition or arm value is sent whole and reads as it does in the file, an arm whose value uses nothing its pattern binds sends that value, a match arm is a clause object, and dad on the last form takes the blank lines before it 2026-09-25 20:29:06 +07:00
JosephFerano 72c35ff05c A dyn value the inspector reads is rendered on the program's own thread, so one that traps or faults is a nested break and the daemon keeps answering. 2026-09-25 20:24:34 +07:00
JosephFerano 8fdf8513fd A _ body's errors are reported with every other error in the file, a literal return takes the type of the other exits, and a recursive _ function that gives nothing returns () 2026-09-25 20:17:44 +07:00
JosephFerano 267e84bada In a .fln buffer numbers are coloured, a colon is not part of a name, TAB keeps a line at a valid column, evil line objects take their newline, and C-x C-e and C-u C-c C-c understand match arms, conditions, clauses and comments 2026-09-25 20:14:51 +07:00
JosephFerano 5d3a0fc316 Inferring a closure return and recompiling a _ caller after its callee changes wait behind .fln 2026-09-25 20:14:22 +07:00
JosephFerano 241ea67fbb A struct can be generic over types and lengths, and an error in a generic names the call that made it 2026-09-25 20:03:49 +07:00
JosephFerano f6384fdf38 Merge master into the generics lane 2026-09-25 20:03:34 +07:00
JosephFerano df00848910 A struct value's printed head comes from Render.head, which every renderer shares 2026-09-25 20:00:51 +07:00
JosephFerano ad40123b25 Master's error recovery and return types read off the body live together, and two ways out of an inferred body that cannot share a type are refused with both named 2026-09-25 19:58:31 +07:00
JosephFerano ff8b61e44c A self-containing or runaway generic struct and a where clause over a length are each one error among the file's others, and a literal that does not fit a variable a typed field decided names that field 2026-09-25 19:57:58 +07:00
JosephFerano ae23787c04 The inspector reads a stopped program's memory through the compiled layouts, so locals, inspect, at, condition and globals build no module, and the inspector reads a generic instance's type arguments. 2026-09-25 19:57:39 +07:00
JosephFerano 8c814b39c1 .fln files open in flan-fln-mode, which evaluates, moves over, indents and edits statements, clauses and top-level forms, and every Flan mode derives from flan-base-mode 2026-09-25 17:13:12 +07:00
JosephFerano 89e4f53895 Places are evaluated once, enums have a predicate, CFn values live in structs, and a program's names cannot break the prelude 2026-09-25 17:12:56 +07:00
JosephFerano d6e6a5dc0c A program's global named as a prelude function or global shadows it for the file, with the shadowing warning a defn gets 2026-09-25 17:12:05 +07:00
JosephFerano ff88b9dad6 A defn whose return slot is _ (or a .fln fn with no arrow) takes its return type from its own body, recursion through such functions is refused by name, and a stale caller of one is told which line changed its type 2026-09-25 17:09:13 +07:00
JosephFerano 20684c2d9f A grown parameter the function returns, or whose field it reassigns, is not warned at 2026-09-25 17:06:19 +07:00
JosephFerano dbdaf66e29 A slice that bytes or clone copied is released with free, and a dev build traps a free through the wrong allocator 2026-09-25 17:05:08 +07:00
JosephFerano 7bcf9e5054 A dev build refuses (free s) on a slice over a Vec's or a Map's storage, and says a formatted number's text is released by free-temp 2026-09-25 17:04:52 +07:00
JosephFerano 4c336687ef Master's error recovery is merged, and the refusal memos are consulted only where a refusal is raised 2026-09-25 17:03:32 +07:00
JosephFerano 2f321fd061 Merge master into the generics lane 2026-09-25 17:03:13 +07:00
JosephFerano 1e8086b4d3 The use-after-release checks to build are recorded 2026-09-25 17:00:16 +07:00
JosephFerano 7c1cbd3a9f Merge branch 'master' into worktree-agent-a9288676abb278ffb 2026-09-25 17:00:08 +07:00
JosephFerano 9d0d42e467 A generic struct's copy prints as (Pair i32 {...}), crosses to C behind a pointer, names each use that made it when refused, settles literal fields at the wider type, and is laid out by stores and restarts that first name it 2026-09-25 16:59:11 +07:00
JosephFerano fc6e2f7dfb The break window evaluates in a frame and steps through a function, and C-c C-c reports every error in the form
# Conflicts:
#	test/test_dev.ml
2026-09-25 16:52:32 +07:00
JosephFerano 2c8bd61a20 (free s) hands a slice (bytes s) or (clone xs) made back to the context allocator or a named one, a dev build traps on the wrong allocator, and a sliced array literal lives for its whole function on x86 2026-09-25 16:48:09 +07:00
JosephFerano 37938db7c2 A call through a failed local is not reported and a miscounted call's arguments are, eval-in-frame answers slotless frames and ended stops, flan dev links its own agent, and the daemon buffer colours only file:line:col diagnostics 2026-09-25 16:47:51 +07:00
JosephFerano 43c7494d54 A Vec or Map field grown through a struct parameter taken by value is warned at the parameter, naming the field path and the (Ptr ...) that reaches the caller's 2026-09-25 16:40:39 +07:00
JosephFerano d5978aeab8 The dev daemon re-runs under two processes, knows whose break it is, guards its socket, runs under sanitizers, and unloads evaluated modules 2026-09-25 16:39:09 +07:00
JosephFerano 96dbcb1f2f An expression that makes a function value keeps its module mapped, a process a merged program starts does not take the session's socket, and an evaluation's value is its own expression's and not one a restart resumed 2026-09-25 16:37:43 +07:00
JosephFerano 00fd022330 A call through a null CFn parks a dev program in the break loop with NullCall named and continue takeable, on LLVM and x86 2026-09-25 16:37:05 +07:00
JosephFerano a95b4c288e Merge branch 'master' into worktree-agent-a37bbb17fdd22924a 2026-09-25 16:35:58 +07:00
JosephFerano 83dc716133 A program's or a session's defn named as a macro shadows the macro for its calls, with the shadowing warning a prelude function gets 2026-09-25 16:32:49 +07:00
JosephFerano 730730a1a7 A relocated prelude refusal's note says only that the refusal is there, since the notes before it name which body 2026-09-25 16:23:19 +07:00
JosephFerano 020392b064 Merge branch 'master' into worktree-agent-a55c4be9d199b8eb4 2026-09-25 16:22:58 +07:00
JosephFerano 7b3e7f0efa A program's global or type cannot change what the prelude means: a type variable wins over a global in a type position, prelude signatures pair against prelude types, and a global or type spelled like a built-in type is refused 2026-09-25 16:22:27 +07:00
JosephFerano f32f88413e Merge branch 'master' into worktree-agent-a4f010dac78379ee2 2026-09-25 16:20:22 +07:00
JosephFerano b51b7d0a53 A refusal in a prelude generic's body stands at the call that asked for the copy, with the prelude's line as a note 2026-09-25 16:20:08 +07:00
JosephFerano 0f9160f3bc flan convert keeps comments beside their forms, and the dev loop reads code in the syntax the editor sends 2026-09-25 16:17:33 +07:00
JosephFerano 30373ffda5 A converted comment stays with the form it was written beside when the printer reorders or splits bindings, the round trip checks each comment's form, and a snippet's first line sets its left edge 2026-09-25 16:17:12 +07:00
JosephFerano 8eac6734f0 Merge branch 'master' into worktree-agent-a37bbb17fdd22924a 2026-09-25 16:15:40 +07:00
JosephFerano ea67e058bf A generic over a generic struct calls another at the same struct variables, and a struct copy is a map key and a function-value parameter on both backends 2026-09-25 16:15:40 +07:00
JosephFerano 78261fccda The collector walks Map storage, arrays of strings or structs are map keys, map-keys and map-values are prelude generics, and spike/ is gone 2026-09-25 16:10:38 +07:00
JosephFerano 729d1a27ed map-keys and map-values walk a Map of closures, since (map-next m cur k) walks keys alone and neither needs a zeroed value, and nothing names spike/ any more 2026-09-25 16:10:20 +07:00
JosephFerano 8fe2a666a1 A refused if is answered from memory for the same condition, scope and expectation, so a refused or/and chain is checked in linear time 2026-09-25 16:09:31 +07:00
JosephFerano 4054c4921b A defstruct whose fields introduce $t or an array length $n is a generic struct, each application of it an ordinary struct copy that generic functions bind against, and a printed call is evaluated once 2026-09-25 16:08:14 +07:00
JosephFerano 6c8f503ebf A --two-process re-run checks the whole program before building it and refuses at a stale caller, and a change made while the child has ended waits in the session for the re-run 2026-09-25 16:08:13 +07:00
JosephFerano 69d17a9d59 Merge branch 'master' into worktree-agent-aebbe657e5dc1f8ef 2026-09-25 16:03:19 +07:00
JosephFerano 527da37633 An evaluated expression's module is unloaded when its only strings are literals or registry names, because a literal is a copy the process keeps 2026-09-25 15:57:40 +07:00
JosephFerano 4b64dc5f65 Code-carrying dev requests name their syntax and starting line and column, so a .fln snippet sent from the middle of a buffer reads with the buffer's own locations 2026-09-25 15:57:09 +07:00
JosephFerano 25e303caf4 Master's indented reader is merged, and its x += v over a place with a call in it reads as update, evaluating the place once 2026-09-25 15:56:51 +07:00
JosephFerano a13c2f9ec3 The Map header mirror the collector reads is checked against flan_rt.c's, array key pairs cannot share a name, and a program holding only a Map of dyn sets up the collector 2026-09-25 15:52:57 +07:00
JosephFerano 8200120bf3 flan convert keeps comments and number spellings in both directions, and a .fln message quotes the text as written, with let-bound match, if and block calls read as values 2026-09-25 15:47:29 +07:00
JosephFerano 9a91440486 C-c C-s installs a defn that stops before each form of its body, and the break buffer steps it with s and runs the rest of the call with c 2026-09-25 15:43:24 +07:00
JosephFerano d46067962d A Vec or Map parameter the function grows with push, reserve or put is warned at the parameter, naming the (Ptr ...) that reaches the caller's container 2026-09-25 15:41:42 +07:00
JosephFerano d0fc036bba flan dev --sanitize builds the host under ASan and UBSan on LLVM, refuses --x86 by name, and the sanitize sweep drives a real session through reloads and breaks 2026-09-25 15:39:00 +07:00
JosephFerano a615e4ecfc The leak sweeps have switches, a C source that includes the dyn header is recompiled when the header changes, and the map-remove and map-keys programs free what they allocate 2026-09-25 15:38:02 +07:00
JosephFerano e616a29ceb A parameter vector paired by a lowercase type the program declares is warned at, naming the type and where it is declared 2026-09-25 15:37:26 +07:00
JosephFerano 17f0358e92 A .fln file is read as indented syntax into the same forms, and flan convert prints a program in it 2026-09-25 15:35:42 +07:00
JosephFerano 1716b146cd A condition check_truthy has already refused is refused again from memory, so a retry through nested not is linear and keeps its message 2026-09-25 15:35:36 +07:00
JosephFerano f67498c789 A re-run under --two-process builds the program again from the session and starts it in a new process, and the daemon outlives a finished child 2026-09-25 15:35:23 +07:00
JosephFerano da9f21ddcd A form sent to the daemon is refused with every error in it, and an expression evaluated from the break buffer sees the stopped frame's locals 2026-09-25 15:33:23 +07:00
JosephFerano 6e29c19097 The collector walks a Map's full slots, so a Map may hold closures and dyn values and keeps them alive 2026-09-25 15:31:50 +07:00
JosephFerano 23af8a4dd3 A bool arm and a dyn arm of an if meet at dyn with the bool boxed, so (or false (box "s")) answers "s" 2026-09-25 15:31:12 +07:00
JosephFerano 074b2da6f3 The indented reader refuses a continuation line that is not deeper than its statement, takes one-line statements in arms, then/else and defer, typed lets and bare-name blocks, and names the shape it wanted where it used to say two values cannot sit side by side 2026-09-25 15:30:52 +07:00
JosephFerano 0f0fbc68ef The prelude's generics name their element types with the sigil, so a program global named t, k or v no longer breaks them 2026-09-25 15:26:29 +07:00
JosephFerano f6ba1e6c4d A CFn may sit in a struct field, a fixed array or a global, and a call through a null one signals NullCall on both backends 2026-09-25 15:26:27 +07:00
JosephFerano 8cc63ed9f6 A place in update, ++ and -- evaluates each of its subexpressions once, and (update place f args ...) stores (f old args ...) back into it 2026-09-25 15:26:27 +07:00
JosephFerano b8c81d94ee enum? admits exactly the enums, entails ordered? and equal?, and licenses a generic conversion from an enum to a number beside numeric? 2026-09-25 15:26:27 +07:00
JosephFerano c30f52c5a7 A stop records whether it was inside an evaluated thunk, and an evaluation waits through a break the program's own code entered 2026-09-25 15:25:09 +07:00
JosephFerano 58d1e35225 A local of a package's type is named by its qualified type in the break buffer's listing and the inspector, pinned by a shape/Box local in dev-inspect.flan 2026-09-25 15:22:48 +07:00
JosephFerano 5b8c4a68fa into refuses to copy elements that own storage as they stand, and names (map clone) where clone takes the element 2026-09-25 15:22:44 +07:00
JosephFerano 580d377847 A fixed array of strings or of structs is a map key, hashed and compared element by element 2026-09-25 15:21:30 +07:00
JosephFerano 82b02b6584 Merge branch 'master' into worktree-agent-ab75070e065de0e53 2026-09-25 15:21:12 +07:00
JosephFerano a41e43cbba (slice d ...) over a dyn text answers a text, in the three spellings the typed slice has 2026-09-25 15:19:39 +07:00
JosephFerano d5fc3aa657 The agent binds the daemon's socket only in the process the daemon launched, named by FLAN_AGENT_OWNER 2026-09-25 15:18:55 +07:00
JosephFerano 6745aa7cb4 map-keys and map-values are prelude generics over any hashable key 2026-09-25 15:17:05 +07:00
JosephFerano 52c154e306 The x86 survey compares against LLVM built at -O0, the level the hand-written backend corresponds to 2026-09-25 15:15:30 +07:00
JosephFerano b0323606fc spike/ is gone: the x86 and JS surveys and the cells check live in test/, their probes in test/programs as x86-p* and js-p*, and dump.sh in tools/ 2026-09-25 15:14:27 +07:00
JosephFerano 8dc90416d4 The daemon's buffer draws errors, warnings and notes in compilation's faces and the program's own output in a face of its own 2026-09-25 15:14:23 +07:00
JosephFerano 680b8e7e59 A refusal inside a generic copy names each call that asked for it, a refused generic body is reported once, and a type variable prints with its $ 2026-09-25 15:13:12 +07:00
JosephFerano d7016f91c7 A .fln file is read by an indented reader that yields the paren reader's forms, every program entry point picks the reader by extension, and flan convert prints either syntax as the other 2026-09-25 15:04:32 +07:00
JosephFerano e2aa0196cf Conditions have parents under Error, restarts carry where and why, and a handler reads the message with its values 2026-09-25 14:51:18 +07:00
JosephFerano 1bada92870 A handled condition's sentence is consumed when it is copied, and the class-slot, migration and destroyed-allocator traps put their own sentence on the wire 2026-09-25 14:48:46 +07:00
JosephFerano 610e9fcf9d A parent's handler reads a name and message made in context/temp, whole and good until the frame ends, and the handler-case landing no longer holds a buffer of its own 2026-09-25 14:36:46 +07:00
JosephFerano d6621827b3 A read-only slice's diagnostics suggest clone where clone accepts the type 2026-09-25 14:31:36 +07:00
JosephFerano 409c5f5c0d A read-only slice's diagnostics name (clone v) wherever clone copies the element type 2026-09-25 14:31:21 +07:00
JosephFerano bb4705b7e0 An allocator is two words and dies with its arena, clone copies a slice, and quick formatting uses a per-frame temporary allocator 2026-09-25 14:26:45 +07:00
JosephFerano b6dbc08c78 An expression at a stop that destroys the program's temp arena leaves context/temp empty rather than restoring the destroyed record 2026-09-25 14:26:31 +07:00
JosephFerano 22d2a4cfb2 An indented surface beside s-expressions is specified as a second reader chosen by file extension 2026-09-25 14:21:51 +07:00
JosephFerano 5646ae148e Merge master; an expression evaluated at a stop runs against a scratch temp arena that is wiped after it, and the program's own temp arena is never rolled back 2026-09-25 14:09:30 +07:00
JosephFerano f04cd18c0a A session's evaluated modules are recorded as never unmapped 2026-09-25 14:06:06 +07:00
JosephFerano 38055b8fdc A file with no main opens a session, and a crash in an evaluated expression leaves the session running 2026-09-25 14:04:14 +07:00
JosephFerano 9373c43113 The nested-overflow test waits for the outer break to be on top instead of sleeping, and names a session that dies 2026-09-25 13:56:11 +07:00
JosephFerano 46a89aac0c An expression evaluated at a stop rolls the temp allocator back to where it was, so its own text is reclaimed and the stopped frames' text survives 2026-09-25 13:54:07 +07:00
JosephFerano 8bb2885e63 An abandoned evaluation puts the context allocator back, and a fault's break loop runs on a guarded stack of its own so a runaway recursion evaluated there is another break rather than a dead session 2026-09-25 13:49:24 +07:00
JosephFerano a0a86d0b80 The temp allocator grows a block that outgrows its chunk, answers a formatted number with one bump allocation, poisons what a dev wipe releases, and is wiped after each expression run while parked 2026-09-25 13:47:00 +07:00
JosephFerano a4e45d9a78 A message longer than its buffer is cut at a character and ends in an ellipsis, and the break loop and the unhandled message use the full text 2026-09-25 13:41:58 +07:00
JosephFerano e6ea657263 The array literal given a slice type by the matches a read-only slice too 2026-09-25 13:41:49 +07:00
JosephFerano b2eb81f7bf A slice or pointer can be read-only, and what it reaches cannot be written through it 2026-09-25 13:39:04 +07:00
JosephFerano 5a78cdd202 Merge master 2026-09-25 13:36:54 +07:00
JosephFerano d2f25a6e44 Merge branch 'master' into worktree-agent-a9288676abb278ffb 2026-09-25 13:35:10 +07:00
JosephFerano f3d969618d A class slot can carry a type, and set writes a declared slot 2026-09-25 13:32:16 +07:00
JosephFerano c6ad71dc5b A value that owns storage is never copied out of read-only storage, and two arguments at one type variable meet at const 2026-09-25 13:30:47 +07:00
JosephFerano 8eca8c78f2 The checker refuses what it cannot type honestly and says which element is wrong 2026-09-25 13:29:56 +07:00
JosephFerano 5066b8d288 The literal an array cannot hold is the element blamed, a negative literal in a generic body names a fix for every instantiation, and a doubly negated literal is positive 2026-09-25 13:29:42 +07:00
JosephFerano 53880eb685 A class slot's type names its own package's class first, an int widens into a float slot exactly when it round-trips, and a constructor's refusal names the call that was wrong 2026-09-25 13:29:30 +07:00
JosephFerano 639ff1859c Aborting an expression that trapped in the park abandons it by a jump back to the poll that called it, and the session stays 2026-09-25 13:28:16 +07:00
JosephFerano 3e09a1f722 Merge branch 'master' into worktree-agent-ac750a5e83cab251b 2026-09-25 13:18:42 +07:00
JosephFerano 9d6694fd92 Merge master: an evaluated expression's lifted bodies are placed with its thunk 2026-09-25 13:18:40 +07:00
JosephFerano bbfb8ca58c The parent-link entry says a parent's handler reads the message with its values 2026-09-25 13:18:10 +07:00
JosephFerano 9aeee9d676 Merge branch 'master' into worktree-agent-a6ffe579d55d1892b 2026-09-25 13:16:58 +07:00
JosephFerano d864818956 A handler for a parent reads the condition's own name and message or the condition printed with its values, kept in the handler-case's own frame across the unwind, and a frame re-entered through a signal or a C call names where it is 2026-09-25 13:16:35 +07:00
JosephFerano 0358f6637c Merge branch 'master' into worktree-agent-ad097f67134ba0552 2026-09-25 13:15:36 +07:00
JosephFerano 1998f57ca8 Merge branch 'master' into worktree-agent-ac49c2c6835655472 2026-09-25 13:14:44 +07:00
JosephFerano 4742d7e7ef A closure that outlives its call keeps its captures on the collector's heap 2026-09-25 13:09:40 +07:00
JosephFerano 1e5be63a1a An array reached through read-only storage slices to a [const T], a Vec or Map copied out of one stays read-only through lets, and an if's const and writable branches meet at const in either order 2026-09-25 13:08:29 +07:00
JosephFerano 02e4fef830 i64->bytes and f64->bytes allocate from a growing temp allocator that (free-temp) releases once a frame and a dev build's agent poll wipes, so a number drawn every frame no longer leaks 2026-09-25 13:07:21 +07:00
JosephFerano f87d596bdc A file with a broken form still starts a session, a package's macros expand bare from its file, a delivery a running program never polls for says so with a fix that compiles, and aborting an expression stopped in the park abandons it and keeps the session 2026-09-25 13:06:36 +07:00
JosephFerano 9394a26449 A negative literal fits no unsigned type, u64 included, and the refusal names the cast that writes its bit pattern, which a constant now folds 2026-09-25 13:02:50 +07:00
JosephFerano 325c3662a8 A numeric type's limits are (max-value T) and (min-value T), and an array literal of numbers with no common type is refused with the conversion named 2026-09-25 12:56:02 +07:00
JosephFerano e7b01ea01c Merge branch 'master' into worktree-agent-adcfc27e0ceac8df4 2026-09-25 12:55:56 +07:00
JosephFerano 9ba99d7d5b A dev build puts every closure handed to a named call on the collector's heap, since a redefinition can make the callee keep it 2026-09-25 12:55:56 +07:00
JosephFerano 042c969e30 Every description of an Allocator value says it is two words 2026-09-25 12:55:38 +07:00
JosephFerano 16427f8d07 A class slot may be a class or an Option, an int widens into a float slot when exact, a gained typed slot starts at its zero, and a migration never re-enters itself 2026-09-25 12:55:25 +07:00
JosephFerano 59a0fc80de Only a string the checker marks as a literal reaches C uncopied, a slice of dyn is refused at its clone, and the context allocator holds an incarnation so a restored context whose arena was destroyed traps 2026-09-25 12:55:18 +07:00
JosephFerano b74002be02 Merge master 2026-09-25 12:54:55 +07:00
JosephFerano e75a66e463 The parent-link entry is followed by a blank line like its neighbours 2026-09-25 12:50:25 +07:00
JosephFerano 2e586ef45f The condition and restart entries this branch closes are off the list 2026-09-25 12:50:16 +07:00
JosephFerano bfd176cce9 Merge master: a handler clause runs without its own handler in force, over the condition descriptor, and StaleCall is an Error 2026-09-25 12:50:01 +07:00
JosephFerano cc95006024 Merge branch 'master' into worktree-agent-ac750a5e83cab251b 2026-09-25 12:49:38 +07:00
JosephFerano 35f455acb3 The's refusal of a dyn says what the boundary does at that type, elements that cannot become a dyn are refused against each other, two literal arms meet at the wider type, and the renamed prelude function and a second startup warning stay out of sight 2026-09-25 12:49:02 +07:00
JosephFerano 1fb4b37125 A restart that takes values can be taken from the break loop, which asks for one per parameter and stores them into the frame as an invoke-restart would 2026-09-25 12:46:23 +07:00
JosephFerano 37e577d5e1 (Ptr const T) is documented beside [const T], and the TODO entries for it are closed 2026-09-25 12:46:07 +07:00
JosephFerano 11e1ff10a7 flan dev starts on a file with no main and links the agent into every program it builds, C-c C-k loads a file keeping what compiles, and an expression sent from a package's file resolves in that package 2026-09-25 12:44:15 +07:00
JosephFerano acec25f8a1 Merge master 2026-09-25 12:43:50 +07:00
JosephFerano ce66bb7f3b Merge branch 'master' into worktree-agent-ad097f67134ba0552 2026-09-25 12:43:04 +07:00
JosephFerano d8945ae4ae The address of read-only storage is a (Ptr const T), which nothing is written through and which a C const T * parameter takes 2026-09-25 12:43:04 +07:00
JosephFerano d3ac1ab4dc Type limits are spelled min-value and max-value, and quick formatting is to use a per-frame temporary allocator 2026-09-25 12:20:12 +07:00
JosephFerano aedd50c646 Merge branch 'master' into worktree-agent-ac750a5e83cab251b
# Conflicts:
#	lib/check.ml
2026-09-25 12:17:58 +07:00
JosephFerano 1d219b526c Merge master into the typed class slots lane 2026-09-25 12:14:42 +07:00
JosephFerano 23b144178a A dev build's frame records the call it is in, so a backtrace names the call each caller is making and the expression the innermost one stopped at 2026-09-25 12:12:40 +07:00
JosephFerano c6cb018b9f Only a closure that outlives its frame takes a collector environment, and the collector reads a Vec's elements only through a block it knows is live 2026-09-25 12:12:25 +07:00
JosephFerano c0b4b22357 Growing, shrinking or freeing a container reached through a [const T] is refused, and a function that only reads a slice stands where one that may write it is wanted 2026-09-25 12:10:47 +07:00
JosephFerano 93ff39c01d flan_destroyed_proc says it is the backstop for a caller holding a bare allocator record 2026-09-25 12:08:47 +07:00
JosephFerano bf0a939093 A dyn negation of a non-number is refused by name, and a prelude function shadowed in a live session is a recorded gap 2026-09-25 12:07:32 +07:00
JosephFerano c116237621 An Allocator value is its record and the incarnation it was made for, so one kept past its arena-destroy traps at every use even after arena-new reuses the record 2026-09-25 12:05:44 +07:00
JosephFerano 73dfaacbc8 A condition type may name a parent and a handler for Error catches every built-in error, a signal passes one descriptor carrying its chain, name, sentence and site, and the break loop shows the sentence the runtime wrote 2026-09-25 12:05:13 +07:00
JosephFerano caf39c4346 A read-only pointer is to sit beside the read-only slice 2026-09-25 12:04:08 +07:00
JosephFerano 6e8cc52bc7 Merge branch 'master' into worktree-agent-ad097f67134ba0552 2026-09-25 12:02:48 +07:00
JosephFerano 5e9be2150f A generic's name disassembles to each of its copies, and completion knows the name 2026-09-25 12:01:06 +07:00
JosephFerano 77497cfc2c A generic's copy made by an evaluated expression is disassembled from that expression's module, and one copy that cannot be shown no longer hides the others 2026-09-25 12:00:50 +07:00
JosephFerano 5c0698d749 (max-of T) and (min-of T) are a numeric type's limits, at a concrete type or a type variable the bound admits, and given a slice they are still the prelude's reductions 2026-09-25 12:00:42 +07:00
JosephFerano 9bb1bde665 A defn with no return type says so, and an operator spelled as another language spells it names Flan's 2026-09-25 11:57:09 +07:00
JosephFerano f629123561 A lowercase vec, ptr, option or map over types in a return slot is a near miss for its constructor, and a bare && is written back as the (and) that compiles 2026-09-25 11:56:56 +07:00
JosephFerano e0af3c9b1d A method of update-instance-for-redefined-class runs on each instance as it migrates, and one that signals offers migrate-by-name 2026-09-25 11:55:40 +07:00
JosephFerano b181003695 Merge branch 'master' into worktree-agent-a2450aee5a656816b 2026-09-25 11:54:15 +07:00
JosephFerano 6c8611cece Merge master 2026-09-25 11:53:00 +07:00
JosephFerano 2ea47f91eb A program's function named as a prelude function takes the name over for its own file, with a warning, and the prelude's own calls keep the prelude's 2026-09-25 11:51:58 +07:00
JosephFerano 3892f1ed34 Merge master 2026-09-25 11:50:29 +07:00
JosephFerano d6217830ff A defn whose body stands in its return slot is told it has no return type, a misspelled type constructor is a near miss, and not=, ==, &&, || and their kin name the Flan builtin that computes the same thing 2026-09-25 11:50:23 +07:00
JosephFerano 7cf57eb8ba A read-only slice refuses a dyn view without promising one later, and taking a read-only element's address is recorded as an open decision 2026-09-25 11:49:17 +07:00
JosephFerano c671bba490 (the T e) gives any expression its type, and an array literal nothing names is typed when its elements agree and a dyn vector when they mix 2026-09-25 11:47:28 +07:00
JosephFerano 0636eb3b8d A generic's copy that the file does not make is named in its lowering rather than left out 2026-09-25 11:47:11 +07:00
JosephFerano c9eb425ed1 A number i64->bytes or f64->bytes renders is copied into the context allocator and outlives the frame that made it 2026-09-25 11:47:01 +07:00
JosephFerano 2ff1da88da A slice can be read-only: bytes-view answers a [const u8], and a store through one is refused at compile time 2026-09-25 11:43:41 +07:00
JosephFerano a2eeb65f50 Program output reaches the editor as it is printed, and every Flan buffer keeps its keys under Evil 2026-09-25 11:43:21 +07:00
JosephFerano 20fa04d9df A program never waits on an editor that stops reading its pushes, and the editor keeps a bounded tail of the program's output 2026-09-25 11:42:54 +07:00
JosephFerano d2867a4267 not= stays refused in favour of != 2026-09-25 11:41:58 +07:00
JosephFerano b05a97b029 A generic's name disassembles to its copies, each headed by the types it was called at, and is listed among the program's names 2026-09-25 11:41:31 +07:00
JosephFerano cb83014715 (clone xs) copies any slice's elements into a block from the context allocator or a named one, through the lowering (bytes s) uses 2026-09-25 11:40:42 +07:00
JosephFerano 2682214499 A defclass slot may declare a type that every store into it checks, and set writes a declared slot 2026-09-25 11:40:08 +07:00
JosephFerano 3cb6cebdbe (- x) negates a typed number, a type variable and a dyn, and a float's negation of zero is -0.0 2026-09-25 11:37:56 +07:00
JosephFerano c6f0dd5450 A dev build fills a Vec's old buffer with 0xDEADBEEF when a push moves it, so a stale slice reads a value nobody wrote 2026-09-25 11:36:59 +07:00
JosephFerano c671f23b2c Merge master 2026-09-25 11:36:46 +07:00
JosephFerano 680c12e686 A restart frame carries where its clause is written, its :report sentence and whether a handler-case made it up, and the break loop shows the first two and hides the third 2026-09-25 11:35:12 +07:00
JosephFerano de8e00314b A string literal handed to a declare-c reaches C uncopied, and both backends write a NUL after every literal 2026-09-25 11:33:03 +07:00
JosephFerano 315125c677 A literal arm of an if, a cond or a match takes its type from the arms that are not literals 2026-09-25 11:32:54 +07:00
JosephFerano 5062052425 Raylib's strings, models, file lists and matrix stack are bound, and a dev build can name what is still loaded at exit 2026-09-25 11:28:02 +07:00
JosephFerano 14bac84f53 != over floats is unordered, so a NaN is unequal to itself on both backends as it already was on the dyn side 2026-09-25 11:27:49 +07:00
JosephFerano 05d86b243f A library resource is known by its identifying field, a Get or a consuming Load is counted as raylib means it, bare pointers are not resources, the report waits for FLAN_DEV_LEAKS, and a release build emits the code it did before the tracker 2026-09-25 11:27:41 +07:00
JosephFerano 9e26275f9b A quasiquote nests as SBCL's does, a match takes an enum, and until is a prelude macro 2026-09-25 11:27:14 +07:00
JosephFerano 0b9ae9318e flan build and flan run of a file with no main say so and show one, rather than failing at the link 2026-09-25 11:26:13 +07:00
JosephFerano 97a2795cb2 A module that makes a closure stays mapped, and a closure held beside a collecting operand is rooted 2026-09-25 11:24:34 +07:00
JosephFerano de26d6e625 ~~@xs splices an unquote per element, a session can call a macro an expansion defined, and an enum member cannot be named else 2026-09-25 11:24:27 +07:00
JosephFerano b818e38cff The type limits are to be reachable from a type variable 2026-09-25 11:24:20 +07:00
JosephFerano 7b22f0ed98 The Map is a Swiss table 2026-09-25 11:20:41 +07:00
JosephFerano 494c57d764 Merge branch 'master' into worktree-agent-ae2997a1e90aa9e02 2026-09-25 11:18:12 +07:00
JosephFerano 840b3b215e A Map's churn test fails a table that doubles instead of sweeping, and nothing recalled is cited as read 2026-09-25 11:18:12 +07:00
JosephFerano 14da41e1d9 A changed signature installs, its stale callers are named, and a handler is off while its own clause runs 2026-09-25 11:17:48 +07:00
JosephFerano f4725788c5 The daemon pushes the program's output and the watch table to an editor that asked for them, and every key a Flan buffer's own map binds is that buffer's under Evil 2026-09-25 11:16:49 +07:00
JosephFerano ed8dbbcd62 A handler-bind clause runs with only the handlers outside its own form in force, and a stale call in main's running loop stays listed until the callee changes back or the program re-runs
The first is CLHS 9.1.4.1: a clause that signalled the condition it handles used to re-enter itself until the stack ran out. The stale list now names a lifted clause by the function it is written in, and BUILT.md's cost line is measured against master.
2026-09-25 11:15:52 +07:00
JosephFerano 389e93ff09 A capturing fn's environment belongs to the collector, so a closure may outlive the frame that made it 2026-09-25 11:13:57 +07:00
JosephFerano d03b02b347 Merge master 2026-09-25 11:12:30 +07:00
JosephFerano 8b4c6f81df The IR and the disassembly name the source form each piece of code came from 2026-09-25 11:08:35 +07:00
JosephFerano 56fdfabc84 A destroyed arena is to trap always, and a mixed array literal is to be a dyn vector 2026-09-25 11:08:28 +07:00
JosephFerano 40bd96ec65 A source heading escapes control characters, names its file by the path it was given, and quotes a form cut at a character boundary 2026-09-25 11:08:11 +07:00
JosephFerano 0d771494a6 Merge branch 'master' into worktree-agent-a8f5ad274482eeb9c 2026-09-25 11:07:02 +07:00
JosephFerano 892989fe85 A container parameter its function grows is to be warned at 2026-09-25 11:03:56 +07:00
JosephFerano 8bebff61eb Merge branch 'master' into worktree-agent-a8f88805fa4af49cf 2026-09-25 11:01:36 +07:00
JosephFerano cbfac9f474 A Map is a Swiss table, one control byte a slot with key and value side by side
Removal marks a deleted slot unless no probe can have passed it, and a rebuild at the same capacity sweeps them. The header, the entry points and the iteration contract are unchanged. A Map has no bounds check, so the entry asking to convert one is closed with nothing to convert.
2026-09-25 11:00:22 +07:00
JosephFerano 573076994e Merge branch 'master' into worktree-agent-a0a469774e7454c6c
# Conflicts:
#	lib/loc.ml
#	lib/x86.ml
2026-09-25 10:59:24 +07:00
JosephFerano e158cd3a66 An intermittent x86 dyn-global failure after an allocating thunk is recorded 2026-09-25 10:58:51 +07:00
JosephFerano 3f30b82bcb A return computes its value before its defers, and a dozen small refusals and constants say what the program meant 2026-09-25 10:54:09 +07:00
JosephFerano 2f3544e7f5 Code after a return emits nothing, a wide element names the u64 array, and $ is refused in fn, handler, :keys and & binders 2026-09-25 10:53:52 +07:00
JosephFerano 0a6ea7c32b A caller excused for going stale takes back the generic copies its failed check cached, and a package's stale callers are named at their own file and line 2026-09-25 10:51:44 +07:00
JosephFerano fe20f68ada The runtime's races are driven by tests, a destroyed arena's record is reused, and a dyn index names its site 2026-09-25 10:51:08 +07:00
JosephFerano 08545c757f cond is a special form again because the prelude relies on it, and until is a prelude macro because only programs use it 2026-09-25 10:48:40 +07:00
JosephFerano cc39ff2a01 A destroyed arena's allocator is reused by the next arena-new with its epoch kept, a stale dyn view names its site, and the spec fixes the Vec at five words in every build 2026-09-25 10:48:30 +07:00
JosephFerano a49ed2243a Merge branch 'master' into worktree-agent-a4be641f87966b149 2026-09-25 10:48:05 +07:00
JosephFerano 0beafedd14 Dead code is gone, and the daemon says what to fix when a session cannot start or reach its program 2026-09-25 10:45:17 +07:00
JosephFerano 0863b62711 A string returned from C is copied into the context allocator, Model, Mesh and FilePathList cross the raylib boundary, rlgl's matrix stack is a package, and a dev build reports the library resources a program never released 2026-09-25 10:44:49 +07:00
JosephFerano 146be41bd4 flan dev refuses at start a TMPDIR that is missing or too deep for a socket path, and a program with no main, and tells a program with no agent how to add one
The dropped (agent/start ...) note named the wrong cause rather than being unreachable: an over-long socket path fails the agent's bind while it is linked. That case is now refused before anything is built, and the comments, the TODO entry and the fixture say so.
2026-09-25 10:43:33 +07:00
JosephFerano 12c3d07acf The x86 backend describes its frames in every build, and converts a u64 to and from a float as LLVM does 2026-09-25 10:43:20 +07:00
JosephFerano 22401a6521 Merge branch 'master' into worktree-agent-acca0e216df6ca366 2026-09-25 10:42:50 +07:00
JosephFerano 85a1368826 The Vec header keeps its epoch in every build, and the spec is to say five words 2026-09-25 10:41:50 +07:00
JosephFerano 39d35f51db A function whose signature changed installs, and a caller compiled against the old one stops on StaleCall at the call
A dev cell carries its body's signature word beside the body, every call through a cell (and every function value taken from one) compares it with the word the site was compiled for, and the session lists the stale callers by file and line on the reply. Both backends, both installers; release builds have neither the word nor the compare.
2026-09-25 10:40:18 +07:00
JosephFerano 60ef7b2e06 A recur from an arm of a match over an enum is in tail position, and a session's blind spot for macros an expansion defines is recorded 2026-09-25 10:39:42 +07:00
JosephFerano 222dc0ae90 A loop, dotimes, match, macro, class or generic binding that starts with $ is refused too 2026-09-25 10:37:43 +07:00
JosephFerano 066fb18940 Every Flan form heads the code it produced in the emitted IR, the x86 listing and both disassembly buffers, and the objects are the same with or without it 2026-09-25 10:34:07 +07:00
JosephFerano e9e151e1a4 A match over an enum names its members as keywords and is refused when it misses one 2026-09-25 10:33:22 +07:00
JosephFerano aca3edff41 CLAUDE.md keeps only the rules the code cannot teach 2026-09-25 10:32:05 +07:00
JosephFerano 3a2837c278 The indent table's docstring says what a with- and a qualified head do 2026-09-25 10:31:34 +07:00
JosephFerano 83b244475a A refusal that suggests a fix suggests one that compiles, for vec-new, map-new and a near miss that is a value 2026-09-25 10:30:52 +07:00
JosephFerano 7abe760288 cond and until are prelude macros, and a macro module holds only the macros its forms reach 2026-09-25 10:28:32 +07:00
JosephFerano 5214343251 Every tracked .flan file reindents to itself 2026-09-25 10:28:28 +07:00
JosephFerano d1fd323043 The destroyed-allocator trap is recorded as having no site 2026-09-25 10:27:39 +07:00
JosephFerano 6c9635cd53 A wide literal is refused for its range as an enum member, names the u64 cast where a dyn is wanted, and comes back wide from a macro 2026-09-25 10:26:03 +07:00
JosephFerano 5581bafacb A dyn at, set-at or push that traps names the file, line and column it was written at 2026-09-25 10:25:28 +07:00
JosephFerano fc9f3059f9 C's calling convention for CFn waits with the callbacks 2026-09-25 10:23:28 +07:00
JosephFerano e16b5cbced A local's type is to print qualified by its package 2026-09-25 10:23:18 +07:00
JosephFerano 0e7a9ab98f The x86 survey is to compare against LLVM at -O0 only 2026-09-25 10:21:28 +07:00
JosephFerano cf04bfc8fd A u64 converts to and from a float on x86 as it does under LLVM, across the top half of its range 2026-09-25 10:21:23 +07:00
JosephFerano ca031d6b38 Every tracked Flan file is what the indenter would write, and a with- head indents its body like a definer
clojure-mode decided each shape. A with- head, and a def or with- head behind a namespace, indents as a body; a qualified head finds its unqualified spec. The rest was hand formatting: cond and match results under their tests, ordinary call arguments under the first one, and lone ; continuation comments rewritten as ;; lines above the code.
2026-09-25 10:19:22 +07:00
JosephFerano dd160aa31b A return computes its value before it runs its defers 2026-09-25 10:19:09 +07:00
JosephFerano e85b2d3e98 A dev session is to run under the sanitizer on request 2026-09-25 10:19:02 +07:00
JosephFerano 4d10f3c327 An allocator outlives the arena arena-destroy hands back, so a container made from it traps on a read of live memory 2026-09-25 10:18:31 +07:00
JosephFerano ea19dedb90 The corpus is to be swept once for leaks nothing owns 2026-09-25 10:18:17 +07:00
JosephFerano 49a7860cc4 The truthiness retry is to reuse the first pass and stay linear 2026-09-25 10:16:45 +07:00
JosephFerano 2b42cecded No test program is left from the move-only rule for Vec that nothing checks any more 2026-09-25 10:16:25 +07:00
JosephFerano e511174d3c No top-level value in the compiler goes unreferenced 2026-09-25 10:16:25 +07:00
JosephFerano 3636f31cba A running program's reply never says it has not called (agent/start ...), because the agent's constructor has bound the socket before main 2026-09-25 10:16:25 +07:00
JosephFerano ddf7ca4974 The flan_dyn.h ABI has one implementation, flan_dyn.c 2026-09-25 10:16:02 +07:00
JosephFerano c1dcdeb5af A stop is to say whose it is from the frame that stopped 2026-09-25 10:15:47 +07:00
JosephFerano f0fc66940f The break loop's snapshot caps and its choice handoff are driven from inside the stopped thread by a poll hook 2026-09-25 10:15:35 +07:00
JosephFerano 9dd7dd4137 The agent socket is to bind only for the daemon that launched the program 2026-09-25 10:14:48 +07:00
JosephFerano 0167fcef43 A quasiquote inside a quasiquote nests, and a macro that writes a macro defines one the program can call 2026-09-25 10:14:41 +07:00
JosephFerano d006a8b480 An array literal's first element types the rest, constant arithmetic folds at a bounded type variable, and a pointer or a plain union takes a byte fill 2026-09-25 10:14:24 +07:00
JosephFerano dab556ab7f Re-run under --two-process is to start a fresh child and say so 2026-09-25 10:14:04 +07:00
JosephFerano 1e462e06f6 Lexical blocks in DWARF wait for a debugger that needs them 2026-09-25 10:13:30 +07:00
JosephFerano 764c5d4561 Every string literal is to end in a NUL on both backends, and a literal crosses to C uncopied 2026-09-25 10:12:56 +07:00
JosephFerano c2f26a9909 The result read's losing side of the seqlock is driven by a hook inside the window between the copy and the check 2026-09-25 10:12:39 +07:00
JosephFerano 96b7ffb654 A program stays one compilation, which is why a generic's body is always there 2026-09-25 10:10:33 +07:00
JosephFerano 9cc820cb52 A class redefinition is to take a migration hook written from the live session 2026-09-25 10:08:22 +07:00
JosephFerano 96be1193c0 The x86 backend describes every frame it emits for an unwinder, in a release build as in a debug one 2026-09-25 10:07:40 +07:00
JosephFerano 4a292b5a73 f64-inf, f64-nan, f32-inf and f32-nan are constants the compiler supplies 2026-09-25 10:07:22 +07:00
JosephFerano 763c835496 Per-version class layouts wait for a case that needs them 2026-09-25 10:07:19 +07:00
JosephFerano cf737076e9 A moved Vec's old buffer is to be poisoned in a dev build, and the unread generation word goes 2026-09-25 10:06:41 +07:00
JosephFerano aa1bbe550c A use-after-release check is to be studied before anything is built 2026-09-25 10:04:50 +07:00
JosephFerano b153f325b8 A declared name that starts with $ is refused where it is declared 2026-09-25 10:04:23 +07:00
JosephFerano 0fb30ad945 The mutation pass is to run again with the next heavy sweep 2026-09-25 10:04:13 +07:00
JosephFerano 299b3bcb61 The OFL font stays out of the repository 2026-09-25 10:03:46 +07:00
JosephFerano 8006f8728f The daemon buffer is to colour the compiler's lines and set the program's output apart 2026-09-25 10:03:19 +07:00
JosephFerano 4ae7beb6ce An evaluation sent from a package's file is to resolve names in that package 2026-09-25 10:01:31 +07:00
JosephFerano bf827dc55b A test binary leaves nothing in the temporary directory it was given 2026-09-25 09:56:39 +07:00
JosephFerano 16d04cbb49 A parameter vector that reads a name as a type is to be warned at, the type winning 2026-09-25 09:56:33 +07:00
JosephFerano f7deba7a3e A test binary's directory is one it made fresh, and a same-pid name already there when it started is not counted as its leftover 2026-09-25 09:56:25 +07:00
JosephFerano 9650f331d6 A pointer and an untagged union are to take a byte fill 2026-09-25 09:55:07 +07:00
JosephFerano 81569b60d8 A compound constant is to fold before a bounded variable checks it 2026-09-25 09:54:35 +07:00
JosephFerano f6cdb4e31b A quasiquote is to nest, counting depth as SBCL's backquote does 2026-09-25 09:54:05 +07:00
JosephFerano ddff84ffed Any slice is to clone into the context allocator 2026-09-25 09:53:36 +07:00
JosephFerano 497881a333 An into temporary is reclaimed by its region, so its leak is not a gap 2026-09-25 09:53:16 +07:00
JosephFerano 51ce9a2ba6 Any expression is to take its type from (the T expr) 2026-09-25 09:52:29 +07:00
JosephFerano 1cec47b5d4 Result and try are cancelled: conditions are the error mechanism 2026-09-25 09:45:48 +07:00
JosephFerano 7a1d9c1263 The Map is to take the Swiss-table layout 2026-09-25 09:44:59 +07:00
JosephFerano 931000fcb5 A test binary's own directory is removed twice over before it is checked, so a child still exiting cannot fail the run 2026-09-25 09:44:02 +07:00
JosephFerano af4da764b2 A dev build is to name the raylib resources still loaded at exit 2026-09-25 09:43:52 +07:00
JosephFerano 836185a174 A diagnostic's clauses have a fixed order, and a stable kind waits for the docs it would link to 2026-09-25 09:41:24 +07:00
JosephFerano 6a1182df5d A test binary runs under a temporary directory of its own and leaves nothing in the one it was given 2026-09-25 09:40:00 +07:00
JosephFerano ff89f25e48 C-c C-c is to report every error in a form, through an error type that absorbs what it causes 2026-09-25 09:37:16 +07:00
JosephFerano 92d63e3b36 Every decision taken in the TODO review is recorded, and NEXT is a keyword the file declares 2026-09-25 09:34:47 +07:00
JosephFerano 795ba945d9 A dyn value held beside a sibling that can allocate is rooted until it is used, on every backend 2026-09-25 09:32:34 +07:00
JosephFerano 0d629d791f The array an index or a slice is taken of is rooted while its index runs, when it is a temporary 2026-09-25 09:30:41 +07:00
JosephFerano 11dc230974 A defn- is private to its module, and the module's own macros may call it 2026-09-25 08:57:20 +07:00
JosephFerano 793b0eea9e A defn- is private to its module, the package's own macros may write calls to it, and the refusal names the fix 2026-09-25 08:55:37 +07:00
JosephFerano 96314accd0 One library pipeline builds every program, one alias names the corpus, and a closed session leaves no directory behind 2026-09-25 08:53:45 +07:00
JosephFerano 00f116ea29 Six small refusals and conversions say what the program wrote 2026-09-25 08:41:31 +07:00
JosephFerano 376e990689 The prelude's macro module drops a defn- that names a macro, as it drops a defn 2026-09-25 08:40:35 +07:00
JosephFerano 6f0f4957f4 The break buffer, the inspector and the watch table show what a stopped program holds 2026-09-25 08:37:15 +07:00
JosephFerano ff744120fc A release build's watch is only its value, next-error lets go of a stack that has resumed, and the frame a stop is in is always shown 2026-09-25 08:36:53 +07:00
JosephFerano 0653f341d0 A dyn value held in an operand list is rooted while a sibling operand may allocate 2026-09-25 08:36:34 +07:00
JosephFerano 63f1a1357f Merge branch 'master' into worktree-agent-ad23aafb29b84cc8b 2026-09-25 08:33:28 +07:00
JosephFerano cb0be62087 An integer written at or above 2^63 is a u64 and nothing else, and vec-new reads a type from its argument only when the builtin is what was called 2026-09-25 07:43:24 +07:00
JosephFerano 6fd456c0a2 A function declared with defn- is callable only from the files of its own package 2026-09-25 07:42:40 +07:00
JosephFerano f986c0728a A test binary names the signal that killed a daemon, and the break buffer keeps its keys under Evil 2026-09-25 07:40:58 +07:00
JosephFerano 414e1e5ae6 The temp directory is left to the build-plumbing lane, and under Evil the break buffer takes only the keys it binds
The dead-session sweep and the remove-on-close half are dropped: the sweep contradicts the recorded non-goals, and the other lane owns removal on a clean close. The Evil keys are defined per state from the map's own bindings, so special-mode-map's h, SPC, < and - no longer shadow Evil's.
2026-09-25 07:40:33 +07:00
JosephFerano 88584f76bc The records say what the tree does, and an arena grown in place answers to its budget 2026-09-25 07:39:11 +07:00
JosephFerano f83e803ef8 An aggregate that reads its own destination sees the old value on x86, as it does on LLVM 2026-09-25 07:39:03 +07:00
JosephFerano 918edfb0b5 plan.org describes containers, pools and classes as they are after the repeal 2026-09-25 07:37:37 +07:00
JosephFerano cf71caefc2 spec-memory.md cites the defer_ok field on the line it is declared 2026-09-25 07:36:57 +07:00
JosephFerano cbd910c117 An arena's budget holds when a block grows in place, and a shrink is never over budget 2026-09-25 07:36:52 +07:00
JosephFerano 52681ec58c Two gaps found on the way are on record: the half-write abort flake and the one-shot commands' empty work directory 2026-09-25 07:33:32 +07:00
JosephFerano cda939c8be Only a place has an address in the inspector, and a watched dyn value and string are pinned armed 2026-09-25 07:32:25 +07:00
JosephFerano 7d75cc0a5d The CLI and the tests load, check and link a program through one module in the library 2026-09-25 07:30:55 +07:00
JosephFerano 54ec52fde6 A dev session that ends cleanly takes its directories with it, and Build.executable says where the IR it kept is 2026-09-25 07:28:34 +07:00
JosephFerano 377ada8d27 The inspector says where a slot's value is stored 2026-09-25 07:27:25 +07:00
JosephFerano 5477c75909 Three of the four under-load and dogfooding entries are settled, and the fourth records what was tried 2026-09-25 07:24:20 +07:00
JosephFerano 37ccca56a3 Every sweep reads the corpus through one alias, and a build that raises is a FAIL line in each of them
The sanitizer sweep also lists the five macro programs that run.
2026-09-25 07:23:12 +07:00
JosephFerano 85b9f35a66 (watch "name" v) renders any value into the watch table the way print renders it 2026-09-25 07:22:59 +07:00
JosephFerano 04f5d5a349 Under Evil, a digit in the break buffer takes the restart, because the buffer's keys take precedence over Evil's 2026-09-25 07:22:42 +07:00
JosephFerano 2250548b07 A test that sees a process die on a signal names the signal, not OCaml's number for it
OCaml numbers the signals negatively and in its own order: SIGTERM is -11. The globals daemon's "transient signal 11" was a SIGTERM, not a segfault.
2026-09-25 07:21:44 +07:00
JosephFerano 991e4423aa A dev session removes its build directory when it closes, and the next session sweeps the ones whose process is gone
Every daemon left about 8MB in $TMPDIR/flan-dev-<pid>. A test_dev run left 300MB, and a few concurrent runs filled the tmpfs /tmp, after which every daemon died on its link before binding. The half-write test's abort also goes through [aborted] now, since an abort that worked can arrive as the socket closing.
2026-09-25 07:19:52 +07:00
JosephFerano 7b843f6753 TODO.org records what this lane settled and the one decision it left 2026-09-25 07:17:07 +07:00
JosephFerano f142cfaadf A u64 constant above 2^63 can be written in decimal, and a cast's integer literal too wide for i32 is read at the cast's type 2026-09-25 07:16:42 +07:00
JosephFerano aa5052e401 A dyn value an expression answers is the reply's value, not the program's output 2026-09-25 07:14:13 +07:00
JosephFerano aa629da2e5 vec-new and map-new take a type expression where they take a type, so (vec-new [u8]) makes a Vec of byte slices 2026-09-25 07:10:48 +07:00
JosephFerano cf028e23ea A class and each case of a data type are names on the definitions list 2026-09-25 07:09:46 +07:00
JosephFerano b4e19bebf8 A loop's initialisers are checked in order and each sees the names bound before it, as a let's do 2026-09-25 07:06:17 +07:00
JosephFerano 8b354d4bc8 The notes say what is built: no pool, frames on x86, implicit widening, and the budget in the spec 2026-09-25 07:05:55 +07:00
JosephFerano 9194f918fc An aggregate that reads its own destination sees the old value, on x86 too
x86 built a settling right-hand side straight into its destination, so (set p (P {.a (.b p) .b (.a p)})) wrote .a and read it back for .b. assign now also asks whether the value reads storage the destination lies in, and goes through the temporary when it does. The global-initialiser entry was already closed by startup_plan's Set; the unrooted-temporary entry turns out to be a both-backends rooting gap and stays open for a decision.
2026-09-25 07:05:18 +07:00
JosephFerano 2e58880160 The break buffer hides the prelude's frames and opens the source behind a frame 2026-09-25 07:04:56 +07:00
JosephFerano 3a3674efb7 A gensym is never the same name twice in one compiler process, however many macro modules it loads 2026-09-25 07:04:10 +07:00
JosephFerano 9c0e659209 next-error stops on a diagnostic's notes, and CFn is drawn as a type 2026-09-25 07:01:48 +07:00
JosephFerano 6b9d1fa644 A cast of NaN or an infinity to an integer says so by name, on both backends 2026-09-25 06:58:06 +07:00
JosephFerano 5557594f31 What the dogfooding session turned up 2026-09-25 06:45:26 +07:00
JosephFerano 9d7da2ba7a A stop takes the window, because the next key is aimed at it 2026-09-22 11:12:53 +07:00
JosephFerano 45cc4d20e8 One file holds what five did
# Conflicts:
#	FIX.org
2026-09-21 21:07:40 +07:00
JosephFerano 57fe91f303 Five records become one, and every citation lands somewhere
FIX.org, NEXT.md, DISCUSS.org, docs/DISCUSS.md and the session handoff at the
root are one TODO.org now: 293 entries under seven subsystem headings, each
carrying an org keyword that says where it stands. A DONE entry is a few lines
saying what was decided and what that rules out; the reasoning that would not
compress — the embedding spike and the four reports the hand-written x86
backend was built from — moved into docs/BUILT.md instead, and its entries
point there in one line.

Every entry was checked against the tree before it got a keyword, and the
prose was wrong in both directions. Things the deleted files called open were
built: the first-evaluation stall, main being redefinable, macro parameter
lists, the type-limit constants, the array constructors, the byte fills,
inc/dec, the discard's fontification, the Emacs buffers, rt_die's _exit, the
backtrace surface, and the acceptance failure that could print and still exit
zero. Things they called done were not: the backend reports' no-plan buckets
had gone stale in the other direction, the value-dependent defvar was
superseded rather than built, and macro-expansion source locations are on an
unmerged lane, so that entry is NEXT and names the branch.

Every comment that cited one of the five by name now cites a heading that
exists, in TODO.org or in docs/BUILT.md. The session reports under
docs/handoffs/ keep naming the files they worked on, because rewriting them
would falsify what those sessions did; each carries a note saying where the
content went.
2026-09-21 21:05:48 +07:00
JosephFerano 91ba4a0743 The reference page says what the language is
# Conflicts:
#	FIX.org
2026-09-21 20:41:50 +07:00
JosephFerano 594b4bae2e A macro call records a handful of forms, not sixty-four 2026-09-21 20:37:30 +07:00
JosephFerano cc90aa6655 An error in a macro-expanded body points at the line that was written 2026-09-21 19:58:18 +07:00
JosephFerano 23e272a24f The reference page describes the language that exists, dyn and classes included 2026-09-21 19:34:08 +07:00
JosephFerano 37d4e7b32b The standing rules go in the repository, and three dated reports leave it 2026-09-21 19:23:42 +07:00
Joseph Ferano a14a872a44 A form a macro splices through is reported on the line it was written on 2026-09-21 19:16:45 +07:00
JosephFerano bcfcf130f7 The README says what the collector touches, and what a dyn is 2026-09-21 19:15:34 +07:00
JosephFerano 612411021c The colour goes on the language, and the program is left plain 2026-09-21 19:06:16 +07:00
JosephFerano d9bb882bbe A dev build's main is reached through its cell like every other call
# Conflicts:
#	FIX.org
#	test/test_dev.ml
2026-09-21 18:29:02 +07:00
JosephFerano ca237adb22 A function that captures and a function that cannot are two types
# Conflicts:
#	FIX.org
2026-09-21 16:17:10 +07:00
JosephFerano f2c20e2a4a The editor learns the language again, and then learns the program 2026-09-21 16:07:09 +07:00
JosephFerano 2d1d88e9d1 A macro has a body, and the entry that said otherwise
Review follow-ups on the mode pass.

The regression first, because it is the one that cost something: giving
macros a kind of their own took them out of three completion tables.
flan-disassemble, flan-disassemble-ir and flan-lowering each filtered
flan--defs on kind "fn", which had been the whole truth right up until
this branch made it half of one. A macro is compiled -- defmacro is a
defn by the time anything emits code -- so all three genuinely work on
one, and only the offer had gone. One flan--compiled-kinds names both
words and the three sites read it.

Then four sentences in the FIX.org entry that were not true, which
matters more than it sounds: that file is the history somebody reads
later to find out what happened.

The corpus claim was the bad one. It said the whole corpus round-trips
with zero differing lines. It does not, and never did -- the script that
measured it bound inhibit-message around its own reporting, which in
batch means the differences were found and then swallowed. Measured
properly: 317 files, 22 files and 389 lines differing before, 20 and 373
after. Sixteen lines fixed in two files, no new difference introduced,
and 373 lines still differing that this pass never looked at.

The other three: the fns/macros dedup is required by the new rows and is
not a fix to a bug that was there before -- a macro used to appear once,
as a fn. handler-case was already a keyword. loops.flan has three
labelled loops and dotimes-range.flan has the other two.

And three gaps the review found while checking: array-fill and array-gen
were never in the keyword list, Unit was in the type rule while the
parser refuses the word, and a prelude macro's row carried a bare name
where a program's carried its parameters. A check that pulls every head
out of parse.ml and diffs it against the three lists now comes back
empty, which is what the docstring had started claiming.
2026-09-21 16:00:49 +07:00
JosephFerano 0c711942f7 A thunk named for the order it was minted in is a different signature after a reorder
Three from a review of the commit before this one, and the first is a dev
loop defect.  Naming the widening thunk thick/<count so far> put traversal
order into a symbol, and Session.compatible compares a reload's functions
against the running program's by name -- so swapping two calls in a body
renamed nothing the programmer can see and reported "thick/0 changes
signature, from [i32] i32 to [i64] i64.  Restart to change it."  The name is
the signature again, written by thick_enc: every type self-delimiting, an
atom as its length and then its spelling, so the boundaries mangle_ty loses
are in the string rather than inferred from the separators.  mangle_ty is
untouched -- it is load-bearing for the instantiation names a backtrace, a
Reach edge and a dev cell show -- and the memo stays keyed on the types.
fn-thunk-reload.flan is the pair of widenings and test_session reorders them.

The second was a silent miscompile.  bind_ty's Fn-pattern-against-CFn-argument
arm was structural, so it matched nested function positions too, and the
catch-up pass that builds the thunk compares the whole substituted parameter
list -- false when the mismatch is inside one, and the fallthrough handed the
argument over unchanged: one word where the instance declares two, 255 on
both backends with nothing said anywhere.  The widening is a value the caller
builds around the whole argument and there is nowhere inside one to build it,
so the arm is admitted at the top of an argument's type and nowhere else, and
the catch-up arm is total over the pair rather than guarded -- bind_ty's
fallthrough is Types.fits, which admits Never, so a binding can still succeed
over a pair the two words cannot bridge.  fn-generic-nested.flan is the
parameter position and fn-generic-nested-return.flan the return one.

And the third closes the hole the escaping inversion was for.  A match arm
binds the case's fields to slots and the store that fills them is inside the
branch, not in any form escape_check reads as a binding, so (match o (Some f)
f ...) handed back through a name what a case-field read cannot hand back at
all -- sound only because Some_ denies suspects.  An arm's slots of function
type are suspect now, for the same reason the read itself is.
2026-09-21 15:44:28 +07:00
JosephFerano 2ba6a6cbf0 One list for the parser, one for the checker, and one the program fills
flan--special was three kinds of name in one list: the forms the parser
dispatches on, the functions the compiler provides, and the words that
stand for themselves. Drawing push like let said they were the same kind
of thing. They are three lists now, each read off the file that decides
it, and the two names in the old one that are not in the language at all
-- cast and none -- are gone with it.

What else had been missed: dyn, Allocator, Vec, Map, Fn, int and float
were not types, $t was not anything, handler-case was not a keyword, a
comma was not whitespace, & was not a name character -- which a macro's
parameter list needs, now that it destructures -- imenu had no heading
for a macro and none for the four dispatch forms, and a labelled loop
indented its body under its own binding vector, which loops.flan has
four of and would have said.

Then the second half. The defs op already told the editor what every
name is, and only completion and eldoc were listening, so a macro you
defined looked exactly like a function you defined. It carried no macro
kind to listen for and could not have: a macro is a defn by the time
there is a program. So the op answers with one -- keeping the location
off the defn it drops, so M-. still goes there -- and with the type
names, read off the checker's environment, since an enum is an i32 and
an alias is gone by then.

The editor draws them by kind, after the static rules and never over
them: a program defining its own length does not get to repaint the
builtin. A hash table and a matcher rather than a regexp of every name,
rebuilt when the cache is and not when a key is pressed. With no session
the rules come off and the buffer is what it always was.
2026-09-21 15:31:32 +07:00
JosephFerano a28aeda633 The thunk memo was keyed on a name that two signatures can share
Review found it, and it is a silent miscompile on both backends rather
than a refusal anywhere.  mangle_ty flattens a whole signature into one
hyphen-joined string, so (CFn [(Ptr i32)] i32) and (CFn [ptr i32] i32) --
the second over a struct someone called ptr -- flatten alike; keyed on
that, the second widening reused the first's thunk at the wrong arity.
The key is the types now, compared with Types.equal, and the symbol is a
counter over the thunks already minted, so nothing is derived from a
spelling.  fn-thunk-share.flan is the pair, and it prints 5 and 17.

And a regression beside it: a generic whose function parameter binds the
type variable.  (defn apply2 [f (Fn [$t] $t) x $t] ...) called as
(apply2 bump 1) compiled before this lane and stopped, because bind_ty
had no arm admitting a CFn argument at an Fn pattern -- and once it had
one, the call still handed one word to an instance declaring two, because
a parameter that still mentions a variable is checked with no expectation
and expect never sees the pair.  Both halves: the arm, and the widening
in generic_call's catch-up pass beside the numeric one.  The same gap hid
new functionality -- a CFn argument at a (CFn [$t] $t) parameter had no
arm either -- and mentions had no CFn case, so bound_exactly answered
wrong for a variable living only inside one.

The corpus missed all of it because the prelude binds $t from an earlier
argument, so the parameter is concrete before bind_ty sees it.

While here: escaping's enumeration is the *clean* set now rather than the
suspect set.  It had a hole where a list like that cannot -- an (at s 0)
over a slice of Fn read as clean while the Vec, struct and pointer
spellings were refused.  Unreachable today, and the header claims the
list is closed.  The same inversion fixes which refusal message an index
read gets.

Three minors: Anull was defined and never constructed; the capture-dyn
message substituted a descriptor into a noun slot; and session.ml
rendered a defn's changed signature as (Fn [...] ...), which is now a
real type and not the same as (CFn [...] ...) -- it writes the parameters
and the return the way a defn writes them.

And one rounding corrected in the docs: handler-bind is not free for a
program that captures nothing.  %handler grew from 24 bytes to 32, every
push writes a null into the new field, every clause gains ptr %env with
an alloca and a store, and flan_signal passes one more argument per
dispatch -- twenty changed x86 lines on loops.flan.  Small, real, and
paid by every conditions program.
2026-09-21 13:41:44 +07:00
JosephFerano 4cb53a0a9f What the two function types changed in the documents
The four refusals the function-value lane wrote down are now three and a
half: capture is a feature, the escape is the refusal in its place, and a
struct field or a global of function type is refused for the same zero as
before with a sharper reason behind it.  The handler note is amended
rather than deleted -- its reading was right, and saying so is worth more
than a clean paragraph.

The convention section carries both rulings, the name and every name it
beat, the warning that CFn is not C interop today, the four reasons to
reach for it, what was measured rather than asserted about an ordinary
defn, and the wasm32 finding that killed the first design -- being
exactly typed is checkable by a verifier and the alternative was an
argument from a calling convention.

FIX.org carries the rulings verbatim, what the escaping lane inherits and
changes, the thunk's environment holding a code pointer rather than a GC
object, and two things recorded and not built: a CFn under a future
--no-conditions reaching C's exact convention, and CFn in a struct or a
fixed array, which ZII refuses and capture has nothing to do with.
2026-09-21 13:41:44 +07:00
JosephFerano 2572f0a537 An fn sees the locals it was written among, and Fn says so in its type
spec-memory.md's case 2, capture by value into a stack environment, and
the calling convention the author's rulings asked for.

    (Fn  [i32] i32)   captures; {code, env}; the common case
    (CFn [i32] i32)   the bare address; one word; cannot capture

A local of the enclosing function that an fn names is copied into a
struct the checker synthesises, held in a slot of that function's frame,
and the value carries its address; the lifted body reads the copies back
into named slots of its own, once, at entry.  So the name in the body
means what the local held at the instant the value was made --
fn-capture.flan changes the local through a pointer after the value
exists and the fn still answers with the old one.

Two types rather than a uniform environment parameter: "while it's dyn
first, static side should never have to pay the price for the existence
of the dyn side... if you fully opt out, for instance, using --no-gc
flag, then we should be operating under Odin/C semantics and never paying
any runtime costs."  The environment is declared by exactly the bodies an
(Fn ...) value can reach -- a lifted literal in an Fn position, every
handler clause, and the widening thunks -- and by nothing else.  An
ordinary defn emits the signature it always did; calc-me and fourteen
corpus programs were diffed to say so.

CFn, because the C carries information: a value with no environment is
the only kind that could ever cross to C, and under the --no-conditions
direction FIX.org records it becomes literally a C function pointer.  It
is not that today -- a declare cannot take a function type at all -- and
crossable's refusal says so where a reader would otherwise be misled.
Nobody needs CFn: Fn accepts everything, and the commonest reason to
reach for the narrow one is that a *named* function handed to an Fn pays
a hop through the widening thunk where a CFn is a direct call.

That thunk is one small function per distinct signature widened, which
reads the bare address back out of the environment and calls it.  The
cheaper trick -- the environment last, ignored by a body that never
declared it -- is legal under SysV and is a trap under wasm32's
call_indirect, which compares the signature at the call.  Every indirect
call is exactly typed now.

A handler clause captures the same way and is sound with nothing left
over: its frame is popped by the body that pushed it.  What is refused
there is a *store* into a captured name -- it is a copy, and writing to
it would leave the local as it was.

And the other half, which is what "non-escaping" means: a value carrying
an environment may be called, passed down and let-bound, and may not be
returned, stored, pointed at or pushed into a container.  A parameter of
type Fn is treated as one, which answers "passed to something that stores
it" with no interprocedural analysis -- the store is refused inside the
callee.  Everything of type CFn is clean for free, which is the second
thing having two types buys.  Every refusal names case 3, the environment
the collector owns.

Two pre-existing bugs fell out on the way.  A lifted fn asked for Fnval,
so `flan reload' on any function containing an fn literal died at llc
with an undefined cell; it takes Flanfn now, which is the choice a
handler clause always made.  And a redefinition module now carries its
own hidden copy of every thunk it names, which is the same bug shape
caught before it shipped.
2026-09-21 13:41:44 +07:00
JosephFerano a885c1b0f1 The page quotes the usage and the grid it has now
Two quotes had drifted: flan check's --warn-memory line, which the page
never carried, and the sand hash, which changed when the generator did.
2026-09-21 13:12:51 +07:00
JosephFerano 09b77adc38 Merge branch 'worktree-agent-a6b21b9d0ff2daa2e' into dev-loop 2026-09-21 12:42:21 +07:00
JosephFerano 290d322b44 An assignment is whole or it never happened, on x86 too
Two bugs, both in lib/x86.ml, both reached by signalling a condition from
inside the value being assigned.

The backend has no register wide enough to hold a struct, so it built every
aggregate in its destination, element by element as they were computed. A
transfer out of the middle left the destination in the middle: a global of
four numbers read part old and part new, and so did a local, a struct field,
an array element, a place behind a pointer. A union case was worse than the
rest, since its destination is zeroed before the fields are written.

The second one only looks like the first. An aggregate result is written
through a hidden pointer the caller supplies, and the transfer exit zeroed
that result on its way out, on the correct reasoning that the caller never
reads it. At (set g (f)) the pointer is g, so it zeroed the caller's
variable. It zeroes scalars only now — and with the first half in place
nothing reaches it, so that one is defence in depth and FIX.org says so
rather than claiming a test it does not have.

Assignment builds into a frame temporary and copies, which is the shape
emit.ml always had. The copy is paid where it buys something: [settles] says
whether lowering an expression is bound to reach the end of it, and a
right-hand side that settles is still built in place. That includes a
conversion, except the one direction that is checked — a float narrowed to an
integer — because an array of this language's literals is written
[(u32 1) (u32 2)] and refusing every cast would have taxed the commonest
aggregate there is. A let pays only inside a loop, because a slot reached
once per frame is recorded as bound after the value lands.

half-write.flan is every destination crossed with every right-hand side, run
on both backends, with a scalar row so a regression in one is not read as the
other. dev-halfwrite.flan asks the break loop the half a running program
cannot ask itself: the local, which is invisible to the program and plain to
an editor reading the frame.

The def-edit paragraph in docs/BUILT.md described this as open and is now the
claim it was waiting for. FIX.org records the two lanes the review of this one
turned up and this one does not take: an aggregate built in place can read its
own destination, which is an aliasing question rather than a transfer one; and
the temporary is a frame buffer no root table names, which matters the day a
struct may hold a dyn field.
2026-09-21 12:37:08 +07:00
JosephFerano 54d12811f8 Merge branch 'worktree-agent-afd7ad8d87f71fa16' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 12:19:15 +07:00
JosephFerano 9ae134beaa The numbers from the rebased branch, and a second shape of test_dev flake
test_reload.exe 25 for 25 after the rebase and the reg_at change, with the
pre-fix binary refusing 4011 of 73306 runs beside it, so the window is known
racy rather than merely quiet.

test_dev flaked twice over while that ran, in two different shapes. Three runs
in six at load 21 were the stale-park rerun, which has a lane. Two of another
six were something else: most of the daemons in the file exiting before they
bound their socket, with no stale socket or daemon left behind, and six green
on a quiet machine after. Neither touches the registry — a daemon that dies
before binding died before any program it builds ran a line of flan_dev.c —
but the second shape does not seem to have been written down anywhere, so it
is written down here.
2026-09-21 12:16:43 +07:00
JosephFerano ef2ac41172 Merge branch 'worktree-agent-a1b7f33fe11893c24' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 12:12:36 +07:00
JosephFerano ae46c5a878 Three sentences that were true only while the state lagged
Follow-ups to the re-run flip, and all of one kind: each said something
correct about a committed re-run that still read as parked, and the flip made
it false.

flan_merged_park's note that the program stays PROGRAM_PARKED across a poll is
now true of every round but the one it leaves on — which is the round that
drains the ring with a run already taken, and the round the fix's own argument
leans on as the long one.

Program.rerun's refusal told the reader to close a window or let the program
finish. With a thunk stopped in the break loop a re-run is accepted, the state
goes running, and a second is refused by that sentence — advice about a game
loop for somebody whose thread is in a break. The C cannot see a break; this
end can, so Dev.rerun passes what it already asked the agent and the refusal
says resume or abort instead.

The five-second timeout in eval_expr and run_render_thunk reached its
break-loop sentence through the Parked arm, so a program stopped between runs
was asked whether it calls (agent/poll). Both gain the arm that names the
break. run_render_thunk's two sentences become a function first: the new one
asks the agent, and that was the body of a five-millisecond tick.

docs/BUILT.md gains the case an editor author meets — :parked nil :stopped t
with no frames running — and FIX.org records that test_dev.ml:763's refusal
assertion was racy before this and is narrowed by it.
2026-09-21 12:11:23 +07:00
JosephFerano 13145cc52a Merge branch 'worktree-agent-aba36ddb6a383320f' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 12:10:23 +07:00
JosephFerano 3df49184a7 The combination nothing built is built now, eight cases of it
--dev --sanitize had been unbuildable for as long as a dev build has
armed the registry from a constructor, and nobody knew because no alias
built it: test_sanitize built the corpus twice and neither time --dev,
test_dev builds --dev and never with a sanitizer. A configuration
nothing builds can be broken for a month, and that one was. af71459
fixed it; this is what keeps it fixed.

dev_sweep, in the alias that already exists rather than a new one --
five names to remember was already four too many, and this is the same
question @sanitize is for. Seven programs built --dev twice, plain and
sanitized, run standalone with no daemon: a sanitizer report fails, and
so does any divergence from the unsanitized dev run.

Standalone is what makes it nearly free, and it works because a dev
build is not a dev session. The cells, the marker and the constructor
are in the executable either way, and a program that never calls
agent/start runs to its own end. That is why the list is mostly
ordinary programs built the other way: dev-noagent is the only one of
sixteen dev-*.flan that does not import the agent, and the rest stop in
the break loop waiting for an editor, which is test_dev.ml's business
against a real daemon.

The three dyn programs are not interchangeable and the difference is
the collector. dyn-vec allocates and never collects -- flan_dyn.c has a
one-megabyte floor and dyn-vec does not reach it -- so what it says is
that the registry and the allocator agree. dyn-map and p13-dyn-collect
are the only two programs anywhere past that floor, so they are the
only two under which a mark and a sweep run; without them a collection
had still never happened in a dev build under ASan.

dev_segv is beside the sweep rather than in it, because the program
that faults cannot be compared against an unsanitized run: that build's
handler prints its line and parks in the break loop, so the plain half
would hang, and the two are supposed to differ. ASan is meant to own
the fault -- flan_dev_crash_enable checks a weak __asan_init and stands
down -- so the case asserts ASan's report and the absence of the
handler's line. At -O0, because at -O2 the write through a bytes-view
of a literal does not fault at all and both builds print the string
unchanged. That yield had never run in any build anywhere; it was
behind a link that did not happen.

Checked both ways rather than asserted: eight failures with the
constructor naming declarations again, clean with it fixed.
Twenty-six seconds of the alias's 2m30 warm, and nothing added to dune
test.

Two aliases were also green only because dune test runs first.
@sanitize never listed the package directories pkg-diamond.flan
imports, and @page never listed sand.flan, which quotes.sh reaches
through sand-headless.flan; from a cold tree the first died before its
first sanitized build, and @page sits inside @checks where CI hides the
same thing. Both listed now. @valgrind, @x86, @js and @cells were
checked and are complete.

What it still does not reach is in FIX.org: a program driven by a real
flan dev daemon under ASan, which wants a --sanitize the CLI does not
have and a way through Dev.serve. --x86 --sanitize is refused by Build
by name, so there is no second backend to track here.
2026-09-21 12:08:36 +07:00
JosephFerano 1440ae4119 Taking a re-run is what ends the park, so the state says so
flan_merged_rerun accepted a request under the lock and left program_state
as it found it: PROGRAM_PARKED, until the parked thread got round to waking.
describe's :parked reads that same state, so a caller that asks for a re-run
and then waits for the program to park again was liable to be answered by the
park it had just ended — the wait fell through on the old park, the next
request went out before the first run had started, and the pair of them made
one run between them; or the thread woke in between and the second was refused
as "already running". One lagging state, two symptoms, and all four of
test_dev's re-run sites could show either under load.

The window was documented over flan_merged_park as "as wide as a flush". It
stopped being that when the ring drain went in front of the park's exit: the
leaving round loads whatever was queued before it breaks, so the window was as
long as the next thing the program had to do.

The store moves to the acceptance, under the lock that made it. There is no
longer a moment in which a committed re-run reads as parked, and the park's own
store on the way back into main stays as the no-op that says where the thread
has got to. Dev.rerun reads the liveness and the break for its note before it
asks, since afterwards the answer is running by construction.

Measured on that loop driven standalone against programs/dev-rerun.flan: 9
failures in 25 runs under two busy-loop burners before, 0 in 50 under the same
load after. FIX.org, 2026-09-21, has the readers that were checked, the path
that cannot exist, and why the two-process daemon has no such window.
2026-09-21 12:02:20 +07:00
JosephFerano 8eaeb992d6 sand.flan asks for length
Two sites, and the second is the subtle one: the let binds a local named
length from (length coll), which reads because a sequential let checks an
initialiser before the name it binds exists. That was the reason for the
rename -- len is a variable now.
2026-09-21 12:01:34 +07:00
JosephFerano 970d5ac38f Merge branch 'worktree-agent-a2b2b99f84ca798de' into dev-loop 2026-09-21 11:59:19 +07:00
JosephFerano d6fc15474b The count is length, so len is a name a program can have
The author: "I think I prefer length over len, because then I'll use len as
the variable name". One arm in check.ml, one row in the table beside it, and
every (len x) in lib, test, examples, vendor, spike, docs, web, emacs,
plan.org and NEXT.md rewritten.

Shadowing and builtin/ had already taken most of the sting out: a (defn len
...) was legal and won in its own file, and builtin/len reached past it. What
was left is that len was still a builtin — the defn earned a warning, and a
wrapper had to say builtin/ at every inner call. Now there is nothing under
the short name: len is an ordinary identifier in every position, which is
what (let [len (length xs)] ...) wants.

length takes over as shadowing's worked example rather than the feature
losing one. shadow-builtin.flan, builtin-qualified.flan, pkgs/shadowed and the
builtin/ rows in test_flan move to it and go on testing shadowing.

A call to a len nothing defines is answered where an unknown function is,
after every table and after the shadowing guard, so a program with its own len
never reaches it. The sentence is said rather than guessed at — len and length
are three edits apart and the did-you-mean's net is one — and the call is
written back out through spell_arg, as-slice's spelling lifted out of it and
now shared, so what is printed compiles.

sand.flan:33 still calls the old name and is the author's to change; until it
does, test_acceptance and test_session abort there. Both were run green
against a copy with that one line changed. FIX.org says so.
2026-09-21 11:58:56 +07:00
JosephFerano df2f23609b The three the review asked for: the last bare retry, and a bound that was a typical
[flan_dev_reg_at] still went straight round its walk-level retries — a failed
scan_open and a failed scan_ok both back to the top. It is the one site where
the argument for leaving it holds water, since the agent gates the verb behind
a stopped program and there is no writer to lose to. It loses to the other
argument: it is the same mistake the slot read was making, three lines under
the note explaining why it is a mistake, and a bare retry left next to that
note teaches that the rule has exceptions it does not have.

And the cost of a refusal was written as a bound when it is a typical. "One
writer, so at most one slot of a walk is odd" is true at any instant and false
across a walk — flan_reg_compact writes every slot under its own counter, so a
repeatedly preempted writer can charge the full patience against several slots
of one walk, 4096 of them in the arithmetic worst case. Both docs now say
typical and give the worst, and record what the larger figure means for
request_lock, which is held across handle_line: a refusing listing delays an
abort by that much. Nothing depended on the old number.

Also written down, because it is the clearest statement of why this was not a
timeout: sixty-four bare re-reads of one word finish in about two microseconds,
so against a writer a millisecond from running the old budget was not short, it
was zero wall-clock.
2026-09-21 11:52:59 +07:00
JosephFerano a29424a7b6 The proof the first commit's numbers could not give
Green runs of a load-only flake, taken on an idle machine, are worth nothing,
and a count alone cannot tell you which kind you have. So the load for the
55-run test_reload proof was a build of flan_dev.c from the commit before this
fix, churning beside it: a positive control as well as a load. It refused 4151
of the 50478 runs it managed in that window — 8% — while test_reload went 55
for 55. Paired on the direct binary too, eight copies at a time: 13 of 80
before, 0 of 160 after.

Also here: the bound BUILT.md used to quote went from two milliseconds to about
sixty-six, and the doc says so rather than dropping the number; the note that
flan_dev_reg_at shared the spin and could answer "never heard of this address"
under load; and the verdict on the two neighbouring dev flakes, which are three
causes and not one.
2026-09-21 11:49:29 +07:00
JosephFerano 8f05ece441 The reader spun where it should have waited, and called a still table unreadable
test_reload's registry-under-a-writer case failed about one run in five on a
loaded machine and never on an idle one. The refusals were always unread=1:
one slot, on the best of eight walks, that would not copy. In that mode the
table cannot even move — re-noting live blocks kills nothing, the dead count
stays at zero, and the compaction trigger never fires — so the reader was
refusing a table that sat perfectly still for it.

flan_reg_snap re-read a slot's counter sixty-four times with nothing between
the tries. That is right for the writer it was written for, which holds the
counter odd for seven stores. It is hopeless against a writer the scheduler
took the core from mid-write, which holds it odd for a quantum: the reader
burns all sixty-four looks inside a fraction of that, and the looking is what
keeps the core the writer needs. Hence load-only.

The walk-level retry already had the answer written up at length — a spin takes
a core from the thread being waited on. The per-slot retry never got it. One
pause helper now, shared by both: eight bare looks for the running writer, then
the same 250us step. A refusal means the table would not hold still.

480 contended runs of regfull after, no refusals; 46 of 240 before.
2026-09-21 11:49:29 +07:00
JosephFerano b8856bef3a Merge branch 'worktree-agent-a39cf777643bbbdcc' into dev-loop 2026-09-21 11:45:38 +07:00
JosephFerano 3672da28be A diagnostic is for someone who has only this compiler, and says what to write 2026-09-21 11:44:59 +07:00
JosephFerano af714598b8 Merge branch 'worktree-agent-a93ce7ad4d1f34196' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 11:28:56 +07:00
JosephFerano 7b4f99261e Merge branch 'worktree-agent-a6bf2d4580db1ab57' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 11:26:40 +07:00
JosephFerano b98108791d Merge branch 'worktree-agent-a1a1e7420cccc5fa6' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 11:05:28 +07:00
JosephFerano 6df50e9ce1 The sand grid hashes the new generator
The draw is 64 bits now, so every grain lands somewhere else and the old
number could not be right again. Re-taken from a run, which is the one
manual step this case has always had.

sand.flan's two calls say (f32 (rand)).
2026-09-21 11:05:03 +07:00
JosephFerano 165685490e Comparisons take a run of operands: the orderings chain, != is all-pairs
(< a b c) was an arity error. The folding operators had taken two operands
or more since fold_left_prim went in; the six comparisons had not, and they
are the ones the game hit.

The orderings and = chain: (< a b c) is a below b and b below c, because the
left fold would compare a bool against a number. != does not — the author's
ruling is that (!= 1 2 1) should be false — so it asks about every pair,
Common Lisp's /=. Whether a sequence is increasing is a question about
neighbours; whether a set of values are all different is a question about the
set, and the pair chaining never looks at is the one that decides it.

Every operand is bound to a slot first, in source order, so an operand two
pairs name is evaluated once — the spelling a reader would write, (and (< a b)
(< b c)), evaluates b twice. The conjunction then stops at the first pair that
fails, with nothing observable riding on it: everything has already run.

At two operands both readings are one pair and neither goes through the n-ary
lowering, so every comparison there is emits what it always did. (< x) joins
(+) and (- x) as a refusal — it would be true whatever it was handed.
2026-09-21 11:03:39 +07:00
JosephFerano 0c03550999 Evaluating a def assigns, because that is what defparameter means
The author edited (def colors [4 u32] [...]) in his running game, pressed
C-c C-c, and the colours did not change — the same complaint def was built
to answer, one step further in.

The reading behind it was that a re-evaluated def is a promise about the
next re-run, so the session republished the lifted global/<n> and stopped;
nothing called it. That is wrong for the reason the form is named after: def
is Common Lisp's defparameter, and evaluating a defparameter assigns. The
difference from defvar is not "one takes effect at restart", it is "one
takes effect, the other does not touch the value at all".

So a def now does both. The storage takes the new value at the next frame
boundary, carried by the thunk a redefinition module already has — one
Set per re-evaluated def, in the same flan_reload_call the class
registrations use, run after the bodies are published and on the game
thread. And the lifted initialiser is still republished, so the next re-run
runs the edited one; dev-rerun.flan pins that half unchanged.

A brand-new def gets its initialiser run too, which needed one thing from
each backend: a lifted global/<n> asked for by name is neither a sibling nor
one of the target's own lifted clauses, so it had no cell, and a dev call
goes through a cell. Both now give an unknown one a slot of the module's
own, filled from the registry by the installer.

An initialiser that signals leaves the old value alone — the value is
computed whole before it is stored — and offers abandon-evaluation like any
other thunk. A retype is refused first, by the pass that names both types.
defonce is untouched, which is its whole contract; defconst was already the
immediate one, through consts.
2026-09-21 11:03:30 +07:00
JosephFerano efcee7ea07 Merge branch 'worktree-agent-ae090b25442f691f1' into dev-loop 2026-09-21 10:57:56 +07:00
JosephFerano 69c1116efc The generic allocation program takes the one slice
The slice lane merged after this program was written and as-slice went
with it. Nine calls, one name.
2026-09-21 10:57:52 +07:00
JosephFerano a0278378ab Five randomness functions, and a draw wide enough to answer them
rand-int, rand, rand-bool, rand-int-range and rand-float-range, at the widths
the author ruled: a u64 draw and an f64 in [0, 1). rand-seed and rand-state
keep their names. The four old names are not names, and each is refused by the
one that is, with a call that compiles — in both the call and the bare-name
position, because a Lisp-1 makes the second a real thing to write.

The generator's step is untouched, so a seed means what it meant. Its output
function is not: PCG-XSH-RR folded the state to 32 bits, and no honest u64 or
53-bit f64 comes out of 32 bits without a second step. PCG-RXS-M-XS 64 answers
64 from the same one, so all five still cost exactly one draw and a seeded run
is reproducible. The price, written where it lives: the permutation is a
bijection of the state, which is what 64 output bits from 64 state bits costs.

The sequence is therefore a different one, and programs/rand.flan pins it —
reproducibility across the five, the single-draw cost of each, the half-open
boundaries, and rand-bool's count over a thousand flips.

sand.flan is not touched. It calls rand-f32, so the cases that compile or
re-evaluate it skip themselves on the fixture rather than on a comment: fix
its two calls and every one of them runs again. Its hash is the old
generator's grid and gets re-taken then.
2026-09-21 10:57:18 +07:00
JosephFerano bb274432a7 Merge branch 'worktree-agent-adff9fa1b537fecf8' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 10:29:07 +07:00
JosephFerano 7794f06f00 The same mistake was wearing two faces, and neither named the fix
($u x) was an unknown function in the same body where (vec-new $u) was an
unbound type variable, because the cast arm did not take the sigil clause
type_named took. It takes it now, so one mistake has one story.

And the story was a rule rather than an answer: "only a defn signature can"
is what to say when nothing is in scope to name — a struct field, a global —
but inside a signature that introduces $t, the name that was meant is almost
always t. It names them. Which names those are comes from tyvars abstractly
and from subst inside an instantiation, because a body is checked under both
and reading one would answer the same mistake two ways in a single run.
2026-09-21 10:27:54 +07:00
JosephFerano 75177a5a18 Review follow-ups: the constructor took a name a program can write
The wrapper was called flan.dev.ctor, and that is a name Flan can reach:
(defn dev.ctor [] i32 7) mangles onto exactly it. The program compiles
and runs as a release build and fails a dev build with a redefinition
clang refuses -- loud, and only under LLVM with --dev, but mangle.ml's
own comment exists to make it impossible rather than loud. The name is
[Mangle.dev_ctor] now and starts with the dot no reader token can
produce, beside .init-globals and .init-data. The colliding program
builds and prints 7.

Two comments in survey.sh, both of them reasoning rather than
behaviour. The counts argument against a per-name -O0 list was no
argument: excluding moves the counts just as much. What actually
carries it is that dies_segv builds both programs at -O0 on both
backends and asserts more than this sweep would. And dev-segv's park
under --dev is a forever-list reason that happens to land on a program
this list already covers, not a second reason for this list.

FIX.org takes the sweep, and one thing the sweep cannot say: the two
heap cases of bytes-copy.flan leak 24 bytes through flan_bytes_dup,
measured with --leak-check=full, and both corpora are blind to it by
policy -- detect_leaks=0 on one side and --leak-check=no on the other.
The row proves the copy is in bounds and written. It says nothing about
who frees it, and a green sweep should not be read as though it did.
2026-09-21 10:12:55 +07:00
JosephFerano 9573884d97 Merge branch 'worktree-agent-a9a425fd59a33124b' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 10:05:42 +07:00
JosephFerano 5ad16d6815 A conversion asks the bound, not a type the variable does not have
(i32 (at xs i)) inside a body bounded integer? was refused with "i32
converts a number, found t". Arithmetic, comparison, min/max, the
bitwise fold and the shifts all ask the where clause; the conversions
were the family nobody had gone back to, and the cast block held three
arms of it.

The machine-type target asked Types.is_numeric of its operand and the
enum target asked Types.Int _, so a variable fell through both to the
refusal however it was bounded. The variable target had the opposite
defect: it asked the bound of the target and then took any generic
operand, so a second variable declared only ordered? passed the abstract
pass on the strength of a sentence about a different one. Nothing wrong
was ever emitted through it — ordered? admits numbers and enums and both
convert at the instantiation — which is exactly why it is worth closing:
the hole opens the day ordered? admits a type that does not.

The rule is the repo's own, applied to a set instead of a type: a
conversion is legal at a bounded variable exactly when it is legal at
every type the bound admits. A machine-type target needs numeric?, an
enum target needs integer? because numeric? admits the floats the
concrete arm refuses, and ordered?/equal?/hashable? admit nothing — the
last by what the predicate says rather than by the set it denotes, since
hashable? already admits strings and ordered? may.

Both float targets and the narrowing i32 stay legal: (f64 i64-x) rounds
above 2^53 and (i32 f64-x) truncates where the types are written, and a
generic that refused what its copies accept would be the fork the rule
forbids. FIX.org, 2026-09-21, has the account, and records what this
costs: no predicate now licenses a generic enum to integer conversion,
and enum? is the eventual answer.

The refusal says what the variable is known to be and what to write, in
the clause spelling unconstrained already uses: a body with no clause
gets the clause, a body that has one is told which predicate to add.
2026-09-21 10:01:18 +07:00
JosephFerano 482b869835 Three membership tests asked the name as written, and one carries a sigil
(vec-new $t) in a generic body was refused as if it had said nothing about
its element type. The feature was not missing: env.tyvars and env.subst are
keyed on the bare name, and type_named and the cast arm asked them of the
name as written, so the spelling with the sigil fell past the guard into the
no-element-type message. (vec-new t) had always worked.

One helper the three of them share, beside resolve_name, which already
stripped for itself. A sigil on a name nothing binds reaches resolve_name
now too, so it is answered as the unbound variable it is.
2026-09-21 10:00:40 +07:00
JosephFerano 4ab1d872b0 Merge branch 'worktree-agent-adecbcf29e2524871' into dev-loop 2026-09-21 10:00:04 +07:00
JosephFerano c3319e6268 Review follow-ups: a leak is the program's, a wrong answer is not
Three things the review found, and the refusal it was right about.

The returned-Vec refusal is gone. It called a leak a dangle: the storage a
returned Vec owns outlives the expression, so the view reads what it says it
reads, and what is lost is the owner. (len (mk)) and (at (mk) 0) lose the same
owner and compile, spec-memory.md already says an overwritten global Vec leaks
its first block, and under a region there is nothing to leak at all. "We're
purposely doing manual memory management for the static side, so whatever."
The array refusal stays exactly as it is — that one is a view into a frame
that is gone and answers bytes the frame has since reused. Wrong answers are
the compiler's business and leaks are the program's, and both docs now draw
that line, because the two forms look alike.

The -1 sentinel was reachable from user syntax: (slice v 0 -1) answered the
whole Vec on both backends while (slice a 0 -1) was refused as a negative
bound. The refusal now runs on the bounds the reader wrote, before the
implicit hi is built — the only order that works, since the sentinel is itself
a -1 and a check on the finished pair would refuse (slice v). The
backwards-pair check moved into the branch where both ends are written.

The bounds seam is closed toward index_expr, and the tiebreaker is not which
half is older. indexed and vec_at both take their index through it, so
(at a c) over a u32 compiled where (slice a c) did not: the fork was between
slice and at as much as between two targets. A bound is a subscript.

Also an x86 row for vec.flan, so the new arity is pinned on both backends in
CI rather than by hand, and the comment columns the sweep shifted left in
slurp, into, format and algorithms.
2026-09-21 09:59:33 +07:00
JosephFerano 9ce51ba94e One slice over everything with elements, and the warning at the push
as-slice was a warning, not an operation. The input type already decides
which of the two things happens — a Vec can only be borrowed, an array or a
string can only be viewed, and no call site picks between them — so the second
name expressed no choice a reader could make. And it warned at the moment the
view is taken, which is the one moment nothing is wrong; the danger arrives
later, at the push. slice now takes a Vec at all three arities and as-slice
is gone.

(slice v lo) was free, and is the arity the Vec never had: the runtime already
reads a hi of -1 as "to the end", so the tail form passes the caller's lo and
the same -1 — no slot, no length read, no second evaluation. The merge is
entirely in the checker; the Vec path builds the flan_vec_as_slice call it
always built and neither backend has a line about any of it.

A Vec a call returned is refused at every arity, and not for the array's
reason. (slice (mk)) over an array dangles. (slice (make-vec)) does not — the
storage outlives the expression — but the header is a temporary, so nothing
can ever free the block. The refusal says that and names the let.

The name's own refusal sits in ordinary_call after every table, so a program
that defines an as-slice still reaches its own. It reads for somebody who has
never heard of the old name and writes the call back out, spelling each
argument that is a name or a number.

The warning moved to where it bites: BUILT.md gains a section beside the Vec
table and the push row points at it, spec-memory.md's Borrowing says the same.
Investigated and deliberately not built — a diagnostic for a live view at the
push. (reserve v 100) then a slice, a push and a read is correct code under
the contract the spec chose, so any flag on it is a false positive by the
language's own semantics rather than by an approximation. FIX.org has the
finding and the syntactic sketch that does not work.
2026-09-21 09:51:35 +07:00
JosephFerano 892366e86d The program the copy was written for is in both sweeps now
bytes-copy.flan came in with the lane that made (bytes s) allocate, and
it went into test_acceptance.ml and nowhere else. It is the one program
in the corpus that takes a block from an allocator, writes through it
immediately, and then takes another from an arena that is freed and
destroyed under it -- which is the shape both opt-in sweeps exist for,
and neither was running it.

Both, not one, because the two tools answer different halves: a copy one
byte short is a heap overflow ASan names, and a copy whose tail was
never written is an uninitialised read only memcheck can see.

Clean under both.
2026-09-21 09:45:57 +07:00
JosephFerano f75b0ca033 A constructor naming a declaration is what clang could not sanitize
--dev --sanitize did not compile. Any program, at any optimisation
level: clang 20's AddressSanitizer module pass segfaults on the module,
and the message it leaves behind is its own crash backtrace rather than
anything about the program. Reduced here to five lines of IR, and the
trigger is narrow -- @llvm.global_ctors naming a function that the
module only *declares*. Both of ours are declarations, because both are
C in the runtime.

This predates the crash-handler lane and is not its doing: the one-entry
table, with flan_dev_reg_enable alone in it, crashes the same way, and
that entry has been emitted since the registry was armed from a
constructor. What the lane did was add a second declaration to a shape
that was already crashing, where nothing built the combination to
notice.

So the table names a local definition that calls the two, which is the
shape clang emits for its own constructors, and it costs a call once
before main. The ordering it fixes was unspecified before -- two entries
at one priority -- and arming the registry before installing the handler
is the order that was wanted.

What it unblocks is the reason to care. flan_dev_crash_enable yields to
ASan through a weak __asan_init so the two do not both own SIGSEGV, and
that line could not have run, because the build it guards would not
link. It does now: a dev build of dev-segv.flan at -O0 under ASan takes
the fault, ASan reports it with the frame, and the handler stays out of
the way instead of parking. No alias covers the combination, so that
check was made by hand.
2026-09-21 09:45:54 +07:00
JosephFerano 10b1abbf09 The last enum member takes its prefix, and the alias goes
TraceLogLevel carried `warning` beside `log-warning` so that sand.flan's
one call kept working while the rename landed. The review found it was not
inert: render.ml folds members last-wins, so a 4 read back as :warning in
the break loop, the did-you-mean offered it, and every TraceLogLevel error
listed it. A spelling nobody should write was the one the compiler taught.

So the call is respelled and the alias is gone. Every member of every mapped
enum carries its enum's prefix now, with no exception to explain.
2026-09-21 09:32:37 +07:00
JosephFerano da987169c8 Merge branch 'worktree-agent-ab561492e212bf034' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 09:27:15 +07:00
JosephFerano 0a0517f1d1 Merge branch 'worktree-agent-a864e4baf08dc8a0e' into dev-loop 2026-09-21 09:04:59 +07:00
JosephFerano 41334b3a8f Review follow-ups: a trap is not an evaluation, and the way out cannot be truncated away
The break buffer captioned every refused restart "below this evaluation", which
is the wrong sentence at a trap: there is no transfer channel, nothing on the
list can be taken, and there is no evaluation to abandon. The terminal listing
had always said the two apart. The wire now does too — a bare ! line ahead of
the entries, because a trap with no restarts at all still has to be able to say
so, and because :abandon being nil means three different things.

The boundary was also the first entry truncation dropped: snap_push walks
innermost first, and the boundary is the outermost frame of the evaluation. A
slot and a name's worth of bytes are kept back for it.

flan_break_resume goes; nothing has called it since choices became positions,
and NEXT.md already said there was no such function. eval_boundary is cleared
between runs beside the two stacks that already were. And the note on a taken
restart stops costing a second round trip: the agent answers ok abandon for the
boundary, which is the end that knows.

Nested boundaries are tested rather than argued: two evaluations, six restarts,
and abandoning the inner leaves the outer with its own still on offer.
2026-09-21 09:04:35 +07:00
JosephFerano fe6744ae41 An evaluation that signals can be abandoned, and the program keeps running
A C-x C-e that blew up took the session with it. The expression's break offered
either nothing at all — a bad index establishes no restart, and the program's
own are below the thunk boundary where a transfer has nowhere to land — or a
list on which every entry was refused. That left abort, and abort is _exit(134)
over a mistyped index.

So the boundary offers a restart of its own. The agent pushes a real frame
around every evaluation, after the floor is read so that it lands above it;
taking it unwinds to the thunk, flan_reload_call drops the channel it holds,
and the poll returns to the game loop. It abandons and does not undo, which is
said in the agent's line, the daemon's note, the buffer's row and the manual.

The other half was a silence. The break buffer drew every restart as takeable
and ignored the :unreachable the wire already carried, so a digit on one went
out to be refused and nothing came back. Those rows now carry the reason and
are refused where they are read, and :abandon names the position that drops the
evaluation — a position, because a program may establish a restart of that name
itself.

Not the threading, which is what the report suspected. The thunk does run on
the game thread; a thunk on a thread of its own would have had the same empty
list and the same abort.
2026-09-21 09:00:26 +07:00
JosephFerano 216d1c0ab0 A program written to fault cannot be compared at -O2
The two new fault-by-design programs came into the corpus with the
bytes/bytes-view lane and the survey has been comparing them at its own
optimisation level, where the store into .rodata is undefined, LLVM
deletes it and exits 0, and this backend -- which has no optimiser to
delete anything with -- executes it and takes 139. That reads as a
lowering disagreement and is not one: at -O0 the two backends agree
exactly, and test_acceptance.ml's dies_segv rows already pin that on
both of them.

So a second exclusion list beside the one for the programs that never
stop, with its own reason written down, rather than building the whole
corpus at -O0 and changing the measurement every baseline was taken
against. dev-segv would belong on it in any case: it calls agent/start,
and under --dev it parks in the break loop rather than dying.

  MATCH 171, DIFFER 0, REFUSED 0, NOX86 0, SKIP 47
2026-09-21 09:00:25 +07:00
JosephFerano 190fdadcb8 Merge branch 'worktree-agent-a617552a2c34ddc3c' into dev-loop 2026-09-21 08:11:55 +07:00
JosephFerano 2d8d9cd5a8 dotimes counts from where you say, and can count down
"Is there a way to do dotimes or a loop in reverse?" — the answer was a
hand-written let plus set. Now it is (dotimes [i 9 -1 -1]).

Three arities: [i n], [i start stop], [i start stop step]. The stop is
exclusive in all of them, so [i 0 n] is [i n] — one rule, not two — and a
negative step counts down, testing with > instead of <.

A literal step of 0 is refused where it is written. One that is only a value
cannot be, so the condition asks the sign first and 0 falls out of it as a
loop that runs no times: terminating and deterministic, and free, because a
literal step still emits the single comparison it always did.

Each bound is evaluated once, left to right, before the counter exists: the
start into the counter, the stop into the hidden slot it always had, the
step into one of its own unless it is a literal.

Still a special form, still a Let and a While with the step in the latch, so
neither backend learned anything — the new program prints the same thing
under --x86 and at -O0. load.ml's Form-level walk had to learn more than one
bound for the same reason parse.ml did; it is part of this feature and not a
bug that was sitting there, because before this a three-bound dotimes was a
parse error long before that walk could reach it.
2026-09-21 08:09:53 +07:00
JosephFerano 52e9c92b0f The enum prefix goes uniform across all eleven
The author's ruling: "I think the prefix reads better, keep it" — so two
prefixed enums out of eleven was the inconsistency, not the prefix.
TraceLogLevel takes log-, CameraProjection projection-, CameraMode camera-,
GamepadButton button-, GamepadAxis axis-, Gesture gesture-, MouseCursor
cursor-, TextureFilter filter-, PixelFormat pixel-.

Two of those are judgement. CameraProjection and CameraMode share raylib's
CAMERA_ and deliberately do not share a Flan prefix: they are two questions
asked of the same struct, and :projection-perspective beside :camera-orbital
says which is being answered. GamepadButton and GamepadAxis take the short
stems rather than a shared gamepad-, which keeps :button-left-face-up and
:axis-left-trigger readable.

It is a reading choice and not a collision fix, and bindings, raylib.flan and
docs/BUILT.md all say so: a keyword resolves against the expected type and
nothing else, so :point at a TextureFilter site was never ambiguous. What the
prefix buys is the call site read on its own.

The three constant exception lines are keyed on the member's full Flan
spelling and moved with it. flan generate-c vendor/raylib is green against
raylib-5.5.h, and the check was confirmed non-vacuous by breaking it:
filter-trilinearr reported TEXTURE_FILTER_TRILINEARR rather than passing.
test_flan pins one member of each of the eleven to the C name the rule
reaches, read out of the real bindings file.

sand.flan line 121 is (rl/set-trace-log-level :warning) and is the author's
to respell. TraceLogLevel carries a warning alias beside log-warning, mapped
by name in bindings, so the suite stays green until he does; FIX.org has the
three-edit removal recipe.
2026-09-21 07:59:48 +07:00
JosephFerano 0a8dc1d82b sand.flan takes the def/defonce split
The colours were the report: edited, re-evaluated, unchanged, because
defvar had said init-once and meant it. They are a def now, and so is
gravity, which is a number to turn while the sand falls, and game-data,
which is a file to edit and see re-read.

The grid, the velocity field and the arena stay defonce: they are what
the running game is, and a re-run that emptied them would be a restart
wearing a re-run's name. current-color is the same argument about a
smaller thing -- a brush you picked is a brush you keep.
2026-09-21 07:34:43 +07:00
JosephFerano 9cfe7e7218 Merge branch 'worktree-agent-aff5ef76736f2ab5d' into dev-loop 2026-09-21 07:31:19 +07:00
JosephFerano 1cd4e0ec2e The defining form is fixed at build time, all three of them
The first cut of the form-change refusal asked only about def and defonce
and asserted in its comment that defconst was another arm's business. It
was not: defconst to def at the same type fell past every arm, and defonce
to defconst fell past them into the consts republish, which stores the
declared value over live storage at the frame boundary. One refusal over
gconst and grerun together now covers all six directions.

And two coverage gaps closed by running rather than reasoning: reload-v6
carries a (def dial i64 5) the host was never built with, so the x86 image
path executes and its 5 shows in the transcript's arithmetic; dev-rerun's
echo reads counter in its initialiser and follows it 40, 41, 42, 43 across
re-runs, where a captured first answer would print 40 four times.
2026-09-21 07:30:57 +07:00
JosephFerano a64bee6d96 Review follow-ups: a new def's image, the keyword that cannot change, and the sweep
Three defects, all from lifting every def initialiser, none of which the
suite caught:

A def typed fresh into a live session came up zero and stayed zero. The
image flan_dev_global copies on the allocation is the only value a new
global ever gets — the host's .init-globals never calls its initialiser —
and both backends chose that image with Tast.const_init, which a def's
lifted Call fails by construction. Emit.initial_image reads the constant
back out of the lifted body; the x86 twin had the same bug.

Changing a global between def and defonce was silently ineffective: the
guard lives in the startup function compiled into the host, which a reload
cannot republish. Session.compatible refuses both directions and says to
restart; editing the value stays allowed.

And global/<n> no longer leaks into the signature refusal when a def is
retyped — the global loop names the same fact in words a reader can act on.

flan check prints def, defonce or defconst off grerun; (defvar) with no
arguments names the shapes rather than offering (defonce ); the docs,
plan.org, runtime comments and valgrind.supp are swept; BUILT.md states
the release-build cost and the uninit caveat.
2026-09-21 07:19:33 +07:00
JosephFerano 801c70bd0d The LLVM cell declaration for a lifted def initialiser, pinned in the IR
Session hands global/<n> to the redefinition when a def is re-evaluated,
and that target is not a sibling — its fparent is the global — so its cell
declaration comes from Emit.redefinition's targets pass, a path no test
compiled: the dev-rerun leg runs on x86 merged, which reaches host cells
through the GOT and never needed the declaration. reload.flan carries a
(def paint i64 7) now and test_reload greps the module text for the cell
extern and the hidden body, which is the idiom the file already uses.
2026-09-21 07:12:04 +07:00
JosephFerano a4c6b996ff def re-runs its initialiser, and defvar is renamed defonce
The trio the author decided on 2026-09-20 is now all built: def is CL's
defparameter — its initialiser runs on every daemon re-run, unguarded, so
an edited initialiser repaints the same storage on C-c C-c plus re-run —
defonce (Clojure's name for CL's defvar, per the author) initialises once
behind the .init~once. flag, and defconst stays the image.

One parse arm reads both forms; the difference is Ast.reinit, carried to
Tast.global's grerun. Emit.startup_plan gives a def no guard flag, and
Check.check_global lifts every def initialiser — zero and literal
included — into global/<n>, so the host's startup reaches it through the
function cell and a re-evaluated def swaps it (Session's def_inits;
Emit.redefinition declares the cell for a non-sibling target). The old
defvar spelling is refused with the rename and both compiling spellings,
and every program, test, doc and editor list is swept — except sand.flan,
the author's live WIP, whose seven defvar lines are flagged in FIX.org
and keep its three dependent tests red on this branch.
2026-09-21 07:12:04 +07:00
JosephFerano 9bb01b77df Merge branch 'worktree-agent-a11e833fd0378f428' into dev-loop 2026-09-21 07:09:11 +07:00
JosephFerano 787eb5095f An expression that signals is holding the break loop, not missing a boundary
The wait recognised one kind of stop and waited five seconds for all the
others, then said the program was not reaching a frame boundary — on a reply
that named the condition two fields along.

Which stop is the thunk's is decided by the agent's stop generation rather
than by the condition's name, so a break entered on the same class as the one
it was evaluated inside is still told apart from it. A (pause) is answered as
a (pause) wherever it came from, the flag having never been what made one
deliberate.
2026-09-21 07:07:51 +07:00
JosephFerano 450728c9f2 Merge branch 'worktree-agent-ab376a9e12b4af136' into dev-loop
# Conflicts:
#	FIX.org
2026-09-21 07:05:41 +07:00
JosephFerano 785d8f93cd Merge branch 'worktree-agent-a85de0a4e4825195f' into dev-loop 2026-09-21 07:03:58 +07:00
JosephFerano 700e762b27 slice takes one, two or three arguments, and reaches a string
A fixed array does not decay to a slice at a call, so passing one to a
function over [$t] meant writing (slice a 0 (len a)) at every call site.
(slice a) is the whole of it now and (slice a n) is the tail from n, filled
in by check.ml into the three-argument form: same node, same static bound
checks, same runtime trap, and on a fixed array the implicit length is the
constant (len a) already folds to. Neither backend grew an arity case. A
target that is not already a name goes through a slot first, so (slice (f x))
calls f once.

at and slice also reach a string, because (bytes s) was the only route to a
byte and it is about to start copying. (at s i) is the byte, bounds-checked;
(slice s ...) at all three arities answers a string viewing the same bytes,
not a [u8], which would be a writable-looking view of storage the program
does not own.

Neither is a place, and the refusal lives in [indexed] rather than in
check_place, which is the part that matters. There are three routes to a
Pindex and they share no code: check_place, the single-index set arm that
checks its own target, and addr. Asked in check_place, the question is
answered for two of them and missed for the one a person writes — a store
into a string literal compiled, and the backends disagreed about it. So
[indexed] takes a ~place location and asks at every dimension, because
(at g 0 0) over a [[2 string]] reaches the string only at the last step.
One message, and addr gets it too, so it reads as value-versus-place rather
than as a rule about assignment.

Slicing an array a call returned is refused at every arity. The view
outlives the temporary, both backends print whatever the frame reused, and
nothing traps — which was already true of (slice (mk) 0 3) and only
survivable while nobody wrote it. (slice (mk)) is short enough to become a
habit. An array literal is not this case and stays legal.

Two backend cases. emit.ml's element_addr grew the String arm beside the
Slice one. x86.ml's index_len had answered None for a string — correct while
nothing could index one, and a skipped bounds check the moment something
could — and now reads the length word, so both check the same thing.
2026-09-20 23:36:40 +07:00
JosephFerano d1443808c6 Review follow-ups: the park had the original bug inside it
- SA_NODEFER. sigaction without it blocks the handler's own signal for the
  whole handler, and here the handler is the park — it never returns. A
  hardware SIGSEGV delivered while SIGSEGV is blocked is not handled: the
  kernel forces the default action. Fault, park, eval something at the
  break loop that faults, daemon gone, exactly the author's session one
  level in. Measured both ways; flan_crash_entered is cleared before the
  hook so each break-loop fault still gets its line, and the case is pinned
  (trap_park ~refault:true), confirmed to fail without the flag.
- Scope the handler to the thread it was armed on. A disposition is per
  process and a merged dev session is one process, so this was shadowing
  OCaml's SIGSEGV handler — and Stack_overflow — for the daemon's whole
  life. Other threads chain to what was installed before. Arming per run
  would leave the parked prompt's evaluations unprotected, since those are
  program code too; the comment says so. Also makes the per-thread
  sigaltstack honest.
- Sweep dyn-view.flan and string-eq.flan, which dev-loop added after the
  first sweep. string-eq:46 wanted the aliasing outright: its comment is
  about two slices sharing a base pointer.
- A StorageExhausted row for bytes, asserting the retry copies once and
  whole rather than re-evaluating its argument.
- Gate the flan_dev_crash_enable declare to dev builds, so this lane adds
  no dev-only text to a release module. flan_bytes_dup stays ungated: a
  release build really calls it.
- Guard the section for wasm32, which compiles this file and has no
  signals.
2026-09-20 23:35:03 +07:00
JosephFerano 2e203f64b8 bytes copies, bytes-view aliases, and a dev-session segfault parks
The INSERTIONSORT crash, all three rulings (FIX.org 2026-09-20):

- (bytes s) allocates a writable copy through the allocator surface —
  context or (bytes s a), StorageExhausted with retry, a registry note in
  dev builds (flan_bytes_dup, lowered like vec-new). (bytes-view s) is the
  old zero-cost reinterpret, renamed, read-only by convention; every
  in-repo reader swept over to it. (string b) unchanged.
- String constants were already read-only on both backends at -O0; now
  pinned — bytes-copy.flan rows on LLVM/-O0/--x86, and dies_segv rows
  asserting the write-through-view trap on both backends.
- A dev build installs a SIGSEGV/SIGBUS handler by the same dev-only
  constructor slot that arms the registry: one line naming the address and
  the innermost frame, then the trap-hook park — stopped, not dead, the
  daemon serving. No agent: message and re-raise. Release builds untouched.
  Pinned by trap_park over dev-segv.flan.
2026-09-20 23:12:42 +07:00
JosephFerano 4a2c04bcc7 Merge branch 'worktree-agent-ab94c69895cc206b3' into dev-loop 2026-09-20 22:59:22 +07:00
JosephFerano ac440d44b0 Say why the byte emitter takes no check_shared 2026-09-20 22:58:54 +07:00
JosephFerano 831cab9fb2 Review follow-ups: x86 parity, ArithError, and three refusal bugs
Five fixes off the independent review, plus the author's u8 ruling.

x86 parity: the bad-index block always ran x86 (it is flan dev's
default) and now says so with an explicit --x86; the condition render
gets an assertion under the x86 backend too, beside the LLVM one, and
a user error is pinned as carrying no site on both.

ArithError's layout is now pinned: {i32 op; i64 lhs, rhs} in C against
the prelude's defstruct, read field by field through the break loop's
render, driven from the editor through a divide under a restart-case.
That also covers condition and site on LLVM.

Three refusals that were wrong: trap_site tested the prefix "err"
and so ate any site whose path began with those letters; source_line
let Sys_error from input_line escape and take the whole break reply
with it, leaking the handle; and a condition with no fields was
reported as a name no struct has. The daemon now sends its own field
count and the buffer tells the two empties apart.

Nits taken: an over-long site is dropped rather than silently
truncated into a plausible one; the caret pads with the source line's
own tabs; the headline says when it has cut the field list;
flan-cnr-layout is live again as the single spelling of that request
rather than dead beside an inlined copy.

And the ruling: a u8 renders as 97 (\a) where a person is inspecting
and stays 97 where the program is printing.
2026-09-20 22:55:11 +07:00
JosephFerano abe7e8cd46 The snapshot consumes the trap site, and the reason prints once
A break nested inside a trap's break — a fix candidate evaluated at a
bounds stop raising its own error — copied the outer trap's site into
its snapshot, which is a caret pointing at an unrelated line under the
inner condition's name. snap_push now consumes the global: each
snapshot owns its copy, a nested entry that set no fresh site gets
none, and the outer break keeps its own. Pinned end to end.

The fields section says why the values are missing once, above the
rows, instead of repeating the sentence per field; a row keeps its own
reason only when it has one (no printer for its type).

strip_rebind and shown_names get direct coverage, including the clean
strip a real frame almost never reaches.
2026-09-20 22:39:46 +07:00
JosephFerano 7ebb97d327 FIX.org: the break-loop display pass, dated 2026-09-20 22:39:26 +07:00
JosephFerano 3a9dbea20a The break buffer says what happened, in the program's own numbers
A full pass on the break-loop display, off a dogfooding session that
met a BoundsError and read design notes instead of values.

The headline now carries the condition's own fields — BoundsError
reads as 'low 648, high 648, length 100' with nothing special-casing
it — over one short status line, and under it the trap's own site with
the source line and a caret at the column, Elm-style. Fields render
with values through the new condition op, falling back to the layout
with one sentence per missing value.

Every choice goes out by restart-at now, so a shadowed restart is
takeable rather than refused, and its line says only whose name it
repeats. The abort line says what abort does. The bracketed
implementation notes are gone from every string that could print, and
a fixture pins that as a property of the rendering.
2026-09-20 22:39:08 +07:00
JosephFerano 1cbe8ed386 The break loop keeps its condition, and the daemon renders it
The break loop used to discard the pointer it was handed, so the buffer
could name a BoundsError's fields and never show 648. Now the snapshot
stashes it, flan_agent_condition hands it back on the stopped thread,
and a daemon-built thunk — locals pointed at the condition — renders
each field. Delivered at-stop, so a resume-and-restop cannot get the
old type read over the new pointer.

The trap sites publish their loc around the hook call, the snapshot
copies it, and break answers :site with the line's text as :source —
the frame lines say where each call was; this is the only record of
the indexing itself.

Compiler temps are hidden from the locals listing rather than refused
as s4; a shadowing rebind strips its ~N except where the outer binding
is on the same list, where both keep their raw spelling.
2026-09-20 22:39:08 +07:00
JosephFerano aa989931dc Merge branch 'worktree-agent-a310fd64a7205d17f' into dev-loop
# Conflicts:
#	FIX.org
#	lib/check.ml
2026-09-20 22:17:58 +07:00
JosephFerano 2b91fd2146 print and println go variadic, with Clojure's spacing
Arguments print in order with a single space between each pair, println
ending the line; (println) is the newline alone and (print) is nothing.
The checker's arm renders each argument exactly as it did alone, so typed
and dyn values mix in one call, one-argument sites are byte-identical, and
an unprintable argument is still refused at its own span.
2026-09-20 22:09:06 +07:00
JosephFerano df89869962 Merge branch 'worktree-agent-a604237a7cbd45379' into dev-loop
# Conflicts:
#	FIX.org
#	lib/check.ml
2026-09-20 22:02:57 +07:00
JosephFerano 5b76237af2 Merge branch 'worktree-agent-aa39c3fbfc562d62a' into dev-loop
# Conflicts:
#	FIX.org
2026-09-20 22:01:15 +07:00
JosephFerano 612696d880 sand.flan speaks the prefixed enum keywords 2026-09-20 21:55:23 +07:00
JosephFerano 49ec1651ae raylib enum keywords carry their enum's prefix
Key members are :key-r, :key-space, :key-left-shift; MouseButton members
are :mouse-left through :mouse-back — mouse- over button- because gamepads
have buttons too. Bare members collided across enums and with user code.

The bindings enum directive grew an optional third column declaring the
Flan-side member prefix, stripped before the C prefix is applied, so key-r
checks against KEY_R rather than KEY_KEY_R; a member that does not carry
the declared prefix is reported, not checked under a guessed name. The
enum-member error grew a did-you-mean: one edit away, or the bare name of
a prefixed member, so :r suggests :key-r.

sand.flan is the author's live WIP and is deliberately not touched; its
three keywords (lines 161-166) leave test_session and sand-headless red
until he moves them. Everything else that calls the two enums moved.
2026-09-20 21:49:11 +07:00
JosephFerano cdda829835 The direction a container-fixed binding admits, pinned
A narrower scalar at a $t a slice already fixed widens into the fixed
type — the same cast a monomorphic parameter applies — where the old
rule refused both directions. One accepts pin, one runtime line in
int-generic.flan, and the web page's predicate table catches up: five
predicates, integer? at the head, and the entailment chain grown one
link.
2026-09-20 21:48:39 +07:00
JosephFerano f71cc40bb5 integer? bounds the integer-only bodies, and abs is one generic
The fifth predicate: integer? admits every integer kind and no float,
entails numeric? (and through it ordered? and equal?), and gates what
only integers support — the bitwise fold asks for it, the shifts admit
a bounded variable under it, and the float literal in an integer? body
is refused in the bound's own words. The literal arm needed nothing:
the entailment admits an integer constant under either bound.

abs-i32 and abs-i64 collapse into one integer?-bounded generic whose
i32/i64 copies even keep the old symbols; abs-f32/abs-f64 stay as the
float spellings because the right float abs is a sign-bit clear no
integer body spells, and (abs 1.5) now refuses naming the bound — the
where clause is checked before the name-collision check, which used to
answer that call with 'abs-f64 is already defined'.

Mixed widths at one $t join at the wider type now, in either argument
order — the author reversed the refuse-both rule on 2026-09-20. A
joinless pair is deferred and re-asked against the final binding, so a
later wider argument settles u32-vs-i32; u64-vs-i64 still refuses, and
a container-bound variable still binds exactly. The out-widened
arguments catch up through the ordinary Cast.

Two review follow-ups folded in: a struct field's unknown-lowercase
message stops suggesting a parameter vector it does not have, and the
tyvar-at-dyn message says defgeneric/defmethod in words instead of a
schematic that does not compile.
2026-09-20 21:42:50 +07:00
JosephFerano 86091541d4 Merge branch 'lane-array-fill' into dev-loop
# Conflicts:
#	FIX.org
2026-09-20 21:39:21 +07:00
JosephFerano c6523fd408 The package half of a qualified name is drawn as a type 2026-09-20 21:38:47 +07:00
JosephFerano 47b3ceb878 The inline generator was owed the want the form already knew
(array-gen [3 4] (fn [i j] ...)) — the canonical form — was refused:
check_fn saw no (Fn ...) want and no position to take types from. But the
form knows them: one i32 index per dimension is the rank's own promise.
check_array_gen now hands an inline fn its parameter types directly, with
the annotated element type as the return want where the annotation reaches
that deep, and the return left for the body to say where it does not — so
a bare inline fn infers its element type the way a fill value does, and a
body that disagrees with an annotated element is reported at the
generator's answer, per element. Named defn generators check as before.

check_fn grows a ?gen way in for exactly this: parameter types without a
Fn want, return optional. An inferred-return body sees Unit as ctx.ret, a
rough edge left rough on purpose.

Pins: inline at rank 1 and 2, inferred element, annotated defvar, the
per-element mismatch, inline arity. The acceptance program gains the
inline form, a struct-valued fill (the per-element store is a struct
copy), and evaluated-once (a counting fill value called one time for four
elements) — riding the three existing rows, no new ones. And the FIX.org
entry the pass never wrote: dims by the [n T] rule, one index per
dimension, the Zero+While/Set/Pindex lowering with no backend edits,
composition by nesting the forms, and this fix.
2026-09-20 21:34:14 +07:00
JosephFerano 97f34a0558 agent-auto waits for a connection the survey never makes 2026-09-20 21:33:01 +07:00
JosephFerano 70b999f8f4 Merge branch 'worktree-agent-a684a973597966330' into dev-loop
# Conflicts:
#	FIX.org
2026-09-20 21:16:48 +07:00
JosephFerano c20a4b90dc Merge branch 'lane-m5-generics' into dev-loop
# Conflicts:
#	FIX.org
2026-09-20 21:09:30 +07:00
JosephFerano 9fae298816 The manual and the record follow the buffer story
MANUAL.md: *flan* and *flan-repl* throughout, the REPL's output and
error-summary behaviour, the two clears on C-c C-o / C-c M-o, the
diagnostics buffer as the one list with the memory section below the
errors, and the settings table without flan-output-buffer. BUILT.md's
three mentions of *flan-output* updated to the routing that exists.
FIX.org records the decision, dated.
2026-09-20 21:07:40 +07:00
JosephFerano e83a50b5c3 Two streams and one list: the buffer story consolidated
*flan-output* is gone. The program's output lands in the daemon's buffer
always — renamed *flan-dev* to *flan* — and at the REPL when one is open,
inserted above the prompt, output first and the value after it. A
rejection puts its message in *flan-diagnostics*, which now pops up, and
leaves one line at the prompt pointing there; the diagnostics buffer got
a major mode of its own, read-only with n/p/RET, and the memory sites
from flan-check-memory render into it as one section below the errors,
replaced whole on every ask. Two clears at the REPL, on CIDER's keys:
C-c C-o for the last send's output, C-c M-o for the transcript.

No daemon changes: output already rides every reply's :output, so both
destinations are editor-side routing.
2026-09-20 21:05:50 +07:00
Joseph Ferano 56040ec6bd The account of generics catches up with the three rules it gained
The spike banner names plan.org's Types section and spec-memory.md's
Generics section as the current account. Neither said anything about a
literal at a type variable, about widening meeting a generic binding,
or about dyn, and all three are now observable from a program -- so
the account had a hole rather than an error. Filled, in
spec-memory.md, in the terms a programmer meets them in.

And one stale claim found and deliberately left: plan.org still lists
five predicates and describes copyable? and move-only-by-default at
length. spec-memory.md already records that copyable? went with the
second repeal and check.ml has four. That sentence belongs to the
ownership-repeal lane, so it is flagged in FIX.org rather than
rewritten here.
2026-09-20 21:04:37 +07:00
Joseph Ferano b64770feb7 A generic crosses a package boundary, and a trial leaves one copy
The two compositions the milestone owed, pinned, and the record of the
whole lane.

A package whose exports are generic: pkgs/gen, imported by
pkg-generic.flan at three shapes. One generic at two element types.
One that calls another in its own package at its own variable, so the
transitive copy is generated from a call site two files away. And a
generic written in the program calling one written in the package at
its own $t, which only resolves once Load has flattened both bodies
into one namespace -- the thing that has to change the day a package
becomes a real compilation unit, because a copy is made from a body
and a body that did not cross cannot be copied. Plus the call-site
half of a bound written in another file, quoted here rather than
pointed at in a file the caller cannot change.

And the composition with the widening trial. A binary operator
re-checks its right operand at its left one's type inside a trial, so
a generic call written there is checked twice and once thrown away.
The discarded pass's instantiation does not go back out: instantiate
rewinds a copy whose *body* refused, which is a different event. It
does not have to, and the reason is this lane's own rule rather than
luck -- a generic call's instantiation is read off its arguments and
never off the ambient want, so both passes ask for the same types and
the second ask is a cache hit. Pinned by counting the copies in the
checked program.

The widening lane's note said that cache already rewinds itself. It
does not. Corrected in the comment and in FIX.org, in place.
2026-09-20 21:00:22 +07:00
Joseph Ferano d5fed12d48 Three messages about milestone 5, from a milestone that arrived
The refusals generics obsoleted, swept. Every message that sent
somebody to a schedule now says what is actually true of the thing in
front of them.

An unknown lowercase type name used to be reported as unimplemented
generic code over a type variable. Generics are implemented, and
resolve_name consults env.tyvars and env.subst long before anything
reaches that arm -- so a lowercase name arriving there is a typo too
far from any type to guess at, or a type variable nobody introduced.
The sentence names the sigil that would introduce it.

A capitalised name given type arguments is the other half, and it is
still genuinely unbuilt: Types.Named is a bare string with no room for
parameters, and giving it some is a change to Types.t and therefore to
the layout calculator, both backends, Render and DWARF. Both sites
that reported it -- the type resolver and the value-position fork --
now say a generic *type* is not there yet and point at the generic
function that is.

Plus the prelude's side of it. pos?, neg? and zero? are three
questions about a number's sign, one body each, answering at every
numeric type -- the family the whole feature was asked for, and the
one thing the landed generics could not write until a literal was
allowed to stand at a bounded type variable.

Two collapses examined and declined, with the real reason written
where the old one was. abs stays per width because numeric? is the
only bound that admits a written 0 and it admits floats too, and the
integer body is the wrong abs for a float: it hands back a negative
zero. It waits on an integer? predicate, which is language surface.
min and max stay builtins because they are variadic and slot each
operand so it is evaluated once; a binary prelude generic would put
the double evaluation back at the call site. Their generic half was
never missing -- ordered? already admits them in any body that
declares it.
2026-09-20 20:46:35 +07:00
Joseph Ferano c372a98238 A refusal about a dyn stopped being reported against a prelude line
Nothing stopped a type variable being instantiated at dyn, because dyn
is an ordinary case of Types.t and substituted like any other type. The
copy was then made and walked into the dyn answers that are not all
there, and the refusal arrived from inside the generic's own source:
(or-else (Some d) e) over two dyns was reported against <prelude>:385,
a line the caller did not write and cannot act on.

Refused at the binding instead, where the call site is. The message
does not only say no: two models answer "one body, many types" here
and they are not rivals -- this one copies per written type at compile
time, defgeneric/defmethod dispatch at run time on a value that
carries its own -- so a dyn argument is asking the second question of
the first machinery, and the sentence names the other spelling.

Only the unbounded half is new. A variable carrying a {:where} clause
was already refused, because pred_holds says no to dyn for all four
predicates, and that refusal is left in front of this one on purpose:
it names the predicate the signature wrote down, which is the more
specific of the two answers.

Whether dyn should eventually flow through a generic is the author's
call and is recorded as open. Refusing now is the direction that can
be walked back: allowing it later adds programs, and nothing written
under this rule stops compiling.
2026-09-20 20:39:16 +07:00
JosephFerano 6ef0f46bea The editor batch: results at line end, inspect at point, a buffer that keeps 2026-09-20 20:30:38 +07:00
Joseph Ferano c91fd51ad6 Which copy a generic call gets stopped depending on argument order
Implicit widening landed after generics did, and the rule the two of
them left between them read off the order the arguments were written
in. (eq2? i8 i64) was refused, because $t bound to i8 and i64 into i8
can lose. (eq2? i64 i8) was accepted, because $t had already bound to
i64 and the i8 widened into the want that substitution had made
concrete. Same two values, same function, one copy at i8 refused and
one copy at i64 generated.

Neither answer was unsound -- a widen cannot change a number -- so
this is not a bug report, it is a decision that was never taken.
Taking it: implicit widening does not cross a generic binding. A
concrete argument at a variable an earlier argument already bound has
to be that type, and both orders now refuse with the same sentence,
naming the binding, the argument, and the cast to write.

Refusing is the direction that can be walked back. Letting the pair
join at the wider type is a coherent rule too, and it can be added
later without invalidating a program written under this one; the
reverse is not true.

The rule costs almost nothing because Types.widens_to admits only
numeric scalars. A variable bound inside [$t] or (Fn [$t $t] bool)
leaves a parameter no widening ever applied to, so sort-by and the
whole fn-literal path are untouched by construction. Two exceptions
keep the ergonomics: an untyped literal has no type of its own to
keep, so it still takes the variable's; and a form with no type
without a want -- (zeroed) -- is asked for its natural type through a
trial, and falls back to the want it always had when the trial
refuses.
2026-09-20 20:30:36 +07:00
JosephFerano 8d49d0dddc builtin/ always reaches the compiler's own name
# Conflicts:
#	FIX.org
2026-09-20 20:27:35 +07:00
Joseph Ferano 879a439951 A written zero stands where a numeric type variable stands
pos? over every numeric type from one definition was the motivating
example for milestone 5 and was the one thing the landed generics could
not write: (> x 0) refused with "expected t, found the integer literal
0", because int_literal had no arm for a want that is a type variable.

It has one now, and the bound is what makes it sound rather than
optimistic. Every type numeric? admits is an integer or a float, and an
untyped integer constant is usable at all of them, so there is no
instantiation of a numeric? variable at which the literal has no
meaning. Under a weaker bound there is -- ordered? admits an enum -- so
numeric? is what is asked for and the refusal names it.

The float literal is refused at a type variable even under numeric?,
and that asymmetry is the concrete arms' own: an integer constant is
usable where a float is wanted and a float literal is never usable
where an integer is wanted, so a body written with 0.5 has no meaning
at the integer half of its own bound. Refusing at the definition is
what the abstract pass is for; the alternative is a surprise at
whichever call site first asks for i32.

The node the abstract pass builds is never emitted. Each copy
re-checks the same form with the variable substituted, and that is
where the literal is built at the concrete width and range-checked --
so (+ x 300) is fine at i32 and a refusal at u8, and u8 is where it is
refused.
2026-09-20 20:21:41 +07:00
JosephFerano 40d62e7643 builtin/name, the spelling a shadow cannot take away
A defn named after a builtin wins for its whole file, and until now that
was the end of it: the builtin had no remaining spelling, so a defn that
meant to wrap one was unbounded recursion. builtin/len is the builtin len
wherever it is written, shadowed or not.

The qualifier is the package one's, and builtin is reserved rather than
resolved: Load refuses it as an import alias, Check refuses it as a
declaration's name, and those two doors are the only ways a qualifier can
be made. named_call and var each strip the prefix and re-enter with a flag
that the shadowing guard consults, so every arm below sees the bare name
and refuses in the builtin's own words.

The shadow warning now names the escape in its second half.
2026-09-20 20:17:17 +07:00
JosephFerano 07981cfbff agent/start finds its own socket, and starts itself under the daemon
# Conflicts:
#	FIX.org
2026-09-20 20:11:12 +07:00
JosephFerano 38a570bc7a A bind that fails gives the flag back, and three sentences get pinned
The bug review found: [start_on] claimed [started] at the top and every
failure exit left it claimed. Under [flan dev] the constructor is the first
caller and reports to nobody, so a path nothing could bind disarmed the
program's own (agent/start ...) as well — it answered 0 with no socket, no
listener and no hooks, where before this lane the explicit form answered -1.
Success reported for nothing at all is worse than the error it replaced.

So every way out that is not a listening socket unwinds: the fd is closed, a
file the bind managed to make is unlinked, and [started] goes back to 0 so a
later start is a real attempt. Pinned by running the zero-argument fixture
with FLAN_AGENT_SOCKET pointing nowhere — constructor fails silently, main's
own call then fails loudly, "cannot listen" and exit 1.

Two arguments to (agent/start) are refused, which nothing held: the macro's
[& args] cannot say "one at most", so what says it is the expansion splicing
every argument into a function that declares one. The message names
agent/start-at and carries the expanded-from note, and that is what the
acceptance row asserts.

And the reply a delivery gets when there is no agent in the process, which
nothing held either. dev-noagent.flan parks, so it was never this case;
dev-noagent-running.flan keeps running, and the answer is a refusal naming the
socket that could not be reached — not install_note's "queued", which would
promise a poll with nothing to drain. Which leaves that note unreachable in
all three shapes rather than merely unpinned, worked through in FIX.org.
FIX.org also now says what an exported FLAN_AGENT_SOCKET would do: start_on
unlinks before it binds, so an agent-linked program started in that
environment takes the path away from whoever bound it first.
2026-09-20 20:10:39 +07:00
JosephFerano d737625a3f A number widens where no value can change, and a trial leaves no trace 2026-09-20 20:08:09 +07:00
JosephFerano f1721e650c The lane caught up by merge, because every commit of it touches the notes 2026-09-20 20:06:26 +07:00
JosephFerano f8dfdaa9a0 Merge branch 'dev-loop' into lane-implicit-widening
# Conflicts:
#	FIX.org
2026-09-20 20:01:54 +07:00
JosephFerano 765562f23e The racy agent row has a cause: a busy machine, not a wrong compiler 2026-09-20 19:58:49 +07:00
JosephFerano 66f925c36f A redefined defclass migrates its live instances on their next touch
# Conflicts:
#	FIX.org
2026-09-20 19:58:42 +07:00
JosephFerano 5b53043fd5 A defn outshadows a builtin and says so once 2026-09-20 19:56:42 +07:00
JosephFerano d9dd1435a6 What was run, and the one thing a green build does not mean
The lane's record in FIX.org: dune test green before and after the rebase,
test_dev.exe run directly because a cached run swallows its label, and
@sanitize clean on the committed source, which is where the
two-thousand-instance migration under collection is actually looked at.

The line worth keeping is about the rebase. dune build does not compile
flan_dyn.c — it is a string the compiler carries and hands to clang at
flan run — so a green build is no evidence about that file. A trap1 call
merged clean into a tree where trap1 had grown a leading location pair,
and nothing said so until a program was compiled.

Also corrects this entry's own description of the session pins, which
still described the needles as they were before they were made to
discriminate.
2026-09-20 19:56:24 +07:00
JosephFerano a983a46ea5 The review's follow-ups: a file decides the shadow, and a set answers the membership
The leak review found: an importer's (defn len ...) reached inside an
imported package's (defvar sz i32 (len "abcd")) and made it 999. A global
initialiser is checked with no enclosing function, so the qualified name the
first cut asked about was not there to ask. The file the definition was
written in is what the shadow follows now, which is what FIX.org had already
named as the fix if it ever mattered. It mattered.

builtin_set beside builtin_names: the guard is the first arm of the dispatch
and ran a linear walk of eighty-odd strings at every named call. The list
stays for the did-you-mean, whose order is its order.

Pinned: a shadowed operator warns and lowers to a Call, and a call carrying
another file's name reaches the builtin. The corpus program grew both cases
and the package grew the initialiser that demonstrated the leak.

And the int/float section's sentence about "the arity precedent, where the
builtin wins" now says that the precedent was deleted the same day, since
this lane is what deleted it.
2026-09-20 19:56:17 +07:00
JosephFerano 5cfefa586b The rebase's four repairs, and two pins that were not pinning anything
Rebased onto dev-loop. Three conflicts were additive and both sides are
kept: FIX.org's two appended sections, want_map's diagnostics argument
against the class_sync inserted beside it, and test_dev.ml's agent-socket
block against this lane's migration block, whose comment no longer says
"the block above" now that something sits between.

The fourth is the one the auto-merge hid. flan_dyn_class_def's argument
check was written against the pre-diagnostics trap1 and merged clean into
a tree where trap1 takes a location first, so the class name would have
been read as a length. dune build does not compile flan_dyn.c, so the
green build said nothing; caught by compiling a program.

Two pins in test_session.ml asserted "flan_dyn_class_def" against the IR
text, which every module contains because emit.ml declares every runtime
entry point in all of them. Both now assert the call and the packed slot
list. Checked by mutation: with the thunk suppressed the old needles pass
and the new ones fail, along with the daemon's slot count.

Also disclosed: say_render is the second raw reader beside render, and
neither syncs, so a stale instance shows its old slots in the inspector
until something touches it. That is the editor-facing consequence of
keeping the printers printers, and it is now in the runtime comment and in
FIX.org rather than left to be met. And the stale-caller walk says in as
many words that it is a tripwire, unreachable on purpose, not a filter to
be tidied away.
2026-09-20 19:54:53 +07:00
JosephFerano 77d6e43b09 The agent binds before main, so a dev program need not start it at all
A constructor in the agent package binds FLAN_AGENT_SOCKET when it is set,
which is the daemon and nothing else — both shapes set it, before the fork in
--two-process and before the exec in the merged build. So a program under
[flan dev] that calls (agent/poll) and has no (agent/start) in it takes
redefinitions anyway, and one that does call start meets an agent that is
already listening and gets a no-op.

The window this closes was the complaint in DISCUSS.org: a program that opens
a window before starting its agent leaves the daemon waiting on a socket that
does not exist yet. Bound here, the socket exists before main whatever the
program does afterwards — so test_dev.ml's late-agent row asserts the negation
of what it used to. The delivery sent during the sleep no longer carries "the
program has not called (agent/start ...) yet", because that is no longer true
of it; what is still late, and still asserted, is the poll that installs it.

It reaches exactly as far as the linker does. Reach prunes a package nothing
calls into, so a program that mentions the agent nowhere does not link this
file and has no constructor to run: auto-start is for a program that polls and
has dropped its start call, not for one that says nothing about the agent at
all. That limit and the release-build residual are in FIX.org, along with the
daemon branch that can no longer be reached.

agent-nostart.flan is the pin, and its two numbers are the honest ones: 1
before anything could arrive, 1000 after the wait, because a listener bound
before main is still not an install.
2026-09-20 19:52:34 +07:00
JosephFerano 0d34831199 A trial puts the whole context back, and the compiler now insists on it 2026-09-20 19:51:51 +07:00
JosephFerano 63fb5c629c The other backend runs the class thunk too, and now something says so
The migration transcript runs on x86, because that is what flan dev takes
unasked. What is backend-specific about any of this is one thing — whether
the registration thunk reaches the runtime at all — and the evidence for
LLVM was that the IR contained the call, which is emission and not
execution. x86.ml's own header claimed for some time that it did not emit
flan_reload_call, which is exactly the kind of sentence not to trust twice.

So: the same program under flan dev --llvm, one instance, one slot added,
and the four answers that say the migration happened. Short on purpose —
everything past the thunk is flan_dyn.c's, and flan_dyn.c does not know
who called it.
2026-09-20 19:46:57 +07:00
JosephFerano 7f92136401 Old instances of a redefined class now follow the class
CLHS 4.3.6's update protocol, minus the user hook, on the dyn side's
defclass. Redefining a class used to be silent: a class is sugar for a
constructor defn, so the edit replaced a body and the instances already in
the program kept their old keys for ever.

Three pieces. A registry in flan_dyn.c holding each class's current slot
list and a generation, made only of interned kw_entry pointers so the
collector has nothing to trace in it and no root to push for it. A uint32
generation on the instance, fitted into the padding kind and mark leave in
front of len's alignment — sizeof(flan_obj) is 48 with it and was 48
without, and flan_dyn_obj_size is there so a later field that moves it
fails a test. And a registration thunk per reload, run by the agent
through flan_reload_call after the module's bodies are published: it has
to be a thunk, because the case this exists for is a class redefined and
not constructed.

Migration is lazy, at want_map, len's map arm and dyn_equal's. Slots kept
by name, gained slots nil, dropped slots gone, identity preserved, entries
rebuilt in the class's order so a migrated instance is indistinguishable
from a fresh one. Equality migrates both operands first, so it is over the
class as it is now.

The session had to stop refusing the constructor's signature change, and
does so only for a defclass and only when no compiled caller is left
behind. The checker gets there first in practice; the walk in eval holds
the reason locally rather than inheriting it.

The registry is advisory: a class instance is an open map, so a key a raw
put wrote that the class never declared is dropped by the next migration.
FIX.org says that plainly rather than pretending enforcement.
2026-09-20 19:46:57 +07:00
JosephFerano e03028819c A defn named after a builtin now wins, and says so once
The author's rule: "allow shadowing but warn". A user (defn get ...) is
legal, the user's definition wins at every call site in the file that wrote
it, and the compiler warns once at the definition.

Builtin-wins was never a rule anybody wrote: named_call is one match on the
name, the builtin arms are string literals, and the three arms that look a
name up are the last three in it. So a guard goes first, the trailing three
are factored into ordinary_call, and both routes into it resolve a name the
same way.

The shadow stops at the file that declared it. An imported package's names
were qualified at the import, so a get written inside one is the builtin's
and stays the builtin's; the prelude is excluded by its file for the same
reason. programs/shadow-builtin.flan is both halves at once.

The warning prints from build_program, which is what every command and the
dev daemon's reload go through, in the shape --warn-memory established:
file:line:col, the squiggle, and an exit status that does not move.

And the message that described the old world is gone — the builtin-arity
note said a defn does not replace a builtin, which is no longer true and is
no longer reachable.
2026-09-20 19:46:43 +07:00
JosephFerano e9c0e96a9b (agent/start) takes no argument, and takes the socket away after itself
The path was ceremony. Under [flan dev] the daemon already decides where it
wants to talk to the program and writes it into FLAN_AGENT_SOCKET, which the
C side has always honoured over whatever the source named — so the argument
was a value nothing read. Outside the daemon any path will do as long as the
program says which one it picked.

So [start] becomes a macro over two functions: no argument picks the
daemon's socket if there is one and otherwise /tmp/flan-agent-<pid>-<clock>.sock,
announced on stderr because a socket nobody can name is a socket nobody can
connect to. The explicit form stays for a program that wants a fixed path.
Extra arguments are spliced into [start-at] rather than dropped, so the arity
refusal is still the checker's, at the call site.

And the socket is removed on the way out. The bind stashes the path it bound
and registers an atexit; the two paths that leave by _exit — the break loop's
[abort] and the orphan handler — unlink it by hand, as the orphan handler
already did for its own copy of the path. Nothing else takes it away: under
the daemon it sits in a temp directory that is still never removed (FIX.org),
and outside there is no daemon at all.

test/programs/dev-loop.flan now names no socket, which puts the whole daemon
block in test_dev.ml behind the zero-argument form; agent-auto.flan is the
standalone half, reached only through the line the program printed, and it
pins the second (agent/start) as a no-op and the socket as gone at exit.
2026-09-20 19:44:34 +07:00
JosephFerano 114ea391aa One more dev-daemon row that raced, seen once in five 2026-09-20 19:38:37 +07:00
JosephFerano 8ea1ff4481 The build artefact, out of the tree for good this time 2026-09-20 19:36:06 +07:00
JosephFerano eb0d883e84 The suite is green on its own merits now, and the sweep says so against the new tip 2026-09-20 19:33:32 +07:00
JosephFerano b4dbbb67ab The review's three findings, and the lanes this one landed on top of 2026-09-20 19:30:04 +07:00
JosephFerano 657f640ec7 A reconsidered operand must leave nothing behind, and a literal is never reconsidered 2026-09-20 19:30:04 +07:00
JosephFerano 3efa261539 A build artefact does not belong in the tree 2026-09-20 19:30:04 +07:00
JosephFerano 13b391b7bf The u64 pin is spelled the way the program that motivated it is 2026-09-20 19:30:04 +07:00
JosephFerano b34a7bbf11 The raylib half of the sweep, and the let the note named 2026-09-20 19:30:04 +07:00
JosephFerano 3b91afd47b What the widening lane changed, kept, and measured 2026-09-20 19:30:04 +07:00
JosephFerano f9ae500949 The lattice pinned at its edges, and the two calls that could go the other way 2026-09-20 19:30:04 +07:00
JosephFerano 3e4267f57c Every no-implicit-widening comment now says what is true instead 2026-09-20 19:30:04 +07:00
JosephFerano d0e33331b5 An expectation outranks the join, because an expectation is information 2026-09-20 19:30:04 +07:00
JosephFerano 0c50f34916 Widening happens at expect, and the wider operand decides a binary op 2026-09-20 19:30:04 +07:00
JosephFerano 080d294bc9 The lattice of conversions that cannot change the number 2026-09-20 19:30:04 +07:00
JosephFerano b87ae11fa8 The editor protocol never waits for the agent, and a parked delivery installs first 2026-09-20 19:27:12 +07:00
JosephFerano bbacbcb666 dead-beef's byte arm learns arity's new ctx argument 2026-09-20 19:27:06 +07:00
JosephFerano 0a4c52d5ee filled and dead-beef, the two byte fills
# Conflicts:
#	DISCUSS.org
#	FIX.org
#	test/test_acceptance.ml
2026-09-20 19:25:08 +07:00
JosephFerano 0cc229dc6c trap_park stops dying on the abort race, and Closed means what it means
# Conflicts:
#	FIX.org
2026-09-20 19:24:47 +07:00
JosephFerano f6416858bb defmacro takes a real parameter list, and [args] means the first argument
# Conflicts:
#	FIX.org
#	lib/prelude.ml
#	vendor/raylib/modes.flan
2026-09-20 19:23:01 +07:00
JosephFerano d225aa4172 int and float are the two spellings i32 and f32 answer to 2026-09-20 19:19:42 +07:00
JosephFerano 26242388b9 int and float are the machine types, not a second name for them
The author's exception to the foreign-spelling list: int is i32 and float
is f32, and nothing else on that list moves.

Spelled in Types.ikind_of_name and Types.fkind_of_name rather than as two
prelude defaliases, because Check.is_cast asks those two functions and never
the alias table — a prelude alias would have left (int x) with no reading
while (i32 x) had one. Both names join primitive_names for the same reason
one layer down: that list is what decides (vec-new int) and the three-element
(defvar x int).

Nothing reverses: ikind_name still says i32, so every message, signature,
inspector line and DWARF name shows the machine type whichever spelling was
written.

A defalias restating the builtin is the no-op it says it is; one pointing the
name anywhere else is refused, since the alias table is never consulted and
the declaration would otherwise mean i32 in silence.
2026-09-20 19:09:26 +07:00
JosephFerano 2afa554cce The flake note said noise; the full-suite numbers say otherwise
Isolated test_dev is 4-in-6 here against 2-in-6 at the base, which is noise.
The full suite is 5-in-5 here against 0-in-5 at the base, which is not — and
with this lane's three acceptance rows disabled it drops to 1-in-3. The rows
add compile jobs to the pool test_dev runs alongside, and a busier machine
loses the trap_park poll race more often.

Still not a new defect, and none of this lane's compiler code is implicated.
But the earlier note's suggested fix is now worth doing rather than noting,
and saying 'noise' would have sent the next reader the wrong way.
2026-09-20 19:02:53 +07:00
JosephFerano 2240100ee9 Review follow-ups: the default's payload, four wrong reasons, a doubled prefix
1. The bare (dead-beef) built its default with Int64.of_int32, which
   sign-extends 0xDEADBEEF to -559038737 on a node tagged u32 — where the
   spelled-out literal arrives as 3735928559, because in_range admits it as
   the unsigned value it is. Masked to 32 bits, so the two spellings really
   do carry one payload; verified by diffing the emitted bodies of (dead-beef)
   and (dead-beef 0xDEADBEEF), which are now identical instruction for
   instruction.

2. The refusal's catch-all told a union and a function value that they
   'carry a tag that names a case'. Neither does: env.unions is the untagged
   unions, and an Fn is a code address. Split into one arm per reason —
   union, Fn, enum, Option, data type — and each is now pinned, so they
   cannot quietly re-merge. Same correction in FIX.org's bullet.

3. js.ml prefixed its own message with 'js: ', which bin/main.ml prepends
   too, giving 'js: js: ...'. Dropped, and the message now names the builtin
   it refuses, which its comment already claimed it did.

4. FIX.org said x86.ml reads both pattern helpers out of Emit. It reads only
   word_of_pattern; the tail walks rax with shr.
2026-09-20 18:58:32 +07:00
JosephFerano 5ea6884d2c The diagnostics pass: every message shows, explains, and names the fix
# Conflicts:
#	FIX.org
2026-09-20 18:49:12 +07:00
JosephFerano 3c39354833 Measure the Wire.Closed flake rather than assume it
It exits 1 with no FAIL line, which is the shape an earlier lane wrote up.
Two-of-two early failures looked like they might be this lane's, so: 4 in 6
here against 2 in 6 on a detached worktree at this branch's own base commit,
running test_dev alone. Noise at that sample size, same exception, same
mechanism.

Adds one detail to the earlier note, which had only ever seen the flake on
dev-trap-null-alloc: one of my six landed on dev-trap-free-all instead, so
what is racy is trap_park and every row that calls it.
2026-09-20 18:46:58 +07:00
JosephFerano 78d9a0f051 The review's fixes: a suggestion that does not compile, and a confident wrong guess
F1 was the blocker and it was the worst kind of fault this pass can have: the
condition message told the reader to write (not= x 0), and not= does not
exist — the operator is !=. Applying the compiler's own advice got 'unknown
function not= — did you mean not?'. Both branches say != now, and all three
— the named form, the float zero, and the unnamed one — were checked by
compiling the sentence the compiler prints.

F5: a typo of a declared capitalised name got the generics lecture. (Piont 1
2) with Point declared was told that a capitalised name given type arguments
is milestone 5 work, which is a confident answer about a feature nobody was
reaching for. The did-you-mean runs first and, for a capitalised head only,
asks the type tables as well; the generics sentence is left for a head that
resembles nothing.

F2: flan_dyn_cast_kind had the site live and passed NULL on the trapping
path — the one entry point on this side that had a location and threw it
away. The acceptance row now pins the prefix it prints.

F3: the case-typo row used (data ...), which is not a top-level form, so it
refused as an unknown top-level form and the needle 'unknown' matched that
rather than the rule. Rewritten with defdata, and as a pair: a capitalised
head gets no accessor advice, a lowercase one does. Both halves were checked
to fail when perturbed.

F4: an end-to-end pin for the headline. programs/dyn-trap-site.flan is
compiled, run, and its stderr read for the file:line:col in front of the
sentence, on both backends and at -O0. Proven live: three failures when the
expected line is wrong.

F8: usize and size_t stay off the foreign-spelling list, and the comment now
says why — the honest answer is pointer-width, which is u64 here and u32 on
wasm32, and a tree that builds both cannot name one of them.

F10 pins the fourth dot shape. F6 moves the not-reached reasons out of the
commit bodies and into FIX.org, where they can be read without git.
2026-09-20 18:46:48 +07:00
JosephFerano d4def945a9 An array is a value you can write, not a place you have to fill first
DISCUSS.org's "need a value-producing array constructor": the author
wanted grid filled with 255 as part of its declaration and could not
write it. (array n T) produces the zeroed array only, and dotimes is
Unit, so it can mutate a place that already exists but cannot be the
initialiser expression -- which has to produce the whole value in one
go. The grid was declared zeroed and filled in main instead.

Two forms, both expressions, both any rank:

  (array-fill [rows cols] 255)   every element that value
  (array-gen  [rows cols] cell)  every element (cell i j)

Spelled apart rather than one form dispatching on the third element's
type, because an array *of* function values is a thing to want and one
form would have to decide whether (array-fill [4] f) meant four copies
of f or four calls of it.

The dimensions are read in Parse, and that is the whole reason they are
recognised there: handed through as an ordinary call, [rows cols] is an
array literal of two names, and where those names are defconsts it is a
perfectly good two-element array of integers -- the wrong reading, and a
silent one. Read in Parse they are the same len the [n T] type spelling
takes, resolved by the same array_len, with one extra condition of their
own: the fill counts in i32 like every index in the language, so a
dimension no i32 can reach has no loop that could end.

The lowering is a loop over a slot, not an aggregate. Tast.Arr is the
node the backends have and both build it element by element from a list
as long as the array; a fill of [600 [800 u8]] is half a million
elements and there is no list to be had. So these bind the array to a
slot, zero it, run one While per dimension writing through Set of a
Pindex, and answer with the slot -- While, Set and Pindex, which is the
argument check_loop already makes for recur. Nothing new reaches a
backend and all three get the form with no edit. The value stays
value-like: the slot is the form's own, and the Local at the end copies
out the way any array-typed expression does.

Row-major is pinned, not incidental: the first dimension is the
outermost loop, and a generator that counts observes it. The fill value
and the generator value are each bound once before any loop starts, so
(array-fill [n] (next-id)) is one call and n copies of its answer.

What falls out for the defvar the note was written about, and neither
half is a carve-out:

  (defvar grid [rows [cols u8]] (array-fill [rows cols] 255))

is the spelling that works -- a typed global with a computed
initialiser, which is the startup-lifted path with the init-once guard
that defvar already had, so the fill runs once and the value survives a
re-run like any other computed one. The three-element spelling means
what the 2026-09-20 rule says it means: not a type, so a dyn global, and
a typed fixed array crosses into dyn only as a view of storage that
outlives the view. A freshly built array is a temporary, so it is
refused -- by the element rule where the elements are themselves an
array, by the lifetime rule where they are one of the three scalars a
view carries. Both refusals are the ones any other temporary gets.

The type an array-fill builds never goes through resolve, so resolve's
own guard is asked again where it is built: a fixed array of function
values would be zeroed, and a zeroed function value is a null pointer.
2026-09-20 18:34:52 +07:00
JosephFerano e807986622 The dogfood batch: empty forms, comment, inc and dec, guards, limits, shorthand
# Conflicts:
#	FIX.org
2026-09-20 18:34:13 +07:00
JosephFerano 7bd2c99353 sentinel-filled is now dead-beef, and takes the pattern
The author's revision. The name says what it writes, and the pattern is the
program's to choose: (dead-beef) is DEADBEEF, (dead-beef 0xBAADF00D) is
BA AD F0 0D. One byte-order rule covers both — a pattern's ascending bytes
are its big-endian bytes, which is how the hex literal reads left to right —
so every candidate DISCUSS.org listed is now spellable without the compiler
naming any of them.

The bare form is not a case a backend knows about: the checker writes
Tast.dead_beef_default in where the argument would have been, so
(dead-beef) and (dead-beef 0xDEADBEEF) are the same node and an acceptance
row prints both to say so.

The operand is an ordinary u32 expression, which is what the byte arm
already accepts for its byte. A literal is byte-reversed at compile time and
still reaches the loop as an immediate; a computed one is reversed at run
time, by llvm.bswap.i32 on one backend and bswap on the other, after which
the tail shifts its bytes out of the word rather than folding them. The
program runs a computed pattern over lengths 6 and 7 deliberately: that is
the case a constant-only implementation would pass by accident.

filled is untouched, and so is the fill boundary.
2026-09-20 18:33:05 +07:00
JosephFerano 097161fd41 The park left for a re-run without draining its ring
[flan_merged_park] drained the agent's ring on one of the two flags that
wake it. [program_poll] — which an expression sets, by way of [Program.wake]
— polled and went back to sleep; [program_asked] broke out of the loop and
re-entered [flan_program_main] with the queue untouched. A plain
redefinition sets neither, so a body delivered to a parked program was still
in the ring when the run it was delivered for started, and installed at that
run's first frame boundary instead: everything main did before its first
(agent/poll) ran the body the person had already replaced, and the change
showed up one run late. A redefined main is the whole of a run, so it would
have had to be asked for twice.

Both flags drain now, and the exit drains before it leaves. The re-run is
still tested first and cannot be starved: the flag is latched at the top of
the round and nothing in the round can clear it.

The transcript row in test_dev.ml asserted the old ordering by name — two
lines out of the second run, the first of them the stale body — so it is a
line shorter now, and the absence of that line is the claim. The park-note
fixture grew a print of the redefinable body before its first poll, which is
what makes the new row able to see which body the re-run started with.

This is what the note the delivery is answered with has been promising: a
module queued against a park installs no later than the program's next run.
It now installs before that run's first frame rather than during it.
2026-09-20 18:32:01 +07:00
JosephFerano 9fd1eb113f No sentence for a & that cannot arrive 2026-09-20 18:27:02 +07:00
JosephFerano 92bf091361 A struct writes itself where its type is already known, and positionally 2026-09-20 18:23:42 +07:00
JosephFerano 865c7e0445 C-c C-c on a defclass went to the expression evaluator and was refused
flan--declaration-heads was topped up a name at a time, so it had lost
defdata and all four of the object and dispatch heads. A head missing
from it is not a quiet fallback: the form goes to the expression
evaluator, and the daemon answers "defclass is a top-level declaration,
not an expression" at the line you pressed the key on. Confirmed both
ways against a real daemon on test/programs/dev-class.flan -- the old
list gets that refusal, the new one installs, and defgeneric and
defmethod install through the same path (area@point, area).

Re-derived whole from Parse.decl, which is the honest authority. The
comment said the authority was the declaration arm of Parse.expr, and
that was never quite true: declare and declare-c have been in this list
from the start and Parse.expr has no arm for either. So the rule is the
plain one -- is it a declaration -- and package is a single named
exception, kept because the two keys have always differed there and
nothing about a line written once at the top of a file asks for that to
change. The rationale in test-flan.el's package check said the same
stale thing and says the surviving reason now.

The new check reads the list off Parse.decl and asserts both keys on
every head, so the derivation is the test rather than a claim beside it.
2026-09-20 18:23:33 +07:00
JosephFerano b2d1df300b macros.flan says which of the two spellings it is 2026-09-20 18:23:22 +07:00
JosephFerano eeac54a8e3 The parameter list, its tests, and the note about what it breaks 2026-09-20 18:21:54 +07:00
JosephFerano de3a3ed3c8 The writeup said eleven checker rows; there are fourteen
Two accepting and twelve refusing. Also records that the three acceptance
rows were confirmed to run rather than inferred from a green exit: the
expectation was broken on purpose once and all three reported.
2026-09-20 18:19:45 +07:00
JosephFerano 69646e534e A macro's parameter list, and one grammar for it
(defmacro do-grid [[r rows c cols] & body] ...) — positional names, a [ ]
pattern wherever an argument is a vector, and & for the tail. The reading of
the list lives in Expand, below both sides that need it: Parse turns it into
the bindings a macro body opens with, and Macro checks a call against the same
reading before expanding it, so arity and shape are refused with the call's own
location rather than with the Loc.from_macro stamp every node of an expansion
carries.

The breaking half: [args] used to bind the whole argument list and now binds
the first argument. The whole list is [& args], and every defmacro in the tree
— prelude, vendor, tests, the elisp fixtures — was migrated to it. One grammar,
not a legacy mode.
2026-09-20 18:18:24 +07:00
JosephFerano 1702a62308 Pin the () body guards, the match-arm rule, and correct two comments 2026-09-20 18:18:20 +07:00
JosephFerano f4fcbe7745 Say what C-c C-i actually takes: the innermost thing, not the enclosing form 2026-09-20 18:17:48 +07:00
JosephFerano 99f519ba6f Two byte fills: (filled BYTE) and (sentinel-filled)
DISCUSS.org's sentinel-fill idea, built as two builtins because the author
asked for both: a memset with a byte the program picks, and the fixed
DE AD BE EF pattern a hex dump reads as DEADBEEF.

Both are spelled the way (zeroed) is — the value of whatever type is
expected of them — so (set grid (filled 0xFF)) fills a place and there is
no second, place-taking form beside set.

What may be filled is numbers, and structs and fixed arrays built out of
them. Everything else is refused by name: a filled dyn is a collector root
pointing at nothing, a filled Vec header frees a wild address, a filled
slice length is a bounds check that passes, and a filled bool is an i1 to
LLVM and a whole byte to x86, which is the one divergence this feature
cannot have.

The byte fill is llvm.memset / rep stosb. The four-byte pattern cannot be
a memset on either side — the intrinsic takes one repeated i8 — so it is a
counted dword loop in emit.ml and rep stosd in x86.ml, with the pattern
bytes and their little-endian word living once, in Emit. A size that is
not a multiple of four ends on DE, DE AD, or DE AD BE.
2026-09-20 18:15:19 +07:00
JosephFerano 16f90a7ef5 The dot advice says assign where the form is an assignment
(set p.x 1) was being told that a field is read with an accessor, which is a
sentence that does not apply to the form it is printed under. The place
spelling is (set (.x p) 1), checked to be a real form.
2026-09-20 18:14:58 +07:00
JosephFerano ce900acfe4 Six editor complaints from the dogfooding notes
Each was decided in DISCUSS.org; this is the elisp side of all six.

The eval value goes to the end of the line now, where flan-watch has
always put one, and it is echoed as well as drawn. Both of those
overturn a documented decision, so both docstrings and the MANUAL say
what the behaviour is rather than still arguing the old case: a value
wedged mid-line reflows the code after it, and a value that is gone at
the next keystroke with nothing left anywhere is worse than a number
said twice.

C-c C-i inspects what is at point with no prompt, and C-u opens the
minibuffer. It takes the form point is inside, not the one behind
point, because the middle of a name is where a key pressed without
looking lands.

Compiler messages are kept: *flan-diagnostics* accumulates one entry
per message, timestamped, never cleared by anything but
flan-clear-diagnostics. The overlay is unchanged. Each message line
keeps the compiler's own file:line:col: shape, which is both what makes
it jumpable and what makes it paste like a terminal line.

*flan-dev* gets compilation-minor-mode, so a main that does not compile
is a buffer you can M-g M-n through. Verified against a real diagnostic
rather than assumed: the default gnu entry matches the message line and
nothing else in the transcript, so there is no custom regexp here.

#_ is drawn as a comment, via a syntax-propertize-function that fences
the discarded span -- clojure-mode's approach, and the nesting rule is
the reader's, so #_#_ a b greys both with nothing counting.

And the two keyword lists are re-read off lib/parse.ml whole, rather
than topped up by name: defmacro, the four object/dispatch definers and
declare-c on one side; when, cond, and, or, break, continue, recur, fn,
quote, array, signal, error and the four condition forms on the other.
2026-09-20 18:14:25 +07:00
JosephFerano c1b18cb181 Two struct spellings the parser was never able to decide
A bare {.field v} had its refusal in Parse.expr, before any checking, so a
defn whose return type was the only place the struct's name appeared could
not build one. The refusal moves to Check: Parse builds an Ast.Bare out of
the same struct_fields the named form uses, and check_bare reads the type
name off the expectation and hands that very list to check_struct. ZII, the
unknown-field refusal and the duplicate-field refusal are therefore not
copies of the named form's rules but the named form's rules.

Braces at a dyn want are the dyn map literal and stay exactly that. A
.field-keyed brace was never part of that spelling, and at a dyn want it is
refused by name rather than given a second meaning.

(Cell 1 2) is the other half, and it is character-for-character an ordinary
call, so only the symbol table separates them. It is decided on the last arm
of named_call, after a local of function type, a generic and the function
table -- so a defclass constructor, which is a real defn, resolves above it
and is untouched. Arity is exact: ZII is what the braces do, and a positional
list cannot say which field it left out, so it is not allowed to leave one
out. The refusal names the first field it did not reach and points at the
spelling that does mean "zero the rest".

Both are gone before any backend sees them -- Tast.Make either way -- and the
three acceptance rows print the same lines to say so.
2026-09-20 18:12:45 +07:00
JosephFerano 89481cf8ec The wait for the agent socket was in front of the accept loop
[merged_serve] waited up to ten seconds for the program to bind agent.sock
before it started [accept_loop]. The listening socket was already up, so an
editor connected fine and then heard nothing: every first request of every
session cost the whole wait when the program calls (agent/start ...) late —
sand.flan starts it after rl/init-window returns — or never.

Nothing the editor asks needs that socket. In one process a delivery is a
call into flan_agent.c, not a connect, and the two-process daemon has already
waited for the bind in [two_process] and fails if it never comes. What the
wait was for is the sentence a program with no agent deserves, and a sentence
does not have to be in front of the loop to be said. So it is a deadline the
session passes ([agent_check]) rather than a wait it does: read from the
accept loop between connections and from [serve] before each request, because
an editor holds one connection for a whole session and the loop is not
cycling while it is attached. No thread, for the reason lib/dune gives about
what the merged link does to this library's dependencies.

Delivery stays honest either way. A program that links no agent at all
refuses through [over_socket]'s ENOENT, as before. One that has the agent but
has not started it takes the module into the ring and is answered with a note
that promises the poll and not a frame: a program with no (agent/poll) in it
never installs this, and "at its next frame boundary" would be the reply that
makes a redefinition look applied when it is not. C-x C-e's five-second
timeout gets the same distinction instead of asking whether a program that
has not got to its loop yet is calling the poll in it.

And the note a parked program's delivery carries is now said once per park.
A finished program is parked, so re-evaluating while a run's output is on the
screen repeated a paragraph on every C-c C-c. The first delivery of each park
explains itself; the rest say the one line that is the claim. [rerun] clears
the flag as well as [eval] does, so a new park is a new reader.
2026-09-20 18:11:32 +07:00
JosephFerano fa2b56ba5a Empty fn bodies, the pins for all six items, and the FIX.org entry 2026-09-20 18:11:20 +07:00
JosephFerano 859e3aa7f4 The shadowed builtin, and's misdirected caret, and the expansion count
Ranks 7, 10 and 19.

A defn whose name is a builtin's is silently unreachable — the dispatch
reaches every builtin arm before it looks in the function table — and the
arity refusal that followed measured the call against the builtin while
pointing at a call the reader had written for their own. The count stays the
builtin's, because the builtin is what runs; the message says so and notes
the definition that is not being reached. The shadowing itself is not
refused: that is a language decision and not a fix pass's to make.

FIX.org recorded and's misdirected caret with three rejected fixes and one
accepted — check_if preferring the arm that is not a compiler temp — and said
it was a check.ml change nobody owned. and's last operand is its then arm and
the sentinel carrying the previous operand's location is its else arm, so the
mismatch landed one operand early. Only and needs it: in an or the chain is
already in the else arm, and with an expectation in hand neither arm is
checked against the other.

'expanding this declaration produced 2 of them' had no antecedent once read
cold. The head of the expanded form is the macro's name and says what
expanded, and the expression form names (do ...).
2026-09-20 18:11:16 +07:00
JosephFerano ea84394dd0 Three more: the return slot, the case payload, the missing collection
Ranks 13, 14 and 9.

A body form in the return slot was blamed at whatever leaf the type parser
gave up on — the [1] in [(defn f [x i32] (+ x 1))], three forms deep — where
the mistake is that the whole form is in the slot. The slot is blamed now and
the parser's own reason keeps its span as a note. Where the parser gave up on
the slot form itself the old shape stands, because a message like 'unit is
written (), not Unit' already names the right thing and leading with it is
better than restating it. The literal -- is an em dash now, like everything
else in the tree.

A one-field case binds the payload itself, so [(match s (Circle c) (.r c))]
reached for a field of an f64 and got a type fact. The binding carries what
the pattern made it, in words, derived at the arm from the case and the
subject; the refusal says the value is already in hand. Set by that path and
nowhere else, so every other binding's refusal says exactly what it said.

The missing collection asserted a thing and contradicted it in the same
sentence — 'is a directory named nosuch somewhere above X, and there is
none'. It states the rule and the two ends of the search instead. The bare
/. it printed was Filename.concat of a directory and a dot, and is cleaned
where the path is made absolute.
2026-09-20 18:06:13 +07:00
JosephFerano 6c017c2cf8 Six dogfooding items: empty bodies, comment, inc/dec, () bodies, type limits, {.field} 2026-09-20 18:02:59 +07:00
JosephFerano 8cba440aca Four more from the worst-20: the constant, the let annotation, the quote, the operand
Ranks 15, 6, 11 and 18, all of them the same fault in different words — the
message states a fact the reader already had and leaves out the half only the
compiler can see.

'k is a constant' was four words. It says what a constant is, names defvar,
and notes the defconst — [no_container_defconst] is the house's shape for
this and [declared_note]'s is the note's.

(let [x i32 5] ...) is what everyone arriving from a typed language writes,
and let has no annotation slot, so the i32 became x's value and the 5 was
left over: 'binding 5 has no value', which reads as if they had miscounted.
The annotation is blamed now, at its own span. Checked only when the vector
was about to be refused anyway, and the test for 'this names a type' is
syntactic because nothing resolves at parse time.

The unterminated string had one column on the opening quote and no note,
alone among this reader's three two-place errors. It has the same note its
neighbours have.

'+ takes numbers, found string' pointed at the whole form when the operand
was right there — the same whole-form-vs-operand fault the condition work
fixed once already. Text gets the extra clause it was reaching for, naming
concat and join without a call shape: the spelling that builds a slice of
byte slices out of string literals is not a clause in a sentence, and a
message that guessed at one would be wrong.
2026-09-20 18:01:08 +07:00
JosephFerano 5afa707d76 The notes live in DISCUSS.org; the empty stub goes 2026-09-20 17:59:21 +07:00
JosephFerano 1526b6fe3f Typed structs do not version; an evolving shape is a defclass 2026-09-20 17:58:26 +07:00
Joseph Ferano 2dfab38d06 A dev build's main is reached through its cell like every other call
The entry point was the one call site a redefinition could not reach. A dev
build gives every Flan function an indirection cell and routes every call
through it, which is what makes C-c C-c land on the sites that already exist;
the emitted C main called the Flan-level main by symbol instead. So
flan_program_main — what M-x flan-rerun re-enters — ran the body main had at
the initial build for the life of the process, and redefining main compiled,
installed, reported ok and changed nothing anyone could see.

Both backends had the same direct call and both get the same split. LLVM loads
the cell and calls the loaded pointer; x86 does what call_flan's `Cell target
does, one load because emit_cells defines the cell in this same object.
Release builds keep the symbol, so emit and emit --x86 are byte-identical to
what they were. The cell is initialised to the body this build compiled, so
the first run is the run it always was.

What is left is an off-by-one that belongs to lib/dev.ml: a body delivered
while parked installs at the next frame boundary, which a re-run reaches
inside main, so a generation runs one re-run later than the key press. FIX.org
has the one-call close and why it is not done here.
2026-09-20 17:57:26 +07:00
JosephFerano 8add0093ba The defvar follow-ups, the spellings other languages use, and two register warts
The four the defvar review left behind, plus the two the author's dogfooding
notes name.

A three-element defvar that is neither a type nor a value gets a paragraph
about the fork it stands at, and the paragraph is right for the name that
genuinely could have been either. Three names cannot: a data case, which is
a third thing with its own spelling; a name another language uses for a type
this one has; and a plain type typo, where a confident one-edit suggestion
was turning a line into four. Each answers first now.

A bracket form never reaches that fork at all — the parser gives it the type
reading outright — so a value name inside one landed in [resolve_name] and
came back as a lecture about generic code. Both readings at the element that
decided it, and the dyn spelling it offers is checked to be a real form.

[int] is two edits from [i32] and so outside the one-edit net, correctly:
two edits is a guess. But the name is not a guess, it is what four other
languages call the default integer, so a short list answers it by name.
Nothing goes on that list without one honest answer — [char] and [void] are
off it, and the comment says why.

A parameter called [i] is not a mistyped [i8]. The machine types size
themselves in the name, so a typo keeps the digits and a parameter name has
none; that is the rule that stopped (defn idx [v i] dyn ...) being refused.

A type written in a two-element defconst was reported as an unknown name,
because that form has no type slot and the brackets read as an array
literal. A type name inside one is unambiguous — a type and a value cannot
share a name here — so it says what happened and names defvar.

Two register warts alongside: no message cites a repo filename at the reader
any more (plan.org in three, spec-memory.md in one).
2026-09-20 17:55:59 +07:00
JosephFerano db2f6c69b9 The call argument names the parameter, and the condition states the rule
Two of the audit's top five, and both are the same complaint: the message
states a type fact and stops where the reader needed the other half.

A call argument's refusal now says which argument of which function it is and
notes the parameter's own declaration, which is [declared_note]'s shape moved
to the place the audit calls the most-hit message in the compiler. [fns]
threw the parameter names and locations away when it resolved the types, so
[fparams] keeps the vector as written; a foreign declare and a generic copy
have no entry and degrade to the message alone rather than a wrong pointer.
The enrichment is conditional on the refusal being raised against the
argument's own span, so a mismatch deeper inside it is not misattributed, and
the rekind stops a nested call being named twice.

A condition that is neither bool nor dyn now states the rule instead of the
fact, with the comparison spelled out using the condition's own name where it
has one. Two things it does not do: it does not offer a comparison for a type
that has no zero, and it does not say anything about the dyn side, where
Clojure's truthiness means 0 is true. The re-check at bool still runs first
and its answer is kept wherever it knows more — a literal names itself, and
[None] names itself, and both beat a type name.
2026-09-20 17:44:11 +07:00
JosephFerano 2b5d793d7a The dot habit, and a did-you-mean over values
Two of the audit's four cheapest structural wins, and they share a raise
point. [p.x] is how C, Go and Odin spell field access, and it arrived here as
the symbol [p.x] and left as 'unknown name p.x' — true, and no use to anyone.
The head is looked up now, so the refusal can say what [p] actually is, and
the struct's declaration comes along as a note. A capitalised head is left
alone: [Shape.Circle] is a real spelling and a typo in one is a mistyped case.

[near_miss] was written, tested and wired to the type tables alone, so a
mistyped value name got the bare refusal. [one_edit] is hoisted out of it so
the value side matches on the same rule rather than a second one that would
drift, and the candidate list at a value position is the scope, the globals
and the functions — plus, at a call, the builtin names, which live in no
table the checker keeps and reach the raise through a forward reference.

Two repairs alongside: the data-type message's format string carried eleven
stray spaces from a wrapped line, and (Pair i32) in a defvar had lost the
type fork's 'generics are milestone 5' answer when it started falling down
the value fork.
2026-09-20 17:40:45 +07:00
JosephFerano 6d82221978 A dyn trap that says where: the nine trapping operators take a site
The dyn arithmetic and ordering entry points printed their sentence with no
file, no line and no column, which in a dynamic-first language is the type
error arriving from nowhere. flan_rt.c's bounds and arithmetic traps have
taken an emitter-threaded (loc, loclen) pair since they were written, and
[flan_dyn_cast_kind] is the fresh precedent on the dyn side; this is the same
pair, threaded through [arith], [want_nums] and [order] to the five
arithmetic and four ordering entry points. [eq] never traps and takes none.

The three trap printers take the pair and print nothing for a NULL loc, so
every other call site in the file — and test/dyn_ops.c, which calls the
runtime directly and has no source position — keeps its sentence byte for
byte. [trap_oom] is left alone: it is reached from [gc_alloc], which has no
site to be given and would have had to grow one on every allocation path in
the file for no reader's benefit.
2026-09-20 17:36:10 +07:00
JosephFerano ac2af7c537 The diagnostics worklist, ranked, so it stops floating 2026-09-20 17:24:35 +07:00
Joseph Ferano 36624d6e33 An abort's reply races the process it ends
test_dev.ml's abort asks were a bare Wire.send/Wire.recv pair against a
daemon that the abort itself is killing. In a merged flan dev the daemon
is the program: flan_agent.c's listener writes ok and sets aborting, and
the break loop's next pass _exit(134)s from the program thread while the
editor's reply is still being composed on the serve thread. Nothing
orders the two, so the reply arrives or the socket closes, at random.

When it closed, Wire.recv raised Closed, nothing caught it, and the test
binary died with no FAIL line and every case after it unrun. Four runs
out of four at the null-allocator trap.

Both endings mean the same thing and neither is the assertion: the
waitpid wait underneath each row is what says the program went. aborted
answers None for the end that arrived as an exit; the three sites that
abort a stopped or trapped program go through it. The fourth abort is
refused by a running program and ends nothing, so it is left alone.

trap_park's describe poll is guarded with the other answer: these traps
park because flan_trap_hook is installed, so a socket closing there is
the trap having ended the program instead of stopping it, which is the
failure that row already names. And SIGPIPE is ignored for the
watchdog's reason, so a send into the socket a dead daemon left behind
cannot kill the binary silently from the write side.
2026-09-20 17:07:15 +07:00
JosephFerano dc39631db7 M2 item 6: defclass, generic functions, and multimethods on the dyn side 2026-09-20 15:49:15 +07:00
JosephFerano 0b4bf850ec The random exit 1 is test_dev's trap rows, and it predates this lane
dune test exits 1 roughly two runs in five with no FAIL line anywhere:
test_dev's trap_park polls a daemon that has just aborted at the break
loop, using a bare Wire.send/Wire.recv pair, and a daemon that exits
between the two raises Wire.Closed with nothing to catch it. The binary
dies and every row after it is skipped.

Measured on a detached worktree at dev-loop's tip with none of this lane
in it: 2 of 5 runs, same exception, same row. Same rate as this branch,
because it is the same code. Written down rather than patched -- the fix
is a claim about what those rows mean when the program ends under them,
which belongs to whoever owns them.
2026-09-20 15:47:06 +07:00
JosephFerano bd29eee3e4 The rebase is what was run, so it is what the note says
The what-was-run paragraph counted three commits and no rebase. It is
five commits and a rebase onto the defvar-dyn lane now, whose load.ml
and ast.ml arms sit beside the four this lane added and were read
against them by hand rather than left to the auto-merge.
2026-09-20 15:38:19 +07:00
JosephFerano ac7608f94b A method's parameter names are bound in parallel, not in sequence
A let binds in sequence, so binding a method's names pairwise from the
generic's reads a name it has just bound. A generic [a b] with a method
[b a] -- a swap, which is what renaming parameters most often is -- was
handed its first argument twice and could not reach its second at all;
[b c] is the same bug one step shorter. Every argument is now copied
into a temp in the unspellable ~ namespace first and every method name
bound from a temp, uniformly rather than only for the pairs that
collide, because a rule that fires on the tangled case alone is one
nobody exercises. Both shapes are in dyn-class.flan, where the values
are what is wrong rather than the types, and across all three rows.

With it, two things the descriptor fix left behind. descriptors_asm
wrote the descriptors into .rodata and a descriptor holds the address of
its own offset table, so every one of them was a relocation in a
read-only section -- a DT_TEXTREL, which ld warns about in a PIE and
refuses in a shared object, and which was warning in the new daemon
case's own output. They go in .data.rel.ro now, in both the executable
and the reload module; readelf -d on a reload module from each backend
shows no TEXTREL. And FIX.org: the stale held line for item 6, the
fourth read site of the shape tag (say_render, not just print), the
warning that a class's qualifier is the importer's alias so a
hand-written :a/point is coupled to one import's name, and the gap
flagged for the next sweep -- marking through a descriptor an x86 reload
module emitted is still unexercised.
2026-09-20 15:36:27 +07:00
JosephFerano 90110f525a Classes and generic functions, written down
Item 6 marked LANDED, and the section under it: the four spellings with
an example each, why the shape tag is a header field and not the
reserved key the queue's note assumed, why the method bodies are inlined
rather than lifted, and what was deferred -- inheritance, multi-argument
dispatch, the qualifier methods, named-slot construction, unknown-slot
checking, computed dispatch values -- each with the reason rather than a
list. Plus the two findings that outlive the lane: the x86 redefinition
module's missing descriptors, and eval-expr never answering a dyn in
:value.
2026-09-20 15:36:27 +07:00
JosephFerano 0511cc1a01 A class's name and its keyword are one dispatch value
(defmethod area point ...) and (defmethod area :point ...) were both
accepted and the second was dead code: a class stands for the keyword
its instances carry -- that is the whole of how the two dispatch styles
share a mechanism -- so the duplicate scan has to compare them as one.
It compared the written forms, which differ. Normalised in the scan and
pinned in test_flan.ml.
2026-09-20 15:36:13 +07:00
JosephFerano 52a1ea183a A method added to a running program, proved end to end
The claim classes were built for, made against a real daemon rather than
at the session's report: dev-class.flan is compiled with one method for
one class, a second method is delivered into the live process, and the
call through the generic's cell answers with the new method's body while
the original one goes on answering. That is what a generic being exactly
one top-level name buys, and it is now pinned rather than argued.

Two things came out of writing it, neither of them about classes.

The x86 backend's redefinition module never emitted the per-type dyn
descriptors. Emit.redefinition has always emitted them, by going through
finish; the x86 twin ended at the rodata section and stopped. Nothing
had reached it, because a redefined body had to construct a struct
holding a dyn to need one, and until NoMethod there was no such struct a
compiler-written body could build. What it looks like is not a bad read
at run time but a link failure — desc_of mints a local label, the body
references it, and ld refuses the module with an undefined symbol. One
line, beside the same call in the executable path.

And the thing the test had to be written around: a dyn value answered by
eval-expr does not come back in the reply's :value at all. It renders to
the program's own stdout, which reaches a *later* reply's :output — the
dyn-global rows already read one that way and say so. So every answer
here is compared inside the expression, and what crosses the wire is a
typed 1 or 0. Left as it is; where a dyn expression's value should
surface is a question about the editor protocol, not about this lane.

The session-level test stays: it pins which names an added method
reports for installation, which is the half a daemon test cannot see.
2026-09-20 15:36:13 +07:00
JosephFerano 5a62770e52 Classes and generic functions, milestone 2's last item
A defclass is a named dyn map with a shape tag, and a generic function
dispatches on it two ways: CLOS's, where the dispatch value is the class
of the first argument, and Clojure's, where a body computes it. They are
one mechanism and not two — a class dispatcher is (class-of arg0) as the
dispatch function, which is what lets a method written for the class
point and one written for the value :point be the same branch.

    (defclass point [x y])
    (point 3 4)                 ; the constructor, positional
    (class-of p)                ; :point, or nil for anything else
    (defgeneric area [self] dyn)
    (defmethod area point [p] (* (get p :x) (get p :y)))
    (defmulti describe [x] dyn (get x :kind))
    (defmethod describe :square [s] ...)
    (defmethod describe :else [s] ...)

A slot is a key in the instance's own map, so get, put and has-key? are
how one is read and written and no operation was added for any of it.
What the class adds is the tag, and the tag lives in the object's header
rather than in a reserved entry — the queue's note said a reserved key
and this departs from it, because a key would be counted by len, walked
by the renderer and compared by equality, so every instance would answer
a length one larger than its slot count and print a key nobody wrote. A
header field cannot be reached by get or put at all, so no user key can
collide with it. It costs nothing: the map arm of flan_obj's union grows
to the size the view arm already had, and sizeof(flan_obj) is unchanged.
It needs no tracing either — the tag is an interned keyword entry, which
is immortal and is not a collector object.

The tag shows up in exactly three places: class-of answers it, equality
compares it (two instances of one class compare by their slots; an
instance and a plain map with the same entries do not, which is
Clojure's answer for a record beside a map), and both renderers print it
— #point{ :x 1 :y 2}, Clojure's own spelling.

None of the four forms reaches the checker. lib/classes.ml turns the
whole declaration list into ordinary defns at the top of build_program,
the way Shim.expand already turns a declare-c into a declare plus a
defn: a class becomes its constructor, a generic becomes one function
whose body binds the dispatch value and compares it down a chain, and a
method becomes a branch of that chain. It is a pass and not a macro
because a macro sees one form and the generic's body is not decidable
until every method is in hand — a method may be written above its
generic, below it, or arrive at a reload an hour later.

That last case is why the method bodies are inlined rather than lifted.
A generic is exactly one top-level name, so adding a method to a running
program is the ordinary redefinition of one function, through the cell
every call site already goes through. session.ml names the generic
alongside the method's own declaration name for that reason. The cost,
recorded rather than hidden: a method is not separately callable and is
not a frame of its own.

A dispatch that finds no method signals NoMethod, a prelude struct
carrying the generic's name and the dispatch value that missed. A
condition and not a trap, because a miss is something a program can be
written to answer, and handler-case around the call is the shape. Its
value field is dyn, the first condition here with one; the per-type
descriptor an item-2 struct carries is what the collector reaches it by.
No restart is established at the miss, which is BoundsError's decision
taken for BoundsError's reason.

Both backends, identically: the two new runtime entry points are
declared in emit.ml and the x86 backend needs nothing, since a dyn call
is a dyn call there. Deferred and written down in FIX.org: inheritance,
multi-argument dispatch, :before/:after/:around, named-slot
construction, unknown-slot checking, and computed dispatch values.
2026-09-20 15:36:13 +07:00
JosephFerano c4e07256db A three-element defvar reads its third element: a type is zeroed static, anything else is dyn
# Conflicts:
#	FIX.org
2026-09-20 15:14:56 +07:00
Joseph Ferano 9321547822 A defvar's third element decides: a type is the static, anything else is dyn 2026-09-20 15:00:15 +07:00
JosephFerano b2558f04de A defconst is a compiler const, on both backends and dyn is not one 2026-09-20 14:50:01 +07:00
JosephFerano 49ee8774d3 The case inside a struct literal is the same unwritable value as a bare one
const_defconst_init matched MakeCase at the top level only, so a case nested in
a struct literal got the general computed message, whose advice nobody could
follow. Emit.const found it by recursing; this finds it the same way, left to
right and first offender wins, which is the order the emitter spelled fields
in. FIX.org's claim that the messages did not change is rewritten to say what
did change about the general one.
2026-09-20 14:47:19 +07:00
JosephFerano ce93ac7622 A defconst's value is what the linker writes, on both backends
The refusal moves to the checker: Emit.const refused a computed defconst by
name while the x86 backend ran it through the startup function behind an
.init~once. flag, like a defvar, so the two backends disagreed about the same
program. One refusal in Check.const_defconst_init ends that, and it is the only
place that can name the way through.

The accepted set is unchanged: Tast.const_init's, which is Emit.const's and the
x86 data_sym path's, plus the integer arithmetic collect's folding pass has
already turned into an Int before the initialiser is looked at.

Emit.const's two refusals become a failwith no program reaches; emit_global's
gconst || const_init loses its left half; x86 needed no edit, since it never
classified by the form. test_flan's infers probe asks Check.expression now that
a defconst can no longer wrap an arbitrary expression.
2026-09-20 14:47:19 +07:00
JosephFerano e19dd5be29 The follow-up batch: honest prose, one prefix, one shared label 2026-09-20 14:39:35 +07:00
JosephFerano e6ac833626 The follow-ups the day's reviews left behind, each re-verified
flan.abi.require was spelled by hand in both backends, which is the one
job Mangle has. Moved; emit and x86 produce byte-identical output on the
reload path either way.

Four comments in the dyn-cast code asserted things that are not true.
The warning's location prefix now reads like every other loc-bearing
runtime diagnostic instead of inventing a shape. widen's contract says
what cast_dyn actually does with it. The thread-safety note names the
torn {ptr,len} overread rather than a duplicated line, and says why no
lock. The site table's borrowed loc pointer names what keeps it valid.

The memory op's note claimed a completeness it does not have: dyn push
and put may allocate and are deliberately silent. Said so, in the note,
in the classifier, and in FIX.org where the decision belongs.

The documented flycheck form only matched warnings, so a real error
made it say the checker returned non-zero and found nothing.

flan-clear-memory cleared one buffer where the toggle clears all.

Two comments claimed test/dyn_ops.c calls every function flan_dyn.h
declares; six are declared and never called there.

flan_dyn_stub.c's deadness is written into FIX.org for the author to
decide on. Not deleted here.
2026-09-20 14:32:17 +07:00
JosephFerano 6baed9a0ba The numeric casts open dyn boxes, coercing across kinds with one warning per site 2026-09-20 14:11:04 +07:00
JosephFerano 34790e78af The cast that opens a box, written down 2026-09-20 13:58:15 +07:00
JosephFerano a45e3806af The cast that opens a box, pinned on both backends 2026-09-20 13:57:43 +07:00
JosephFerano 10610fb76b The corpus program for a cast that opens a box 2026-09-20 13:54:07 +07:00
JosephFerano b6ea14f930 A numeric cast opens a dyn box itself 2026-09-20 13:52:27 +07:00
JosephFerano 2127d4d070 The transfer out of an initialiser is a restart's, not an unhandled signal's 2026-09-20 13:21:11 +07:00
JosephFerano b0ee9a3ce0 The runtime structs are one table, and the mangles one module 2026-09-20 13:20:47 +07:00
JosephFerano 0e9707c9ef The review batch: honest headers, named temps, the seqlock's odd window
# Conflicts:
#	FIX.org
2026-09-20 13:11:36 +07:00
JosephFerano 811c37dca4 Two sentences said more than was checked
The defconst divergence is dated from the history rather than asserted, and
the claim that it is the backends' only disagreement is gone — x86.ml has
unsupported paths of its own.
2026-09-20 13:09:04 +07:00
JosephFerano 5b39730f07 The init-once flag moves out of the namespace a program can write
The guard flag was named .init-once.<global>, and . and - are ordinary
symbol constituents, so (defvar .init-once.x i64 7) beside a computed x
emitted the same symbol twice: the dev build died at the assembler on both
backends, and the flag's Bool was registered over the user's global in
Emit.globals so the store came out as an i1. It is .init~once.<global> now;
~ terminates a symbol in the reader, the same trick destructure~N uses.
test/programs/dev-rerun.flan carries such a global and no new printed line.

Three places said a defconst is the linker's image on one backend and a
constructor's stores on the other and a re-run reaches neither. Tast.const_init
splits on the initialiser and not on the form, so that holds only for a
constant initialiser; a computed defconst is guarded like a defvar on x86 and
refused outright by emit.ml's const. The sentences now say that, including
the divergence.

emit.ml also claimed Check.no_transfer_in_init made it impossible to leave the
guarded branch between the store and the flag. It is syntactic over the
written initialiser only: a callee can signal unhandled and take the call's
transfer edge out, leaving the flag false — which is what should happen, since
the next run retries. Read off the emitted IR for such a program.

The x86 float-Rem comment says why the dead movabs before fmod is kept, and
its mid-sentence line break is gone; math3.flan's first float-% line prints
six values, not four.
2026-09-20 13:07:09 +07:00
JosephFerano dd239d6cd5 Two review findings this lane could not fix, written down 2026-09-20 13:03:01 +07:00
JosephFerano 2d29528cad Three escape tables became two, and the docs catch up
SPIKE-DUPLICITY.md 9 and SPIKE-DYNAMIC.md both still counted three and quoted
a flan_dyn.c comment that has since been rewritten. The typed side two are one
flan_escape_char with a framing each; the dyn copy stays, with the ownership
defence the section already accepted. NEXT.md named flan_break_resume, which
does not exist -- flan_restart_frame and flan_restart_take are the pair a break
loop resumes through.
2026-09-20 13:00:55 +07:00
JosephFerano 6461322aa9 The length write-back moves back inside the seqlock odd window
end_value did the release store and left the caller to narrow its length
afterwards, which is a length a reader is entitled to have missed. Split into
truncate_value and close_value so flan_dev_watch_end can store its 32-bit
length between them. Benign today -- only a full slot is truncated and a full
slot length is already WATCH_VAL -- and not a rule anyone would keep.

The two externs flan_dev.c borrows from flan_rt.c now get a value probe, once
per process on the first render, the way flan_vec_layout ties the three
statements of the vec header together: nothing else compares those prototypes
and the link matches names, not types.
2026-09-20 13:00:55 +07:00
JosephFerano 2daf7e9164 A short-circuit temp stops calling itself a destructuring one
fresh_temp takes the purpose, so and/or mint and~N and or~N where they used to
mint destructure~N. The names are shown -- the inspector lists a frame locals
by name -- and nothing pinned the old spelling; destructure~nth is an
unrelated compiler builtin. Confirmed in a --debug build DWARF.

The caret wart on and last operand in a want-free position is documented at
the site and in FIX.org rather than fixed: every candidate fix reads worse
than what is there, and the real fix is check_if choosing which arm to blame.
2026-09-20 13:00:55 +07:00
JosephFerano 9eed4cbd8c The dyn header claimed a guarantee the linker does not give
test/dyn_ops.c includes the header and calls most of what it declares, so a
renamed function fails to link. It does not catch a signature that drifts
while the name stays -- C links on names, not types -- and it does not reach
flan_dyn_map_get, flan_dyn_map_set, flan_dyn_map_contains, flan_dyn_is_nil or
flan_dyn_need_not_nil at all, none of which dyn_ops.c mentions. The comment
now says that, and says which hole is closable from here.
2026-09-20 13:00:55 +07:00
JosephFerano 1829cd43b6 One field list per runtime struct, one spelling per symbol prefix
The %handler, %restart, %fninfo and %flanframe shapes were written twice:
as LLVM type strings in emit.ml and as hand-computed byte offsets in x86.ml,
with the two %fninfo initialisers spelled a third and fourth time. Emit.Rt
now holds one field list per struct and derives all four — the type string
and the getelementptr index for LLVM, the offset and the size for x86, and
the initialiser for both. The derived numbers were checked against every old
constant before the call sites moved.

The flan. prefixes were spelled in four files, including both backends
hand-writing "flan." ^ name for a DWARF linkage name instead of calling
their own helper. Mangle now holds them unquoted; each backend adds its own
sigil. The ABI markers stay apart on purpose: flan.abi.llvm and flan.abi.x86
differing is what makes the loader refuse a crossed pair.

The float-to-integer cast bounds and the division-check elision policy are
Emit.cast_range and Emit.div_checks. The second is a language decision and
had been byte-identical in both files; the first had drifted cosmetically.

emit and emit --x86 output for all 166 test/programs, at -O0 release, --dev,
--debug and --dev --debug, stdout and stderr, is byte-identical to the
pre-change compiler.
2026-09-20 13:00:52 +07:00
JosephFerano f030c5f7f1 Memory diagnostics on demand: gc and native allocation sites, faintly
# Conflicts:
#	FIX.org
2026-09-20 12:57:30 +07:00
JosephFerano cb58288f4d The test binaries share one support module instead of ten forks
# Conflicts:
#	FIX.org
2026-09-20 12:49:32 +07:00
JosephFerano 26439dc22e The runtime's twin emitters share their bodies, and six dead symbols go 2026-09-20 12:06:48 +07:00
Joseph Ferano a84026736e One request paints every open buffer, so the toggle has to ask about all of them
flan-check-memory painted every buffer visiting a file the answer named
but decided whether it was already on by looking at the current one.
Asking again from a third buffer therefore stacked a second copy on the
two that were marked, with a second help-echo, and the toggle in the
third buffer took nothing down. Both the toggle and the repaint now go
through the whole buffer list.

The comment beside the faces claimed a dotted underline. The gc face
draws a wave, the same style the error face does; what actually makes
these fainter is that neither inherits error and neither draws its
message into the line. Say that instead, in flan.el and in MANUAL.md.
2026-09-20 12:02:43 +07:00
JosephFerano 0b4f5e139e defvar keeps its value across re-run; the form is the contract
# Conflicts:
#	FIX.org
#	test/test_dev.ml
2026-09-20 12:01:50 +07:00
JosephFerano d7d6d72877 check.ml's four verbatim copies become helpers 2026-09-20 12:00:10 +07:00
Joseph Ferano 5b0062219e Ask which lines allocate, and believe the runtime over the enumeration
[Check.memory_sites] is the pass [Check.no_gc]'s shape: it runs over the
finished program, answers a diagnostic list, and tells nothing downstream
that it ran. Two classes on the diagnostic's kind — the collector's heap
and an allocator the program named — so the CLI, the daemon and the editor
dispatch on one field and none of them reads a message to find the class.

[--warn-memory] on check and build prints them where errors go, in the
shape flycheck parses, without moving the exit status. [(:op "memory")]
answers the same list over a session's last checked program, needing no
program on the far end. [M-x flan-check-memory] paints it two faces fainter
than an error's, cleared by an edit or by asking again.

Two of the spec's own examples turned out not to allocate, and the
precision rule outranks the enumeration: (vec-new T) passes a capacity of
literal zero to flan_vec_init, which returns before the grow, and
flan_map_init takes no block at all and says so in its own comment. The
block arrives at the first push, which is the line that is marked. The
classifier reads the capacity argument rather than the symbol, which is
what lets slurp be marked through the same entry point vec-new is silent
through. FIX.org has the rest of the evidence.
2026-09-20 11:58:47 +07:00
JosephFerano e441d62874 Eight emitters were four renderings, and the escape table was three copies
The dev runtime rendered a value twice over: [flan_dev_emit_{u64,i64,f64,str}]
into the result buffer an evaluation is read back from, and
[flan_dev_watch_emit_*] into the current watch slot, with the same four bodies
either side and the sink as the only difference. [flan_dev_result_end] and
[flan_dev_watch_end] were the same ellipsis-and-generation close, comment for
comment, over two buffers.

So the rendering takes the sink as a parameter and the eight entry points are
eight one-line calls into four statics. The exports stay eight: the compiler
emits four of them by name (Session.externs) and a program reaches the watch
four through declare-c. ABI does not collapse because the bodies did.

The escape table is now [flan_escape_char] in flan_rt.c, once: what one byte
reads as inside a quoted string, into a caller's four bytes. A table and not a
printer, because the framings are genuinely different — [flan_escape_bytes]
builds a capped slice to hand back and the dev pair streams into a buffer it
does not own the end of — and the framing is the part that is each caller's.
The dyn printer keeps its own copy, which is docs/SPIKE-DUPLICITY.md §9's one
defended repeat: it is inside the runtime that owns the storage it walks. Its
comment, and flan_rt.c's, no longer tell a reader to change the other two.

Same for the NaN rule, which was spelled four times: [flan_f64_format] is
flan_rt.c's [flan_f64_to_bytes] without the slice, and the REPL emitter and the
watch table call it rather than restating "%g, and nan unsigned".

Byte-identical, checked two ways. A session driven over the daemon's socket
before and after — every arm of the emit family, the escapes, and a string long
enough to reach the truncating close — diffs empty. And a harness linking both
trees' flan_rt.c + flan_dev.c compares 8173 renderings: every byte 0..255
through both string emitters and through flan_escape_bytes, every length across
both caps and the ellipsis either side of them, both NaN signs, both infinities,
i64 and u64 at their extremes. Identical.

Dead code, each verified by its own grep before removal. These are exported C
symbols, so a program could reach one through declare-c; the evidence is that
nothing in the tree does, including the docs that write the surface down.

  flan_dev_watch_u64 (flan_dev.c) — one occurrence repo-wide, its own
  definition. The i64/f64/str siblings are declare-c'd in
  test/programs/dev-watch.flan and written down in emacs/MANUAL.md; this one
  appears in neither, and in no other file.

  flan_break_resume (flan_rt.c) — the only non-prose reference was a stale
  extern in vendor/agent/flan_agent.c with no call under it. Both gone.
  [flan_name_id] stays: the bounds and arithmetic conditions still hash through
  it. [flan_restart_take]'s comment no longer points at a function that is not
  there.

  flan_dev_watch_enabled (flan_dev.c) — prototyped in flan_agent.c, never
  called. [watch_on] is still read directly by the three sites that gate on it.

  clang_stamp (lib/build.ml) — a [lazy] never forced; one occurrence.

  marshal (lib/expand.ml) — no reference anywhere. [write], which it wrapped,
  is called twice in [call], so the [let rec] group is demoted to keep it.

  is_bytes (lib/js.ml) — dead within js.ml. Nothing else in that file is
  touched: the JS backend is parked, not dead.

Kept on purpose: [Loc.forget_sources], documented in docs/BUILT.md as
deliberately retained, and [flan_dev_watch_num_f64], which emacs/MANUAL.md
declares as public surface.

Two comments in lib/dev.ml argued the orphan grace in terms of elisp symbols
from before the rename — [flan-dev--open], [flan-dev--connection],
[flan-dev-poll-interval], and a file called emacs/flan-dev.el. None of those
exist. Re-spelled as [flan--open], [flan--connection], [flan-poll-interval] and
emacs/flan.el, which is where they are; the reasoning is load-bearing and is
unchanged.

dune test: exit 0. 59 lines of code out, 24 lines net of the prose that says
why.
2026-09-20 11:57:35 +07:00
Joseph Ferano 76270eac0d The test directory had been copying its own plumbing, file by file
Ten test binaries share a directory and had shared nothing in it but
watchdog.ml. Everything else each one needed it wrote out again: the
failure counter and its FAIL line, the three-line report tail, a poll,
a socket connect, the wait for a [flan dev] daemon to bind, a substring
search, and the Load -> Check -> Reach.link front half of a compile.

[listening] was the clearest case. Three copies, byte for byte apart
from one comment, and two of them said in that comment that they were
kept separate because "these three files have no module between them".
That was not true when it was written: watchdog.ml was already named in
the same (modules ...) stanzas. test_support.ml is the second such
module, wired the same way, and those two sentences go with the copies
they were explaining.

test_repl.ml's [quote] was Wire.quote character for character, in a file
that already links Wire and already names Wire.quote in a comment about
what the case below it is checking. It is Wire.quote now.

One real behaviour change, and it is a fix. [connect] existed twice over
with different retries: the agent's narrowed to ECONNREFUSED with a
comment saying why -- the socket file appears at bind, a moment before
listen -- while dev's and repl's retried any Unix_error, which meant an
ENOENT or an EACCES was retried to the full timeout before raising
something the reader still had to interpret. The shared one takes the
narrow version. Every caller connects to a socket [listening] has
already seen on disk, so the race it does catch is the only one left.

The rest is left where it is, on purpose. The three output-capturing
[run]s differ in what they wrap -- a pid suffix, a sanitizer environment,
a valgrind invocation -- and are not the same function. The report tails
in test_repl, test_web and the two sweep binaries print different things
for different reasons. The per-file scratch prefixes are the feature that
keeps two suites running at once from unlinking each other's sockets, so
the shared helper takes the prefix rather than choosing one. And the
[match Sys.command "command -v clang ..."] probes stay as they are:
their skip lines are output this suite pins.

bin/main.ml has the compile pipeline written out twice more. Left alone
-- this was a test/-scoped change and bin/ should not be reaching into a
test module -- and noted in FIX.org as what it actually needs, which is
the pipeline moving into lib/.

dune test: exit 0, and its output is the same line for line once the
temp-directory hash and the millisecond counts are normalised.
2026-09-20 11:56:30 +07:00
JosephFerano 1c49b8c610 The monotonic origin bound measures the clock, not the machine 2026-09-20 11:53:37 +07:00
JosephFerano 001f17b8bf Four copies in the checker become four callers
get and map-remove asked the runtime the same question through two
identical bodies; only the symbol differed, so map_lookup takes it and
both entry points keep their own preambles and their own prose.

invented_ctx existed and six sites still wrote the sixteen-field literal
out by hand. All six go through it now — three verbatim, three with the
two or three fields that make them a written body rather than an
invented one — so a field added to the record is one edit.

dyn_descriptors and dyn_sites each walked every place a value can live,
and the two walks had already drifted: an unnamed frame slot was "a
local of f" in one and "a local in f" in the other. One value_sites
walker now, with the callers' filters kept on their side; the surviving
text is "a local in f", because a named slot has always read "n in f"
and neither spelling was pinned by a test.

The duplicate-field scan appeared three times and the ZII fill twice —
check_case's own comment conceded the copy. given_once and zii_fill.
The union keeps its unknown-member pass ahead of the scan rather than
folding it in, because that ordering is what it reports today.
2026-09-20 11:50:19 +07:00
JosephFerano 7db5ec1885 Float % on x86 is a libcall, and the parity ruling is written down 2026-09-20 11:33:26 +07:00
JosephFerano da40dc2de2 The rule a re-run follows is written down, and a test fails without it 2026-09-20 11:30:58 +07:00
JosephFerano 9611fce108 Arithmetic does not fork across the spaces; the tiebreaker stops at semantics 2026-09-20 11:29:20 +07:00
Joseph Ferano 6896128407 Say what the objdump actually showed, and where it showed nothing
The first version of both notes claimed a call to fmod in any build with a
typed float % in it. A literal pair is folded before any call exists, which
is the same fact two paragraphs further down explaining why the corpus block
uses globals. Both now say the measurement: the calls are in the build whose
operands come through globals.
2026-09-20 11:25:49 +07:00
JosephFerano 4f060d07db The park's root reset stops at the globals' watermark
# Conflicts:
#	test/test_dyn.ml
2026-09-20 11:24:46 +07:00
Joseph Ferano 58d5ccda94 Typed float % on x86: the same fmod LLVM calls
There is no SSE remainder instruction, and LLVM does not invent one: at -O0
it lowers frem to fmod or fmodf. The backend now calls those two symbols
rather than refusing the operator, which is agreement by construction rather
than a second hand-written identity that would have to get every rounding,
every signed zero and every infinity right on its own.

Rem was the only gap. emit.ml's float surface is Add, Sub, Mul, Div, Rem and
the six comparisons; x86 had everything but Rem, and its comparisons already
build LLVM's ordered predicates out of ucomis, setcc and setnp.

math3.flan grows the operator spelling beside the fmod-f32/fmod-f64 calls it
already had, through globals so the pair is not folded before either backend
sees an operator. FIX.org records the ruling the fix came from.
2026-09-20 11:21:25 +07:00
JosephFerano eec9efc94c M2 item 3: typed containers cross into dyn as views of permanent storage
# Conflicts:
#	lib/emit.ml
#	runtime/flan_dyn.h
2026-09-20 11:18:18 +07:00
JosephFerano 931cf860c3 A defvar's initialiser runs once, so its value survives a re-run 2026-09-20 11:16:37 +07:00
JosephFerano e6af2d3f77 The park kept the frames' roots off and the globals' on
flan_merged_park called flan_dyn_root_reset, which emptied the collector's
root stack. The frames' roots had to go — main is left by longjmp, so they
name stack the next run overwrites — but the dyn globals' roots are on that
same stack, pushed once by the emitted main and never popped, and the park
took them with the frames.

The park is not a quiet state. It services evaluated thunks, a thunk
allocates, and an allocation collects. So a program with (defvar config dyn)
answered (get config :s) with its string before any thunk ran and with nil
after one that allocated past the heap's floor — a read of memory the sweep
had freed, answering nil by luck of what the freed words decoded as.

The emitted main now brackets its global pushes: flan_dyn_root_globals_begin
empties the stack, the pushes go on, flan_dyn_root_globals_end records how
many of them there are, and the park resets to that line instead of to zero.
Nothing between the two allocates, which is what keeps the globals from being
swept in the window where they are unrooted — and [begin] emptying the stack
rather than adding to it is what makes a re-entered main re-root the same
globals rather than push a second copy of each, which also closes the other
half: a re-run used to re-push roots over slots left dangling by the park.

Both emitters, because the dev loop's default backend is x86 and a fix in one
lowering is not a fix. A program with no dyn globals emits neither call and
its root stack still resets to empty, which is what an empty push list should
leave behind.

flan_dyn_root_pop now clamps at the globals rather than at zero. An
over-popping frame eating the globals is the one way that clamp could turn a
miscount into this same use-after-free.

Covered twice. test/dyn_ops.c's park mode is the runtime's half — a run, a
park with a collecting thunk in it, and another run, three times over,
asserting both that the global survives and that the frame's five hundred
objects do not. Under ASan the old reset reports heap-use-after-free in
flan_dyn_tag with the free in gc_sweep; under memcheck it reports 24 errors
and still prints the right answer, which is the shape of the bug. test_dev.ml
drives the whole daemon over its socket on both backends against
programs/dev-dyn-global.flan.

Not touched, and it wants a decision rather than a patch: a re-run re-enters
flan_program_main, which re-runs the lifted startup function, so every global
with a computed initialiser is reset by a re-run. That contradicts dev.ml's
own note and FIX.org item 1. It is independent of this — the roots are right
whether or not the values are re-initialised.

Nor is this the reload path. A defvar added by an evaluation gets its storage
from flan_dev_global (emit.ml's new_globals, x86.ml's counterpart) and there
is no flan_dyn_root_push anywhere on that path in either backend, so a dyn
global added to a live session is unrooted. That is a separate defect with a
separate fix, and nothing here makes it better or worse.
2026-09-20 11:06:14 +07:00
JosephFerano c765aad70f A slice level after the first index was invisible to the lifetime rule
(at g i j) is one Tast node carrying the whole index list, so the At
arm's guard on target.ty settled level zero and nothing after it. A
global [2 [[3 i64]]] indexed twice reached a slice's element, crossed
into dyn as a view, and printed a returned frame's contents with exit
0 (ASan: stack-use-after-scope in view_box). The arm now steps each
index the way [indexed] does and demands an array at every level;
the Field and Slice arms inherit the fix by recursing into it.
2026-09-20 10:51:22 +07:00
JosephFerano 3a9088604e and hands back its deciding operand too, and both forms point refusals at the operand 2026-09-20 10:45:16 +07:00
JosephFerano bddc8fc5dd and hands back its deciding operand too, and both locs get sharper
(and a b) desugared to (if a b false), so it answered the last operand
only when every operand was truthy; a falsey one came back as a bare
false, where Clojure answers the falsey operand itself. It now uses the
same expansion or got in ad0f1fb -- (let [t a] (if t b t)) against or's
(let [t a] (if t t b)) -- so the operand that decided the form is the
answer, and the test is still evaluated exactly once.

The temp binding and its if now carry the operand's own loc rather than
the whole form's, which the or fix had lost: (or (vec-new i32) v) blamed
the enclosing form at 3:13 and now points at 3:18, the operand, and and's
second operand gained the same precision.

The parse pins in test_flan.ml now tie the bound name to the temp the if
tests and the bound value to the first operand, so a desugaring that
dropped the temp and wrote the operand into the arm twice no longer
passes; and has its own pin. dyn-if-truthy.flan grows the falsey-nil and
falsey-false answers, 0 and "" as truthy operands, one- and zero-operand
forms, and a printing operand that proves both the short circuit and the
single evaluation.

One behaviour that used to compile changed: with both arms of the
desugared if now holding real values, (and dyn-value typed-bool) unifies
on the typed arm and a non-bool dyn decider traps at the strict bool
boundary -- (and (box nil) some-bool) printed false and now traps, the
mirror of what (or false (box "s")) already did on dev-loop. Recorded in
FIX.org as the author's call on how check_if should join a bool arm and a
dyn arm.
2026-09-20 10:36:05 +07:00
JosephFerano 633a7b2025 A global slice's element is not permanent, and four comments that were not true
The [At] arm of [permanent_root] recursed through any indexed target, so an
element of a global SLICE answered permanent the way an element of a global
ARRAY does. An array's elements are inside the global's storage; a slice's
are ptr+len pointing wherever, which can be a frame already returned — the
program that stashes (slice local 0 2) in a global slice and views an element
compiled and segfaulted with no diagnostic. The arm now recurses only when
the target's own type is an Array.

With it, the refusal/acceptance pair in test_flan.ml (one word apart) and a
view over an element of a global array in dyn-view.flan's mode 0.

The element check now runs before the lifetime check in all three container
arms: a local (Vec string) was told to make it a global, and a global
(Vec string) is refused anyway, so the advice was a dead end.

And the four strings that claimed more than the code does. flan_dyn.h
already had the honest version — a view is exactly as stale-safe as the
thing it is a view of — so the refusal message, box's comment and FIX.org
now say that instead of promising a dyn value can never dangle; a global
[i64] cut from a dead frame still passes and still reads it (ASan:
stack-use-after-scope in view_box). The element message no longer tells a
(Vec string) that string is not the case the restriction exists for.
dyn_ops.c's hand_vec comment no longer says flan_rt.c is unlinked when it
calls two of its functions; flan_rt.c said the same thing and is fixed too.
FIX.org's arena paragraph now separates the header's lifetime (compile time,
already covered) from releasing the arena under a live view: free-all traps
cleanly on the epoch, arena-destroy is a heap-use-after-free in
view_vec_check, the same gap flan_vec_check has on the typed side.
2026-09-20 10:34:23 +07:00
JosephFerano 3f7c42257f Review found the hazard relocation-safety missed: a view can outlive its frame
Relocation was proved sound and stayed sound — a Vec view holding the
header's own address survives a push that grows and moves it, because
there is no snapshot to invalidate. That was never the whole of the hazard.
Refusing every container into dyn outright, before this lane, meant a
dangling view was unreachable; the moment box stopped refusing, three
routes opened at once — a view returned from the function whose frame the
Vec lived in, one stashed in a dyn global and read after that frame is
gone, and one left behind when a condition transfer unwinds it. All three
are stack-use-after-return, reachable for the first time.

The rule: a typed container crosses into dyn as a view only when its own
storage is permanent — a global's. On the dynamic side Flan follows Clojure
and Common Lisp, where holding a value can never hand you garbage; treating
a view as a bare pointer and calling the lifetime the programmer's problem
is the Odin answer, and it is the wrong trade on this side of the language.
check.ml's permanent_root walks the checked expression back to its root: a
global is permanent, a field or an array element of one is permanent at the
same fixed offset, and a slice cut directly from one at the call site
inherits it — the trace is what a slice carries, and it is lost the moment
the slice is bound to a name first, so that case is refused too rather than
guessed at. Everything else answers false: a local, a parameter, a
temporary, and anything reached through a (Ptr T), because a heap-durable
pointer and a frame's own are the same type and the checker cannot tell
them apart — admitting one admits the other, which is the whole hazard this
closes. An arena-held header turns out not to be a separate case at all: an
arena changes where a Vec's elements live, never where its own header — the
binding — lives, so it is already covered by the storage-class check above.
Both directions of the F1 escape were reproduced before the fix (a genuine
ASan stack-use-after-return, reproduced by building the pre-fix tree) and
confirmed refused at check time after it, for all three routes.

Three more findings, all in the runtime rather than the boundary:

view_vec_check, on finding a stale container, rendered the very view it had
just declared unsafe to read — which called back into the same check,
unconditionally, an infinite recursion rather than the intended trap. Fixed
by never rendering the container in the stale message at all; the sentence
names the two epochs and nothing else, which is everything a reader needs
and the one thing that was safe to read.

dyn_equal's VEC arm read x->len and x->u.v.items regardless of kind, which
for a view answers 0 and the union's other member reinterpreted as dyn
words: two views with different contents compared equal, a view and an
equal heap vec compared unequal, and a map keyed by any view collided with
every other view, silently. vecish_len and vecish_at read either shape
correctly and the arm now goes through them. obj_words gets the same
explicit OBJ_VIEW case on the same reasoning, unreachable today only
because mark_push's own gate already excludes the kind — this is the belt
next to that brace.

The three restatements of flan_vec's layout — flan_rt.c's real struct,
flan_dyn.c's mirror, and dyn_ops.c's hand-built one — had a comment
claiming a reorder would not compile or link, which was never true of a
void*-typed forward declaration. flan_vec_layout and
flan_dyn_vec_hdr_layout each report their struct's size and field offsets;
dyn_ops.c's new "layout" mode compares both against offsetof on its own
hand_vec, so a disagreement is a FAIL line in dune test instead of a
silent corruption at whichever view reads through the wrong offset next.

Also: the survey program's comment excusing a by-value parameter's view as
"value semantics, not a hole" was wrong on its own terms — a write through
such a view does reach the caller's storage, only growth diverges — but the
question is moot now: every container the program views is a global, and
the file was rewritten around that rather than patched. And an i32 element
does not cross into a view either, but the refusal used to say why in words
that were true only of a string element; it now says what i32 actually is
and what the restriction is actually for.

Rebased onto dev-loop's item-4 landing (221df5a).
2026-09-20 10:10:52 +07:00
JosephFerano f61f83f796 M2 item 7: dyn if tests truthiness, and or hands back its deciding operand 2026-09-20 10:08:44 +07:00
JosephFerano b5f17826fe The queue records item 7's review pass: the or fix, and two things left alone
FIX.org's item 7 gets the full account of the review that followed
landing: or's fix (ad0f1fb), named there rather than left as "review
pass"; the keyword-condition diagnostic given up on purpose, the
author's call; and the exponential retry on a chain of nested not that
does not type-check, looked at and left alone since a cheaper retry
would cost message fidelity on a compound condition wrapping a literal,
not just speed.
2026-09-20 09:57:17 +07:00
JosephFerano ad0f1fbd6a or hands back its deciding operand instead of a bare bool, review pass on item 7
The dyn if truthiness review turned up that or's answer position, unlike
and's, still traps on a non-bool dyn value: or's short-circuit sentinel
sat in the then arm of its own if, the one check_if types first, so that
sentinel decided the whole expression's type and a later non-bool dyn
answer hit the strict bool boundary and unboxed itself into a trap
rather than surviving as itself. (or nil "x") — the canonical Clojure
(or x default) idiom — crashed instead of answering "x", identical on
all three backends.

or now binds its test to a temp and answers the temp itself, exactly the
way Clojure's own or macro expands: (or a b) becomes (let [t a] (if t t
b)), not (if a true b). The temp evaluates a once and lets the answer be
a without writing it a second time as the then arm; it is the temp's own
type check_if sees first, so or hands back the actual truthy operand the
same way and always has. Verified real output, unchanged, on LLVM, -O0
and --x86, and the survey program now exercises the case its own header
used to exclude for being unsafe: a non-bool value stopping or and being
handed back as-is.

check_truthy also gets three corrections a closer look found. Its own
[loc] used to come from the enclosing if/while/not rather than from the
condition itself, so the rt call and cast it builds carried the wrong
column in an --x86 disassembly or the dev inspector whenever the
condition was not the form's first token; it now takes loc from the
scrutinee's own AST node, confirmed against a real --x86 dump. A comment
now names the precondition its exception-swallowing retry rests on: none
of check.ml's save-restore sites (barrier, in_frames, in_defer, loops,
scope) are exception-safe, which is harmless only because the retry
always either succeeds cleanly or re-raises and aborts the compile
before ctx is read again — and would stop being harmless the day some
want-sensitive elaboration on this path could succeed differently on
retry. And a bare keyword condition, which used to be checked with
want:Bool from the start and refused by the keyword arm's enum-or-refuse
case, now resolves as the dyn keyword instead and is unconditionally
truthy — a deliberate loss of that diagnostic, the author's call, pinned
in test_flan.ml so it does not regress by accident.

The two typed-refusal messages captured before this pass (a float
literal condition, an i32 while condition) are unchanged, checked again
against the same baseline. test_flan.ml's parser test for or's shape is
updated to match the new let-bound desugaring.
2026-09-20 09:56:34 +07:00
JosephFerano 29a9441f12 Typed containers into dyn as views — M2 item 3
A (Vec T), a slice or a fixed array crossing into dyn no longer refuses; it
is a view, one word in the box, over the container's own storage. Reads box
the element on the way out; writes tag-check the dyn value's tag against the
element type on the way in and trap, by name, on a mismatch, never coercing
or silently storing.

The open question the decision left — whether the descriptor points at the
container or snapshots pointer and length beside it — is settled by kind. A
Vec view holds the address of the Vec's own header (flan_rt.c's flan_vec,
restated in flan_dyn.c under the file's standing "if either table changes,
change both" rule) and reads ptr and len live on every operation, so a push
that reallocates cannot leave it stale: flan_vec_grow overwrites that same
header in place, and there is nothing captured at the crossing for the
growth to invalidate. A slice and a fixed array cannot grow, so a flat view
snapshots data and length once; pointing it at the value's own slot instead
would be worse, since a slot's lifetime is not the slice's.

The element set is i64, f64 and bool, not everything box already handles
typed-to-dyn. A string element's dyn form is a pointer into the collector's
heap, and a typed container's storage is arena or stack memory the collector
never scans — a wider set would let a write plant a live reference nothing
ever traces, which no care at the write site closes. (Vec string) and a
typed (Map K V) keep the "does not cross into dyn yet" refusal, now for that
reason.

flan_dyn.c gains a fourth object kind, OBJ_VIEW, and flan_dyn_len/at/set_at/
push and the printer each grow one branch for it beside the existing vec
one. A view's own stale-container check is the runtime's own spelling
(flan_trap, park-and-inspect) rather than flan_rt.c's rt_die, per the
duplicity doctrine; growing a Vec through a view calls flan_rt.c's own
flan_vec_push rather than re-implementing doubling and allocator adoption a
second time. (set (at target i) x) against a dyn target — a plain dyn vec or
a view alike — was a hole in the base dyn milestone rather than something
item 3 introduced; it is wired to flan_dyn_set_at here because a view's
writes needed it to exist at all.

Both backends: emit.ml and x86.ml both already passed a Vec or a Map to a
runtime call by address rather than by value; a fixed array crossing into a
view needed the same arm added in both, for the same reason — a copy would
view the copy and never see a write to the caller's own array.

test/dyn_ops.c drives the runtime directly with a hand-built Vec header and
a plain C array, ahead of any compiler involvement: reads, writes on both
element kinds, the tag-check refusal on every element kind, the range
refusal, and the push that grows and moves a hand-built header out from
under the view watching it. test_flan.ml turns the old "does not cross into
dyn yet" refusal into acceptances for Vec/slice/array, keeps it for a string
element and for Map, and adds the element-restriction refusal by name.
test/programs/dyn-view.flan is the compiler-level survey: a Vec view mutated
through both sides including the grow-and-move case, a fixed array's and a
slice's views, a bool Vec's view, and its own two trapping modes for the
acceptance rows to run against. test_sanitize.ml carries the survey's happy
path; test_dyn.ml's new refusals are the runtime's own.
2026-09-20 09:19:17 +07:00
JosephFerano d7070501e4 The queue marks item 7 landed, and names the or asymmetry it turned up
dyn if's truthiness rule reached when, cond, and, or, not and while
through one checker funnel, and turned up something nobody had decided:
and's short-circuit answer already carries a non-bool dyn value through,
Clojure-style, but or's does not, because its sentinel occupies the arm
check_if types first. Written down here rather than fixed, since the
queue's items get decided one at a time and this one is not yet.
2026-09-20 09:16:21 +07:00
JosephFerano 264765a6a5 Dyn if tests truthiness — M2 queue item 7
A dyn scrutinee is no longer required to already be a bool: it is tested
for truthiness, Clojure's rule, not C's or Python's — nil and false are
the only falsey values, and everything else, including 0, "", an empty
vec, an empty map and a keyword, is truthy. A typed scrutinee is
unchanged and keeps needing a strict bool.

The runtime side is one new entry point, flan_dyn_truthy
(runtime/flan_dyn.c/.h), reading the tag directly rather than unboxing —
it never traps, unlike flan_dyn_need_bool. Both backends reach it the
same generic way flan_dyn_need_bool already did: check.ml emits an
ordinary Rt call plus the existing i32-to-bool Cast, so emit.ml only
needed the LLVM declare added and x86.ml needed nothing at all.

check.ml's check_truthy is the one funnel every boolean position in the
language goes through: if's own condition, while's, and not's argument.
when and cond reach it for free because they desugar to Ast.If in
parse.ml, and so does and's condition; or's condition does too, but its
answer position is a separate story — its short-circuit sentinel is the
then arm of its own if, which check_if types before anything else, so a
non-bool dyn value reaching that position still meets the strict bool
boundary. and's sentinel sits in the else arm instead, so the real
value's type wins and and hands back the actual last operand,
Clojure-style; or does not get that for the reason above, and reordering
it is a decision for another day, not this one. shortcircuit in parse.ml
carries the note.

check_truthy checks the scrutinee with no expectation first, so a dyn
value takes the truthy path and everything else takes the strict one. A
refusal on that second path is re-checked with the old want:Bool rather
than reported from the bare check, because a bare integer or float
literal, or a bare None, answers "what type is this" differently than
"is this a bool" — check.ml's own arms only give the nicer sentence
("expected bool, found the integer literal 5", "expected bool, found
None") when asked the second way, and that sentence is preserved exactly,
letter for letter, against what a typed if already said.

test/programs/dyn-if-truthy.flan surveys every falsey and truthy case —
nil, false, true, 0, a nonzero number, an empty and nonempty string, an
empty and nonempty vec, an empty and nonempty map, a keyword — through
if, when, cond, and, or, not and while, with real output pinned in
test_acceptance.ml across LLVM, -O0 and --x86. test_flan.ml covers the
checker side directly: a typed if still takes a bare bool and still
refuses a non-bool scrutinee and a bare None with their original
messages, a dyn if/not/while/when/cond/and/or all accept a non-bool dyn
condition. test/dyn_ops.c gets a matching set of direct calls to
flan_dyn_truthy, keeping the header's own contract with the C side.
2026-09-20 09:16:21 +07:00
JosephFerano 221df5af1c Three review findings on M2 item 4, none blocking
(Some nil) at an already-(Option T) want reported expect's bare-T
sentence instead of its own: checking the argument against inner's
element type routed a literal nil through expect's "wrap the type in
Option" refusal before Some's own is_nil_lit guard ever ran, and the
advice was nonsense there — the type already is one. The argument is
now checked with no want when it is syntactically nil, which is what a
bare nil resolves against on its own, so it arrives at Some's own
check still dyn and still nil.

no_fallback_slots cannot see the slot unbox_option mints: %dx/%ax are
the pool-ran-dry fallback for a temporary root_plan counted, and a
named local root_plan never counted emits neither mark. The comment
where nil-option.flan and some-nil.flan were added to that list said
otherwise; corrected to say what the check does and does not cover,
and to record that the slot was verified by reading the IR directly
instead — flan.unbox-opt's dyn slot is pushed, flan.as-dyn's is a
plain alloca correctly, since its element is always scalar there.

dyn_offsets falls through Option/Vec/Map with no arm of its own,
correct today only because Check.hidden_dyn refuses a dyn inside any
of them at every storage site first. Commented at the fallthrough,
naming hidden_dyn as the gate and the typed-container view (M2 item 3,
in review now) as the kind of change that could relax it for Vec/Map
without anything here pointing back.
2026-09-20 07:41:55 +07:00
JosephFerano c71ae8020c M2 item 4 landed at 3c1fb1b 2026-09-20 07:20:45 +07:00
JosephFerano 3c1fb1b31e nil <-> None at (Option T) boundaries, and (Some nil) unconstructible — M2 queue item 4
Both directions of the boundary go through expect, the way every other
dyn crossing does. A dyn's tag decides which case an (Option T) becomes
on the way in; an Option's own tag decides nil or a boxed payload on the
way out. box_option/unbox_option build the same If-over-a-tag shape get
and map-remove already build for the same reason, reading an Option's
tag and payload with the raw Field access Render's structural printer
already uses — nothing new for either backend to lower. A literal
Some/None skips the runtime check entirely, since the checker already
knows which case it is.

A bare T has no None to become. The literal nil the checker can see is
refused right there, at compile time, in expect itself — the author's
decision to do both halves rather than settle for the runtime trap
alone. Everything one step removed from the syntax — a dyn that only
turns out to be nil once the program runs — reaches flan_dyn_need_i64's
existing DynType trap, unchanged; there is no dataflow in this checker
for it to be otherwise (see "Ownership tracking repealed").

(Some nil) is refused the same way: the literal at compile time, with a
message saying why nil and None would collide; a dyn that turns out to
be nil only at run time through the new flan_dyn_need_not_nil, which
traps by the same route flan_dyn_need_i64 does.

(Option (Option T)) does not cross either direction — boxing Some of an
inner None would box it as nil, indistinguishable from the outer None,
the same ambiguity (Some nil) is refused for. The type itself stays
legal on the typed side; only the crossing does not exist for it.

(Option dyn) needs no case of its own in the boundary code — the
payload is already dyn, so box_option/unbox_option treat it as the
identity — but it is not yet a value a program can hold anywhere. The
per-type-descriptor pass (M2 item 2) refuses it at every storage site
today, the same way it refuses (Vec dyn), because a struct's dyn fields
are marked by byte offsets and (Option dyn)'s payload has none. Item 4
does not lift that gate; it only makes the boundary already correct for
the day items 2/3 do.

expect grew a ctx parameter to build the fresh slot the two new
crossings need — every call site threaded through, one context
mismatch caught and fixed in check_fn's tail-expression case along the
way. var's None case grew a direct Dyn arm: None at a dyn want is nil
outright, with nothing to build.

nil-option.flan carries the crossings that succeed and ends on the
bare-T trap; some-nil.flan is (Some nil)'s run-time half, kept in its
own file the way dyn-boundary.flan is one trap per program. Both are
in no_fallback_slots and test_sanitize.ml: the new dyn temporary
unbox_option's tag test mints is rooted, and reads its Option's tag and
payload through ASan clean, --sanitize matching the unsanitized run
byte for byte.
2026-09-20 07:20:27 +07:00
JosephFerano ba7f31e99f Merge branch 'worktree-agent-a0b464fd2273c74d2' into dev-loop 2026-09-20 00:27:40 +07:00
JosephFerano 31b0f2175f Four holes review found in the compile pool, closed one at a time
A worker's own pid was not enough to keep two variants of the same program
from colliding: raylib-image.flan and raylib-audio.flan export through the
raylib FFI to a fixed absolute path under /tmp, baked into the program
itself rather than passed in, so a default row and its -O0 row running as
two different workers could still race each other's file. Measured at a
~1.7% flake before. Both clusters now run through outputs_sync, off the
pool, the same as slurp.flan and files.flan already were for the same
reason.

The watchdog's own cleanup had two gaps. First, an inflight entry was popped
off the queue before the parent blocked reading its pipe, which is exactly
the moment a hang leaves the queue blind to the one worker it needs to see.
The entry now stays visible until its result is actually in hand. Second,
killing a worker's pid did not reach its clang, spawned three processes
away through Sys.command -> sh -> clang, which a forced alarm could leave
running as an orphan. Each worker now calls setsid on the way in, so it and
everything it goes on to spawn share one process group, and the cleanup
hook signals the negated pid to reach the whole group in one call. Eight
repeated forced alarms, zero processes left behind afterward.

Two comments claimed more than the code delivered. The order rows print in
is fully deterministic but is not serial — an inline FAIL still prints the
moment it happens, while a pooled one can sit behind up to eight other
submissions first — so the comment now says reproducible rather than
serial. And outputs_sync's isolation from the pool was true only because
nothing pooled happened to touch the same paths; a Pool.drain_all () now
runs before each of the four unpooled clusters, so that isolation holds
regardless of what gets added to the pool later.

And a worker now removes its own Build.workdir on the way out when its row
passed, rather than leaving one directory behind for every compile it ran
on top of what a single serial run already left. Left in place on failure,
where the original reason that directory is never swept — findable by name
when something is wrong with it — is exactly what the next person wants.

Reverified after all four: the raylib probe clean across twelve full runs,
the forced-alarm probe clean across eight, dune test read for FAIL lines
rather than trusted by exit code, three standalone runs byte-identical.
2026-09-20 00:26:37 +07:00
JosephFerano 70f87d90b2 A visited set crossed a question it was never asked
The foreign boundary walks two questions, and they are not the same question:
one counts a dyn reachable only through a further pointer, the other counts
any dyn at all.  They shared a [seen] set across the crossing between them, so
a type name marked visited on the way down was pruned from the walk below the
pointer — and the shape that hits it is a struct with a pointer to its own
name.  [(defstruct Node [next (Ptr Node) x dyn])] at a [(Ptr Node)] parameter
was accepted, while exactly the same thing unrolled into two types was
refused, which is the tell.  A cycle of two, [A {b (Ptr B), x dyn}] and
[B {a (Ptr A)}], went the same way.

C hung a malloc'd node off [next], put a dyn in it, and Flan allocated a
hundred thousand times: heap-use-after-free in flan_dyn_tag out of c_peek,
freed by gc_sweep out of flan_gc_collect.  The checker accepted it and the
collector freed a live value, which is the one failure this boundary exists to
prevent.

The set does not travel across the crossing now.  It still terminates — each
walk's own set guards its own recursion over names, and a crossing starts a
separate finite walk — and it does not start refusing a recursive shape with
no dyn under it, which has rows of its own here because that is the way a fix
like this goes wrong.

Neither recursive shape had a row.  That gap is why it took four passes over
this code to surface, so both are pinned now, alongside the two acceptances
that say termination held.  And the three return rows stop sharing one needle:
each names its own type, because three rows against the common half of one
sentence would all pass on a message that named the wrong shape.

dune test --force: green, 0 failures.
2026-09-20 00:25:49 +07:00
JosephFerano a1111f7b3c The foreign boundary is about ownership, and it was reading shape
Three shapes hand a dyn word into memory nothing roots, and the check written
last round caught one of them.  A (Ptr S) return was refused; (Ptr (Ptr S))
and (Ptr [S]) were not, because the predicate it asked was dyn_anywhere, which
had just been taught to stop at a pointer.  Stopping there is right — a
pointer is a view of storage something else roots, and that is what lets a
(Vec (Ptr Cond)) be written — but it is the wrong question at a boundary where
nothing roots the far side at all.  So there are two predicates now.
dyn_anywhere is the storage-shaped one and is unchanged; dyn_through follows
pointers and slices, and only the foreign boundary asks it.  The second of
those shapes matters more than it looks: (Ptr [S]) is what shim.ml's own
advice tells people to write when C returns an aggregate.

Neither was a regression.  At the commit before last there was no walk over
the externs at all and all three were accepted; what landed was one level of
a check that wanted to be recursive.

And the direction.  The refusal was justified by what C hands back and then
applied to parameters as well, which made the ordinary read-only borrow
unexpressible: a foreign parameter receives the address of a *place*, and a
place is a frame slot, a global or an array inside one, every one of them
rooted with its descriptor and marked for the whole call.  So (Ptr S) and [S]
as parameters are borrows and stay writable.  One level down the storage is
C's again — a (Ptr (Ptr S)) parameter is an out-parameter and what C writes
into it is a pointer of C's own — so the parameter question is asked below the
outermost level and the return question is asked from the top.

Evidence, all three shapes with the check lifted, ASan: heap-use-after-free in
flan_dyn_tag, freed by gc_sweep out of flan_gc_collect, allocated by the Flan
frame that stored it.  With the check back, all three refused by name.  And
the allowed direction proved rather than assumed: a C read-only borrow of a
(Ptr S) and a [S] slice argument, called before and after twenty thousand
allocations, dyn fields intact and clean under ASan.

A Tast.extern carries its declare's location now, so these refusals point at
the line rather than at <unknown>:0:0.

Last, the one arm of the saturation that did not saturate: a negative array
length reached the multiplication as a small number, which is the exact shape
the cap must never be handed.  Out of range in either direction saturates.

dune test --force: green, 0 failures.
2026-09-19 23:02:26 +07:00
JosephFerano 27b672a3d2 Five back from review, and the first one was the mangle eating a type
A descriptor's symbol was the type's printed form with every character an
assembler would refuse replaced by a dot, and the table was keyed by that.
The mangle is many-to-one — a Flan name may hold -, +, *, ? and / — so row-a
and row+a were one entry, the second of them was pushed with the first's
descriptor, and the collector read at another type's offsets: past the end of
the object when the first was the larger, and never where the second's dyn
actually sat.  ASan named it, a stack-buffer-overflow inside gc_mark_all.  It
is the same corruption root_plan pools its temporaries to avoid, arriving
through the name rather than through the supply, which is a lesson about where
identity lives: the table is keyed by Types.to_string now, which is an
identity, and the symbol carries a counter so two types cannot collide however
they mangle.  dyn-struct.flan grows the pair, held live across the churn, and
an acceptance assertion asks the emitter directly how many descriptors it
wrote under that label — two, or the two are sharing one.  That assertion is
the half with teeth: whether an overread off the end of a frame slot lands on
anything is luck, and the run's own output was not red under the defect.

The cap on a flattened array's offsets was bypassable by the thing it was
meant to stop.  [4611686018427387904 S] wrapped the multiplication negative,
so the test read as under the cap, the declaration was accepted, and the
emitter then sat building the offset list until something killed it.  A
refusal that overflows into an acceptance is worse than no refusal.  The count
saturates at one past the cap now and the message says more-than rather than a
figure that came out of a wrap.

dyn_ops.c's second assertion had no teeth: a marker never writes through a
root, so "the word at a non-dyn offset is untouched" passed under any marker
at all.  What discriminates offset-driven from word-driven is a dyn word the
descriptor leaves out, holding five hundred objects, that must NOT survive —
and it is checked by adding its offset to the table and watching the line go
red.

dyn_anywhere descended through Ptr and Slice, so (Vec (Ptr Cond)) was refused
with a sentence about a dyn inside a type whose storage contains none.  A
vector of pointers to condition structs is an ordinary thing to write.  It
stops at a pointer now, which is the line hidden_dyn already took for a bare
(Ptr S) and the line the whole argument rests on: a pointer is a view of
storage something else roots.

Which leaves the one honest hole, and it is named at the boundary where it
opens rather than left in a comment.  Storage C hands back was never rooted
and never will be, so a (Ptr S) crossing a declare with a dyn anywhere under S
is refused by name — the same sentence a bare dyn already gets there, one
level down.

dune test --force: green, 0 failures.  dyn-struct.flan clean under ASan and
UBSan and identical at -O2, -O0 and --x86.
2026-09-19 22:53:16 +07:00
JosephFerano f6ab3b62fc A struct's dyn fields become markable, so the refusal comes off
The crux was never where to put a descriptor; it was how an instance finds
one.  A bare struct on the stack has no header to hang a pointer off, and
giving it one would change the layout C interop agrees on, change the stride
of an array and change what embedding a struct in another costs.  So it has
none.  The instance never carries a pointer to its type and the collector
never derives one from the bytes: the pairing of an address with a descriptor
is made at the *push*, by the code that put the value there and therefore
knows its static type.  That is the same trick the shadow stack has always
used, and it makes the stack case the easy one rather than the impossible one.

A descriptor is the size of an instance, a count, and a table of byte offsets,
emitted once per type as private static data.  Flattened, not a graph — a
struct held by value contributes its offsets shifted by where it sits, and a
fixed array contributes its element's once per element — so nesting costs
nothing at run time and there is no recursion in the marker.  The offsets of a
big array would be a big table, and that is capped with a sentence rather than
half of the repeat form item 3 will bring.

Four places a value of such a type can live, and all four are rooted: a frame
slot, a global, the temporary a call's by-value return is spilled into, and
the slot a condition that is not a place is evaluated into.  The last two are
new and are the ones that were not obvious.  A callee roots its dyn words and
pops them in its epilogue, so between the return and the caller's store the
only copy is a register, which a collector that finds its roots by address
cannot see; the same hole was open for a Flan call answering a bare dyn and is
closed here too.  And a condition crosses as a pointer into the signalling
frame while a handler allocates, which is exactly what the original refusal
said could not be made safe.

dyn_roots grows into root_plan and both backends read it, which is what the
older note about one counter deciding both ends was always for.  The aggregate
temporaries are pooled by type rather than handed out in mint order: a
positional supply that drifted would pair an address with another type's
descriptor, and marking arbitrary offsets off a base is corruption where a
missed root is only a bug.  Pooled, the worst a drift can do is run out.

What is still refused is a dyn no static offset can reach — inside a typed
container, in a data type's payload or a union's members where the cases
overlay, or under an Option where the payload exists only beneath the tag.
A (Ptr S) and a [S] are deliberately not on that list: neither owns storage,
and the only storage this compiler hands out for such a type is a frame slot,
a global or a fixed array in one, all of them already rooted.  That is what
lets a handler clause take its (Ptr Cond) and read a dyn payload.

test/programs/dyn-struct.flan is the evidence.  It runs forty thousand rows
past flan_dyn.c's one-megabyte floor, so marks and sweeps really happen, and
it holds live values through them in all four places at once.  It has teeth:
with the descriptor walk stubbed out of the marker, the kept vector's length
comes back 24 instead of 628 and its first element is a stale word.  Clean
under ASan and UBSan, same output at -O2, -O0 and --x86.  dyn_ops.c grows an
aggregate-root mode so the runtime half can be wrong on its own, with a
header word holding a bit pattern that looks boxed and is not a dyn slot.

--no-gc still refuses, and had to be told how: a struct with a dyn field is a
collected value even when no expression in the program ever has the type dyn,
because a zeroed one still has a word the collector is asked to mark.

dune test --force: green, 0 failures across every suite.
2026-09-19 22:53:16 +07:00
JosephFerano 7b3773c344 A worker pool for the 112 clang calls test_acceptance was making one at a time
They were the whole of this binary's wall clock, and none of it was CPU the
machine did not have: one core busy, fifteen idle, for 30-odd seconds of
independent programs run strictly one after another. A small pool now forks
up to eight of them at once and reads results back through a pipe in the
order the rows are written in, so the log is byte-for-byte what it always
was — just produced faster.

Eight, not sixteen: dune already runs this suite's eleven binaries at once,
and test_dev's daemon is the actual critical path at roughly 46s against
this one's 30. Handing the pool the whole box would rob the binary that
matters more to speed up the one that matters less.

Every temporary path a worker touches now carries its own pid, so a
program's -O2 row and its -O0 row, run in different forked processes, can
never collide on the same exe or the same captured stdout underfoot. Three
rows could not be pooled that way regardless: slurp.flan and files.flan
write to fixed relative paths their own variants share, with a clean()
between them that only makes sense run in order, so those three stay on the
direct, unpooled path the whole file used before.

A worker that dies without ever writing its result — signalled, OOM-killed,
segfaulted mid-compile — is counted and named rather than silently read as
a pass; the exit status is trusted over a payload that never arrived. And a
worker clears its own SIGALRM handler on the way in, so the parent's
watchdog can never be triggered from inside a child holding a stale copy of
its sibling list. If the watchdog does fire from the parent, it now kills
every worker still in flight before it exits, so a hang does not also leave
orphans behind it.

Standalone, test_acceptance went from 43.4s to 13.8-20.8s across three runs
with identical output every time. The full dune test did not move — 49.4-
49.9s after against 49.8s before — which is the eleven-way sibling
parallelism already claiming what this pool would have used.
2026-09-19 22:22:42 +07:00
JosephFerano 7c1a818001 The defer counter costs something on the warm path, and the valgrind row rests on one half
Three notes, all of them about a comment that was true as far as it
went. The cost sentence on defer_slot said one i64 and one compare on
a path that is already unwinding, which is the unwind's share and not
the whole bill: every function with a defer also pays a store of zero
at entry and a store of an ordinal at each defer, on the ordinary path,
whether anything transfers or not. Small, correct, and now written
down as what it is.

init-conditions.flan is in test_valgrind.ml, and only the game-data
half earns that row -- it is the one that reaches a real (free src)
over a slot slurp transferred out of. The note half is output-only:
revert the fix and its trace changes, but a wrong integer in a global
is not a memory error and memcheck stays quiet. A later edit trimming
the edn dependency would leave the row green and blind, so the header
says so.

And register_defer saves in_defer rather than clearing it. Unreachable
today, because defer_ok is false inside a defer and nothing can nest
one; written so that the flag comes back rather than being dropped on
the day that changes.
2026-09-19 21:59:11 +07:00
JosephFerano 4c667cf062 A defer the text never reached is not cleanup the unwind owes
The report was that handler-case segfaults in a top-level global
initialiser. It does not, and never did. What crashes is one frame
further down, in a position that has nothing to do with startup:

    (defn read-file [path string] dyn
      (let [src (slurp path (heap-allocator))]
        (defer (free src))
        (read (as-slice src))))

slurp signals FileError, a handler further out unwinds, and the
transfer leaves this frame through its defers -- all of them. But src
was never written: the form that would have written it is the form
that transferred. free then reads whatever the stack held under that
slot, which at -O0 in a small program is zero and at -O2 is a live
pointer, which is why the same program looked like an optimiser bug
from one direction and a startup bug from the other.

return never had this. The checker splices the defers registered above
it and no others, and says so where it does it. The transfer exit took
the whole list, because it is one landing block per function and
nothing in the IR said where each defer had come into being.

So the count is kept. The first defer in a function mints an i64 slot
zeroed at the top of the body; each defer leaves a store of its own
number where it was written; and fdefers -- the transfer path's copy,
and only that copy -- tests the count before running each one. The
normal paths are untouched and still need no test. It is all in the
checker: what reaches a backend is a slot, a store and an if, so
neither emitter learned anything and the x86 one needed no frame of
its own.

test/programs/init-conditions.flan is the survey. The top half is the
part of the report that was never true: handler-case with its
condition firing and with its body completing, handler-bind, and a
restart-case, all four in a global initialiser, all four answering
what they answer anywhere. The bottom half is the part that was: a
defer below the signalling form, which must not run, beside one above
it, which must -- a fix that took the unregistered one off by taking
them all off would have traded the crash for a leak, and 302/2 is the
line that would catch it. Three acceptance rows, LLVM, -O0 and --x86.

The detector was valgrind, not ASan: reading a stack slot nobody wrote
is not ASan's bug class and it reported nothing on the broken binary,
while memcheck named the conditional jump in flan_vec_free with the
unwinding frame directly above it. The program is in both lists --
test_valgrind.ml because that is what saw it, test_sanitize.ml because
that is where the transfer exit's frames are already watched.

spec-conditions.md section 5 now says which defers a transfer runs.
The author's (defvar game-data dyn (handler-case (edn/read-file ...)
[(FileError [c] nil)])) works.
2026-09-19 21:57:00 +07:00
JosephFerano c3e0703642 Five more, and the pattern held: the messages were right, the prose wasn't
check_struct's comment claimed a nonempty map is never mistaken for a
struct literal; is_struct_map (parse.ml) says otherwise for any size of
.field-first map, not just {} -- (g {.x 1}) on a function g still reported
bare "unknown struct g" with none of the help {} gets. Extended the
helpful message to that shape too rather than only fixing the comment,
since it is the same trap one shape over. The two shapes need different
advice, not the same one reworded: an empty map can be bound to a variable
and passed as an ordinary argument, (let [m {}] (g m)); a .field-keyed one
cannot, because expr itself refuses a bare {.field v} outside a
struct-literal position, so there is no let-binding that rescues it. The
message for that shape says so instead of repeating advice that would not
work.

Watchdog.is_dying is gone. The reviewer's own probe settled it: forcing
watchdog.ml's dying to flush_all and Unix._exit 2 directly, bypassing
at_exit entirely, gives the same exit 2 with no shared flag, no LIFO
assumption, and no window for a raise between setting a flag and exiting
to leave failures recorded and the process at exit 0. test_acceptance.exe
has no other at_exit registration (grep confirms), so nothing depends on
the watchdog path running through that chain.

test_acceptance.ml's own comment claimed calling exit inside an at_exit
handler recurses through do_at_exit. Checked directly against this
compiler (OCaml 5.2.0): it does not -- each handler gets a run-once guard
since 4.14, two stacked handlers with the inner one calling exit 7 both
ran exactly once and exited 7. Unix._exit is kept, but for the true
reason: it is not about correctness, it is about being the last word --
_exit terminates immediately and skips whatever the rest of the at_exit
chain would otherwise still do, so a handler this file grows later cannot
change the outcome underneath this one.

test_flan.ml's loose needle "keyword/value pairs" matched both the
odd-number-of-forms message and the wrong-key message; tightened to
"keyword/value pairs — found", which only the row's actual message
contains.

And {:where 1} as a defn's whole single-form dyn-map body now refuses,
where {:a 1} and {} in the same position do not -- :where is peeled
unconditionally, with no single-form exception, because that is what lets
it catch a moved closing paren leaving a stray predicate as ordinary body
code. Deliberate, and now pinned, so the next edit to this arm has to
notice it is choosing to narrow the language again rather than finding out
from a bug report.

Verified every case by compiling, including the two-handler exit(7) probe
run directly against this OCaml. dune test --force: exit 0, clean grep for
FAIL and Fatal error.
2026-09-19 21:51:31 +07:00
JosephFerano 87aeb7b0da Six defects back from review, fixed rather than reworded around
F3: the unknown-struct-vs-function message overgeneralized a one-case
parser quirk into a language rule that does not exist. (g {:a 1}) compiles
fine -- only the empty map is stolen by is_struct_map's Map [] -> true.
The message now names {} specifically and says why: it is read as the
zero-field struct literal, not "a map literal cannot be passed as an
argument".

F2: the ordering refusal named machine numbers only, when Types.is_comparable
also admits enums and enum-compare.flan orders one with (< k :mid). Both
messages -- equality and ordering -- now name every type each actually
covers.

F1 and F5, together, since both live in the same parse.ml arm: the
rest = [] carve-out that let a single-form dyn map body through
reintroduced the exact bug the earlier fix was for. (defn mx [a $t b $t]
$t {:where (ordered? $t)}) -- a :where clause with nothing after it, what
a moved closing paren produces -- no longer matched, fell through to expr,
and printed "unknown function ordered?" from inside what was meant as a
predicate. A :where map is peeled unconditionally again, whether or not
anything follows it; every other keyword, plus the empty map and any
non-keyword key, is still peeled only when the body has more after it, so
a real single-form dyn map body is still left alone. The comment
justifying the discarded-map refusal claimed a map literal has no side
effects; it does -- {:a (println "hi")} prints, confirmed by running it --
so the reasoning now names the actual problem, a value going unused, not
a false claim about purity.

Closing that F1 hole this way opened two more, both traced by rebuilding
the pre-fix parse.ml and diffing real compiler output rather than
reasoning about it: {.x 1} at a defn body's head used to get its own
message, "a bare map is not an expression", and briefly started getting
"a constraint map is keyword/value pairs" instead, because the broadened
guard no longer required a keyword and started catching the struct-field
shape too. Excluded explicitly, with a comment saying why, so expr's
dedicated diagnostic fires again. And (defn main [] i32 {} 0) and
(defn main [] i32 {"a" 1} 0) -- the empty map and the non-keyword-keyed
map, the two siblings a keyword-only guard could never have caught --
now get their own refusals alongside the keyword case, each pinned with
a rejects_check row, along with the where-with-no-body case and the two
legitimate single-form bodies that must keep compiling.

F6: the acceptance runner's tail already caught every failure on every
path through the binary -- there is no skip branch that bypasses it, and
the no-clang branch runs zero rows -- so last round's at_exit guard and
the FIX.org note both overstated what was broken. Both now say what was
actually true: the exit status was already trustworthy, the guard is
insurance against a future case leaving past the tail instead of through
it, and the other nine test binaries were already sound the same way.
The guard also had a real bug of its own: forcing exit 1 whenever
failures was nonzero would stomp the watchdog's own exit 2 if a hang
followed a few already-failed rows, since Stdlib.exit runs at_exit
handlers LIFO. watchdog.ml now flags when it is the one unwinding, and
test_acceptance.ml's guard defers to it -- shared state for a single
caller, justified by there being no other way for one at_exit handler to
know a sibling handler is already mid-exit with a code of its own to
protect.

Verified every case in this commit by compiling and, where it mattered,
running the actual program -- not by inspecting the arm and assuming.
dune test --force: exit 0, clean grep for FAIL and Fatal error.
2026-09-19 21:35:08 +07:00
JosephFerano c76a507d3b The batch from two reviews plus the acceptance exit code, item by item
Constraints parsing peeled a body-leading map only when its first key was
literally :where; any other keyword fell through to the body, so a typo'd
key surfaced as a baffling error from inside what was meant as a predicate
and a stray map at body start compiled away silently. Any keyword-first map
is read as a constraint map now, but only when something follows it in the
body — a single-form map body is a real dyn value and not a discarded
statement, so that case is left alone.

An empty map literal still parses as a struct literal, (P {}) still meaning
the zero struct for a real struct name — the parser has no symbol table to
tell (take {}) apart from it at that point. check.ml now catches the case
where the name turns out to be a known function instead and says so, rather
than "unknown struct take".

flan_dyn.c's tag comment still said 6 and 7 were free; keywords and maps
took 4 and a kind field under BOX_OBJ, not new top-level tags, so 5, 6 and 7
are what is actually open for the interop handle. NEXT.md and json.flan both
still pointed at test/programs/arena-edn.flan, gone since edn/read stopped
taking an allocator; both now point at what replaced it.

flan_rt.c's flan_str_eq comment claimed the empty string literal was a
hypothetical null-pointer string; it isn't, its address is an interned
symbol's. The real case the zero-length guard exists for is a zero-length
container converted to a string. check.ml's ordering refusal said a string
has no comparison at all, which stopped being true when typed = and !=
grew strings in daed039 — split the message so an equality refusal and an
ordering refusal say the right noun, and updated the pinned rejects_check
rows to match. string-eq.flan gained the row the fast path most wants
tested, a slice against the prefix it was cut from sharing a base pointer at
different lengths, plus a != row at equal length with differing bytes;
acceptance now carries the real output, captured by running the program on
all three lanes. x86.ml's xor-1 comment now names the 0/1 return contract as
a requirement flan_str_eq must hold, not an incidental fact. SPIKE-DUPLICITY
now says plainly that its equality-and-ordering argument landed in daed039
and marks its transcript as the historical state that argument was made
against. FIX.org ticks M2 queue item 5.

And the acceptance runner: the tail check that turns a nonzero failure count
into exit 1 was already there and already fired — a fresh build with one row
broken already exited 1 before anything here changed. What wasn't proven is
that every path through the file's clang/wasmtime/raylib/lldb probes still
reaches that tail rather than skipping past rows that already failed. An
at_exit guard now closes that class regardless of which path the process
leaves by, flushing stdout first so a failing run's FAIL lines survive
Unix._exit rather than being dropped from the buffer. Verified both
directions with a deliberately broken row: dune test exits nonzero and the
log still carries the FAIL line and the failure count; restored, the same
run is exit 0 with nothing printed but green summaries. The other test
binaries were checked for the same gap and none have it — each gates its
own exit on a single failures ref that the tail already reads.
2026-09-19 21:18:57 +07:00
JosephFerano f360531918 Merge branch 'worktree-agent-a5df4c0409bf9d7c4' into dev-loop 2026-09-19 21:13:29 +07:00
JosephFerano e3565b30e8 The survey was counting the defers, not watching them
Every mark it left was an addition, and addition commutes, so a backend that
ran the defers outermost-first produced byte-identical output and the row that
was supposed to be watching the order could not have told. The claim was in the
comments and not in the numbers. cleanup.flan already had the device for this —
a shift rather than a sum — so the log here is a digit trace now, and the two
frames under a catch read 12 where a wrong order reads 21.

Rewriting the trace made room for the three behaviours that worked and nothing
pinned. A return inside a clause is an ordinary return from the function that
wrote the form, because that is where a clause runs: it leaves through the
function's own exit, runs the defer registered there after the two the unwind
already ran, and leaves the handler stack empty behind it, which the bare
signal that follows in main is the check on. A defer inside a clause is refused
for the reason every nested form is refused one. And a handler-case inside a
defer works, because a defer may not start a transfer that leaves it and this
one begins and ends its own.

The program is registered with the sanitizers, where the interesting failure is
not the heap but a handler or restart frame left on a stack pointing into an
alloca that has gone — an output comparison cannot see that until something
much later calls through it. It is clean; it was also leaking sixteen bytes out
of the vector main allocates to prove the allocator context came back, which is
the test's own litter and is freed now.

docs/PORTING.md ranked handler-case as one site handler-bind covers. It still
is one site, and handler-bind still covers it, but it is no longer the closer
translation: a catch block is assumed everywhere it is written to see the
locals around it, and only the clause that runs at the form does.

The handler clauses are lifted left to right rather than by List.map, whose
order is unspecified. Each lift names itself after the count already on the
list, so an order nobody chose would number the clauses of one handler-bind
differently between builds, and those names go into a redefinition module.
2026-09-19 21:13:03 +07:00
JosephFerano ec1a5358b1 Merge branch 'worktree-agent-a47ac6844d7e0f8a9' into dev-loop 2026-09-19 21:06:22 +07:00
JosephFerano 624b595869 The two models get named, and the gap handler-case fills gets written down
The dynamic paths follow Clojure and Common Lisp, the static paths follow
Odin, and the ML share is what neither supplies. Where the two Lisps
disagree the question is which one the rest of Flan already agrees with:
conditions say Common Lisp, maps and keywords say Clojure. The missing
unwinding handler is recorded alongside it, since a caller that wanted a
default had nothing to write once read-file stopped returning an Option.
2026-09-19 20:54:11 +07:00
JosephFerano a251dca67f handler-case is a handler-bind plus a transfer, and nothing more
The unwinding handler, which spec-conditions.md named and left unwritten while
it asked whether the thing should be a macro over the two operators that were
already here. It should. (handler-case B [(T [c] A)]) is checked as
(restart-case (handler-bind [(T [c] (invoke-restart 'R c))] B) (R [c T] A))
with R a name the form makes up for itself, which is Common Lisp's own
definition of the operator and means neither backend needed a line.

What that buys is not economy, it is the correctness of the parts nobody can
see. The defers between the signal and the form run, and the allocator a
with-allocator rebound is put back, because a transfer already does both for
every frame it leaves. The body and every clause agree on one type, because a
restart-case's body and clauses already do, and a clause that disagrees is
refused with the same message an if with disagreeing arms gets. A condition no
clause lists installs no frame that matches it and goes on outward untouched.
A clause sees the establishing function's locals, which a handler-bind clause
cannot, because a restart clause runs where it was written.

The body comes first and the clauses after it, the opposite of handler-bind's
order: one reads as something put around a body and the other as a body with
answers hung off the end of it. The restart the two halves meet over is named
after the function and numbered within it, and it has to be unique per form,
because two nested handler-cases sharing a name would have the inner frame
shadow the outer one and land a condition at the wrong place.

The refusals name handler-case rather than the machinery underneath it, which
is why check_handler_bind and the restart clauses now take the word the reader
wrote. A break loop entered under a handler-case still lists the made-up
restart, and taking it there is refused loudly rather than answered wrongly;
hiding it would mean a field in a frame layout spelled out in three files.

The survey program runs the same under LLVM, at -O0 and under --x86: normal
completion, a caught condition, one nobody listed passing through with the body
carrying on, both defers on the way out, the two nestings against handler-bind,
a clause that signals and is caught outside the form it belongs to, and a
with-allocator whose restore is on the transfer path.
2026-09-19 20:53:31 +07:00
JosephFerano 5adc1e2b03 The orphan-grace scenario's wait no longer proves patience
FLAN_DEV_CLIENT_GRACE dropped from 0.2s to 0.1s, so the daemon's Live
threshold (six graces) is 0.6s instead of 1.2s -- still three ticks
past accept_loop's own 0.2s select granularity, which is where the
margin has to live rather than in the test's own wait. The structural
await scales with it, from three seconds to one: the assertion was
never about the clock, only about serve() sitting in Wire.recv while a
client holds the socket, so the number just had to clear the threshold
with room, not any particular multiple of it.

The other awaits in this test are already ceilings that return the
moment their condition is true, so they cost what the daemon actually
takes and nothing was touched there. A trace of the eval-expr path
elsewhere in this file turned up a second five-second wait, twice over
-- but that one is a documented hang-detection timeout on a program
that structurally cannot reach a frame boundary, the exact shape the
task said to leave alone, so it stayed.
2026-09-19 20:24:19 +07:00
JosephFerano b77b4f3a1f Merge branch 'worktree-agent-a22794807b6ae26e1' into dev-loop 2026-09-19 20:17:26 +07:00
JosephFerano 9b4337f9e5 Merge branch 'worktree-agent-a3009901a950e8daa' into dev-loop 2026-09-19 20:17:23 +07:00
JosephFerano d3a9c790f0 The queue learns what today decided: descriptors, sweeps, and a parked JS hole
Item 3's descriptor is its own thing rather than the slice type reused, item
8 is settled with both spellings intact, and the sweep policy now says a lane
runs the fast check while the surveys are paid for once across several lanes.
The JS dialect's wrong answer on string equality is written down where the
next reader will find it, along with the surface-syntax discussion that ended
in deferral.
2026-09-19 20:17:18 +07:00
JosephFerano daed039331 Typed = and != grow strings — M2 queue item 5
Types.is_equatable splits from is_comparable: a string answers equal?
now, bytewise, but still answers no to ordered? — there is no collation
the language has picked, so < and friends keep the refusal they had.

The comparison itself is one new runtime entry point, flan_str_eq
(runtime/flan_rt.c), length-mismatch and same-pointer fast paths ahead
of the memcmp, called identically from both backends: emit.ml pulls a
string's ptr and length out of the %slice SSA value and calls it
directly in the Eq/Ne arm; x86.ml adds an arm ahead of the generic
scalar comparison that reaches it through call_native, flipping the
answer for != the same way Not already flips a bool.

test_flan.ml covers the checker side directly and through a generic
instantiated at string, including the two different ways ordered? and
equal? fail at that type. test/programs/string-eq.flan is the survey
program — same pointer, differing lengths, equal content at distinct
addresses (a literal against a fresh heap string), a difference in the
last byte, and the empty-string cases — with acceptance rows for LLVM,
-O0 and --x86 in test_acceptance.ml.
2026-09-19 19:56:36 +07:00
JosephFerano ab82e46119 Dyn maps, keywords and nil land: milestone 2's first item
The dyn runtime gets a map object and an interned keyword, alongside the
vec it already had. {:a 1 :b s} is a map literal wherever a struct
literal isn't — the parser tells the two apart by whether the first form
in the braces is a .field symbol — and a bracket literal builds the
runtime's own vec rather than a typed array wherever a dyn is wanted, which
is what lets a map literal's values nest arrays and maps freely. get, put,
len and has-key? all learn a dyn-map arm alongside the typed-map one they
already had, and (keyword s) builds the same interned value a :foo literal
does, for a name that only exists at run time. nil is now a literal, the
dyn absence value that get answers for a key a map does not hold.

On the runtime side, flan_dyn.c gets an OBJ_MAP that shares the vec's
storage arm and doubles its accounting, a linear-scan intern table for
keywords that makes equality an identity compare, and structural map
equality by lookup rather than position. The marker traces a map's
interleaved keys and values the same way it already traced a vec.

edn/read and its callers move off the old (Option Value) union entirely:
a document is plain dyn now, sets are dyn maps to true, and arena-edn.flan
is retired along with the union it demonstrated. The acceptance suite's
edn-read and json rows were recaptured against the new shape, and a new
dyn-map.flan program exercises the map and keyword operations end to end,
including a 200k-iteration churn loop against a rooted map that runs
GC for real, across the LLVM, -O0 and x86 rows, and under the sanitizer.

Keywords are dyn everywhere an enum isn't expected, which changed what a
couple of existing checker tests actually see refused; both were updated
to the sentence the checker gives now rather than the one it used to.
2026-09-19 19:44:56 +07:00
JosephFerano 73ab213134 Merge branch 'worktree-agent-af0dbd9d75a1c8bee' into dev-loop
# Conflicts:
#	test/dune
#	test/test_acceptance.ml
2026-09-19 16:02:39 +07:00
JosephFerano 92fae67fb3 The collector's Flan-side roots go under the sanitizers
The sweep's note said there was no Flan program that reached flan_dyn.c, so
the only sanitized run over the collector was dyn_ops.c's -- which pushes
its roots by hand. That note stopped being true when the dyn programs
landed, and it stayed in the file. The gap it left is the one that matters
for the backend lane just committed: a root the *emitter* forgot is a live
object swept, and no amount of C testing can see a mistake the compiler
made. dyn-vec and dyn-defer are in the corpus now -- the second for the
roots that come off on a transfer's path out rather than a return's -- and
so is p13-dyn-collect, which is the only program anywhere that allocates
past flan_dyn.c's one-megabyte floor and therefore the only one under which
a mark and a sweep actually run. Everything else in that list agrees with
ASan by never collecting at all.

p13 lives in spike/x86 because that is the lane that wrote it, so the
alias's deps grew a glob for that directory; it is in this sweep for what it
does and not for where it sits.

What this cannot cover, and the compiler says so itself when asked: there is
no sanitizer pass over hand-written assembly, so --x86 --sanitize is refused
by name. ASan sees the x86 lane's roots only from the collector's side of
the call, never as frame slots. p13 through --x86 under the @x86 sweep is
what stands in for it, and it is a weaker check honestly labelled rather
than a stronger one assumed.

--force @sanitize: clean, and non-empty, which the previous run was not --
an alias satisfied from cache prints nothing and reads exactly like a pass.
dune test --force still green.
2026-09-19 16:01:26 +07:00
JosephFerano 5f55c177ba The M2 queue, decided item by item 2026-09-19 15:46:19 +07:00
JosephFerano d722b267e6 Two dyn rows had been asserting the stub's output, not the runtime's
Found by adding the --x86 rows beside them: the new rows failed, and so did
the LLVM rows they were copied from, identically and on the tip. That is the
tell -- a backend cannot change what a runtime prints, so the expectation
was what had gone stale. Both were written while flan_dyn.c was the stub
that mallocs and never frees, and the real renderer landed with different
answers to two questions the stub never had to answer.

The container line has a space after the open bracket because the space is
a prefix per element rather than a separator between them, and a text nested
in a container is escaped and quoted while the same text printed alone is
not -- three bare on its own line, "three" inside the vector. Both are
deliberate and both are pinned by the runtime's own C test, which asserts
"[ 1 2 3]" and "[ \"x\" \"a b\" ...]"; this file is the side that had not
caught up, so this file moves.

The trap sentence is the same story: it names the tag it found and the tag
it wanted, and the row now matches on that half rather than on the wording
the stub used.

dune test --force: 6 failures to 0.
2026-09-19 15:06:50 +07:00
JosephFerano 01e60fa5b7 A struct cannot hold a dyn field the collector would never find
The condition payload was already refused on exactly this ground; the plain
struct got the same shape past the checker.  The audit's probe showed the
emitted program rooting the temporary, popping it at ret, and leaving the
field's vec reachable only through arena memory the marker never walks —
a use-after-free on a timer.  Lifted with milestone 2's per-type
descriptors, alongside the condition's.
2026-09-19 14:30:46 +07:00
JosephFerano 477ec2226e The duplicity audit: where the two sides meet, and where they already leak 2026-09-19 14:27:11 +07:00
JosephFerano 5bd533c64e Merge: the inspector writes, against the stop it rendered under 2026-09-19 14:25:13 +07:00
JosephFerano 6d87c02584 Both backends are asked whether they store where the render said they do
The x86 block asks the break loop's questions of an --x86 host so that the two
sets of answers can be read against each other rather than merely found
plausible. A store is where they could most easily differ: the place forms the
walk ends at are lowered by each backend's own `place', and the two disagree
about an Option — which is why what may be written is settled in session.ml
above both of them and not in either. So the same slot is written, read back,
and refused for a type that does not fit, on this backend too.

And `Check.expressions' carries the comment that was written for the function
it replaced; the one-expression entry beside it has its own line.
2026-09-19 14:24:10 +07:00
JosephFerano c2d378957e The x86 backend and dyn finally meet, which is where the dev loop is
x86 is what flan dev takes by default and dyn is the iteration feature, so
a backend that refused dyn meant the two halves of the dev loop could not
be in the same program. The refusal was one arm of is_agg, and it said the
true thing: it was never the representation that was missing. A dyn is
uint64_t, a scalar in both calling conventions, classified by every rule
this file already had; every operation on one is a Tast.Rt primitive and
call_rt has always known how to make one of those. What the lane actually
cost was the collector's root discipline.

Which is emit.ml's, reused rather than rewritten: Emit.dyn_roots counts the
roots for both backends now, so the pushes and the pops balance because one
counter decides both ends, and the two backends root the same nodes because
there is one counter and not two. A zeroed frame slot per dyn slot and per
dyn-producing call, minted beside the channel and outside every scoped --
the bump allocator reclaims at the end of a statement and a slot minted in
the body would be handed out again while the collector still held its
address. Pushed from the body buffer, not the prologue's, because a call
clobbers the registers the prologue is still spilling from. And one pop in
the epilogue, which is the whole of why this backend needed no landing-pad
work for it: there is exactly one epilogue, and the return, the fall-through
and the transfer exit all arrive at it. emit.ml needs the same pop at five
separate rets.

The ABI point the dyn handoff left open for the integrator is settled by
reading the other side rather than by agreeing: flan_dyn.c's mark follows a
value only when the quiet-NaN prefix is set, and the zero word does not have
it, so a zeroed root decodes as the double 0.0 and is never an address
anything dereferences. Zero is safe for a reason. The header says so now.

And one line in dev.ml that was never x86's: the merged dev host resets the
condition stacks and the frame chain between runs, because main is
re-entered by longjmp and pops no frame -- and it never reset the root
stack, so every root a finished run pushed still named stack the next run
was about to write over. That gap was an LLVM dev build's too.

Verification, and one of the numbers is new. @x86: MATCH 129 -> 135, DIFFER
0, REFUSED 0 -- the five dyn programs off survey.sh's llvmonly list, which
is gone rather than empty, plus p13. dune test --force green, with --x86
acceptance rows beside the LLVM ones for all five dyn programs, dyn-boundary
asserted on the same exit 134 and the same sentence on both.

p13-dyn-collect.flan is the one that is not a formality. Nothing else in
this repository allocates past flan_dyn.c's one-megabyte floor, so nothing
else collects even once, so a program whose roots are entirely wrong passes
every output test there is -- the handoff wrote that about the stub and it
outlived the stub. p13 allocates several megabytes of garbage while holding
live values across it: at forty times the corpus size it peaks at 4MB of
RSS, which is the collector running many times over, and both backends
still print the same four lines.
2026-09-19 14:22:55 +07:00
JosephFerano 5bd25b61df Where the session stopped, and what is still in the air 2026-09-19 10:59:29 +07:00
JosephFerano 18b834275c The inference spike's report, which the dyn decision was made against 2026-09-19 10:59:00 +07:00
JosephFerano d43c372efe A listing drawn after a commit is a listing again
`flan-inspect-edit' turns off read-only and line truncation, and `--show' is
the one funnel every drawing goes through — but the mode body that set them in
the first place does not run a second time on a buffer already in the mode. So
a listing drawn after a commit or an abandon presented as a listing and was
plain editable text: `C-k' on one of its lines did what `C-k' does. Put back
beside the flag that says which of the two the buffer is.

And that flag is now the marker it should have been. The header above the
value is two lines or three depending on whether the daemon named a type, and
the count that skipped it was written once in the writer and once in the
reader that parses the buffer back — two places to keep in step for nothing.

The MANUAL's key table lists the two keys that commit and abandon, which were
in the prose and not in the table anybody scans.
2026-09-19 10:38:02 +07:00
JosephFerano 416ce10ee7 The inspector buffer is the value, and a commit is a diff
`e` on a line sets that field: prompted with what is there, sending a Flan
expression that the program evaluates and the checker measures against the type
of the place it is going into. `C-c C-e` turns the buffer into the value — the
Flan literal the program wrote, which is the value's own spelling and not a
second notation invented for editing it — and `C-c C-c` commits, `C-c C-k`
abandons.

A commit is a diff. The buffer is read back as a value, compared leaf by leaf
with what was drawn, and one write goes out per leaf that changed, so editing
one field does not rewrite the others with whatever was on the screen. A shape
that changed is a refusal and it voids the edits collected before it: the walk
visits fields in order, so a struct with one good change and one impossible one
has already collected the good one, and handing that back beside the refusal
would make it possible to send half of what was asked for.

The truncation guard is the one that is easy to miss. `...` is what the
renderer writes where it stopped, and a commit read off a buffer holding one
could not tell a field that was never written from one somebody deleted — so
such a value refuses to be opened for editing at all.

Writing is refused on the expression and address roots, by name. An expression
is evaluated wherever the evaluator stands and whenever it next reaches a frame
boundary, which for a write means possibly into a running program; globals stay
unwritable from here until they have a root that names a stop.

test-flan.el runs the whole chain against a real daemon — a frame, a slot index
off the listing, a render, a set, a re-read, a typed refusal, an edited buffer,
a commit, and a commit against a stop the program has left.
2026-09-19 10:30:56 +07:00
JosephFerano 37f8f77380 The inspector can store, and a write names the stop it was addressed to
`(:op "set")` is `inspect`'s addressing with the arrow turned round: same
frame, same slot index, same path steps, same fingerprint check, and a list of
(path, expression) edits relative to what the buffer is showing. The thunk
stores and then renders the same place, so what comes back is what the program
holds afterwards rather than an echo of what was asked for.

`stopped-only` was not enough for a write. It asks whether the program is
stopped, and for a read that is the whole question — the worst a render can do
against the wrong stop is print something true of a different frame. A module
that *stores* through `flan/dev-slot` reaches its target through whatever
snapshot is on top when the store runs, so a resume and a second stop inside
the ~300ms build window lands it in the same slot index of a different stack.
Not a fault: a plausible shape, in the wrong place, silently. So the agent
grew `at-stop N` beside `stopped-only`, the generation `snap_push` already
mints for the restart machinery, checked on the game thread at the moment the
job is claimed. `stop` answers it, `inspect` carries it out on every reply, and
a write that names a stop the program has left is refused before anything is
built.

Three refusals about where rather than what, and they are in session.ml above
both backends because emit and x86 disagree about two of them. A data type's
case field has no address that does not also settle the tag. An option's
payload has none that does not settle whether there is one. A pointer would be
an address this end made up, which is the blessing the registry exists to
insist on.

`Check.expression` grew a `want` and `Check.expressions` a shared frame: the
first is why `3` into an `f32` field is an f32 three rather than "expected f32,
found i32", and the second is why two edits in one commit are not two `let`s
reading each other's storage.
2026-09-19 10:16:03 +07:00
JosephFerano 2ec12c064d handler-bind has a value, and both backends now say the same one
(defn compute [] i64 (handler-bind [...] (risky))) printed 0 through LLVM
and 2 through --x86, and neither was the restart's answer. The divergence
was real and the cause was in neither backend: check_handler_bind wrote
[ignore want] and typed the form Unit, so a unit in value position was
never checked against the expectation that would have refused it. Both
lowerings then answered a caller that had no business asking -- emit.ml a
literal zeroinitializer, x86.ml whatever the body's last form had left in
the destination slot. One of those looked like a value.

Unit was the wrong answer anyway. Every use of handler-bind in value
position in this repository -- restarts.flan, cleanup.flan,
p6-transfer.flan, p10-defer-transfer.flan -- writes it as a restart-case
body, where §3 requires the body and the clauses to agree in type; making
the form unit refuses all four. So it takes with-allocator's shape, which
is the same shape for the same reason: the body's last form is the value,
threaded through [expect] like any other. handler-case, whose value is the
handler's rather than the body's, is untouched and still refused by name in
parse.ml -- that difference is the whole of what separates the two, and it
is not this one.

emit.ml returns [last] with no phi and no slot: the pad terminates at
current_pad and never at the join, so the join has one predecessor and the
body's value dominates it. x86.ml needed no change at all -- it had been
passing dst and the type through to the body all along.

p12-handler-value.flan is the shape the survey could not see, plus the
neighbours a divergence usually travels with: a clause parameter, nested
restart-cases, a defer between the signal and the restart-case, and f64
and string across the transfer. All six already agreed; the handler-bind
value was alone. @x86 MATCH 128 -> 129, DIFFER 0.
2026-09-19 10:14:07 +07:00
JosephFerano 1d74a4f694 Merge: a macro reads the file, and the struct is the file's shape 2026-09-19 09:58:50 +07:00
JosephFerano 55543747d3 The dyn expectations follow the typed printer, which the stub did not 2026-09-19 09:58:04 +07:00
JosephFerano 889fdc30d9 A file that does not parse says so, and the provider is written down
The louder failure had the quieter answer. A generated reader accumulates
errors on the cursor — which is what lets it be a straight line of
assignments — and the cursor is made and dropped inside the entry point, so a
stray brace in a file read at run time handed the program a zeroed struct and
said nothing at all. That is the one thing the rest of vendor:edn refuses to
do: read-file answers an Option precisely so a malformed document is
distinguishable from one that is literally nil, and the hand-written reader in
test/programs/edn.flan tests ok? and prints the reason. ReadFailed is the
derived reader being as honest, in both packages, and it sits beside
SchemaDrift because both are "the file is not what this program was built for".

Then the writing-down. docs/BUILT.md gets the section: the four things the
macro system did not have and now does, each general and none of them
mentioning EDN — a macro reading a file at the call site's path, a package
macro calling its package, one call answering several declarations, and
compile-error, which is the one piece that had to go in the compiler and the
reason it had to. The set rule the real game file decided is there too, and
what defjson shares, which is the design and not the code.

NEXT.md item 9 and PORTING.md §3.9 both close. Not as (read-edn T bytes): the
struct comes from the *file* rather than from a type declared by hand, so the
~80 lines PORTING prices for two schemas are not written at all. The competing
answer PORTING names — compile-time embedding — turned out to be the other half
rather than a competitor: the shape comes from the file at compile time and the
bytes may come from an embed beside it, which is exactly what
test/programs/edn-provide.flan does.
2026-09-19 06:59:22 +07:00
JosephFerano f885e1abfa Merge: unannotated means dyn, and the typed world pays nothing 2026-09-19 06:52:39 +07:00
JosephFerano dc34c62ce0 defjson, which shares the design and not a line of the code
vendor:json depends on vendor:edn for nothing, and borrowing a shape walk
across that line would be a dependency for the sake of a resemblance. What
carries over is the shape of the answer — one walk giving a type, the
declarations that type needs and the expression that reads one; a refusal
carried in a field rather than raised; a typed one-line constructor per
collection; and a compile-error wrapped in a defn nothing calls.

What is genuinely different is four things. Strings go through string-of and
never through .text: .text is the raw interior with escapes undecoded, so a
field read off it would hold a backslash and an n where the file meant a
newline — which is the first two lines of the acceptance output and the reason
they are two. Commas and colons are tokens rather than whitespace. An object's
keys are strings, so a key has to be refused when it is not a name a program
could write, and refused again when it carries an escape: the generated reader
compares against the bytes as written, which costs no allocation per key and is
only the same question when the name is written plainly. And there are no sets,
so there is no map-key path and no fixed array — every collection is a (Vec T)
and defjson is the smaller of the two by half.

JSON has no integer type; the tokenizer draws the line at whether a number has
a fraction or an exponent, which is the only line there is, so 1 derives i64
and 1.0 derives f64. That is the file's own distinction and the honest one to
take.

@x86 matches on it and @sanitize is clean.
2026-09-19 06:41:57 +07:00
JosephFerano bd981ff87a Four dyn values in a defer the collector had never been told about
A defer is in the typed IR twice -- spliced into the body for the normal path,
and again in fdefers for the path a transfer leaves through -- so a dyn
temporary inside one is emitted twice. dyn_roots counted only the body's, and
the second copy went into slots nothing had rooted.

Nothing failed, and that is the whole reason this is worth a commit of its own.
dyn_tmp falls back to a plain slot rather than unbalancing the stack, so the
pushes and the pops still matched, the program ran and printed the right answer,
and the values were simply invisible. Against a stub that never collects there is
no symptom to find -- no leak, no crash, no wrong number. It would have become a
symptom the week the real collector landed, in a defer reached only on a handled
condition, which is close to the worst place to start looking.

What found it was the IR: a rooted slot is spelled %dr and the fallback %dx, and
the assertion is that no dyn program in the corpus emits one of the latter. That
is now a test over all five dyn programs, and it is the only check in the lane
that can see a missing root while there is still nothing to lose one by. When
the collector arrives it is the thing to extend rather than replace.

Also checked, both clean: flan dev --llvm builds and runs a dyn program, which
is the route the x86 refusal sends people to and would have been a link error in
the worst possible place; and the daemon's own refusal already names the flag.
2026-09-19 06:40:53 +07:00
JosephFerano 4aa24c0e33 The zero-root question is the integrator's, and the handoff says which asks are open
Two decisions in this lane went against the brief and both are written down
where the next reader will meet them: the return slot stayed mandatory, so the
third state ret = None was to grow does not exist and neither does the fallout
listed for load.ml, shim.ml and cimport.ml; and the parameter rule is resolved in
Check rather than in parse.ml, because cimport passes C type names through
verbatim and POSIX's lowercase stat and timespec are writable in parameter
position, which is what makes a syntactic rule unsound rather than merely
awkward.

The open ABI point is in flan_dyn.h beside the root functions rather than only
in the handoff, because the header is what the two sides diff. A rooted slot
holding 0 is not a value: the compiler zeroes every root at entry because the
push happens before the code that fills it and possibly for a branch that never
runs, and 0 is the only pattern it can write without knowing the encoding. If
the real runtime NaN-boxes and integer zero is the zero word then this is wrong
and both sides change together.

Session.compatible needed nothing: it compares with Types.equal over the
parameters and the return, and dyn is equal to itself and to nothing else. Both
directions are pinned anyway, because this is the one place "changes signature"
covers a change the source does not spell out -- a parameter can become dyn, or
stop being dyn, by a type being declared elsewhere in the program.

@x86 128 match 0 differ 0 refused, @sanitize clean, dune test green.
2026-09-19 06:36:23 +07:00
JosephFerano 7c7586ebc6 --no-gc is a pass, not a flag the emitter can see
The promise is that this program carries no collector, and the way to keep it is
to refuse every dyn rather than to emit a different program: a dyn value is one
the runtime allocates and the collector owns, and there is no smaller version to
fall back to. So it runs between checking and emission, answers unit or raises,
and hands the very same program on. Emit has no field to branch on and is told
nothing.

That is what makes the byte-identity claim true rather than approximate, and it
is tested by compiling three annotated programs twice and comparing the text. A
field, a mode, or a comment that mentioned the flag would break it on something
incidental, a long way from anything to do with dyn.

Every site is named, the way the global cycle refusal names the whole ring: a
reader who has to annotate their program wants the list, not the first one and
then another compile. Globals and signatures as well as body values -- the two
files it is tested against report nine sites each, and the floors are set under
that so an added line does not fail the test and a pass that named one site and
stopped would.

The four programs run at -O2 and -O0. dyn-boundary is asserted on its exit
status as well as its output, because the boundary is only interesting in that
it can fail and a test that showed it working would be testing the easy half.
The x86 survey skips them by name: a REFUSED there means a node that backend has
stopped lowering, which is a regression, and this is the opposite -- a lane that
has not started. Take a name off llvmonly when the lowering arrives and the
survey will say whether it works. 128 match, 0 differ, 0 refused.

Checked while writing these: a dyn function with an early return pops its roots
on both paths, and one with a defer pops on the transfer path too.
2026-09-19 06:33:28 +07:00
JosephFerano 76f23fc68b C-c C-m over a provider, and what it has to show
The live loop's two halves, asserted where they live. The path first: a macro
reading a data file resolves it against the directory of the source file the
form is written in, and here that file is an origin the editor sent rather than
anything on a command line — assets/edn/tuning.edn sits beside
programs/edn-provide.flan and nowhere near the process's own directory, so an
expansion that answered at all read the right file. Nothing is cached but the
macro module, which holds the macro's code and not the data, so editing the
.edn and expanding again is the loop.

Then that the answer is readable, which is the point of generating code a
person can look at rather than the smallest code there is: the struct with a
type per field, the nested struct named for the path that reaches it, and the
reader's dispatch on a key onto a field. Substrings and not a golden copy — the
expansion is some hundreds of characters and a full one would fail on every
comment reflowed in the derivation.

@x86 matches on the generated reader and @sanitize is clean over it. The x86
run is the one worth naming: the set became a (Map [2 i64] bool), and a
fixed-array key is a hash and equality pair the backend emits, which nothing
generated had asked it for before.
2026-09-19 06:26:33 +07:00
JosephFerano 9f2f0b1635 Roots, pushed where the addresses are stable and popped where the frame leaves
A precise collector has to be told where the live dyn words are, and the shadow
stack next door is the precedent for where that goes: set up in the entry block,
undone in ret, which is the one funnel all five exits pass through -- the tail,
both returns, the none arm of (some x), and the landing block a handled
condition unwinds through. A pop written only on the normal path would leave a
frame's roots on the stack after every handled error.

It differs from the shadow stack in two ways, and both are forced. It is not
gated on dev: a backtrace is a convenience and a collector that cannot find its
roots frees live values. And it is a count rather than a saved head pointer,
because the ABI offers root_pop(n) and no way to read the stack's height -- so
the number has to be known before the body is emitted, since ret runs during
emission and a tally accumulated as roots were discovered would be short at
every early return. dyn_roots works it out up front by walking the same nodes
the emission will visit, the slots are minted from that count at entry, and
dyn_tmp only hands them out. The pushes and the pops balance by construction
rather than by two walks agreeing.

Every dyn-producing call is spilled into a rooted slot the moment it exists. An
SSA value is invisible to a collector that finds roots by address, and the next
allocation could be the one that frees what it holds. Rooting all of them rather
than only those that outlive a call is conservative and is the only thing
available here: this file has no liveness and no lexical scope, the checker
having resolved both into flat slot indices long before. The cost is a stack
slot and a store per dyn value at every optimisation level, because a rooted
alloca has its address escape and mem2reg cannot promote it. That is the price
of an address-registration ABI rather than stack maps.

A function with no dyn emits nothing at all -- no push, no pop, not a pop of
zero -- which is what makes an annotated program's IR identical to what it was
before any of this existed.

Globals are rooted in main, before the startup function that fills them and
before any other push, because every pop takes the top of the stack and these
are the ones that must never be at the top. They are never popped, which is what
a global's extent means. A dyn global needed no new machinery otherwise: a call
is not a constant, so it is a computed global, and that already existed.
2026-09-19 06:12:41 +07:00
JosephFerano de792fe141 A container holding an integer, a float, a string and a boolean at once
(vec-new dyn) is not a (Vec dyn). At milestone 1 the heterogeneous container is
the dyn runtime's own object and its type is dyn like everything else the
runtime hands back, which is what lets push, at and len on it be the dyn
operations instead of a type-erased Vec over eight-byte elements. It takes no
allocator, and the refusal says why: the storage has to be storage the collector
already knows about, where a Flan Vec's block would hold roots inside memory the
collector does not own.

len answers an i32 and at answers a dyn. The asymmetry is deliberate -- a length
is what an index loop compares against, and handing back a boxed number would
make (< i (len xs)) a dyn comparison and two allocations an iteration.

The operand-order bug, which the first test could not see because both its
operands were dyn: (+ n x) over a typed n and a dyn x threaded i64 into the
second check, expect did what an annotation site had asked for and unboxed, and
the result was a machine add of a value the runtime was never asked about -- the
program trapping on a float instead of promoting it, with nothing in the source
to say why. (+ x n) boxed correctly, so it was visible in one operand order
only. binary now takes dyn_ok from the operators that have a dyn lowering and
checks both operands on their own terms, which is safe exactly when neither
needs an expectation to check -- a literal still takes the other's type, and a
keyword still gets one, since :lo has no meaning without it.

Cast had no bool arms, so the bool boundary failed to emit; reachability hid it,
because the program that used it dropped the function. dyn does not cross to C:
it is one word and would have passed as an integer, and C has no way to ask what
the word means. A condition may not carry one either, nor hold one in a field --
a payload crosses a handler boundary and has to stay rooted across the transfer,
which is the collector's question and milestone 2's.
2026-09-19 06:06:44 +07:00
JosephFerano a5a77867f9 The provider, checked against the reader that was written by hand
edn-provide.flan reads assets/edn/tileset.edn through a struct derived from it,
and its first five lines are edn-read.flan's first five character for character.
Two readers over one file agreeing is what says the derived one is right; either
alone could be self-consistently wrong. The pair memberships are the derivation
deciding in public: the set became a (Map [2 i64] bool), so [3 4] is a key and
[9 9] is not, where a version that made it 108 loose integers would have
compiled and answered differently on all four.

A tuning file beside it covers the rest of the matrix — a string, an integer, a
float, a boolean, a vector summed rather than counted, and a map inside a map
read two field loads deep — and then drift: the struct was derived from a file
with :speed and without :level, and the bytes read carry the opposite. Both are
named, and the read carries on.

The refusals write their own data file, because the data file is the test. Each
is asserted on the position it names, not on the fact of failing, and one of
them checks a line and column into a file the compiler is not reading — which
is the whole of what compile-error was added for.

Two things the tests caught. Load extends the ambient macro set rather than
replacing it, so a package reached twice handed its declarations over twice and
the module refused them as a redefinition; Macro.compile dedupes by name, which
is the rule macro_union already applies a level up. And `where` held a line and
a column at once, which the prelude's note over append-i64 says cannot be done:
i64->bytes renders into one shared static buffer, and both numbers read as the
second one.
2026-09-19 06:00:54 +07:00
JosephFerano ca192f1691 Merge: a tagged value, and a collector that only runs when asked for memory 2026-09-19 05:59:42 +07:00
JosephFerano 090054bee1 The collector is not dropped by the linker, and a literal is where dyn is sharper
Two claims corrected against the thing they claimed about.

A named object is linked whole -- symbol-driven selection is an archive rule,
and dropping unreached code inside an included object needs -ffunction-sections
and --gc-sections, which the link line does not pass. nm on any corpus program
finds flan_dyn_add and flan_gc_collect in it. So the file said something the
build does not do. What makes --no-gc possible is the other half of the same
argument and was already written beside it: nothing refers to flan_dyn.c, so
not compiling it is a change at three sites and nowhere else.

And the boundary. "Typed Flan has no implicit widening" is true of a value and
not of a literal: (g 1) against (defn g [x f64] ...) compiles, because the
checker gives the literal the type the parameter asks for, while (defn h [y
i64] f64 (g y)) is refused. A dyn value written as 1 has been through
flan_dyn_from_i64 and cannot remember, so the same source read as dyn traps
where read as typed it does not. Stated where the compiler lane will find it,
with the two ways to close it, both of them the compiler's.
2026-09-19 05:58:52 +07:00
JosephFerano c8091bdbf9 One unannotated add, answering 5 to the integers and 3.75 to the floats
The boundary and the operators, which are the two halves of dyn being a type
rather than a word the checker tolerates.

Typed to dyn is implicit and dyn to typed is not, and the asymmetry is the
design: boxing loses nothing and can happen wherever a dyn is wanted, while
unboxing can fail at run time on a value the compiler cannot inspect, so it
happens only where somebody wrote a type. Both go through expect, because
expect is already the one place a wanted type meets a produced one, and every
annotating site already calls it.

Literals take their width from the dyn, not from the default. (defvar x dyn 5)
holds an i64 five: the ABI carries one integer width, so the defaulting question
never arises, and the literal is built at i64 rather than boxed after defaulting
to i32 -- which also means 3000000000 is a dyn integer.

An operator with one dyn operand is the runtime's. binary has already checked
the second operand against the first, so a mixed pair arrives with the typed
side boxed and the fold only has to call flan_dyn_add instead of adding. The
comparisons answer bool and not a dyn holding one, because a comparison is
almost always the test of an if; a program that wants it as a value boxes it
again for free at that boundary. = and != never trap -- two values of unrelated
types are unequal, not an error -- and the orderings do.

Types.equal had no Dyn case, so dyn was equal to nothing including itself.

print hands the whole value to the runtime rather than walking it: every other
arm of the structural printer exists because a Flan value carries no header and
only the compiler knows what it is, and a dyn is the exact reverse.

The compiler carries the dyn runtime the way it already carries flan_rt.c, with
the header pasted in front of the stub so there is one self-contained
translation unit and one contract.
2026-09-19 05:55:48 +07:00
JosephFerano 1422d4faf3 defedn: the struct a data file implies, and a reader for it
(edn/defedn Tileset "assets/tileset.edn") reads the file while the program is
being compiled, derives the struct its shape implies, and emits that struct
with a reader over the tokenizer next door. From there (.texture-path data) is
a field load: no Value, no match, no runtime tag, nothing looked up by name.

The real game file is the case it was built against, and its set of [x y] pairs
is why a vector inside a set becomes a fixed array rather than a Vec — a set is
this repo's (Map T bool), so its elements are map keys, and [2 i64] is one
where (Vec i64) is not.

Two things found while writing it. A quasiquote inside a package's ordinary
function was not qualified — only a defmacro's body goes through
qualify_macro — so a (defn ... [c (Ptr Cursor)] ...) emitted from a derivation
helper reached the importer naming a type it had never heard of. The name
survives into a string, which is the property the expander depends on and
exactly what puts it out of a rename's reach; load.ml now qualifies a literal
(Form.Sym {.s "..."}) naming something the package owns. Until a package's
macros could call the package's functions there was no helper that built code,
so this could not have shown before.

And (vec-new) and (map-new) have to be told what they build by naming a type,
which (Vec i64) and [2 i64] have no way to be. Both fall back to what the
context wants and a signature is a type position, so each collection gets a
one-line constructor stating its type. The reader reads better for it.
2026-09-19 05:55:00 +07:00
JosephFerano 7d4bec521e A value carries its own type, and the heap under it collects
Milestone 1 of dynamic-by-default, the runtime half: NaN-boxed values in one
machine word, a mark-sweep heap, and the operations over them.

A double is itself, which is what a language with a physics loop and a float
calculator in its corpus wants; everything else hides in the quiet-NaN space,
three tag bits and a 48-bit payload that is exactly an x86-64 user pointer.
The negative-NaN collision is answered by canonicalising every NaN on the way
in, which flan_rt.c had already decided was the right thing to print. An i64
past the payload goes on the heap rather than becoming a 48-bit integer with a
64-bit name.

The collector is mark-sweep and nothing else -- no generation, no barrier, no
free list -- because the answer to wanting it faster is to type the program.
Roots are pushed, not scanned: NaN-boxing makes a conservative guess wrong in
both directions, and flan_dev.c's frame chain is the precedent. A fixed ring
of the last sixty-four allocations is marked unconditionally, which closes the
window where an expression with two constructors in it can collect its own
first result before the compiler has rooted either.

A type mismatch traps rather than aborting, through a flan_trap exported from
flan_rt.c so it takes the same path the six existing traps take: parked for
inspection in a dev session, dead where it stands otherwise. The sentence
names the operation, both tags as words, and both values.

flan_dyn.c is its own translation unit and nothing in the release runtime
names a symbol in it, so a program with no dyn operation links no collector
and --no-gc can be file-level selection rather than an argument with the
linker.

docs/SPIKE-DYNAMIC.md carries the argument. test/dyn_ops.c drives every
operation and all twenty-four refusals from C, the way dev_limits.c does,
including a million allocations against a hundred live and the control that
says an unrooted object really is reclaimed.
2026-09-19 05:52:47 +07:00
JosephFerano 3e68089cde A parameter with no type is dyn, decided where the type names are all known
The type itself, the ABI its operations call into, and the one decision the
feature could not avoid: (defn f [x y]) is one parameter or two, and which one
depends on whether y names a type.

Parse does not decide it. That lookup is the one its defn comment records being
removed for being wrong twice in one day -- the set of type names is incomplete
at parse time by construction, and macros generating definitions is what
widened the failure. So the vector is carried undecided, as Ast.pitems, and
paired in Check, after every file is loaded, every macro expanded and every
header imported. The set is complete there. It is not complete across time, and
the comment says so: a defstruct written later changes a signature with no edit
to the function.

The return slot stays mandatory and dyn is written out in it. The ambiguity
there has no syntactic resolution at all -- a capitalised head in a list is both
a type application and a struct literal -- so the third state the parameters
needed does not exist for the return type, and ret = None goes on meaning Unit.

What the feature costs, and what is taken back: a slot with no type used to be a
syntax error, so a mistyped type now reads as an extra parameter with no
diagnostic. A name within one edit of a type's gets the resolver's own
did-you-mean, and an unknown capitalised name is reported as the unknown type it
is -- not one parameter in the corpus is capitalised. A lowercase name
resembling no type is the feature working, and is the residual.

The x86 backend refuses dyn by name; both callers already name --llvm, and the
daemon takes that backend by default, so this is the first thing a user of dyn
sees. The JS dialect refuses it too, for the opposite reason -- every value
there is already dynamic and what is missing is only the lowering.

runtime/flan_dyn.h is the fixed ABI. flan_dyn_stub.c stands in until the real
collector lands and says in its header that it verifies nothing about roots.
2026-09-19 05:47:49 +07:00
JosephFerano a2004ae7a6 One macro call, several declarations, and a refusal that carries a sentence
A type provider produces a struct and a reader over it, and a struct per
nesting level in the data. Expansion is form-for-form, so one call could only
ever become one declaration — which was enough while every macro expanded to an
expression. A top-level (do ...) is now its items, spliced in place, after
expansion and before the declaration walk. Nobody writes one in a file, and the
single-declaration entry point says so by name for anyone who tries.

And (compile-error "...") is what a macro expands to when it has to refuse. The
prelude's `unless` records the gap: a macro has no error facility, so a
malformed call answers a name nothing defines and the report is the right place
with the wrong sentence. A name carries a name. A type provider's refusals are
all sentence — the third element of this vector is a string where the first two
were integers, at line 3 column 9 of a file the compiler is not reading — and
no symbol an expansion could invent holds that. Loc.from_macro already stamps
the call site onto the expansion, so the location is the form the author wrote.

A builtin because it has to fail while checking: a declared function would
compile, link and run, and the compile it was meant to stop would have
succeeded.
2026-09-19 05:43:51 +07:00
JosephFerano 32ff350001 A macro may call its package, and may read a file at the call site's path
Two things a type provider needs and neither of which a macro could do.

A package macro could not call its own package's functions. Load already
renamed the body so that (next c) reads (edn/next c) — the intent was written
down — and the module was then compiled from the prelude and the defmacros
alone, so the call arrived at the checker as "the call edn/next into an
imported package". The declarations now travel beside the macros in
Parse.imported_decls, trimmed in Macro.compile to what the macro bodies
actually reach. raylib's five with-* are pure quasiquote, so nothing of raylib
is reachable and its module is the one it always was — which matters, because
raylib's declarations are declares against a library a macro module has no
linker argument for.

And a macro had no way to resolve a path. (embed "assets/x.edn") resolves
against the directory of the source file the form is written in; a macro knows
the path it was handed and not what it is relative to, because a Form carries
no location. So the compiler pokes the call site's directory into two C
symbols before every expansion and (macro-slurp "...") joins the two. C data
and not a Flan global: the module is emitted with hidden visibility and only
the flan.macro.* thunks stay exported.

None rather than a condition, which is why this is not slurp: a condition
signalled inside an expansion goes through the module's own copy of the
runtime, and that is the failure Build.macro_module's hidden note measured.
2026-09-19 05:39:06 +07:00
JosephFerano 4a8a78caac Byte-compiled files do not belong in the tree 2026-09-19 05:23:11 +07:00
JosephFerano 95d3da0712 Merge: the bang leaves the names; mutation was never optional 2026-09-19 05:22:15 +07:00
JosephFerano a0f37e72a2 The ! suffix retires: a mutator is named for what it does, not marked
The !-means-mutates convention distinguished nothing — there is no
immutable counterpart to contrast with — so every mutating name drops
the mark: sort, sort-by, sort-bytes, swap, reverse, append, append-i64,
append-f64, encode-rune, split-next, map-remove, map-next, and the test
helpers beside them. Two could not simply shed it: map! is map-in-place,
because map is the into transform's word and means the non-mutating
thing; put! is put-at, because put is the Map builtin. The ?-means-asks
convention stays. Dated records keep the old spellings; watch.clj's
reset-spies! and the other Clojure names are not ours to rename.
2026-09-19 05:21:02 +07:00
JosephFerano 69c033e946 Merge: the dev daemon compiles with its own backend unless asked not to 2026-09-19 04:47:30 +07:00
JosephFerano 6055e9c247 Merge: an x86 dev build pushes the frames the inspector reads 2026-09-19 04:47:30 +07:00
JosephFerano 2133ac6f18 Merge: an Option opens without a match, and a file reads in one call 2026-09-19 04:32:33 +07:00
JosephFerano a7e1208ee4 A stopped x86 program says where it is, and what its frames hold
The backend had a full dev story for everything except the one thing the
break loop reads: emit.ml pushes a shadow-stack frame on entry to every body
in a dev build and x86.ml pushed none, so an --x86 session answered backtrace,
locals, inspect and globals with "this program was not built with --dev",
which was false of it.

emit_fn now builds the same four words runtime/flan_dev.c describes -- the
previous head, a static descriptor, and a table of one address per slot, null
until the binding that fills it has run. The push is emitted at the head of
the body buffer rather than into the prologue's, because the prologue is where
the incoming registers still hold the arguments and every instruction of it
clobbers rax. The pop is one store at the epilogue label: this backend has one
epilogue and a return, the body's tail and the transfer exit all arrive there,
where emit.ml needs the same restore at five separate rets.

Two decisions are taken from emit.ml rather than reasoned out again, so that
the two dev builds answer a stopped frame identically. A function with no
named slot gets no table and reports no slots -- there the gate buys mem2reg
an alloca that does not escape, here it buys nothing but agreement, and
agreement is the only thing the break loop can check. And the descriptor's
bytes do not go through string_const: that counter is what decides whether an
expression thunk's module may be unloaded, and counting a descriptor in it
would pin every C-x C-e module's mapping for ever.

The descriptors are the one constant this backend emits that holds an address,
so they go in .data.rel.ro and not .rodata -- a relocation the loader applies
needs a section it may write, and a redefinition module is such an object.
Verified on an assembled module: R_X86_64_RELATIVE for the two pointers,
flan_frame_head through the GOT, no TEXTREL.

layout_ctx fills its globals table, which it never had to before. Reach's
ref_fingerprint asks whether a name is a global, and with an empty table no
name is one -- every frame would carry the hash of the empty set, the daemon
would recompute the real one, and the globals section would refuse a body
nobody had touched while its locals still read.

Release builds gain nothing: all of it is behind md.dev, and three corpus
programs emit byte-identical assembly before and after.

The new coverage stands up an --x86 daemon on dev-locals.flan and asks the
four questions, against the values the LLVM block above asserts of that same
program -- the claim is that the answers are the same and not merely
plausible. The existing --x86 two-process daemon gains one more: a backtrace
through a redefined body, which reports the installed body's own file, since
that descriptor travels in the module and a frame pointing at the host's would
name the body it replaced.
2026-09-19 04:32:15 +07:00
JosephFerano f35af50f51 or-else is Java's, not Rust's, and the None it is tested against is reached
Four corrections to the prose and one to the test, none to the design.

The provenance line said "the name is Rust's or_else, the behaviour is
Rust's unwrap_or", which conflates two functions that differ in both
eagerness and return type — Rust's or_else takes a closure and answers
another Option.  Java's Optional.orElse is the exact match, and its lazy
sibling orElseGet is the one already declined a paragraph above.

The helper reaching or-else's None branch at an owning type asserted a
refusal nobody had run.  Compiled, it is "nothing here says what None is
an Option of — annotate the function's return type or the binding", so
the comment quotes that and the helper is a return type and nothing
else: its other branch was never called, in a program whose header says
every line is a claim.

read-file's comment claimed both restarts arrive unchanged and the test
runs use-value.  Narrowed to the mechanism (nothing here establishes a
handler) plus the half that is actually executed.

And edn-read.flan now says what becomes of its defn wrapper when the
computed-initialiser work lands, since that is the only thing keeping
the motivating line from being written as the defvar.
2026-09-19 04:30:57 +07:00
JosephFerano 617440aee6 Merge: a global's initialiser may compute, and main runs it first 2026-09-19 04:25:54 +07:00
JosephFerano 495629f5f3 A global's initialiser may be computed, and both backends run it the same way
The x86 backend ran initialisers from .init_array and the LLVM one refused
them by name, so (defvar frame Allocator (arena-new 262144)) — which the
author kept writing — was a program on one backend and an error on the other.
A rule that holds on one backend and not the other is not a rule.

The checker lifts a computed initialiser into a function of its own and the
global's initialiser becomes the call. That is what gives it a frame, which is
the bug underneath the feature: a `let` or a `match` in an initialiser indexed
a slot array of length zero and took the x86 emitter down with an uncaught
Invalid_argument.

Both backends call the lifted initialisers from main, after flan_rt_init and
before a line of the program's own code — Odin's __$startup_runtime shape, not
a constructor, so the runtime is up and the order is the compiler's to choose.
x86 keeps .init_array for one thing only, and it is named: writing the
constant image this backend has no folder for, which is standing in for the
other backend's object image rather than for a program.

The computed globals are sorted by what they read, transitively through the
functions they call, so a global written above the one it reads works and a
ring is refused with every name in it. A reload still re-runs nothing: a new
global with a computed initialiser starts as ZII on both backends.

The refusal that lived in x86.ml is now the checker's and is narrower. Nothing
can escape an initialiser — the handler and restart stacks are empty and every
frame it pushes it also pops — so what is refused is a signal or an
invoke-restart with no handler-bind or restart-case around it, which is inert
by construction. A restart-case inside one is ordinary code, which is what
makes (defvar data (Vec u8) (slurp "level.edn")) an ordinary program.

Three refusals go with the premise they rested on: a container global with a
computed initialiser, a union member in a defvar, and a data type case in one.
A defconst is untouched and keeps all three.

One change here is not about any of that. sand.flan carried an unfinished
line — (defvar game-data (embed (with-allocator frame ))), which parses as a
declaration whose type is (embed ...) — so the checker refused the file and
`dune test` was red at the tip of dev-loop before a line of this landed,
verified by stashing this work and rebuilding. It is commented out rather than
guessed at: the arena above it is the half that works, and what the global
should read is the author's to decide.
2026-09-19 04:24:52 +07:00
JosephFerano 7a0b2b7803 An Option opens without a match, and a reader takes a path
Two things the motivating line wanted and could not have.

or-else and some? are the first prelude family over (Option $t), and the
first that declares no {:where} at all: they move the payload out or read
the tag, and neither is an operation a type variable has to be admitted
to.  So they instantiate at every type, including the ones that own
storage — where the answer is a header onto one of the two buffers and
the branch not taken is still the caller's to free, which the comment
says because "or a default" reads like it consumes the default.

none? is declined as (not (some? o)), and an unwrap that signals on None
is declined for the reason file-size is an Option at all: absence is a
reply and not a fault, and whether an empty one is an error is the
caller's question.

edn/read-file is worth having for one fact the package already argued:
every string in a Value is a copy, so the source buffer is dead the
moment read returns and nothing outside the call can be holding it.  It
slurps against the heap by name — the one allocator this package names,
because the buffer's life is inside the call and is not the caller's
tier to choose — defers the free for the transfer path, and passes
slurp's FileError straight through with both restarts armed.  Folding a
missing file into None would collapse the very distinction the Option
exists for.

There is no json/read-file and json.flan now says why: a Token's text is
a slice into the caller's buffer, so the prerequisite is a json/read
answering a self-contained document, and there is no Value type there to
answer with.

The defvar initialiser in the motivating line is still refused as
computed, so edn-read.flan writes it as a defn and says so; everything
inside the with-allocator is verbatim.
2026-09-19 04:16:37 +07:00
JosephFerano 14416a877f The dev loop is the x86 backend, and --llvm is how to leave it
flan dev takes the hand-written backend unasked now: it is what that
backend was written for, it halves the C-c C-c round trip, and nothing it
builds outlives the session. Every other command is LLVM by default and
unmoved, which is what keeps lib/x86.ml's calling convention licensed.

--debug picks LLVM on its own rather than landing on the refusal: a
redefinition module from this backend carries no line table, so the one
flag someone types to get a debugger must not choose the backend without
one. --x86 --debug together is still refused, and now says which to drop.

What the flip costs is more than the forms this backend refuses. It
pushes no shadow stack, so a session built by it stops on an error and
cannot say where -- and the agent answered that with "this program was
not built with --dev", which is false of an x86 dev host. Dev.ask
rewrites it; the IR view refuses by name instead of failing to find a
define in a .s file; and a refusal at the host build or at C-c C-c names
--llvm rather than stopping at "unsupported".

flan-daemon-args carries the flag from Emacs, spliced into the one
argument list the daemon buffer's first line is now printed from.
2026-09-19 04:01:39 +07:00
JosephFerano e054de610a Merge: a top-level expression evaluates, and its value is drawn where it was typed 2026-09-19 03:53:15 +07:00
JosephFerano 8d64eda2d4 end-of-defun steps over the newline, and the value was drawn on the next line
lisp.el finishes end-of-defun by skipping blanks and stepping over the
newline after the closing paren, so the bounds C-c C-c works from end at the
start of the next line. The declaration path never cared -- a newline more or
less in the text sent changes nothing -- but a value drawn at that position
sits in column 0 below the form, in the gap before the next one.

The fixture that missed it inserted the expression at point-max with nothing
after it, which is the one shape where end-of-defun has no newline to step
over. It has one now, and the check is the closing paren rather than a line
number.

The error-overlay check was the same kind of weak: it asserted the line was
not 1, which flan--text alone would have given. It asserts the token now,
which is what flan--text-at is for -- and eval-expr's replies do respect the
column padding, so the switch stands.
2026-09-19 03:52:41 +07:00
JosephFerano 9417fdec42 C-c C-c evaluates what is not a declaration, and shows a value where it was
The routing C-x C-e already had, asked of the form C-c C-c already picks:
the top-level form point is in, not the one behind it, so reloading the defn
you are standing in is untouched. Both keys are one command and both route --
a person who writes (+ 1 1) and presses C-c C-c has the complaint that started
this, and splitting them would buy a rule to remember and nothing else.

A prefix on the expression path is a flag, the way C-u C-x C-e's already is:
there is no inside for a position to point at, so C-u and C-u C-u say the one
thing, and it does not stick because the thunk is thrown away.

package is where the two keys disagree, which is the one real finding here.
Parse.expr has no arm refusing it, so C-x C-e's question answers no; Parse.decl
has one, so C-c C-c must keep installing it. flan--defun-heads is that list
plus that head.

The value now goes beside the form, in flan-watch's ghost shape and with the
error overlay's lifetime, because eros and CIDER put it there and the echo
area cannot say which of two forms returned 2. flan-inline-result turns it
off; where the two would overlap the overlay wins and the echo is not also
written. A refusal clears it rather than sitting beside it.

The expression path sends flan--text-at now: an expression does not start at
column 1, and an unpadded snippet put its refusal on line 1 of the file.
2026-09-19 03:49:27 +07:00
JosephFerano d757fd23f5 Merge: a builtin is a name the documentation knows 2026-09-19 03:15:13 +07:00
JosephFerano 88b21dd5af sand.flan keeps the experiment out of the tree
Two half-typed lines went in with an unrelated keybinding commit and left
the tree not checking.  They are the arena-at-top-level experiment and they
belong in a working tree, not in history.
2026-09-19 03:15:10 +07:00
JosephFerano 7f9017bb3e The two lines in sand.flan that 668f0d6 did not mean to take
That commit is about C-M-x and touches three files; the third is a
snapshot of an editing session caught mid-thought:

  (defvar frame Allocator (arena-new 262144))
  (defvar game-data (embed (with-allocator frame )))

The second does not check — embed takes a path, and there is no path
there — so the acceptance corpus fails to build and `dune test` has been
red at the tip on its own account. Removed rather than repaired, because
what it was going to say is the author's to finish.
2026-09-19 03:12:43 +07:00
JosephFerano 38a04f7a47 The compiler's own names answer C-c C-v, and M-. says where they are
`arena-new` is a builtin, so it is in no program's symbol table and `defs`
never mentioned it — which made the editor answer "the running program
defines no arena-new" about a name that works. The fix is not a better
refusal: it is that the seventy-eight names the checker answers without
being told are now in the reply, under a kind of their own.

check.ml carries the table, beside the arms it describes, because a table
in another file drifts from them with nothing said. test_flan reads both
the arms and the table and fails on either having a name the other does
not, in both directions — there is no reflecting over a match, so it reads
the source.

Each entry is a signature in `signature_of_fn`'s shape and one line. The
arms that do not have one shape say what is true instead of pretending:
`?` for an argument that may be left out, `|` for the types an arm really
takes, and the checker's own predicate names for the type-directed ones.
The three user-allocator names carry a bare name and no bracket list,
because they are refused wherever they are written.

`defs` grows a fifth string for the prose, and builtins are appended last
so a completion table does not bury the names being worked on. A defn has
no docstring to put there and does not get one here: the Tast keeps none,
and that is a different piece of work.

The editor reads the kind, not a special case. `flan-doc--where` and the
xref backend both answer before their empty-location branch, because a
global's missing location and a builtin's absent one are different facts
and only one of them is about the daemon.
2026-09-19 03:12:38 +07:00
JosephFerano 668f0d6686 C-M-x evaluates a top-level form, where every other Lisp puts it
Emacs binds it in emacs-lisp-mode-map and nowhere else, so a mode derived
from lisp-mode inherits nothing and the key answers "undefined" — which
reads as the client being broken rather than as the key being free.
2026-09-19 02:50:43 +07:00
JosephFerano 5c228d35dc Merge: the command is flan, and it knows what buffer you are in 2026-09-19 02:20:29 +07:00
JosephFerano 14c246c27c Merge: the prompt remembers what was typed at it 2026-09-19 02:19:43 +07:00
JosephFerano 6960a7e929 Merge: sets tokenize, and the reader belongs to the package 2026-09-19 02:19:39 +07:00
JosephFerano 871d23dcc4 Merge: a JSON reader, in the tokenizer's shape 2026-09-19 02:18:53 +07:00
JosephFerano d5c969d58b Merge: a parked program answers an expression on the thread that is already asleep 2026-09-19 02:18:44 +07:00
JosephFerano 08ec402e0b Merge: a daemon whose editor is gone reaps itself 2026-09-19 02:18:41 +07:00
JosephFerano 3165cb73a6 A propertized string is not a string once it reaches the daemon's reader
comint hands the input sender its buffer text with font-lock's properties
still on it, and prin1 writes such a string as #(...) — which the daemon
reads as a bare symbol followed by a stray list, so the field stops being
a string without anything saying so.  The wire layer strips rather than
asking every caller to remember: it is the one place that knows the text
is about to become bytes.
2026-09-19 02:18:38 +07:00
JosephFerano d9404bb34a The client drops -dev- from its names, and starts the buffer you are in
`-dev-` was in every Emacs symbol this client owns and meant nothing to anyone
typing one: the daemon is `flan dev` at a shell, but from inside Emacs there is
no other kind of connection to distinguish it from. `M-x flan-dev` is now
`M-x flan`, `flan-dev-quit` is `flan-quit`, the private prefix `flan-dev--` is
`flan--`, and every defcustom follows — ninety-odd symbols, with the two files
renamed to emacs/flan.el and emacs/test-flan.el so the file names say the same
thing as the symbols in them.

No aliases. Renaming a defcustom breaks a config that names it and there is no
way around that; the repo has no precedent for softening one, and an alias left
behind is what keeps a rename from finishing. MANUAL.md says the old names are
gone and how to fix a config, which is the whole of the migration path.

Three strings are not symbols and keep their spelling: `.flan-dev.sock`, which
bin/main.ml writes and which a renamed variable searching for a renamed file
would simply never find; and the two buffer names `*flan-dev*` and ` *flan-dev*`,
which name the `flan dev` subcommand's own output rather than anything in elisp.
`flan dev` with a space is the CLI and is untouched everywhere.

The entry point also stops asking a question it already has the answer to. From
a buffer visiting a .flan file it starts that file; from anywhere else it reads
one from the minibuffer as before; `C-u` reads one either way, which is how you
start a second program without leaving the first. The current buffer is still
the only source of the default — the bug where a previous project won over the
buffer you were in was fixed by removing `flan--file` from that position, and
nothing here puts it back.

Four checks on the `interactive' form, evaluated on its own rather than by
calling the command, because calling it would build and launch a program and
the question is only which file the form arrives at and whether it had to ask.
A fifth asserts that nothing answers to the old names. test/test_emacs.ml loads
the test file by path and test/test_session.ml names the client file in a
comment, so the rename reaches those two lines; nothing else outside emacs/ and
the docs moved. Verified by byte-compiling every file
clean and by `dune test` and `@page`.
2026-09-18 23:20:26 +07:00
JosephFerano 9b877a2835 A parked thunk's output, its empty globals section, and a better silence
Three gaps in the first pass. The park flushes after the poll, which is the
only thing that gets a printing expression's line out of a fully buffered pipe
before the next run — now pinned by its position in the transcript. The globals
section under a parked break answers with everything skipped, which is a
different reply from the timeout a job nobody polled would give, so it is asked
for rather than assumed. And the render thunk's five-second sentence no longer
asks a parked program whether it is calling agent/poll.
2026-09-18 23:16:35 +07:00
JosephFerano b206e9a9a4 An expression evaluated against a parked program runs on the parked thread
The park waited on one flag and could do one thing, so C-x C-e on (+ 1 1) was
refused for want of a frame boundary — an expression that needs nothing from
the program, in a process holding every global the run left.

It waits on two now. A re-run leaves the park; a wake drains the agent's ring
and waits again, with the state still PARKED, which is what makes running the
thunk there exactly as safe as running it at a frame boundary: while parked
there is no concurrency to be unsafe against. The break loop is the precedent
and CL's spawned worker is deliberately not copied.

A thunk that stops now stops on a parked thread, so the restart ops refuse on
whether a break is engaged rather than on the state, and a paused expression
against the park is resumable.
2026-09-18 23:11:12 +07:00
JosephFerano 51df451e7b The map dedup is asserted by the case that can only pass if it works
#{{:a 1} {:a 1} {:a 2}} answers 2 whether tables=? compares anything or
compares nothing, so it was proving the count and not the compare. The
pair beside it isolates both halves: one map twice must collapse to 1,
and two maps of one entry each with different keys must stay 2, which is
what a size-only compare would get wrong.

And read's comment stops implying a property it does not have: empty
input answers (Some Value.Nil), indistinguishable from the document that
is nil. Empty is not malformed and the reader is not the thing that gets
to decide it is.
2026-09-18 23:03:56 +07:00
JosephFerano 6b668e7d9d A JSON document read into a value that outlives the bytes it came from
vendor/json is vendor/edn's shape with one decision reversed. edn never
allocates, so its tokens are views into the source buffer and escaped
strings are refused for want of anywhere to put the unescaped copy. This
one has an allocator, so it unescapes, and to unescape it copies —
string-of is the only function in the package that allocates, and it
copies even when there was no escape to resolve, because a Value whose
lifetime depended on which bytes happened to be in it is not a contract
anyone can hold. Odin answered the same question the same way:
tokenizer.odin allocates nothing, parser.odin's unquote_string does the
copy, and it clones in the no-escape branch too.

What that buys is at the bottom of test/programs/json.flan, which is
programs/edn.flan and programs/arena-edn.flan in one file because for
JSON they are one claim. The source buffer is overwritten with `?` bytes
while the document is live and the strings read back afterwards are
still the strings. arena-edn's header has a section admitting it cannot
do that.

Strict JSON and not Odin's JSON5 default, and the difference is where
most of the refusals come from: comments, single quotes, +1, .5, 1.,
0x1f, 01, NaN, Infinity and unquoted keys each get a sentence naming the
dialect they belong to, rather than one shared unexpected-byte. A lone
surrogate is refused too, and that one is forced rather than chosen —
rune-size answers None for the whole D800-DFFF block, so encode-rune!
would write nothing and the character would vanish.
2026-09-18 23:03:29 +07:00
JosephFerano cc2cfa175b Sets are read, and the reader that answers a Value is the package's
The tokenizer refused #{} because "it needs a hash set to even
represent" — which is a claim about a reader, and a tokenizer represents
nothing. #{ now pushes } on the same balance stack { does, there is one
new token kind and no new closer, and err-set is gone rather than kept
with a message it no longer earns. skip-value needed nothing: it is
written against the depth and not against the kinds.

The dynamic reader moves out of test/programs/arena-edn.flan and into
vendor/edn/read.flan as (edn/read bytes), answering an (Option Value)
against whichever allocator the caller bound. Two decisions are written
down where they are made:

  * a set is a Value.Set holding a deduplicated (Vec Value), because
    (Map Value bool) does not typecheck — keyable refuses a key holding
    a Vec or a Map — and restricting elements to keyable Values would
    refuse #{[0 0] [1 0]}, which is the file this was built for. Insert
    is O(n) against a structural value=?, so building the tileset's 54
    pairs is 1458 comparisons, once.
  * a Value copies every string into the allocator where a Token stays
    a view. A view handed back out of the function that owns the buffer
    is a dangling pointer, and free-all would not even take it. Odin's
    json parser clones for the same reason.

An imported defdata was a refusal in load.ml — "not implemented yet
(milestone 4)" — and it had to go first. It is the type's name plus the
Type. half of a constructor symbol, which arrives as a Var node when the
case has no fields and a Struct node when it has; a match pattern needed
nothing, because a case resolves against the scrutinee's type and was
never a top-level name. programs/pkg-data.flan is that on its own.

programs/edn-read.flan reads assets/edn/tileset.edn, which is the
editor's real output: :texture-path and a :selected-cells of 54 integer
pairs, with no type declared for any of it. It also overwrites the
source buffer in place after reading and prints the document back, which
is the copy contract asserted rather than described.
2026-09-18 22:50:54 +07:00
JosephFerano 5d1ea9dede A daemon whose editor was killed no longer waits for it for ever
[close] was the only way out of the accept loop that anybody ever took, and
an editor that is killed rather than quit never sends it. Four of the eight
orphans found on the author's machine were that.

The signal is the socket, not a new op: Emacs opens one connection for the
whole session and drops it only on paths that send [close] first, so an
editor left open overnight is an editor still attached and the grace cannot
accumulate under it. Armed only once a client has been there, so a daemon
still waiting for its first one is untouched. Parked sessions get the short
clock and live ones six times it, because a parked program is invisible and
a running one is a window somebody may be looking at.
2026-09-18 22:43:32 +07:00
JosephFerano 541c845688 The prompt's history reaches the up arrow, and outlives the session
Two things were missing from the REPL's history rather than broken in it. The
ring worked; nothing reached it by the key almost everyone presses, and nothing
survived quitting Emacs.

`<up>' and `<down>' are history on the line being typed and line motion
everywhere else. The conditional is the whole point: the text above the prompt
is a transcript people scroll back through, and a blunt binding would take that
away to buy something `M-p' already does. Inside a form typed over several
lines the keys still move between them, and reach history from the first. The
search is prefix-filtered, which is comint's `...-from-input' pair and what a
shell does. Each wrapper hands `this-command' over to the comint command it
delegates to, because comint tells a continued search from a fresh one by
looking for its own name in `last-command' -- without it the first press looks
perfect and the second never moves.

History is kept in `flan-repl-history' under `user-emacs-directory', through
`locate-user-emacs-file' so that no-littering and a moved state directory take
it along; the file and the size are both defcustoms. It is read when the mode
starts and written on kill and on exit, but not when the ring is empty and not
when the ring is shorter than the file -- a write replaces the file whole, so
either would throw away a session somebody else's Emacs saved. The exit sweep
is not installed in batch, where every prompt belongs to a test and the file it
would land in belongs to whoever ran it.

Entries are separated by a form feed on its own line rather than by comint's
newline, which is also what is inside a multi-line form: with the default the
file shreds one such form into fragments. The separator is let-bound around
each call instead of set in the buffer, because `comint-write-input-ring' reads
it inside a temporary buffer where a buffer-local value is invisible.

And the multi-line case the recall made reachable: `comint-send-input' sends
the process mark to *point*, and `comint-eol-on-send' carries it no further
than one line, while `flan-repl-return' judged completeness over the whole
input. Recall parks point where the typing stopped -- for an empty prompt, at
the start of what was just recalled -- so RET on a recalled form sent nothing
at all. It now sends what it measured.

emacs/test-flan-repl.el covers both halves from a stub prompt, loaded from
test-flan-cider.el for the reason test-flan-mode.el gives: `emacs/*.el' is
already a dependency of that stanza, so a file here needs no build change. No
daemon: history is a function from a ring and a position to what the buffer
says. Seventeen checks, which cost that suite nothing measurable -- the whole
of it still runs in 0.15s.
2026-09-18 22:39:01 +07:00
JosephFerano 233bb780b0 The last lane landed after all 2026-09-18 13:56:15 +07:00
JosephFerano 6479834ffc Merge: a program does not outlive the daemon that owns it 2026-09-18 13:55:24 +07:00
JosephFerano eb2fbb75dd A program whose daemon was killed no longer waits forever for it
Under --two-process the compiled program is a child of the daemon, and the
daemon is the only thing that ever ends it: the program is a loop with no
reason of its own to stop, and lib/dev.ml kills it from a Fun.protect finally
block on the way out. That block does not run when the daemon is SIGKILLed —
by a harness tearing down a daemon it gave up on, by a watchdog, by anybody —
and what was left behind was a program with ppid 1, sleeping, holding a socket
nothing will ever connect to. Eight of those were on the machine this was
written on, the oldest six days old, and one had been minted by this suite.

The child now arms PR_SET_PDEATHSIG when it starts its agent, gated on a
FLAN_DEV_PARENT the daemon sets and nothing else does, and closes the race
where the daemon died before the arming by comparing getppid against that pid
rather than against 1 — under a subreaper an orphan is not reparented to init.
The signal is SIGPWR and not SIGTERM, because the daemon's ordinary teardown
already sends the child SIGTERM and the child's stderr is the daemon's own; a
handler there would print "the daemon is gone" into the daemon's output on
every clean close. The handler writes one sentence, unlinks the agent socket
it bound, and exits 0, and it does nothing else because it can run on the game
thread inside stdio's lock.

A merged build arms none of this and must not. There the daemon is the
process, so a dead daemon is a dead program; its parent is whoever typed
flan dev, which is a shell or an emacs and not the session's owner. Merged
daemons do leak on this machine — four of the eight — but for a different
reason, which is that accept_loop has no idea whether its editor still exists.
That wants a mechanism that tracks the client rather than the parent, and it
is not this change.

The daemon's read end of the program's stdout pipe is also cloexec now. The
child used to inherit it, which is both a leak and the reason waiting for EOF
on that pipe could never have been the mechanism: the pipe always had a
reader. A printing program now takes SIGPIPE once its daemon is gone, which is
a second net under chatty programs and not a substitute — the test uses
dev-watch, which is silent, so that only the thing under test can end it.

The test spawns the two-process shape, sends one request so that what it kills
is demonstrably serving, reads the program's pid out of the daemon's
/proc children before killing it — it is a grandchild, so waitpid is ECHILD —
and SIGKILLs the daemon. SIGKILL and not SIGTERM: SIGTERM lets the finally
block run and the assertion would be green with this change reverted. It fails
that way, and passes with it, in well under a second.
2026-09-18 13:54:22 +07:00
JosephFerano da2280cceb Where the evening stopped 2026-09-18 13:51:58 +07:00
JosephFerano e6d85c80dd Merge: all six traps park for inspection, two with the resume refused 2026-09-18 13:48:16 +07:00
JosephFerano 566c010822 A trap with no channel stops the program, not the session
Six refusals in the runtime called _exit(134) where every other error had
learned to park: no restart by that name, a restart taken with the wrong
arguments or with none, a defer that invoked one, a null allocator, and
free-all on something with no region. Under a merged flan dev the compiler is
in that process, so a program that named a restart nobody established took the
session down with it, which is the one thing the break loop exists to prevent.

They park now. Not through flan_break_hook, which is what bounds and
arithmetic use: that hook may answer by aiming a transfer channel, and these
six are called by emitted code that falls off the end with no channel anywhere
in the call, so a restart chosen against one would be accepted and dropped.
flan_trap_hook says the other thing instead — stop here, let everything be
read, and refuse the resume with a reason.

All six park, for two reasons rather than one. Four are guards that fire
before the operation they guard, so nothing is half done and the frame reads
like any other. The other two fire mid-transfer, with the frame's defers
possibly half run, and they park only to be looked at: stopping on a torn
unwind is strictly more than exiting before anyone can ask what tore it.

The break loop grew a per-snapshot resumable flag for it. Restarts are still
listed and still numbered, the terminal marks them untakeable and the socket
reports the same positions as unreachable, and the listener refuses a choice
with the trap's own sentence rather than the thunk-boundary one.

Standalone builds die exactly as they did: nothing installs the hook in a
program that did not import the agent, and the acceptance case for free-all
still wants exit 134 and the same message.

The review entry that asked for this named flan_exit_hook, which is normal
termination and not this at all; it is struck out with the correction.
2026-09-18 13:47:42 +07:00
JosephFerano 7187f58ff0 The day is closed out where it is kept 2026-09-18 12:59:15 +07:00
JosephFerano e104bca12b Merge: a stopped-only inspection refuses to run against a resumed program 2026-09-18 12:48:43 +07:00
JosephFerano 8b48744864 A render job carries the stop it was built under, and the agent will not run it without one 2026-09-18 12:45:46 +07:00
JosephFerano 41a794c255 Merge: Pool, the move-only concept, and the gen word follow the repeal 2026-09-18 12:39:14 +07:00
JosephFerano 0faacb3e57 The spec records the second round: two containers, everything copies, five words in a header 2026-09-18 12:38:26 +07:00
JosephFerano bb2471f7a1 The gen word leaves both headers: a check nothing runs is weight, and Odin carries none of it 2026-09-18 12:23:39 +07:00
JosephFerano 4a563d0e67 The move-only concept follows the flow analysis out: everything copies, and the frees are yours 2026-09-18 12:16:05 +07:00
JosephFerano c46fd56447 Pool and Handle leave the language: two containers are enough, and a slab is a Vec you free less often 2026-09-18 11:56:22 +07:00
JosephFerano 9d73900f3e Merge: a reversed slice traps in every build, and a NaN has no sign to print
# Conflicts:
#	docs/BUGS-2026-09-18.md
2026-09-18 08:22:10 +07:00
JosephFerano c4b6bb9821 Merge: the pipe drains, the park flips first, and a trap cannot hang 2026-09-18 08:20:50 +07:00
JosephFerano 28f20eb146 Merge: the ownership repeal 2026-09-18 08:11:19 +07:00
JosephFerano a925d31151 Merge: a narrow shift masks its own count on x86 2026-09-18 08:01:40 +07:00
JosephFerano 8e5fcf54cc The two sweeps do not race a printing fixture against a clock
dev-chatty.flan outlives the surveys' twenty seconds by design, and unlike
dev-repl it prints while it does -- so the two backends stop at different
lines and the diff reports on scheduling rather than on lowering. It joins
dev-loop and dev-watch in the excluded-by-name list in both sweeps, with the
distinction written down.

rt_flush_out is guarded on __wasm__: the pipe it is careful about belongs to a
merged flan dev, which is only ever a native host, and wasm32 need not answer
for a descriptor mode its runtime may model differently.

And three comments that went false with the _exit: the atexit registration in
the merged entry point is no longer there for rt_die, which unlinks the socket
for itself now, so both places that said so say what it is actually left
covering.
2026-09-18 08:00:57 +07:00
JosephFerano c22c896f83 The alias hands the sweep the js spike too, and an empty glob is a smaller sweep 2026-09-18 08:00:32 +07:00
JosephFerano d11dfd8a44 The reversed slice the survey can reach
bounds.flan covers this already and the x86 survey cannot see it: the
program picks its case out of (at args 1) and survey.sh runs everything
with no arguments, so re-gating x86's lo <= hi would have failed
nothing. This probe reaches the reversed slice on its own, through
(len args) so that neither optimiser can fold the branch and the checker
has no literal to object to. It matches under the default sweep and
under SURVEY_FLAGS=--no-bounds-checks, which is the claim.

Also records what the same reading turned up and did not fix: x86 still
reports a negative slice-from-ptr promise through flan_slice_error, so
the two backends print different sentences for it, and no corpus program
reaches that case without arguments.
2026-09-18 07:59:41 +07:00
JosephFerano 534907d204 Merge: the registry answers honestly under churn 2026-09-18 07:50:48 +07:00
JosephFerano 739b871ff1 The eight attempts at reading the table now wait between themselves
Retrying immediately looked like eight chances and was one: a walk that bails
at the epoch check costs almost nothing, so all eight fit inside the single
compaction they were all losing to, and the listing refused having waited for
nothing. A quarter of a millisecond between attempts -- the agent's break-loop
idiom, legal here because the waiter is the listener thread and never the game
loop -- bounds the whole refusal at two milliseconds. Measured with a writer
noting and freeing on top of three thousand live blocks: 8 right answers in 200
without the pause, 200 in 200 with it. It is not magic, and the comment says
so: a writer that spends most of its time rearranging the table still gets
refused, which is the honest answer and used to be a zero-row lie.

The two cases the last commit left unwired are wired now, and a third joins
them: a listing taken while the table really is being compacted, which nothing
covered -- the full-of-live case never compacts and the churn case is
single-threaded, so the retry itself was exercised by nothing. It asserts only
what a slower machine cannot change: never zero rows, never a count that is
neither right nor a refusal. How the rest divides is printed, not pinned.
2026-09-18 07:47:57 +07:00
JosephFerano 2b9f271cc3 The repeal is written down where the rules were: spec, BUILT, NEXT, the ledger, FIX 2026-09-18 07:46:37 +07:00
JosephFerano d8fc41a142 Merge: a failed module leaves the session as it found it 2026-09-18 07:45:02 +07:00
JosephFerano 2edd441ecf The flow analysis is repealed: ownership lives in the types, the allocator, and the dev runtime 2026-09-18 07:45:00 +07:00
JosephFerano 1d788dfda4 The queue-full refusal stops contradicting the note two arms above it
It ended "send it again once the program is running", which reads as a
withdrawal of what eval promises a parked program on every other evaluation:
that a body redefined while parked installs when the program is run again.
What is true is narrower — this module was not taken, and the run that drains
the sixty-four already queued is what makes room for it.
2026-09-18 07:43:38 +07:00
JosephFerano b23dcf73f3 The session is put back however the module failed to land
The three named arms are not every way a step after the check can fail.
write_file makes the daemon's copy of the module's text before llc is called at
all, and a Sys_error from it went out through serve's guard with the session
already holding the declaration — the same stranded declaration under a
different exception. Dev.eval now restores on the way past anything that
escapes, and carries an accepted flag so that a module the agent has already
taken is never rolled back out from under the process.

That is also the seam the crash can be reproduced through. test_dev takes the
daemon's working directory away from it — its own, not the macro cache the two
failures above use, which cannot fail a build with no macro in it — evaluates a
defn, and then calls it. Without the restore the daemon lists probe-two in
describe and the call closes the socket: the null cell, the game thread, and
address 0.
2026-09-18 07:42:41 +07:00
JosephFerano 95058502f5 The timing check only has to sit under the timeout
Two seconds was measured on one warm machine and has no margin against the
6.8s cold build listening already records. The status and the absence of the
frame-boundary sentence are what discriminate; this bound exists only so a
timeout cannot be read as an answer, so it moves to 4.5 and says so.
2026-09-18 07:40:55 +07:00
JosephFerano 8e5798fa1c The sweep reads spike/js too, where a wrong shift had been waiting 2026-09-18 07:40:52 +07:00
JosephFerano 1eea388a47 Merge: the Emacs client stops eating frames, replies, and second sessions 2026-09-18 07:40:37 +07:00
JosephFerano 866c0792e0 Two checks that would have been read wrong, and one that could have flaked
The unreadable-frame case wrote both frames as one string, where `(:a14' reads
as a typo rather than as the end of one frame and the start of the next; they
are two inserts now, with a line saying which is which. And the check that a
request after a daemon restart does not wait out a reply the old connection
owed was asserting against the same number it had bound the timeout to -- the
one arrangement that cannot tell "fast" from "timed out a moment ago". The
timeout is ten seconds and the assertion is three, which is the gap the claim
is actually about.
2026-09-18 07:40:10 +07:00
JosephFerano 885470820e A NaN has no sign to print
(/ 0.0 0.0) printed nan through LLVM, which folds it at compile time to
the positive quiet NaN, and -nan through x86, where divsd computes the
negative one. Put the operands in globals so nothing folds and both say
-nan, so the divergence is the folding path and not the arithmetic.

The sign bit of a NaN is not a property of the number and IEEE 754 does
not specify it, so the print site is where this is answered.
flan_f64_to_bytes renders any NaN as nan, and the two dev emitters do
the same. That is not a new rule: format-f64 in the prelude has always
answered nan for this value, so a build where (print x) said -nan and
(show x 2) said nan was contradicting itself inside one backend. An
infinity still prints signed.

format.flan prints the three non-finite values through print as well as
through show. It is in the survey corpus, so the one program pins the
printed form under dune test and the agreement between backends under
the survey.
2026-09-18 07:37:14 +07:00
JosephFerano 5caf455e66 A compaction that can reclaim nothing does not run, and a listing nobody could read says so
The registry compacted whenever the table was three quarters full, and a
compaction reclaims dead entries and nothing else. A program holding more than
three quarters of the table in live blocks therefore compacted on every
allocation for the rest of its life, reclaiming nothing each time and holding
the table-wide epoch odd while it did. A listing racing that loop lost all
eight of its attempts and answered with zero rows -- "nothing is held", about a
program holding three thousand blocks, from the verb that exists to find a
leak. Measured at 199 wrong answers in 200.

The trigger now also asks whether there is an eighth of a table's worth of dead
to reclaim, which is a count four places maintain: a death, an arena's
free-all, a note written over a dead slot, and the sweep itself. That bounds
the cost from the other side too, since a sweep that runs reclaims at least 512
slots and so cannot run twice in 512 allocations.

Separately, flan_dev_reg_by_type answered a walk it could not take with zero
rows, which is the same number a program that had freed everything gets, and
stepped past slots flan_reg_snap could not copy while still calling the walk
whole. It now counts those slots and returns -1 with the count, the agent
refuses in a sentence the daemon already renders, and the snap contract says
which caller keeps it and why reg_at is allowed not to.

A note that finds no slot is still dropped -- dying because a diagnostic ran out
of room would be the diagnostic shooting the patient -- and now says so on
stderr once, quoting how many entries were dead rather than claiming the table
is all live.

test/dev_limits.c gains three modes, driven from test_reload: 3100 live blocks
read under a writer thread (1 right in 200 before, 200 after), 3000 live with
600 churned on top of them to prove the sweep still runs, and a genuinely full
table that must say so exactly once.
2026-09-18 07:36:45 +07:00
JosephFerano b93b6120d1 A reversed slice is not a slice, in every build
The lo <= hi test in check_slice and slice-from-ptr's n >= 0 sat behind
--no-bounds-checks in both backends, while the comment beside each said
they could not be dropped. They are not bounds checks: hi <= len asks
whether a range fits inside a length, and lo <= hi asks whether the word
about to be written into a %slice's length field is a count at all. The
first stays behind the flag, the second is now emitted everywhere, the
way flan_vec_as_slice has always validated its own l > h in plain C.

emit.ml emits two signal blocks rather than one and i1, so an unchecked
build carries one compare. x86.ml keeps all three frame temporaries
stored outside the flag and gates only the second compare, because the
third is the length the message prints.

The IR assertion in test_acceptance now says the two slice calls are
present under --no-bounds-checks rather than absent, and the same build
is run: case 2 and case -2 of bounds.flan must still die.
2026-09-18 07:36:21 +07:00
JosephFerano 0c0eadb424 A trap dies where it stands, and does not wait for a reader first
rt_die began with fflush(stdout) and ended with exit(134), and under a merged
flan dev both could hang. The flush waits on a 64K pipe whose reader is busy;
exit runs the atexit chain and the ELF destructors, which want the loader lock
a dlopening listener thread may be holding, and in that build the chain also
holds OCaml's shutdown. A bounds failure that hangs is worse than one that
kills the process, because nothing is printed and nothing exits.

So the flush every trap does first goes through rt_flush_out, which puts fd 1
into non-blocking mode and accepts a truncated tail, and rt_die takes _exit
and unlinks FLAN_DEV_SOCK by hand -- the same shape as die_now in
flan_agent.c, which argued all of this first and is quoted rather than
diverged from. On a terminal, a file, or a pipe with room, O_NONBLOCK changes
nothing, and the acceptance corpus diffs that output.

One consequence worth knowing: _exit skips ASan's exit-time leak report, so a
trapping program under @sanitize no longer reports leaks.
2026-09-18 07:35:56 +07:00
JosephFerano f186284a4d Quitting ends one session, and connecting elsewhere says what it is leaving
`flan-dev-quit' promises to stop the daemon this Emacs started. It sent `close'
down the current connection as well, on the assumption that the two are the
same program -- and `flan-connect' is the command for attaching to one that is
not, so the assumption was one keystroke from being false. Connect to a daemon
running in a terminal, quit, and two programs ended: the one Emacs owned, and
somebody else's.

The daemon's socket is now kept apart from the connection's, which is what lets
one be told from the other. `flan-connect' names both programs before it
abandons a connection belonging to a daemon this Emacs started, and refuses
outright for a Lisp caller, as `flan-dev' already did for a running program.
`flan-dev-quit' on a connection that is not the daemon's closes that connection
and says what it left running. `flan-dev-restart-program' restarts on the
daemon's own socket, and refuses while the connection is elsewhere: there is no
reading of "build it again" that leaves one session where there was one.
2026-09-18 07:35:55 +07:00
JosephFerano c1ccc318f7 The park says the program is over before it waits for a reader
Closing the window of a printing program left the pipe full, and the flushes
on the way out blocked on a compiler thread that was busy answering the very
request asking to run it again. program_state still said RUNNING, so rerun
refused a finished program as "the program is already running".

Two flushes moved. flan_exit no longer flushes before the hook -- exit(3)
flushes for itself, so that call only ever ran on the merged path, and there
it was the first thing to block. flan_merged_park flips the state under the
lock first and flushes after, because a state flip is two stores and cannot
wait on anything.

What widens is the window in which the program is parked and not yet on the
condvar. Nothing is lost there: program_asked is the fact and a signal
delivered to nobody is discarded. Two re-runs in that window are both
answered ok for one run, which is the trade against a refusal that was false.
2026-09-18 07:33:39 +07:00
JosephFerano eed0cfa950 C-x C-e at the top of a file has nothing behind it, and says so
`backward-sexp' does not signal when there is no form before point; it goes to
the beginning of the buffer and stays there. At point-min that is no movement,
so the form the dispatch then looked at was the one *after* point -- whose head
is very often a declaration -- and the region between start and end was empty.
The key installed the file's first declaration by name with no body, which the
daemon accepts, and the echo area reported it as a change. Both paths check for
movement now: the predicate refuses, and the expression arm refuses in words
rather than asking the daemon to evaluate the empty string.

The same predicate asked only for the depth at the open delimiter, and a form
written at column 1 inside a comment or a string is at depth 0 like any other.
A `defvar' in a comment is prose; it is not offered as a declaration any more.
Depth in a narrowed buffer still means depth within the narrowing, which is
noted where it is read rather than changed.
2026-09-18 07:32:34 +07:00
JosephFerano a2449ed7c1 Merge: an enum member is checked against the i32 it will be 2026-09-18 07:32:28 +07:00
JosephFerano 8c9e8a314c A narrow shift masks its own count, since the 64-bit instruction masks to 63 2026-09-18 07:31:50 +07:00
JosephFerano 6197b32b50 A program that prints is not a program that stopped polling
The merged build's stdout is a 64K pipe back into the daemon's own process,
and the accept loop is the only thing reading it -- which it is not doing
while serve is answering a request. The two five-second waits for a frame
boundary now drain the pipe on every tick, so a program stopped inside fwrite
is one the daemon lets go rather than one it waits out and then accuses of
not calling agent/poll.

drain and not take: the text stays in the buffer until with_output puts it on
the reply, which is where the output an evaluation caused belongs. And the
drain sits beside the sleep rather than inside the select, because a readable
pipe would make the tick free and count the timeout out in a fraction of it.

dev-chatty.flan prints 4K a frame, which is the only fixture here that fills
the pipe at all; without the drain it fails in 5.1s with the old sentence.
2026-09-18 07:31:25 +07:00
JosephFerano 9585d8c2fe An enum member is checked against the i32 it will be, before anything reasons about it
The members of a defenum are i32 at run time, but the reader hands the parser
an int64, so a value too large for the type arrived looking ordinary: truncated
by the x86 backend, malformed in the LLVM IR, and -- the reason this is a
correctness hole and not a nicety -- invisible to the duplicate-value rule
sitting right below it. That rule compares int64s, so (defenum E [A 0
B 4294967296]) passed it: the two differ as int64 and are both 0 as i32, and
the one check written to catch two names for one number waved through exactly
the case it exists for.

Each value is now checked where it is resolved, which is before the collision
scan runs, so the scan compares the numbers the program will actually have. A
value that does not fit is refused rather than quietly made to fit, naming the
member, its enum, and the value, with a different sentence for a value that was
written and one autoincrement walked into -- nothing in the source wrote
2147483648, so the refusal has to say where it came from before it can say it
is wrong.

The check is bound with a let rather than inlined into the cons, and that is
load-bearing: OCaml leaves :: operand order unspecified and takes the tail
first, so an inlined check would run after the recursive Int64.add and let
(defenum E [A 9223372036854775807 B]) wrap to min_int and refuse B for a number
in no one's source. Bound first, A is refused and the wrap is unreachable.

The parser is the only place this needs to happen: Parse.decl is the sole
constructor of Ast.Defenum's member values, and Load only re-qualifies the
enum's name.

Explicit-duplicate aliasing is untouched; that rule is deliberate.
2026-09-18 07:31:00 +07:00
JosephFerano acf38ac064 The poll settles like every other sender, and the settle runs before the reconnect
`flan-dev--poll' guarded on `flan-dev--busy' and nothing else, and the watch
timer is precisely the sender that does not bind it: it sends without waiting,
so it has nothing to hold. A poll landing in that gap sent `describe' down a
connection that already owed a reply, read the watch's answer as its own, and
left its own for the watch to read a tick later -- after which the two
consumers stayed swapped for the session, each of them answering the other's
question. It runs `flan-dev-settle-hook' now, which is the invariant the hook's
docstring already claimed.

And the hook runs before `flan-dev--live-connection' rather than after. An
outstanding reply belongs to the connection it was asked on; when a daemon has
been restarted the old connection is gone and no reply is coming on the new
one, but the hook was being asked about the new one and blocked a full
`flan-dev-reply-timeout' -- a frozen Emacs on the first key pressed after a
restart, with the watch armed. `flan-dev--busy' now covers the reconnect too,
which asks the new daemon what it defines and so must not be interrupted by a
poll either.
2026-09-18 07:30:43 +07:00
JosephFerano a70cfadd94 A bad frame costs one request, not the connection
Two orderings in the reply reader, and both of them were permanent. A frame
whose header arrived and whose body did not fell through the wait loop into
`flan-dev--extract-reply', where `byte-to-position' signalled a wrong-type
error on a position past the end of the buffer -- so the timeout message the
function goes to some trouble to word was never the one anybody read, and the
header stayed at the front of the buffer, where the next request took it as
its own and every request after that was answered by the one before it. The
body deadline is tested again now rather than trusted, and the dead frame is
erased: the timeout is said in the words meant for it, and the connection is
back in step. The header deadline still erases nothing, because a partial
header is a valid prefix of a reply that is merely slow.

The other is `flan-dev--extract-reply' reading the payload before deleting it,
so a payload that would not read was never consumed and the same bytes
signalled again on every later request. It is deleted first now. That makes
the frame gone whether or not the read succeeded, which `flan-watch--tick' has
to know: it cleared its pending flag only on a reply it got back, and would
otherwise wait for ever for one no longer in the buffer.
2026-09-18 07:29:34 +07:00
JosephFerano 47cb46aa00 Merge: a while condition joins the loop's move regime 2026-09-18 07:28:16 +07:00
JosephFerano c31feb85eb A while condition runs every trip, so it may not move what it stands on
The condition was checked before in_loop, outside the diff that catches a
loop giving away what the next iteration needs. Emit puts the condition in
the loop header, so it runs again every trip: a condition that frees a
local freed it once per trip. It is now diffed against the same dead set,
with its own reason. A dotimes count and a loop's initial values stay
outside: those are evaluated exactly once.
2026-09-18 07:26:28 +07:00
JosephFerano d36c77ba6b A parked program's full reload ring says why it is full
The agent refuses a module it has no room to queue with "the program is not
calling agent/poll". For a running program that is the cause. For a parked one
it is confidently wrong: there is no game thread left to poll with, nothing
drains the ring until somebody runs the program again, and the ring is full
precisely because Dev.eval accepts evaluations while parked and promises they
install at the next run. At install sixty-five that promise breaks, and the
reply sent the reader to inspect a loop that is not running.

The agent cannot know this — parking is the merged shim's state — so the
daemon, which has just asked liveness, rewrites that one reply the way
abi_mismatch rewrites dlerror's. Every other refusal is still quoted in the
agent's own words, and a running program's is unchanged.

Pinned on Dev.refusal rather than end to end: filling the ring is sixty-four
real clang runs, and what the daemon does with the agent's words is the whole
of the change.
2026-09-18 07:26:07 +07:00
JosephFerano cf410c5da3 A declaration that fails to build or deliver leaves the session as it found it
Session.eval committed the checked program the moment a form checked, and the
two steps that can still refuse it — the build, and the agent taking the
module — come after that, in the daemon. Either one left the editor holding an
error and the session holding a declaration the process has no body for. The
next module built for that session lists the name in its install prologue,
which interns a cell for it and never stores anything into it, and a dev
build's call through a cell has no null test in front of it: the game thread
jumps to address 0 at the next C-x C-e, locals render or globals refresh.

Session.held takes the four fields eval commits as one unit and Session.restore
puts them back. Dev.eval and Dev.eval_expr take one before checking and restore
it on every arm where nothing was accepted — a failed build, a refused
delivery, an unreachable agent. eval_expr needed it for the generic instances
it keeps, whose own comment already claimed the invariant: "the session must
not come to believe it holds a body that no module was ever written for."

A delivered module that then times out is not a refusal and does not roll back:
the agent has it and will install it at a frame boundary.
2026-09-18 07:24:45 +07:00
JosephFerano ce3e879e83 The hunt's ledger: five dispatched, the rest recorded 2026-09-18 07:08:49 +07:00
JosephFerano 1f75744b06 Four lanes in, and what is left is the author's 2026-09-17 23:25:10 +07:00
JosephFerano c0ccbe2df2 The runtime stops trusting multiplication, sharing one buffer, and keeping an unread word ambiguous 2026-09-17 23:14:28 +07:00
JosephFerano c59cc95679 The removal joins the deferred map operations in the test and in the note
The arm was written with the others and through the same deferral, and neither
the generics row in test_flan.ml nor the paragraph in BUILT.md that enumerates
what defers had it. Its placeholder is get's, for get's reason: it answers an
(Option V), so the match around it still has to check while the key is a
variable.
2026-09-17 23:13:30 +07:00
JosephFerano 9f85139f1f An Option has no fields to take the address of, and two rows now say so
FIX.org carried Addr(Pfield ...) on an Option as a hole in both backends. It is
not reachable from the language: a field access goes through struct_target,
which admits a struct or a pointer to one and refuses everything else by name
with a location, so (addr (.x o)) is refused at the field and never reaches a
place. The node that failed was one the compiler built for itself.

The refusal is pinned on the bare field and on the address of one, and FIX.org
now records the finding, including the asymmetry that stays: the x86 backend
lays out an Option's tag and value as fields and the LLVM backend does not.
Neither path is reachable, so matching them would be untestable code written to
balance a road nobody drives on.
2026-09-17 22:59:03 +07:00
JosephFerano 81b807f544 A rendered number goes in the caller's frame, not in one buffer for the process
Every number-to-text conversion wrote into one file-static in the runtime and
answered a slice over it, and nothing copied. Two of them in one expression
printed the second number twice — no crash, no diagnostic, and nothing a
sanitizer could find, because every byte read was inside an object that was
alive. The wrong object.

The buffer is now the caller's, one frame slot per call site. The slot is
allocated in the checker rather than in either backend: a slot is a
function-lifetime location in both of them, where an x86 backend temporary is
bump-allocated and reclaimed at the end of the expression that made it — which
is the one lifetime a returned slice must outlive. Each backend gains one
pointer argument and no reasoning of its own, which is what keeps them
symmetric.

The static is gone rather than left unused, since a buffer with nothing but a
comment beside it is a loaded gun. What remains is the ordinary lifetime a
pointer into a frame has: storing one of these slices in a container that
outlives the frame, or returning it, is still a copy the caller has to make.
NEXT.md's sharp edge now says that instead of what it used to say.
2026-09-17 22:55:30 +07:00
JosephFerano 9d10e7edb0 The allocation registry stops being read out from under its writer
The writer is the game thread, in every allocation and every free; the reader
is the agent's listener, on a program that is running. Nothing stood between
them. The consequence is not a slightly wrong count: a row's type pointer and
its length mean nothing apart, and a reader that takes the new pointer with the
old length reads off the end of a string literal.

Each entry now carries the watch table's seqlock, odd while it is written, and
a reader copies the entry and re-reads the counter before believing it. The
compaction bumps a table-wide counter around itself, because it moves entries
between slots and no per-slot counter can describe that; a scan that sees that
counter move walks again. It clears the table slot by slot rather than with one
memset, since the memset would zero the counters a reader was holding.

The breakdown and the leak report stay answerable while the program runs, which
is the moment they are for. reg at does not: whether one address is still live
is exactly what a running program is changing, so it is refused the way every
break verb is refused, which is what the daemon already did on its own side.
2026-09-17 22:37:19 +07:00
JosephFerano 594a42b54e A map you can take a key out of, and the run closes behind it
(map-remove! m k) answers the value that was there, or None, which is the
answer get already gives and for the same reason: a key that is not in the map
is an answer, not a failure. Handing the value back rather than dropping it
makes "take this out and use it" one call instead of two that hash the key
twice.

The removal shifts the probe run back over the hole. A Robin Hood lookup stops
at the first empty slot, so a hole left in the middle of a run hides every
entry after it — and the hidden ones are precisely what a test that only asks
after what it removed never looks at, which is why the program removes a
thousand of two thousand keys and then asks for the other thousand.

Odin was read rather than recalled here, and it does the opposite: its erase
marks a tombstone and its insert carries the repair loop. Staying tombstone-
free keeps the shape the rest of the file already assumed, and the lookups —
which outnumber the removals — pay nothing for it. The note in the runtime and
the two in BUILT.md that said Odin deletes by backward shift were describing
Odin's insert, and now say which is which.

It allocates nothing and releases nothing, so there is no guard around it and
it means the same thing on a map in an arena as on one in the heap: a key and a
value live inside the one block the map allocated, and there was never anything
per entry to hand back.
2026-09-17 22:31:58 +07:00
JosephFerano 0fd83dc95e The driver refuses in sentences, and CI exists 2026-09-17 22:23:19 +07:00
JosephFerano aaca776afd A clock, the rest of libm, getenv, and the small file verbs 2026-09-17 22:21:24 +07:00
JosephFerano 9a32910882 The header says where this stops and what picks it up next 2026-09-17 22:19:16 +07:00
JosephFerano 263b9bb627 Four places the runtime answered with something other than the truth
The argument vector's malloc was unchecked, and a failure there would have
published a null pointer with a length beside it. It now dies naming what it
was building, because argv has no allocation site for a condition to hang on.

flan_slurp_into read a capacity of elements as a capacity of bytes and skipped
the epoch check every other container operation runs. The element size is now
a parameter and the length it publishes counts whole elements, so the day slurp
answers something other than (Vec u8) it does not answer with bytes nobody
wrote.

A string with a NUL in it is refused at the C boundary, which is the policy
flan_path_cstr has always had for a path: C reads to the first NUL, so what
crosses is a prefix of what was passed, and a window title is no different from
a filename in that respect. The refusal names the declare-c, which is the name
the program's author wrote.

The runtime's two translation units are compiled with -Wall -Wextra. They were
already clean under both; the flag is there so the next one is caught rather
than read.

The generation word keeps its place and loses its "yet": a reader for it is a
third word on every slice in the language, which is a spec amendment rather
than a runtime patch, and the comment now says so where someone deciding to
trust the word would read it.
2026-09-17 22:17:19 +07:00
JosephFerano 96e5fab77f flan run does not offer --dev, and says so rather than building one
--dev builds a program whose call sites go through indirection cells so
something can attach and redefine through them. Nothing can attach to a
process this command builds, execs, waits for and deletes, so the flag had no
meaning here -- and an --x86 --dev route through run would have falsified
Build's own statement that flan dev never reaches that fork because --x86 is
read only by flan build. It falls into the refusal arm with a sentence
instead.
2026-09-17 22:12:54 +07:00
JosephFerano 16498a4e60 The alias that nobody ran now has something that runs it
A GitHub Actions workflow on push: dune build, dune test --force, dune build
@checks. @x86 parity is not under dune test, so the routine suite never
protected it; both of this repository's silent failures would have been caught
by one person typing one command, and the problem was never the command.

The suite step keeps its log and greps it for Fatal error, because a suite
that passes while leaving an unhandled exception on stderr is one that is
telling you something and being ignored.

FLAN_LLC is pinned to the llc matching clang's version rather than left to
PATH order: the live loop goes llc + ld -shared + dlopen and never calls the
clang driver, so a mismatch breaks every reload test while flan build keeps
working, which is a bad failure to debug from a log.

What an Ubuntu runner cannot cover -- raylib by exact Fedora soname,
emscripten, a wasi sysroot, lldb -- is written in the workflow with the skip
path each one already takes, so the tick does not read as more than it is.
README.md and test/dune both said there was no CI; both now say what there is
and what it misses.
2026-09-17 22:11:25 +07:00
JosephFerano 4d1af07cd0 Rebased onto defdata, and the untagged union is refused by name
The tagged sum is Tast.data now and Tast.unions is C's untagged union, which
arrives as a structure whose every field sits at offset zero. That is a
layout, and a layout is the one thing an object has none of here -- two
properties cannot share a run of bytes -- so it is refused with the type's own
name rather than met as "not a struct".

Two hardenings the sweep could not have found. $str built a view onto node's
Buffer pool, where small Buffers share one ArrayBuffer, so a write through one
string literal's slice could have reached another's bytes; native puts a
literal in .rodata where that write is a fault. And copy_of's Option arm named
its argument three times, which evaluates it three times.

SURVEY_FLAGS reaches both sides now, as it does in the x86 sweep, so the
unchecked indexing path -- a bare a[i] with no $at around it, which nothing
had ever run -- is swept too: 23 match, 0 differ.
2026-09-17 22:08:42 +07:00
JosephFerano 98dce374d1 The FFI example declared a name the prelude now has
web/examples/ffi.flan opened with (declare cos-f64 [x f64] f64 "cos"), which
was a fine one-liner until the prelude grew cos-f64 an hour ago. @page caught
it: "cos-f64 is defined twice", with the prelude line named as the other site.

It is cosh now. Same shape, same answer, and the collision is worth keeping in
the page rather than editing around silently -- a reader reaching for a libm
function needs to know the common ones are already there and that a second
declaration of a name is refused, not shadowed. The example says so in one
sentence.

This is the cost of filling out the prelude, and it is the whole of it: a
program that declared one of the new names for itself stops compiling, with
both sites named. Nothing in the corpus or in examples/ hit it; this page did.
2026-09-17 22:06:59 +07:00
JosephFerano f0fd54f0f0 The sweep is behind @js, because dune test stays fast
The alias is @x86's shape and deps: one rule over spike/js/survey.sh, FLAN
passed so the script does not start a dune inside dune's own lock. It is not
in the default run and node is probed rather than assumed.
2026-09-17 22:05:48 +07:00
JosephFerano dfe0296479 The sweep is the evidence: 24 match, 0 differ, 71 refused by name
spike/js/survey.sh is the x86 sweep's shape with one deliberate difference in
what it counts. That backend is behind, so a refusal there is a regression and
its strict mode fails on one. This is a dialect, so a refusal is the design
working -- a pointer, an allocator, a Map, the FFI and conditions are refused
permanently and correctly. What fails the @js alias is a DIFFER, which is a
wrong answer, and a CRASH, which is JS this backend emitted and node would not
run.

Two probes carry the decisions the corpus does not reach. p1-int-semantics
prints wrapping at all eight widths, a multiply past 2^53, truncating division
with a negative operand, shifts whose count is out of range, bitwise over a
u32, f32 that is not a double, and the conversions both ways -- 35 lines, all
identical to the LLVM build. It found two real bugs: >>> binds tighter than &
in JavaScript, so a bit-and on a u32 answered -1; and a 64-bit value through
Number() rounds to 53 bits before it can be truncated, so (i32 i64hi) answered
0 where it must answer -1.

p2-value-copies goes past values.flan to the cases a shallow copy would pass:
a struct inside a struct, a struct returned out of a function, an element read
out of an array of structs, and a global.

Also fixed, and all three were found by the sweep rather than by reading: a
unit-typed call in statement position was compiled to an expression nobody
emitted, so (load-xs) silently did not happen; an arrow body that starts with
a brace is a block, so a zeroed array of structs was a syntax error; and a
bounds message must carry the index expression's location, not the form's,
because that is the one emit.ml passes to check_at.

Render reads an Option's tag as field 0 and a union's as field 0, which is the
LLVM layout and not this one, so both are answered here rather than refused.
fdefers is dropped rather than refused: nothing in the dialect can start a
transfer, so the transfer exit path is unreachable, and refusing it would have
refused every program that writes a plain defer.
2026-09-17 22:05:25 +07:00
JosephFerano dfb02ee26a A Flan struct is a JS object, and the memory model does not come along
lib/js.ml lowers the same checked Tast the other two backends take to one
CommonJS file, by object mapping rather than linear memory: docs/DISCUSS.md
item 5 settled that fork before this was written, and item 5's consequence is
the whole shape of the file. Object mapping means the host's collector owns
every value, so there is no (Ptr T), no free, no arena and no allocator, and a
program that uses one is refused by name with a location rather than compiled
badly.

flan build --target=js leaves Build.executable through its own two lines,
before anything that assumes a clang: there is no object to compile and no
linker to run. --dev, --debug, --sanitize and --x86 are refused there rather
than swallowed. Js.Unsupported exits 3 beside X86.Unsupported, so a sweep can
count refused-by-name apart from did-not-compile.

What runs end to end: integer and float arithmetic with the normalisation each
width needs, let, if, while with break and continue, calls, function values,
structs, fixed arrays, slices, unions, options, match, and println through the
same structural printer the other backends walk.

Value semantics is the trap the object mapping sets and the reason the header
carries a section on it. A Flan struct and a fixed array copy on assignment and
a JS object does not, so every site emit.ml memcpys emits a generated
Point$copy here. Fable's JS backend faces the same question for F# structs and
answers it the other way -- it inserts no clone, and its Rust backend does --
so the divergence is deliberate and the survey pins it.
2026-09-17 22:05:25 +07:00
JosephFerano 901376ba49 The three new programs join the two memory sweeps
Both corpora are explicit lists and not globs, so a program added to
test/programs is covered by dune test and by @x86 and by nothing else until
somebody types its name here. files.flan, math3.flan and time.flan are typed.

time.flan is the one with something to say. getenv hands back a slice viewing
the process environment and never a copy, which is the exact shape a
use-after-free or an off-by-one length would be, and neither ASan nor memcheck
had ever seen it. files.flan brings three more path buffers through
flan_path_cstr. math3.flan is the cheap one and is here for completeness.

files.flan makes and removes its own tree, so the sweeps' two runs of it see
the same directory both times.

@sanitize is clean with all three in. @valgrind is not run here -- it is tens
of minutes and opt-in -- so those three entries are checked by the next person
who runs the alias.
2026-09-17 22:05:07 +07:00
JosephFerano c5b8af23a1 Files beyond slurp and barf, split by whether a handler could act
Five more: file-exists?, file-size, delete-file, rename-file and
make-directory. The interesting thing is not the list, it is the line drawn
through it.

file-exists? and file-size answer a value -- a bool and an (Option i64) -- and
are prelude functions over one declare that the compiler knows nothing about.
Absence is the reply to those two questions and not a fault, so a condition
would make the ordinary case pay for a handler search, and there is no restart
a handler could take that would turn "it is not there" into a different
answer.

delete-file, rename-file and make-directory answer () and signal FileError,
and they are check.ml builtins for the one thing a declare cannot do: they go
through file_guard, so each failure arrives under retry and use-value. Those
are restarts a handler really can take -- make the parent directory and retry,
or supply another path -- which is exactly the case a bool return throws away.
op continues the prelude's numbering as 2, 3 and 4.

One C function behind the two questions rather than two, because they are one
question: stat answers whether the path resolves and how big it is in the same
breath. It is stat and not flan_file_size's fopen-plus-ftell, which is shaped
by slurp being about to read the file and is wrong as a general size -- fopen
on a directory succeeds on Linux and ftell then answers a number that is not a
file size. The two coexist and answer different questions.

rename holds the source in the guard's path slot, so a use-value renames a
different file to the same destination. Both readings are plausible until
somebody says which, so check.ml says which.

The errno mapping is not extended. Its three buckets are what a handler can
act on; EEXIST and ENOTEMPTY land in io with everything else, and that is
honest until conditions have a hierarchy to hang a fourth reason off.

All three carry barf's decision 2 unchanged: they change the filesystem, so on
the web they signal rather than succeeding quietly into a filesystem the page
throws away.

Not here, and not half-parsed either: a directory listing, which needs an
allocating builtin and a Vec of owned strings, and streaming IO. Neither has
a name to trip over.

programs/files.flan makes and removes its own tree and takes both restarts on
operations that write. The runtime additions continue the block at the end of
flan_rt.c.
2026-09-17 22:04:48 +07:00
JosephFerano 2dd13b5ae0 The language can tell the time, and read its environment
Nothing in it could. A game got a clock from raylib and a program without a
window had none at all, so "how long did that take" was unanswerable in the
half of daily use that is a tool rather than a game.

Two clocks, because the mistake a single one invites is using it for the other
job. monotonic-ns measures: it never goes backwards, nothing adjusts it, and
its zero is arbitrary, so it is meaningless alone and correct as a difference.
unix-ns dates: nanoseconds since 1970, which is what goes in a save file, and
which jumps in either direction when somebody sets the system clock. The names
are picked so that reaching for the wrong one reads wrong.

This is Odin's shape, from core/time/time.odin and core/time/time_linux.odin:
Tick against Time, both an i64 of nanoseconds, over MONOTONIC and REALTIME,
with the seconds-valued face derived rather than a second syscall. Three C
functions here and six Flan names over them, which is the rule flan_rt.c's own
header states -- a primitive is the only thing implemented twice.

The monotonic origin is the first read of the clock in the process, not boot,
and that is the one decision worth arguing. CLOCK_MONOTONIC counts from boot,
so on a machine up a hundred days the raw value is past 2^53 nanoseconds and
monotonic-seconds would lose sub-microsecond resolution depending on the
machine's uptime rather than on anything the program did. Latched to first
read it stays integer-exact for a hundred days of process life, and it also
matches what a game already has: raylib's GetTime is seconds since
InitWindow, so the two numbers now mix without a conversion at every site.

sleep-ns loops on EINTR, because otherwise a signal cuts the wait short and a
frame loop wobbles for reasons nothing in the program explains. It is
documented as at-least and not as a frame limiter; the shape that actually
paces a loop is a deadline recomputed from monotonic-ns each turn, and the
comment says so where somebody will read it.

getenv answers an (Option [u8]) viewing the process environment, which needs
no allocator and no free and is safe precisely because nothing in this
language can call setenv or spawn a process. The absent case rides in the
length rather than in the pointer: there is no null test to write, since a
(Ptr T) here always addresses something, so flan_getenv answers -1 and a
pointer at a valid empty string and the Flan side tests arithmetic.

The runtime additions are a single block at the end of flan_rt.c, with
<time.h> inside it for the reason <errno.h> sits beside the file section.

programs/time.flan asserts invariants and never a reading -- t2 >= t1, a sleep
that did not return early, a date after 2020 and before 2100 -- because the
same file is in the corpus @x86 builds twice and diffs, so a timestamp would
fail a correct compiler on its second run.
2026-09-17 22:04:48 +07:00
JosephFerano aeeb6de59d The maths is the whole family now, at both widths
The prelude's declare surface was five f32 functions, and the five were there
because somebody needed each one. Everything else a caller wanted was written
as a declare at the top of their own file -- the identical libm call with none
of the caveats written down.

So the rest of libm is here: tan, the three inverses, the three logarithms,
exp, fmod, hypot, cbrt, fabs, and an f64 face for every one of them including
the five that already existed. A declare is a line, a symbol already on the
link, and nothing in either backend, which is why this was cheap enough to do
completely rather than one function at a time.

The f64 half is not decoration. f32 is what a position is; f64 is what a
measurement is -- the clock, parse-f64, format-f64, any sum over more than a
few thousand terms -- and having only the f32 face forced a cast down and back
at each of those boundaries, which is where the precision went.

The paragraph the sqrt note draws for itself is now drawn once for the family:
IEEE-754 specifies sqrt, fabs, floor, ceil, round and fmod as exact or
correctly rounded, so those agree bit for bit across glibc, musl and
wasi-libc; it requires nothing of the rest, so the sand-grid rule covers all
of them unchanged. floor, ceil and round are Flan at f32 and libm at f64, and
that is not an inconsistency: the f32 bodies work because every f32 with a
fraction fits in an i32, and at f64 that trick is gone.

abs-i32 and abs-i64 are Flan, one per width because min and max are builtins
and no generic covers the numeric types. pi and tau at both widths, written
out rather than derived so the compiler rounds each literal once.

programs/math3.flan covers it at values that are exact in binary, so nothing
pins one libm's last bit. The -O0 case is the one that matters: at -O2 LLVM
folds a call over two literals and leaves no symbol to resolve, which is how a
missing -lm hid the first time.
2026-09-17 22:04:48 +07:00
JosephFerano ccb100d74c The command list is all eleven, the environment is written down, and one promise is withdrawn
README documented four subcommands of eleven. The seven missing ones are
there now, with import-c and generate-c given a worked example each -- they
are the most valuable thing here that nothing documented at all.

An environment table, checked against the getenv sites rather than against a
list: thirteen variables, each with where it is read, plus the llc/clang
version coupling that breaks C-c C-c while flan build keeps working. The
FLAN_DEV_* set that flan dev hands itself across its own exec is named as
internal rather than left looking settable.

DISCUSS.md's survey of what the x86 backend had no plan for still listed the
whole condition family. x86.ml:1587-1615 lowers all of it and the survey is
104/104; the row is struck through and corrected in place, because other
files cite that table by position.

prelude.ml promised a core: package at milestone 3. Milestone 3 came and went
and the package did not, so the docstring states the limit instead of
promising a way out of it. The loader could carry one -- what is missing is
the decision about what core: means for a program that imports nothing.
2026-09-17 21:50:33 +07:00
JosephFerano 366a8724ba A byte count that cannot be represented is StorageExhausted, not a smaller block
Every size the containers compute is a product of a capacity the program chose
and an element size the checker did, and a product that wraps leaves a block
that fits beside a capacity that does not. The next write goes past the end of
an allocation a sanitizer was told to expect, which is the one corruption
nothing in the suite could have found.

The Vec's growth, the Pool's two blocks and their sum, the map's five runs and
the budget check now go through checked arithmetic. A size with no
representation reports along the path an out-of-memory already takes, with the
largest number the condition's field can hold, since the true one has none.

The test pins the case the guard exists for: an element of 2^33 + 1 bytes at a
capacity of 2^31 wraps to 2 GiB, which a heap allocator answers.
2026-09-17 21:49:40 +07:00
JosephFerano 668268b6cc The wait for a reply is a setting, and the directory installs
The 30s deadline in the reply reader was a literal and its message said only
that nothing had arrived. It is flan-dev-reply-timeout now, and the message
names the daemon buffer to look in -- a first compile on a cold cache is the
case that legitimately runs long, and the build log is what says so -- and the
setting to raise. Still never resent: a request the daemon took and died on
may already have run.

Package headers on all eight client files, so package-install-file on the
directory works and the client is not reachable only by load-path. The
daemon-buffer defcustom moves up beside the other buffer names, because the
reply reader now names it and the byte-compiler reads a file in order.
2026-09-17 21:48:03 +07:00
JosephFerano 652361e169 A missing file is a sentence, and flan run's two argument lists are told apart
with_errors had no Sys_error arm, so flan check nosuch.flan ended in OCaml's
default handler; the daemon has had that arm since before the CLI did. A
Not_found backstop joins it — nothing reaches it today, and the day something
does the failure should name the file rather than say nothing at all.

flan run handed every flag it did not understand to the compiled program:
flan run game.flan --debug built at -O2 and gave the game a --debug. Build
flags are now the build's, -- ends them, and an unknown dash argument before
-- is refused by name with -- named as the way to mean it for the program.

-O0 through -O3 get a spelling on build and run, which they did not have at
all: Build.default pinned -O2 and --debug was the only route to anything
else. Four levels and not five, because -Os is clang's and llc rejects it,
and the same string reaches both. --debug with a higher level is refused
rather than quietly overruled by Build's own -O0.
2026-09-17 21:44:55 +07:00
JosephFerano 223e282e44 An escaped diagnostic says which file and which line, not (_)
Three suite runs left "Fatal error: exception Flan.Loc.Error(_)" on stderr:
OCaml's default handler knows nothing about the diag record, so a process
that does not catch prints a constructor name and none of the message. The
trigger was a corpus file the test binaries check directly and nothing there
wraps. Registering a Printexc printer changes no control flow and costs the
drivers that do catch nothing; it only makes the corpse legible.
2026-09-17 21:44:46 +07:00
JosephFerano ac7ee912e7 A wrong main signature points at the main that is wrong
check_main raised against Loc.unknown, so both of its refusals opened with
<unknown>:0:0. env.locs is the table of where each type was declared and a
function is not in it, so the location comes from the declaration list the
caller already holds. A main that arrived without a defn keeps the unknown
span rather than being given an invented one.
2026-09-17 21:44:40 +07:00
JosephFerano 8a430bb50c The review has four lanes, and the JS backend has its references 2026-09-17 21:43:58 +07:00
JosephFerano fbbd6c4984 What stands between this and daily use, ranked to be implemented from 2026-09-17 21:23:53 +07:00
JosephFerano e9d0b99096 The refusal was about teardown, and a region has none 2026-09-17 20:46:44 +07:00
JosephFerano 1ad146fdbf Where the list stands, and the branches kept off it 2026-09-17 20:38:08 +07:00
JosephFerano d57eeb3265 The refusal was about teardown, and a region has none
A (Vec Value) where a Value may itself hold a (Vec Value) — the recursive
dynamic value an EDN reader has to answer with when nobody hands it a target
struct type — was refused five different ways, and every one of the five gave
the same reason: the container runtime is type-erased, so it copies and
releases slots bytewise and cannot reach inside a slot. A free would release
the slots and leave every block they point at stranded.

That reason is about teardown, and it does not hold for a region. free-all
never releases an individual slot; it takes the whole arena, and every block
the elements own is in it, because they came out of it. The refusals were
over-broad, and what they were guarding was never ownership — ownership
tracking is untouched here, moves are still moves, and Types.is_move_only is
the same function it was.

So the question moved rather than disappeared. It could not stay at the type,
because can-free is a capability on an allocator value and with-allocator
rebinds a dynamic variable: which tier a (vec-new) will meet is not a property
of the place its type is written. What is decided at compile time is only
whether to ask, which is a property of the element type; the answer is a
run-time branch on the allocator, one per container and never per element,
because the alternative is a walk at release and a walk at release is the
registry of destructors the frame tier's reset exists to not have. It is
emitted at every growth and not only at the construction, because ZII means a
container can exist without ever passing through (vec-new) — a case field left
out of a literal, a global that starts zeroed — and those adopt the context on
their first push.

free on such a container is refused rather than made quietly shallow. It cannot
recurse, which is the whole premise, and releasing the outer block alone would
be "I freed it" written over a program that stranded everything inside; this
runtime refuses that collapse everywhere else. The message names free-all,
which is reachable by construction. clone stays refused for a reason the region
does not dissolve, and the old message had bundled the two failures under one
sentence: what disqualifies clone is not that it copies a header — so do at and
get, and they are fine, because they promise nothing — it is that clone
allocates a new block and promises independence, and a bytewise copy hands back
elements still pointing into the original's region.

A struct or union field is admitted only where the field's container holds
owning elements, because that container can only have been built against a
region. A field holding a plain (Vec u8) stays refused: nothing would force
that one into a region, and two copies of the aggregate would be two headers
over one heap block. vec-in-struct.flan still pins that.

The epoch already covered use after free-all, including the case this makes
reachable — an inner header copied out of an arena-held element into a local
still traps, because an Allocator is a pointer and a copied-by-value one would
carry its own epoch.

arena-value.flan builds the value by hand; arena-edn.flan reads a real document
through the tokenizer, and its reader takes no allocator and names none,
because spec-memory.md already puts the allocator in the calling convention.
arena-region.flan is the branch itself: run 0 is the (Vec (Vec i32)) control
that must not trap, and runs 1 and 2 are the two ways this dies.
2026-09-17 20:37:08 +07:00
JosephFerano 22866d864a The tagged sum is defdata, and defunion is C's union 2026-09-17 20:17:37 +07:00
JosephFerano aa9a2e03bb A union member is refused in a constant as well as in a global
The refusal was on the defvar path alone, so (defconst c U (U {.i 1}))
still came back from the emitter as "a global's value must be a
compile-time constant — this one is computed", which is true and says
nothing about unions. Both kinds of global reach the same encoder, so
both get the same message.

It is decided on the checked value rather than on the declared type now,
which is what lets the one initialiser that *is* a constant through: (U
{}) is all-bytes-zero, the same value a declaration with no value gets,
and refusing it would have been telling someone to write the thing they
had written.
2026-09-17 20:08:04 +07:00
JosephFerano 6cc94e00d6 defunion is C's union, and reading the member you did not write is defined
The name freed up by the rename now means what C means by it: the members
overlay one storage, the size is the largest of them, the alignment the
strictest, and nothing anywhere records which one was written. It serves
two things that wanted it. Binding a C header means holding the union the
library holds and reading whichever member the library's own tag says is
live -- a tag Flan cannot see, because the rule relating them is prose in
a manual. Overlaying an f32 on a u32 to look at its bits is the other,
and it is the same read.

So that read is defined rather than refused. This is the one place in the
checker where bytes win over safety on purpose, and the alternative was
not a safer language, it was no feature: type punning *is* reading the
member that was not written. The promise is the one C's implementations
make and C's standard does not -- the layout is the target's, the bytes
are the bytes, a read is a reinterpretation of them -- and what is not
promised is anything about bytes nobody wrote, where a member wider than
the one last stored reads a tail that is indeterminate exactly as a
struct's padding is. ZII narrows that to almost nothing: a union starts
all-bytes-zero unless uninit says otherwise.

uninit on one is allowed, unlike on a defdata. The refusal there was
never about garbage; it is that a tag steers, and a tag no case names
falls past every comparison in a match into a block LLVM may treat as
unreachable. An untagged union steers nothing.

Which is also why three things are refused, each for a reason that does
not expire with a milestone. No move-only member: nothing knows which
member is live, so nothing can tear one down, and unlike the struct and
defdata refusals this is not waiting on recursive teardown -- there is no
fact for teardown to read. No bool at any depth: an i1 loaded from a byte
that is neither 0 nor 1 is a value the optimiser may assume cannot exist,
and a union is the only type that can produce one. No defdata at any
depth, for the reason uninit gives, arriving the other way round. An
Option member is fine and the walk says why: its match is a tag test and
a branch, not a chain with an unreachable tail.

Two members in one literal, a match on a union, a union map key and a
member written into a global initialiser are each refused by name.

A union is a field list whose every offset is zero, so it travels as a
Tast.structure and the checker, the emitter and the x86 backend each grow
one table rather than one shape. A value is a zeroed temporary and a
store -- Set over Pfield, which every backend already has -- so there is
no new IR node and no layout rule spelled out a second time per backend.
The LLVM type is the blob clang gives a union, the DWARF is
DW_TAG_union_type with every member at zero, and the printer names the
type and does not walk it: it cannot know which member is live, and one
of them may be a pointer.

cimport can now check what it could not. A C record holding a union
member was not recorded at all, so the defstruct beside it went unchecked
rather than checked wrongly; a named union member resolves to a defunion
now and the whole record is compared field by field. The defunion itself
is compared against the header's union as a set and not in order --
every member is at offset zero, so a permuted one is the same type and
reporting it would be a finding that is not one -- while a member the
header has and Flan lacks is reported, because that is what changes the
size. A defunion against a C struct, or a defstruct against a C union,
is reported in both directions. An anonymous union member is still
skipped, and the comment now says that the gap is on the Flan side:
there is nothing to declare.
2026-09-17 19:54:32 +07:00
JosephFerano c7e31709d2 The macro module keeps its own Flan bodies, so --x86 can share a process 2026-09-17 19:53:24 +07:00
JosephFerano 5a46ab488f The one caller of ~hidden is named, and the pair it must never form is refused
Emit.program ~hidden and ~dev are opposites: a dev build exports its cells,
globals and flan.abi.* so that a redefinition module can bind against them, and
hiding those would give a host that links, runs and silently installs nothing.
Nothing in the types stopped the combination being written, so it fails with a
sentence instead. Build.macro_module is the only caller that asks for hidden and
it never asks for dev.

The handoff's verification is narrowed to what it actually covers: the 540-file
IR diff is about Emit.program's default, and the new path is read off nm -D on
the linked module. It also now says why sand-headless.flan is the fixture that
checks 65d14f4 -- it reaches rl/with-drawing through an import, which is the
package-macro shape the reproduced bug had.
2026-09-17 19:52:35 +07:00
JosephFerano 3eaa3e23bd A macro module keeps its own prelude, and --x86 gets the merged daemon
flan dev's merged build is the program and the compiler in one -rdynamic
executable, so it exports every flan.* body it has, and ELF gives it precedence
over anything dlopened afterwards. The compiler expands a macro by dlopening a
module into that same process, and the module is built by Emit.program whatever
backend the session uses -- so under --x86 the caller was LLVM's and the body it
landed in was the dev backend's, which is a crossed pair. It died with SIGSEGV
inside flan.[clamp] during the first expansion, before the program had run a
line, and Dev.start refused the combination rather than do that.

Build.macro_module now asks Emit.program for hidden visibility on the module's
own Flan definitions. There is nothing left for the host to interpose, and the
flan.macro.* thunks stay exported because dlsym is how the compiler reaches
them -- nm -D on the built module lists those three and nothing else of Flan's.
The -Wl,-Bsymbolic that had been binding everything locally since 65d14f4 goes
with it: the module links its own flan_rt.c, and binding that locally aimed its
calls at a runtime flan_rt_init never ran on, with a null flan_exit_hook, so a
trap raised inside an expansion would have exited the process instead of parking
it.

Nothing about the host moved, which is what keeps redefinition modules reaching
its cells, its globals and flan_dev_cell. hidden defaults to false, and the 540
IR files this compiler emits for the test corpus are byte-identical to the ones
before it.

test_dev.ml's assertion that the merged daemon refuses --x86 becomes the session
it was standing in for: dev-macro.flan calls a prelude macro at the top level,
so the daemon coming up at all is the old crash not happening, and one build
then carries C-x C-e, a C-c C-c whose body calls a macro again, the park and the
rerun.
2026-09-17 19:50:29 +07:00
JosephFerano d0d94a05f6 The refusal was about teardown, and an arena has none 2026-09-17 19:44:00 +07:00
JosephFerano 9e80147527 What landed, and the one thing re-run cannot do 2026-09-17 19:11:08 +07:00
JosephFerano dd7a4eaea7 C-x C-e on a top-level form installs it
# Conflicts:
#	emacs/flan-dev.el
2026-09-17 19:10:11 +07:00
JosephFerano 7704463ecd A closed window parks the program instead of ending the session 2026-09-17 19:08:45 +07:00
JosephFerano 0d72835842 The escape hatch says which hatch it is when asked for a re-run
--two-process has no parked thread to wake. Its program is a child, and a
child that finishes is gone, so running main again is not something that shape
can do — and saying "the program is already running" would send somebody back
to try again after it had exited. It names itself instead.

--x86 arrives here too and is the reason this is worth a round trip rather
than none: --x86 refuses the merged daemon, for the -rdynamic reason the case
below already records, so every x86 dev loop is a two-process one and this is
the answer it gets.
2026-09-17 19:08:12 +07:00
JosephFerano ff2c949361 The tagged sum is defdata, and the old spelling is an error by name
Flan's tagged sum has been spelled defunion since it landed, which was
accurate right up until the language wanted C's untagged union as well.
Both cannot be called the same thing, and the tagged one is the one with
an alternative name that says what it is: a case, its fields, and a tag
that steers which case is live is a data type, not a union.

So the form is defdata everywhere -- the parser, the AST, the checker,
both backends, the prelude's Form, the editor's font-locking and imenu,
the docs and every .flan file in the tree. The internal vocabulary moves
with it: Tast.union is Tast.data, uname is dname, the tables the checker
and the emitter keep are datas. Leaving them would have inverted the
words permanently, with surface defunion meaning one thing and
env.unions meaning the other, which is exactly the kind of drift the
comments in those files exist to prevent. What did not move is case,
variant and vfields: a tagged sum still has cases, and it still has one
live at a time.

defunion is not kept as an alias. An alias would compile the day the
untagged form lands and mean the opposite of what it used to -- the same
silent misparse that made defn's return type mandatory, and worse,
because the reader would have no reason to look. The old spelling is a
named refusal instead, parse/defunion-renamed, which says what it is now
called and that the name is reserved for something else. It fires on the
head alone, so (defunion U [A B]) -- which would otherwise have parsed
cleanly as one field A of type B -- is refused with the rest.
2026-09-17 19:03:27 +07:00
JosephFerano 836c23295c Two sentences a parked program was still getting wrong
basis had the same false promise the eval note did. It branches on the break
state, which answers "running" while the program is parked — the agent's
listener is alive and nothing has stopped — so disassemble reported that a body
delivered while parked installs at the next frame boundary, which is not a
boundary the program will reach until somebody runs it again.

And flan-rerun had no autoload. The keymap binds it, and flan-mode.el says in
as many words why every command there needs a real autoload rather than a
declare-function: a user who loaded only flan-mode would otherwise find the key
bound to nothing at the exact moment they most want it.
2026-09-17 19:01:49 +07:00
JosephFerano 0c523cfe8b The program can be run again, in the process that is already there
You run a program under flan dev, it opens a raylib window, you close the
window, main returns — and there is no way to get another window short of
flan-dev-restart-program, which throws away the build, the session and every
global with it. In Common Lisp or Clojure the image outlives main, so you call
it again. The process here already outlived main: the exit hook flushed, closed
stdout and sat in for (;;) pause(). Nothing could wake it.

So main() is a loop. The hook records the status and longjmps back into a
setjmp in main() — there is no return available, since flan_exit is reached
from wherever the program happened to be — and the thread waits on a condition
variable until the new rerun op signals it. The main thread is the one that
runs main again: a window belongs to the thread that opened it, and on macOS to
the first thread of the process. A longjmp pops no frame, so the park first
empties the handler stack, the restart stack and the shadow frame chain, each
of which was a chain of allocas in stack the next run is about to write over.
Nothing else is reset; the second run reads whatever the first left in the
globals, which is the semantics that was asked for.

Closing stdout had to go with it. That was how the compiler learned the program
was done, but a pipe delivers EOF once, so the signal and the program's output
were the same resource and spending it left the second run with nowhere to
print. The descriptor hazard the old code reopened /dev/null for goes away with
the close that caused it. Liveness is asked for instead, through a weak symbol
in the same style as the agent's, and is now three states rather than two: Live,
Parked and Gone. Every guard branches on that before consulting the break
state, because the agent's listener answers "running" while the program is
parked and telling somebody whose program has finished that it is running is
worse than saying nothing. Only eval accepts a parked program — it queues and
waits for nothing, and the queued module installs at the first frame boundary
of the next run, so a body can be fixed while parked and the re-run executes
it. Everything else needs a frame boundary or a stopped stack, has neither, and
says which, naming the command that gets the program back.

A re-run while the program is running is refused rather than queued: the test
and the signal happen under one mutex, so two mains writing the same globals at
once never starts.

:parked rides on every reply beside :stopped, for the reason :stopped does —
finishing is as unannounced as stopping, more so when the way it happens is a
mouse click on a title bar. Emacs shows flan:parked in the modeline and binds
flan-rerun to C-c C-M-x.
2026-09-17 19:01:01 +07:00
JosephFerano ce3b62340b C-x C-e evaluates the form it is given, declaration or not
A defvar typed at the top of a file could not be evaluated with C-x C-e. The
key was wired straight to eval-expr, so the parser met a declaration where an
expression was required and said so: "defvar is a top-level declaration, not
an expression". The only way to get the var installed was C-c C-c. That split
was never a property of the compiler, which has had both evaluators side by
side in lib/dev.ml all along — one installs a body under a name, the other
wraps an expression in a thunk because there is no name to install into. It
was a property of the keymap.

So the key now dispatches on the form it would actually send. Two questions,
both of which have to answer yes before the declaration path is taken: the
depth at the form's open delimiter says whether anything encloses it, and the
head says whether it is a declaration. Requiring the first is what keeps an
inner expression inside a defn body evaluating as an expression — the
enclosing form being a declaration is not a reason to reinstall it, and the
cursor's position in the file was never the question. Requiring it also
leaves a defn written inside a let to the parser, which refuses it correctly,
rather than quietly installing it as a top-level definition.

The head list is spelled out in flan-dev.el and points at the arm of
Parse.expr that is its authority; package is deliberately left out of it,
because this is the set of heads that fail when sent to the expression
evaluator and not the set that introduces a name.

The echo area is now the only thing that says which evaluator ran, so it has
to say it. An expression still reports "=> 42". A declaration reports what
landed, and reports it by name: a defvar installs no bodies, so leading the
sentence with the generic label left it reading "form installed in 4 ms (also
ticks)" — the one name that changed, parenthesised as an afterthought. Where
there are no functions the names are what changed, so they are what the
sentence is about, and the aside is then empty rather than a repeat of it.

C-c C-c is untouched and stays the explicit "reload the definition I am
standing in", which is still the command to use from inside a body.
2026-09-17 19:00:13 +07:00
JosephFerano c124df36ca A move-only global is borrowed, never moved 2026-09-17 18:42:37 +07:00
JosephFerano 69f5d8a05a A global Vec is borrowed, never moved, and outlives every entry to main
A program that wants to load its data once and keep it could not say so. Every
move-only global was refused where it was declared, on an argument about the
dead set being per function: two functions each freeing the same global would
be a double free nothing could see. The argument was sound and the conclusion
was too strong. It assumed a global has an owner. It does not.

Reading a move-only global is now always a borrow. Nothing may take ownership
of one, so nothing may free one, and with no owner to hand over there is no
double free left to catch. This is not a general ownership model for globals
and is not meant to grow into one: it is sound precisely because the lifetime
question that model would exist to answer has a constant answer here, the
process's. The refusal lands at the read, which is where a move would have been
recorded for a local -- passing the global to something that owns its
parameter, binding it to a local, returning it and freeing it all reach the
same place, and each is told to borrow instead, or to clone if it really wants
something of its own.

Such a global is mutable where it stands. push, put, reserve and set already
take their target through the borrow path, so a global (Vec u8) is filled and
grown in place, and the aliasing that raises is the one every Vec has:
spec-memory.md's explicit Zig/Odin contract, where a push that reallocates
invalidates a slice taken before it and the dev build's generation word traps
on the stale one. Globals get no borrow rule locals do not have, because the
hazard is not new and the trap lives on the Vec rather than on the binding.

What a move-only global may not do is carry a computed initialiser. A global's
initialiser is a link-time constant -- there is no init-at-startup path in the
LLVM backend by design, and the x86 backend that has one deliberately leaves it
out of a reload module, because re-running an initialiser wipes the live state
reloading exists to preserve. So the global starts zeroed, which for a Vec is
an empty Vec and therefore a value rather than a placeholder, and the load is
an ordinary assignment in whichever function loads it. That is also what makes
the data survive: nothing runs between one entry to main and the next, so a
re-entered main finds the global as it left it. A defconst cannot be one at
all, since a constant is not an assignable place and nothing could ever load
it; both refusals name the (defvar g (Vec u8)) that works.

The reload fixture gains a global Vec in the host and another that arrives at
run time, because that is where declaring instead of defining has teeth: a
module that defined the host's Vec would take a zeroed header of its own and
strand the block the process is still using, which a re-zeroed i64 cannot
demonstrate.
2026-09-17 18:42:01 +07:00
JosephFerano de3ad8c052 defenum members may leave their value to the previous one 2026-09-17 18:36:34 +07:00
JosephFerano 3771f6820b An enum member's value is optional, and a value nobody chose is refused
Every defenum member had to carry a literal integer, so an enum of twenty keys
was twenty numbers typed by hand and renumbered by hand the first time a member
was inserted in the middle. A value may now be left out, and then it is the one
above it plus one, starting at 0 -- C's rule, because the enums written here are
as often a transcription of a header as they are original.

Autoincrement brings its own silent failure with it. Renumber a member, or slip
one into the middle, and the member below can land on a value some other member
already holds: two names for one number, the program still compiles, and one of
the two is now unreachable through a match on the other, with nothing in the
source saying so. So a duplicate that was *written* is kept -- a Count or a Last
pointing at an existing value is a real idiom and is somebody's decision -- and
a duplicate autoincrement walked into is refused, naming both members and the
number they collide on, and saying that writing the value out is how the alias
is declared to be intended.

The rule lives in the parser rather than beside the duplicate-name check in the
checker because it is a question about the source text. Ast.Defenum holds
resolved numbers and no per-member locations, so by the time the checker has an
enum in hand it can no longer tell which of the values were typed, nor point at
the other member. All members are resolved before any of them is checked: the
value collided with is as often below as above, and (defenum E [A B 0]) has to
refuse A.
2026-09-17 18:34:52 +07:00
JosephFerano 798c852934 Where a program starts, how a big array clears, and which key folds
flan-dev's start command proposed the last program it had started, so
invoking it from a fresh project's buffer offered the previous project's
file. It now proposes the buffer it was called from; restarting the
previous program is what flan-dev-restart-program is for.

Zeroing a fixed array wrote one typed store per element. Above 64 bytes
that becomes a memset, which LLVM can lower as a bulk clear; below it the
inline stores are still cheaper than a call.

Outline's minor-mode map owned TAB in the lowering buffer, so the folding
keys that buffer defines never ran. A buffer-local overriding map gives
them back without touching Outline anywhere else.

FIX.org collects the rough edges found while using the dev loop.
2026-09-17 18:08:51 +07:00
JosephFerano 65d14f42f0 A dlopened macro binds to its own copy, not the merged host's
flan dev's one-process build links the program and the compiler into one
-rdynamic executable, so it exports flan.rl/with-drawing -- the package's
defmacro compiled as an ordinary function, whose body was qualified at the
Ast level, after the quasiquote had already become a string literal. ELF
gives the executable precedence over a dlopened object, so the macro module
called the host's copy and (rl/with-drawing ...) expanded to an unqualified
begin-drawing the checker then refused. The module is self-contained, so
-Wl,-Bsymbolic is the whole fix.

merged_setup reports a Loc.Error as a diagnostic now, which is how the
failure had a location at all instead of an exception constructor after an
apparently successful build.
2026-09-14 22:26:52 +07:00
JosephFerano a4dce138c0 The page catches up: the backend serves the editor, and emit --x86 is annotated 2026-09-14 13:12:53 +07:00
JosephFerano 45c5b43021 Merge branch 'worktree-agent-ae9bc74da338fa214' into dev-loop 2026-09-14 11:55:14 +07:00
JosephFerano 238f65db59 A listing says which form it came from, and what each slot is
`flan emit --x86` printed a three-line header and then nothing but .byte
blobs. The information was all there and none of it was written down.

Each run of bytes is now headed by the Flan form that produced it, with the
position it was written at, indented by how deeply the form nests. The
headings are queued rather than written, so a form that emits nothing does
not leave its heading on the next form's bytes; atoms queue none at all,
because a literal operand would otherwise steal the heading standing above
the imul that consumes it.

Above each function is a frame map, which is the half no disassembly
recovers: every value in this backend lives in a frame temporary, so
-0x20(%rbp) is the whole vocabulary of the listing and nothing says what it
means. It is read out of what emit_fn already keeps, so it cannot drift.
Beside it, where the arguments arrived and whether there is a hidden sret.

And the bookkeeping is named where it appears -- the transfer guard, the
bounds triple, the arithmetic guards, rep movsb, the dev indirection cell --
with each explained once in a legend at the top rather than at every site.

Always on for `emit --x86`, which exists to be read, and never for a build,
whose .s is a temp file handed to clang. spike/x86/annot.sh is the check that
this costs no byte: emit both ways, assemble both, compare every section.
342 SAME / 0 DIFFER over the corpus in default, --dev and --debug. dump.sh
now shows the annotated listing beside objdump's disassembly -- why beside
what, which is the pairing that answers the mnemonics question.

survey.sh has not been run on this; see the handoff.
2026-09-14 11:54:36 +07:00
JosephFerano 1615e3ed8b The section refresh is checked against the file it must not have touched 2026-09-14 11:29:13 +07:00
JosephFerano 1961c7cc9d The plan for an annotated listing, and the two decisions it rests on 2026-09-14 11:28:30 +07:00
JosephFerano 042ddd73d9 One buffer for four lowerings, and it remembers which one you were reading 2026-09-14 11:25:05 +07:00
JosephFerano 5a2f627842 Four lowerings want a buffer, and the buffer wants to remember 2026-09-14 11:11:13 +07:00
JosephFerano f77216212e Every lowering of one program, side by side 2026-09-14 11:05:35 +07:00
JosephFerano 7f44cc3c8f The backend serves the editor, which is what it was for 2026-09-14 11:01:20 +07:00
JosephFerano e7bb643d9d Merge branch 'worktree-agent-aaf84e55296df121c' into dev-loop 2026-09-14 10:52:13 +07:00
JosephFerano d0e8bdf051 Why test_emacs got no x86 case 2026-09-14 10:51:29 +07:00
JosephFerano ef1c2e6fa5 The survey and the three forced test runs, with what the skip count moved for 2026-09-14 10:51:03 +07:00
JosephFerano fd7fa78720 The two sweeps have no lane left to convert, and had stopped telling a real hit from a false one 2026-09-14 10:47:47 +07:00
JosephFerano 391fc62cc8 The handoff, with both backends measured through a real daemon 2026-09-14 10:40:56 +07:00
JosephFerano ab5a381de8 A README section on what checks what, and the full account of the third rot
The third silent failure was waiting, and it was the September 12th one again:
web/index.html showed the value renderer spelling struct fields with colons, two
days after the sweep that made them dots everywhere else. The check that should
have said so was anchored in NEXT.md, which is a scratch document, so it had been
reporting 'whatever this quotes has moved' into a report nobody could read.

README gains a Checking it section: the four commands, what each one means, and
the plain statement that nothing runs @checks for you. The convention it proposes
is the one this repository already has -- a lane's handoff quotes its counts.
2026-09-14 10:39:20 +07:00
JosephFerano f182fb4728 flan dev --x86: the host and its modules, chosen together
Item 3, and the reason the backend was written. Until now --x86 was read only
by flan build's argument list; the daemon built both halves through LLVM, so
none of this reached the dev loop at all.

The choice is a session setting, not a per-command flag, and it is spelled
exactly as [debug] already is -- one field on Session.t, set once in Dev.start,
carried on every change the session emits. session.ml's comment on [debug]
already gives the reason and it is the same one: the modules have to match the
process they are loaded into. Session.redefinition is the single place that
picks a backend, so the six call sites cannot disagree and the refusal has one
home. Session.change carries the answer beside the text, so the builder and the
text can never come from two different decisions.

There is no fallback and there must not be one. X86.redefinition refusing a form
is reported to the editor; quietly building an LLVM module instead is precisely
the crossed pair flan.abi.x86 exists to refuse at dlopen. A refusal reaches the
editor as a diagnostic like any other -- X86.Unsupported is re-raised as a
Loc.Error at the form it is about, because every caller already handles that and
none handled the other, and a session that died on the first unsupported form
would be worse than one that says so and stays up.

flan reload got the same flag at the same time. A command that could build a
module for a host the other backend compiled is how the crossed pair was
reachable from the CLI at all; the aggregate handoff's two-line reproduction no
longer has a second half.

And the finding: flan dev --x86 refuses the merged daemon. A merged build is the
program and the compiler in one process, and the compiler expands macros by
dlopening a module Build.macro_module made through Emit.program, cached on disk
by the macro source rather than by the backend. The merged host is linked
-rdynamic so a redefinition module can reach its cells, which also exports every
flan.* body it has -- so the macro module's own copy of a prelude function is
interposed by the host's. With an LLVM host nobody notices. With an --x86 host
the caller is LLVM and the body it lands in is this backend's, and the process
dies inside flan.[clamp] during the first macro expansion, before the program
has started. flan.abi.x86 does not catch it and was never meant to: a macro
module deliberately neither defines nor requires a marker. The honest fix is
hidden visibility on a macro module's Flan bodies, which changes the cached
object for both backends and wants a lane of its own. Until then the refusal
names the mechanism and the remedy, and --two-process has no such meeting.

start_merged keeps its --x86 plumbing, unreachable for now, because it is the
half that is right and will be wanted the day the macro module is fixed.

test_dev.ml drives an --x86 daemon through C-c C-c, C-x C-e, a literal, a new
defvar with a value of its own and a new defn, and asserts (twice fresh) is 82 --
which only holds if both registry lookups resolved. The merged refusal is
asserted there too. bin/main.ml learned to print a bare Failure as a sentence
rather than an uncaught exception and its backtrace.
2026-09-14 10:34:49 +07:00
JosephFerano 5b1567c49f The transient thunk, so an expression has something to compile to
Item 2 of HANDOFF-x86-redef.md. C-x C-e and every value a break loop computes
are not a redefinition: there is no name to install a body into, so the
expression is wrapped in a function with nowhere to be called from and the
module says "run this once". flan_reload_call is that wrapper, and the agent
dlsyms exactly that spelling.

Its shape is emit_main's rather than a body's: no caller hands it a transfer
channel, so it owns a null cell on its own frame and passes that cell's address
on. Sixteen bytes of frame rather than eight, because rsp has to be 16-aligned
at the call and that is the whole of what the ABI asks of a frame making one.

The thunk itself is excluded from everything else the module does -- no cell, no
publish, no registry slot. There are 4096 slots and an expression evaluated in a
loop would exhaust them, and a module with nothing pointing into it is what lets
the agent unload it at all.

@flan_reload_transient is that claim, under emit.ml's three conditions. The
third is about data rather than text and is the one that can be got wrong in the
dangerous direction: a string literal lives in this module's image, an
expression may store one anywhere it likes, and a global left pointing into an
unmapped image is silent garbage rather than a fault. So the count is kept where
the literals are made -- string_const bumps the same Emit.m.nstr field emit.ml
counts on, and float constants, which are loaded and never retained,
deliberately do not. The install function's own registry name strings go through
string_const too, which is right rather than incidental: a module that interned
a name left something behind.
2026-09-14 10:34:29 +07:00
JosephFerano aa82364066 Two aliases for the checks nobody ran, and one word that names them all
@page runs web/examples/check.sh and web/examples/quotes.sh against the compiler
dune just built. @cells runs spike/x86/cells.sh, which was a real pass/fail check
-- four builds, two backends, 22 22 against 42 42 -- that nothing in the tree ran.
@checks is @page, @x86 and @cells together, and its comment argues for where the
boundary sits: everything you can run while making coffee is in, @sanitize and
@valgrind are out because folding tens of minutes in would make the umbrella the
thing nobody has time for, which is the disease rather than the cure.

All three scripts learned to resolve FLAN to an absolute path, which is what
actually stood between them and a dune rule: %{workspace_root} expands relative to
the directory the rule is written in, and every one of these scripts cd's somewhere
before using it. The first run of @page failed with twenty diffs all saying
'../bin/main.exe: No such file or directory', which is at least a failure that says
what is wrong.

docs/BUILT.md carried the same colon-spelled renderer block index.html did, from the
same sweep. Nothing checks BUILT.md, so it is corrected here by hand.
2026-09-14 10:26:48 +07:00
JosephFerano c57ca6a24f The renderer's own output, spelled the way the renderer spells it
The renderer check greps a line out of NEXT.md, and NEXT.md is a scratch document
that gets rewritten. The line went, the grep went empty, and the empty-needle guard
did its job and said so -- into a report nobody was reading. Behind that noise the
page was genuinely wrong: the colon-to-dot sweep rewrote every field label in the
corpus and lib/render.ml writes .field today, so the inspector block on the page had
been showing {:x 1.5 :y 0} for a renderer that prints {.x 1.5 .y 0}. test_repl.ml's
fixtures are the authority and they are dots throughout, with an enum member still a
colon; the page now matches. The anchor moves to test/programs/raylib-imported.flan,
which dune test builds and runs, so it cannot quietly stop saying it.

The LLVM excerpt beside it moved too: a --dev main pushes a condition frame before
anything else now, which shifted the SSA numbering by one. The three quoted lines are
what flan emit --dev prints today, with the frame push marked as elided rather than
silently dropped.
2026-09-14 10:20:34 +07:00
JosephFerano a003073be7 Three quote probes compared a caret excerpt against a page that quotes one line
quotes.sh had been red since the compiler's diagnostics grew a source excerpt: the
needle swallowed the caret lines, so result, quoted and i64index failed against a
page that is in fact correct. It now compares the message and nothing else.

Six other probes are deleted rather than fixed. Vec, Map, Handle, an Fn-typed
parameter, a defer inside a let and break were all refused as not-implemented when
the loop was written; all six work today and the page's table lost their rows as
each landed, so the probes were the last thing in the tree asserting a claim the
page no longer makes. What made this invisible for weeks is worth recording: flan
check answers a program that compiles with its whole symbol table, so each of those
probes failed by printing eighty lines of prelude signatures. A clean exit is now
its own one-line failure saying the page's claim has gone stale.
2026-09-14 10:19:18 +07:00
JosephFerano 5a67cf458a A name the host was never built with, compiled by the x86 backend
The first of the three things HANDOFF-x86-redef.md left: a function or a
defvar the running process has no symbol for. ELF cannot grow one, so the
address is asked for by string at install time -- flan_dev_cell for a cell,
flan_dev_global for a global's storage -- and parked in a slot this module
defines.

The reference side is one new [loc] case and nothing else. [Lslot] loads the
slot and answers [Reg (scratch, d)], which is exactly what [Lgot] already did
with [Got] where this has [Sym]; every site that reaches a cell already
double-loads, so no call site, no place expression and no [sym_loc] caller had
to learn a third case. [fnctx.slot] is a second predicate rather than a widened
[ext] because they answer different questions -- [ext] says "the host's, reach
it through the GOT", [slot] says "nobody's yet, reach it through a slot I
filled". It defaults to [fun _ -> None], so the whole-program path emits
byte-identical output and the survey goes on being a structural check.

flan_reload_install is now a function with a frame rather than a run of loads
and stores, because it makes calls and a call on an unaligned stack faults
inside glibc's movaps rather than anywhere a reader would look. Its shape is
emit_globals_init's, down to owning the null transfer cell no caller hands it.

A new global's declared value travels with it: flan_dev_global copies the image
onto the allocation the first time the name is interned and ignores it after,
which is where "a reload must not reset the state" lives. emit.ml folds that
value into an LLVM constant and this file has no folder, so the image is a
module-local buffer written by the initialiser lowered as ordinary code -- the
same bargain emit_globals_data already documents.

Republishing a defconst came free once the rest was there: one store of the new
constant into the host's global, which is what emit.ml does.

reload-v6.flan is new. v3's [extra] is declared zero, which calloc also gives,
so a run-time-new global whose initial value never arrived would still pass;
v6's [tuning] is 42 and the host prints 88.

test_reload.ml's x86 section now runs all four modules against the same
transcript the LLVM path is held to, and the refusal it used to assert is gone.
2026-09-14 10:16:42 +07:00
JosephFerano 066322c47d The checks nobody runs, and a plan to find the rest of them 2026-09-14 10:12:05 +07:00
JosephFerano 9ad886abe5 The plan for wiring the x86 backend to the dev loop, written before the work 2026-09-14 10:11:34 +07:00
JosephFerano f4593525f2 Merge branch 'worktree-agent-ae54bef3575a698ce' into dev-loop 2026-09-14 09:53:35 +07:00
JosephFerano 3b1d3e82b5 Where this is at the end of the session, and the three things waiting on a decision 2026-09-14 09:50:42 +07:00
JosephFerano a9b7eb1556 The survey landed, and neither census comment states a number any more 2026-09-14 08:49:43 +07:00
JosephFerano 0661e6042e The one verification this session could not finish, said so 2026-09-14 08:44:08 +07:00
JosephFerano 3d668f125c A build artefact that wandered into a commit 2026-09-14 08:41:02 +07:00
JosephFerano 7ebf7ec9c4 The counts beside the census, and the language claim the walk used to carry 2026-09-14 08:40:32 +07:00
JosephFerano c355739f55 The handoff says what was built, and the index knows it is there 2026-09-14 08:37:59 +07:00
JosephFerano 155ec0ea31 The two gaps read as closed, and a census that had rotted stops being a census 2026-09-14 08:37:30 +07:00
JosephFerano 99c5d87125 Both raylib workarounds, and where the second one went instead 2026-09-14 08:37:30 +07:00
JosephFerano 7e59085916 Two bindings the pointer arm makes writable: a backwards codepoint read, and a void* the caller types 2026-09-14 08:37:30 +07:00
JosephFerano 37d94ed66f The pointer arm agrees promised, and the comment stops promising it 2026-09-14 08:37:30 +07:00
JosephFerano bb2a9b202b The plan for the pointer arm cimport already promised 2026-09-14 08:37:30 +07:00
JosephFerano c9bb12e591 The two-process daemon took the same signal, and has no test that says so 2026-09-14 08:17:43 +07:00
JosephFerano f543643d19 The flake handoff follows the others into docs/handoffs 2026-09-14 08:12:01 +07:00
JosephFerano 82ca001683 The flake was SIGPIPE, and the handoff says how it was caught 2026-09-14 08:11:35 +07:00
JosephFerano ff2bd1da12 A socket file must not outlive the process that bound it
The merged daemon now unlinks its socket on the ways out it does not
control as well as the one it does: an atexit for exit(3), which is what a
runtime trap takes, and by hand in die_now and in main's fallback, which
are _exit and skip the chain on purpose.

The client says so too. A refusal on a path that exists is a leftover, not
a daemon declining, and the raw 'Connection refused' has now misdirected
two investigations.

Separately, and it is separate: dev-repl.flan gets dev-robust.flan's
24000-tick budget. Twenty seconds of program under a two-minute test is a
second flake waiting its turn, and it is not the one fixed above -- that
one fails honestly, saying the program exited.
2026-09-14 08:11:35 +07:00
JosephFerano 4074eb7b7f A client that leaves before its reply arrives, which used to kill the daemon
Eight hit-and-run connections against the robustness daemon, then a check
that the session is still there and still knows what it installed. Fails
with 'killed by SIGPIPE' against the parent of this commit.
2026-09-14 08:11:35 +07:00
JosephFerano fbafee4957 An editor that leaves mid-reply is a closed connection, not a dead session
SIGPIPE's default action was killing the daemon outright. In the merged
build that is the program, the compiler and the listener at once, and the
socket file is left behind - so the next client got ECONNREFUSED on a path
that exists, which reads like anything but what it is.

Two halves: ignore the signal where the listening socket is bound, and
guard the reply write, because an [exception] case on a [match] covers the
scrutinee and not the branch body, so the EPIPE went past both handlers
below it and out of the accept loop.

test_emacs.ml keeps the daemon's wait status and the program's output on a
failure now, instead of removing the evidence before reading the exit code.
2026-09-14 08:11:35 +07:00
JosephFerano 330bfa1578 The emacs flake, and what the error text already rules out 2026-09-14 08:11:35 +07:00
JosephFerano 0251cf4aaa Both comments predated the marker, and now neither does 2026-09-14 07:57:41 +07:00
JosephFerano 64e4f6cc7e The last opt-in claim, the macroexpand op, and two quoted programs that match their files
NEXT.md contradicted itself about the raylib header: two entries said the check
runs on every build and a third, further down, still explained why it is opt-in.
It gets the strikethrough its siblings have, with the reason the argument
dissolved -- the commit, not a change of mind about the property.

plan.org's list of what the dev runtime answers over nREPL had gained a member it
never listed. And the page quotes a program so that the line numbers in the output
beside it resolve, so both new examples are now pasted from their files rather than
trimmed, and cast.flan gets the source block every other example has.
2026-09-14 07:52:35 +07:00
JosephFerano 8bd5547ddc The generator's own output, quoted with the number it prints 2026-09-14 07:49:00 +07:00
JosephFerano acda9edd7c The reference page: a stale recorded output, a refusal that no longer happens, and the condition it never mentioned
web/examples/check.sh was failing, and had been since 2026-09-12. printing.out
records the structural printer's output with colon field labels; the printer emits
dots. The language is right and the recording is stale -- the colon-to-dot sweep
worked on Flan source forms and never touched a .out file, and check.sh is not
part of dune test, so nothing has run it since the day both landed. The acceptance
suite settles which side is correct: it expects the dot spelling everywhere and is
green. The page quoted the same stale line.

The x86 paragraph told a reader that conditions are the visible gap and quoted a
build failing by name. That exact command now succeeds and produces a binary:
conditions, guards, cells, redefinition modules and DWARF all landed, the survey
is 103 MATCH / 0 DIFFER / 0 refused, and what is still refused is narrow enough to
name -- an aggregate crossing the C boundary, which is the classifier this backend
exists not to have.

ArithError was absent from the page entirely. It sits beside the bounds section
because it is the same decision twice, including the part where no restart is
established at the failing operation. Its two programs are in web/examples/ and
checked, which is the page's own rule for anything it quotes.
2026-09-14 07:43:52 +07:00
JosephFerano 1a6ea7ae69 Citations follow the move into docs/ 2026-09-14 07:38:23 +07:00
JosephFerano 5a4f12bacd Three type-constructor comments spell the map type the way the parser does 2026-09-14 07:36:24 +07:00
JosephFerano f47f9ffe59 Two paragraphs in BUILT.md superseded in place, and the spike report says it is one
BUILT.md appends and never dates, so a paragraph that was true when it was
written reads as fact forever. Two are not: the map type spelling is (Map K V)
and braces in type position are refused by name, and sand.flan has not called
load-texture since it was cut back to port parity. Both get the parenthetical
the FLAN_RAYLIB_H paragraph already got rather than an edit, since that is the
convention the file has.

SPIKE-GENERICS.md gets the treatment overview.md has: a header saying what it is
and when it stopped being current. Its body stays. The two things in it that
would now mislead are named there -- its account of what plan.org says, which
plan.org has since overtaken, and the bare-t-at-every-use rule, which is narrower
than what shipped, since (Option t) does not compile.
2026-09-14 07:36:24 +07:00
JosephFerano dcba88ffd6 The 09-13 handoff says it is a snapshot, and two settled questions stop reading as open
NEXT.md prepends, so its second section still opened with "Read this first" while
pinning a tree six lanes behind and listing as live a queue most of which has
landed. It keeps its contents; what it gains is the sentence saying when it
stopped being where the tree is, and the survey count it quotes measured again on
this tree -- 103 MATCH, 0 DIFFER, 38 skipped.

The two generics entries get the file's own strikethrough treatment, which their
siblings already had: the {K V} catch resolved exactly as it predicted it would,
and there are five predicates rather than four.

Also here: conditions.org gains ArithError beside BoundsError, with the split
between the two runtime conditions that offer retry and the two that deliberately
offer nothing; and tast.ml's header stops naming a tree-walking interpreter among
the backends that consume the typed IR.
2026-09-14 07:36:24 +07:00
JosephFerano 0aebef62f3 The sketch's condition syntax, checked against the parser rather than recalled
handler-bind takes (Type [name] body ...) and has since it was written; the
sketch paired a type with an fn, which is the shape parse.ml names in its own
refusal message. load-level had two return types. And there is no defcondition
anywhere in the tree -- a condition type is an ordinary struct, which is what
both spec-conditions.md and conditions.org say, so the one form in this file that
introduced one was inventing it.

The header's rules went with them: lowercase-is-a-type-variable and "no sigils"
are both the pre-$t spelling, and let never took an annotation.
2026-09-14 07:36:24 +07:00
JosephFerano d94e864dba The syntax sketch shows syntax that compiles, and says where it does not
Every generic example in syntax-sketch.flan was written in the lowercase-variable
spelling that $t replaced, so the file whose job is to be copied from was the one
place the wrong spelling cost the most. Checking the replacements against the
compiler rather than against the sketch turned up three more, and each is now
either fixed or named:

- reduce is (reduce s init f) and its accumulator is the element type. The old
  argument order was Clojure's and the fold in centroid is not a shape it has.
- an fn cannot be written inline into a (Fn [$t $t] bool) parameter, because a
  generic body is checked with nothing substituted and the fn has no types to
  take. Inside a generic the callback is a named defn.
- (largest hps >) does not work and never did: an operator is not a name. Both
  this file and spec-memory.md asserted it.

centroid is left as it was, because component-wise arithmetic over a fixed array
is planned rather than built and an aspirational example is not a defect -- but
it now says so instead of leaving it to be found.
2026-09-14 07:36:24 +07:00
JosephFerano daee402026 The two specs catch up with generics and with the conditions the runtime signals
spec-memory.md's generics section still said there were no constraints and wrote
its type variable as a bare lowercase name, which is the spelling the $t sigil
replaced -- the largest example in it would not have compiled. The where clause
and the five predicates are plan.org's account and this now agrees with it rather
than contradicting it. The empty-map example named no types and used defvar,
neither of which is how map-new is called.

spec-conditions.md named BoundsError once and nothing else. The four conditions
the runtime signals, and the split between the two that establish a retry and the
two that deliberately establish nothing, belong in \xc2\xa75 because that is the section
whose account of restarts the split could have falsified. It does not: the rule is
that the restart a bad index wants is the one the program already had.
2026-09-14 07:36:24 +07:00
JosephFerano 00163bcf34 There is no interpreter, and the plan stops promising one
The Compilation section was written around a permanent tree-walking backend for
expression eval. Open decision #7 closed the other way and BUILT.md records it:
compiling is the only way a form is ever run. The diagram, the milestone-2 exit
criterion, milestone 7's "free in the interpreter", the dev/release table and
the decision itself all said otherwise, and lib/expand.ml states the settled
answer at the top of the file.

Also here, because the same section was the place they were missing: the
hand-written x86-64 code generator, which is a second route from the typed IR to
the same observable behaviour rather than a second semantics; DWARF from both
code generators rather than from LLVM alone; OCaml as a settled host language;
and the map-new example, which has named its key and value types since braces
stopped being a type spelling.
2026-09-14 07:36:24 +07:00
JosephFerano be39f32cb6 Update README 2026-09-14 07:34:26 +07:00
JosephFerano 6ac11be5b1 The README points at the docs index too 2026-09-14 07:31:15 +07:00
JosephFerano 5b54e1b7da Merge the tidy: the crossed-pair case is the marker's, and the citations follow the move 2026-09-14 07:30:14 +07:00
JosephFerano 9735bcc2d8 The survey numbers, measured 2026-09-14 07:29:17 +07:00
JosephFerano cc6c046ed1 Merge branch 'worktree-agent-a4bfc3fb14f6fdc0c' into dev-loop 2026-09-14 07:25:20 +07:00
JosephFerano 38d402e326 The marker's name is a literal in four places, and the note says so 2026-09-14 07:24:54 +07:00
JosephFerano e62b3bfe04 The survey did not move, which is what the dev gate is for 2026-09-14 07:24:31 +07:00
JosephFerano 82557df51b The agent's half of the refusal is checked where it actually ships 2026-09-14 07:22:19 +07:00
JosephFerano 1f30d9a067 The marker, written up: what it refuses and what it deliberately does not 2026-09-14 07:19:27 +07:00
JosephFerano 1795441140 The index names the handoffs from where it sits, not from the root 2026-09-14 07:17:28 +07:00
JosephFerano 0afa530295 An index for docs/, and a .gitignore that explains itself 2026-09-14 07:16:46 +07:00
JosephFerano fa83056708 A backend mismatch is refused by the loader, and says so 2026-09-14 07:15:13 +07:00
JosephFerano 9d5689ffa2 Every citation of a moved document now resolves from where it is written 2026-09-14 07:12:27 +07:00
JosephFerano eb641cf8eb Move the reports and the history into docs/ 2026-09-14 07:10:57 +07:00
JosephFerano b47f137b05 A plan for the ABI marker, written before the code 2026-09-14 07:10:54 +07:00
JosephFerano c1c1f8ed76 A plan for tidying the root 2026-09-14 07:10:46 +07:00
863 changed files with 175758 additions and 30152 deletions

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
# The thing README.md and test/dune both said did not exist.
#
# `dune build @checks` on every push, which is the whole job. Both of this
# repository's silent failures — two x86 refusals that sat for a month, a page
# of examples that stopped compiling for two days — would have been caught by
# one person typing one command, and the argument for CI here is not process:
# it is that nobody typed it. @x86 parity in particular is *not* under
# `dune test`, so the routine suite does not protect it.
#
# `dune test` stays what it is: seconds, run constantly, unchanged by this
# file. The workflow adds a caller, not an alias.
#
# ── What this does not cover, and why ────────────────────────────────────
#
# Stated plainly, because a green tick that quietly checks less than it looks
# like it does is worse than no tick.
#
# raylib. vendor/raylib/link names `-l:libraylib.so.550` by exact soname,
# which is a Fedora spelling; Ubuntu ships neither that soname nor 5.5.
# So every raylib program is skipped here, on three separate paths that
# all already existed: the acceptance cases probe with `ldconfig -p |
# grep libraylib` and print a skip line; survey.sh records a program that
# will not link as `does-not-compile`, which SURVEY_STRICT deliberately
# does not fail on; and web/examples/check.sh skips shimdemo.flan by name.
# Nothing had to be weakened to make this pass — but the graphics half of
# the corpus is unchecked here and is checked only on a machine with
# raylib installed.
#
# emscripten and the web target. test_web probes for emcc and a raylib
# archive built by vendor/raylib/build-web.sh and skips with the reason.
# Installing an emsdk per run is minutes for a target TODO.org has
# deprioritised under "The web target does not reach four things", so it
# is left out on purpose.
#
# wasm32-wasi. Needs a wasi-libc sysroot and a builtins archive that no
# Ubuntu package supplies. The acceptance case probes by building and
# running the smallest program and skips with what failed, so this is a
# gap in coverage and not a failure.
#
# lldb. The debug-information cases want lldb on PATH and say so when it is
# absent. The DWARF is still emitted and still checked for
# self-consistency; what is skipped is the half that says a person can
# actually sit in a debugger.
#
# @sanitize and @valgrind. Out of @checks by design — tens of minutes each
# — and out of here for the same reason. They are the thing to run before
# a release, by hand, not the thing to run on every push.
#
# One platform. ubuntu-latest only. macOS and Windows portability is
# recorded as real and not current (aligned_alloc, MSG_NOSIGNAL,
# stdatomic), and a matrix that goes red for known reasons teaches people
# to ignore red.
name: checks
on:
push:
pull_request:
# A superseded run is wasted minutes and a confusing status on the commit it
# no longer describes.
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
# clang compiles the IR and the runtime's C; llc and ld are the live
# loop, which never calls the clang driver; as is the --x86 path; emacs
# is what test_emacs and test_cider drive the client with, and they skip
# silently without it, which would be four suites' worth of coverage
# disappearing without a word.
- name: Install the toolchain
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
clang llvm lld binutils emacs-nox
# llc has to come from the same LLVM release as clang. The live loop
# goes llc + ld -shared + dlopen rather than through the clang driver,
# so a mismatch breaks `C-c C-c` and every reload test while `flan
# build` keeps working perfectly — which is a confusing shape of failure
# to debug from a CI log. Ubuntu installs llc under a version suffix and
# may or may not provide the bare name, so the version is read off clang
# and FLAN_LLC is pinned to match it rather than left to PATH order.
- name: Point FLAN_LLC at the llc that matches clang
run: |
set -eu
clang --version
major=$(clang --version | sed -n 's/.*version \([0-9][0-9]*\).*/\1/p' | head -1)
if command -v "llc-$major" > /dev/null; then
llc="llc-$major"
elif command -v llc > /dev/null \
&& llc --version | grep -q "LLVM version $major"; then
llc=llc
else
echo "no llc at LLVM $major; clang is $(clang --version | head -1)" >&2
echo "what is on PATH: $(ls /usr/bin/llc* 2>/dev/null || echo none)" >&2
exit 1
fi
echo "FLAN_LLC=$llc" >> "$GITHUB_ENV"
"$llc" --version | head -3
- uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: "5.2"
# There is no (package ...) stanza yet — the install story is Tier 2
# of docs/REVIEW-production-readiness.md and undecided — so there is
# nothing to pin and nothing to resolve dependencies from. The
# library depends on unix and on nothing else.
opam-pin: false
dune-cache: true
- run: opam install -y dune
- name: Build
run: opam exec -- dune build
# --force because the point of a CI run is to run the tests, not to
# discover that dune's cache already knew the answer.
#
# The log is kept and then read, because a suite that passes while
# leaving an unhandled exception on stderr is a suite telling you
# something and being ignored. That happened here — three suites left a
# message-less `Fatal error: exception Flan.Loc.Error(_)` while
# reporting zero failures — and it was found by a person reading a log,
# which is exactly the kind of finding that should not need a person.
- name: The suite
run: |
set -eu
set -o pipefail
opam exec -- dune test --force 2>&1 | tee suite.log
if grep -n 'Fatal error' suite.log; then
echo "the suite passed but left an unhandled exception above" >&2
exit 1
fi
# The part `dune test` does not cover: the reference page's examples
# still print what the page says, the hand-written x86 backend still
# agrees with LLVM over the whole corpus, and a --dev build still calls
# through its indirection cells.
- name: Everything else that can fail
run: opam exec -- dune build @checks
+16
View File
@@ -50,7 +50,18 @@ old-ocaml/
/sand.html
/sand.js
/sand.wasm
# The agent harness: its settings, its transcripts and the worktrees it
# registers all live here, and none of them are this repository's business.
.claude/
# A stale unix socket left behind by `flan dev`. The daemon writes one beside
# the program it is serving and unlinks it on a clean exit; a killed daemon
# does not, and the leftover is a socket, not a file worth keeping.
.flan-dev.sock
# Hand-run scratch: a `probe` executable and the C it was compiled from, from
# poking at codegen by hand rather than through a test.
probe
probe.c
@@ -68,5 +79,10 @@ test/web-files-out.txt
# Python bytecode from the tools directory
__pycache__/
*.pyc
# Two more hand-run leftovers: a shared object built beside a form experiment,
# and the history directory a spike driver writes when it is run from the
# source tree. Anchored where they appear, and nothing in the build makes
# either one.
/forms.so
_hist/
+72
View File
@@ -0,0 +1,72 @@
# Working in this repository
Standing rules for any agent here, including a lane in its own worktree.
## Never
- **Touch anything under `/home/joe/Development/fnm/`.** The author's live
working copy, with an editor session attached. This repository's `sand.fln`
is a different file and may be edited normally.
- **Execute `examples/*.fln`, `sand.fln`, or anything linking raylib.** They
open a window that strobes the author's desktop. Compile, emit, diff — never run.
- **Run `dune clean`.** It escapes a worktree and deletes the main checkout's
`_build`. Run `dune` with `--root .` from inside your own worktree.
- **Kill a `flan dev` daemon you did not start.** The author keeps one attached
to their editor.
- **Put `Co-Authored-By`, a `Claude-Session` trailer or any watermark in a commit.**
## Commits
`master` is the trunk. Lanes work in their own worktree and never merge or push;
the dispatching session merges. A commit message is one declarative sentence
saying what is now true, not what was done.
## Tests
A lane never runs the full `dune test`: it builds with `-j 2` and runs only the
programs and test executables its change touches, one at a time, and lists them in
its report. After about five lanes merge, one tester agent runs `dune test --root .`
on master and fixes what broke; grep its output for FAIL, since the exit code alone
has lied. `@checks` (`@page`, `@x86`, `@cells`), `@sanitize` and `@valgrind` are
slow and run only with the author's permission. ASan misses uninitialised stack
reads; `@valgrind` catches them.
`test_acceptance` compiles `FLAN_TEST_JOBS` programs at once, 2 by default.
Run any compiled program under a memory cap and a timeout, one at a time:
`timeout 60 systemd-run --user --scope -p MemoryMax=1G -q <cmd>`. A runaway once
pushed the author's machine 10 GB into swap.
A test executable run straight from `_build` reads stale copies of its inputs unless
their dune deps are built first: build `@test/corpus` and the test's own deps before
running it, and run test executables under `MemoryMax=4G`.
## Evidence
Read the source, do not recall it — this repository and the reference clones
listed in `docs/REFERENCES.md`. Verify a brief's facts before building on them.
## Records
Keep only what the code cannot say.
- `TODO.org` — one `**` heading per item under a subsystem: `TODO` a gap, `NEXT`
decided and queued, `WAIT` blocked (say on what), `CANCELLED` rejected with a
one-line reason so it is not re-proposed, `DONE` only when the decision is not
obvious from the code (two lines, what it rules out). On a merge conflict keep
both sides.
- The reason for non-obvious code goes in a comment beside it.
- `docs/BUILT.md` holds only reasoning that spans several files.
- No handoff files.
## Dispatching
Group TODO items that touch the same files into one lane. Lanes and reviews run
on Sonnet; use Opus only for a subtle design call or after a Sonnet attempt fails.
## Reviews
Every lane except a trivial one (a one-line fix, a rename, an added binding)
gets an independent adversarial review before it merges: build and run rather
than read, measure both sides of a claimed fix, and reproduce any number a lane
reports. The dispatching session never reviews; a review subagent covers up to
five lanes at once, preferably related ones, and reports findings per lane.
-1432
View File
File diff suppressed because it is too large. Load diff
-4
View File
@@ -1,4 +0,0 @@
* Additional things
** Why do we have prints dedicted per type? That's terrible, what do we need to have proper (println) work for everything?
** The current std is suffering from the same problem, functions should be generic? Why is it like this?
** Why do we have =at= and =nth= if they're the same? Pick one, maybe =at=. For all STD functions make sure we don't have redundancy, even if it matches clojure or lisp
-2708
View File
File diff suppressed because it is too large. Load diff
+283 -203
View File
@@ -1,231 +1,311 @@
<div align="center">
<img src="assets/flan-logo.svg" alt="Flan" width="360">
<img src="assets/flan-logo.svg" alt="Flan" width="300">
# flan<span>.</span>
# Flan
**A statically typed Lisp for making games.**
Clojure's brackets. C's memory. No garbage collector. A REPL into the running process.
**A statically typed Lisp for native games and interactive development.**
</div>
---
Flan is an experimental, ahead-of-time compiled Lisp for programs that need
predictable memory use and a fast edit–run loop. It combines S-expressions,
static types, explicit allocation, and a development session that can replace a
function in a running program without resetting its state.
Flan is what happens when you want Odin's memory model and Common Lisp's debugger
at the same time and refuse to pick.
It is being built around games, but the interesting part is broader: a compiled
language where the running program remains available for inspection,
experimentation, and small changes.
There are no object headers, so a Flan struct **is** its C struct. There is no
collector, so nothing runs between your frames that you did not write. And the
program you are running is not a build artifact you replace — it is a thing you
can edit while it is still going.
In practical terms: you get parentheses, a debugger that would like to have a
conversation, and a collector that only ever touches values you asked to be
dynamic.
```
Edit the code, keep the sand.
```
## What it has
## The twenty-millisecond loop
- Native compilation through LLVM, plus an in-progress direct x86-64 backend.
- C-like data layout: structs, fixed arrays, pointers, slices, and explicit
allocation.
- `Vec` and `Map` containers that take the allocator they are to use, plus
slices as the non-owning view of them.
- Generics, algebraic unions, enums, macros, packages, `defer`, and a C FFI.
- Conditions and restarts for recoverable failures and interactive debugging.
- A raylib package and a collection of ported raylib examples.
- Native, WASI, and web build targets. The cross targets are useful but less
complete than the native development workflow.
Run `flan dev sand.flan`. A window opens, sand falls, and a socket appears next to
your source file. Open Emacs, hit `C-c C-z`, and you are attached to the live process.
There is also a `dyn` type: one machine word whose contents the runtime knows
and the type checker does not. Writing `dyn` is how a value opts in to being
dynamic, and those values — and only those — live on a collected heap. A
program that writes it nowhere pays nothing for it, and `--no-gc` is how to
have that checked: it names every `dyn` in the program and refuses to build.
Now put your cursor in a function and press `C-c C-c`.
The project is exploratory software, not a stable language release. Some
features are deliberately refused while their semantics are still undecided;
the compiler aims to say why rather than quietly accepting a partial version.
It has opinions, but at least they arrive as error messages.
That function is recompiled and installed into the running program at its next frame
boundary. The whole round trip is about **19 milliseconds**, of which the actual
hot-swap — `dlopen` plus `dlsym` — is **0.04 ms**. The rest is the compiler doing
its job. It reads your *buffer*, not your saved file, so there is no ceremony.
## Quick start
The window does not blink. The grid does not reset. Your sand keeps falling.
| | |
|---|---|
| `C-c C-c` | recompile this function into the live program |
| `C-x C-e` | run this expression **inside** the running process — `(len enemies)` returns the real number |
| `C-u C-c C-c` | set a breakpoint without editing the buffer |
| `C-c C-m` | expand a macro, one step or to the fixpoint |
| `C-c C-b` | open the break loop when something goes wrong |
| `C-c C-r` | a REPL, scoped to the program |
## When it breaks, it does not die
Most languages give you two options when something goes wrong: crash, or have
guessed in advance what you wanted. Flan has Common Lisp's third option.
```
flan: unhandled Missing — stopped, not dead.
0. restart: carry-on
1. restart: use-placeholder
```
The program is sitting on the frame where the error happened, with everything it
had still in scope, waiting for you to decide. Pick a restart and it carries on —
it never unwound, so there is nothing to reconstruct.
Handlers run **on the signalling frame, without unwinding**, which means you can
also just… not have an error:
```lisp
;; A handler that returns normally accumulates and lets the signaller run on.
(handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
(load-all))
```
No monad. No `Result` threading. No early return. The signaller carries on.
A breakpoint, incidentally, is not a feature. `pause` is an ordinary function that
signals a `Pause` condition, and a breakpoint is just a condition nobody handled.
## The language
**Four container types, four honest ownership stories.** `[n T]` is a fixed array
and it is a *value* — it copies. `[T]` is a slice: a pointer and a length that owns
nothing. `(Vec T)` and `(Map K V)` own their storage and move rather than copy.
```lisp
;; No initialiser means all-bytes-zero, so this lives in BSS and costs nothing.
(defvar grid [rows [cols i32]])
(let [row (slice (at grid 1) 0 cols)] ; ptr+len, borrows
(set (at row 0) 5)
(println (at grid 1 0))) ; 5 — the same storage
(set grid (zeroed)) ; a memset, not an allocation
```
**`defer` is a compile-time construct**, copied into the exit paths. Innermost
first. There is no runtime stack of thunks to pay for.
```lisp
(defn work [n i32] i32
(defer (println "second"))
(defer (println "first"))
(when (< n 0)
(return 0)) ; runs both defers above it
n)
```
**Generics are monomorphised, and checked once.** The body is verified abstractly at
the definition, so an unsupported operation is an error where you wrote it — not at
whichever call site happened to pass a type that worked.
```lisp
(defn clamp-to [x $t lo $t hi $t] $t
{:where (ordered? $t)}
(min (max x lo) hi))
```
**Macros are compiled, dlopened, and called.** There is no interpreter in this
project and there is not going to be one — compiling is the only way a form is ever
run, so there is no second evaluator to disagree with the first. Running a file that
calls a macro means the compiler built a shared object and loaded it into *itself*
before parsing your first line.
**The FFI is one line per function.** No wrapper, no `shim.c`:
```lisp
(declare-c init-window [width i32 height i32 title string] "InitWindow")
(declare-c window-should-close? [] bool "WindowShouldClose")
```
`Color` crosses by value. `Vector2` comes back by value. The compiler writes the
flattening C so you don't. There are **more than 470** raylib bindings in the box, and
**29** of raylib's own examples ported.
**Keywords are enums with an integer's ABI:**
```lisp
(defenum Key [space 32 escape 256 left 263 right 262])
(key-pressed? :space) ; resolved at compile time; a typo is an error here
```
## The demo
`sand.flan` is a falling-sand toy: 180×120 grains at 120fps. Hold the mouse and
sand pours out of the cursor, release and the colour cycles, `R` clears it.
It is 206 lines, and it deliberately uses almost nothing — no `Vec`, no `Map`, no
generics, no macros of its own, no allocator beyond the stack and static storage.
The grid is `[rows [cols u32]]`: flat, unboxed, in BSS, exactly `rows*cols*4` bytes.
The same memory the Odin port has. Nothing in the frame loop allocates.
The same file is also the regression test. `sand-headless.flan` imports it as a
package and — because the linker follows what the program actually reaches — pulls
in neither a window nor libraylib. It runs N frames and hashes the grid:
```
15595743031174623232
```
That number is byte-identical on native x86-64 and on wasm32-wasi, at `-O2` and at
`-O0`. The random number generator is written in Flan rather than borrowed from
libc precisely so that it would be.
## Two backends that agree
There is the LLVM backend, and there is a second one — about 3,400 lines of OCaml
that emits x86-64 machine code directly, byte by byte, because `llc` was most of
those 19 milliseconds and that was annoying.
Every program in the test corpus produces **byte-identical stdout, stderr and exit
status under both backends**. Not similar. Identical. There is a script that checks
this and it is the only reason anyone trusts the second one.
It also has a rule, stated in capital letters in two separate file headers:
> There is still no aggregate classifier and there must not be one.
## What works, and what doesn't
**Works, and is tested at `-O2`, `-O0` and `--dev`** — often under Valgrind and
ASan too: structs, unions, enums, `match`, generics, `Option`, macros, packages,
conditions and restarts, `defer`, arenas and explicit allocators, all four
containers, handles and pools, bounds checking, arithmetic errors as conditions,
the raylib FFI, UTF-8 strings, compile-time `embed`, the whole Emacs loop, a
wasm32 target, and DWARF debug info you can step through in lldb.
**Designed, refused by name, not built yet:** `(Result T E)` and `try`, `errdefer`,
`handler-case`, `find-restart`, user-written allocators, threading macros, and
`await`. The compiler will tell you which milestone each belongs to rather than
producing a confusing parse error.
**Honest limits.** Redefinition cannot patch a frame that is currently executing and
resume at the same instruction — its register allocation belonged to the old
compilation. "Resume" means re-entering from a restart. And changing a function's
*signature* is rejected rather than applied; changing its *body* is always safe,
because old code is never unloaded.
## Try it
Building requires a current OCaml/Dune toolchain, LLVM/Clang, and the native C
toolchain. Raylib is only needed for programs that use the bundled graphics
package.
```sh
dune build # the compiler
dune test # 232 checks
flan run sand.flan # falling sand
flan dev sand.flan # falling sand you can edit
dune build
dune exec ./bin/main.exe -- run web/examples/hello.flan
```
Then in Emacs: `M-x flan-dev`, or `C-c C-z` to attach to the one you just started.
To build a standalone native executable:
## Reading further
```sh
dune exec ./bin/main.exe -- build web/examples/hello.flan -o hello
./hello
```
- [`plan.org`](plan.org) — the design and the open decisions
- [`spec-memory.md`](spec-memory.md) — ownership, containers, generics
- [`spec-conditions.md`](spec-conditions.md) — what a restart actually is
- [`emacs/MANUAL.md`](emacs/MANUAL.md) — every key binding and what it does
- [`web/index.html`](web/index.html) — the language reference, including a table of
everything that is *not* implemented and the exact words the compiler uses to
refuse it
The falling-sand demo uses raylib:
## Licence
```sh
dune exec ./bin/main.exe -- run sand.fln
```
MIT. See [`LICENSE`](LICENSE).
Once you are iterating regularly, put the built executable on your `PATH` if
you want to use the shorter `flan` commands shown below.
The `vendor/` directory carries other people's work under their own terms —
raylib is zlib/libpng, and the committed `raylib-5.5.h` stays under it.
## The live development loop
---
Start a long-lived development session:
<div align="center">
```sh
flan dev sand.fln
```
Flan is a custard.
The program runs normally and publishes a local socket beside the source file.
The bundled Emacs mode can attach to it, evaluate expressions in the live
process, inspect a stopped program, and recompile a top-level function from
the buffer. A body change takes effect on the next call; changing a function's
signature is intentionally rejected. The program keeps its state, which is
especially nice when you have finally arranged the sand into something almost
worth saving.
</div>
To set up the mode:
```elisp
(add-to-list 'load-path "~/path/to/flan/emacs")
(require 'flan-mode)
```
Then use `M-x flan` to start and attach, or `C-c C-z` to attach to a
session started in a terminal. The editor workflow is documented in
[emacs/MANUAL.md](emacs/MANUAL.md).
## A small example
```lisp
(defstruct AssetMissing [id i32])
(defn load-asset [id i32] i32
(signal (AssetMissing {.id id}))
100)
(defn asset-or-placeholder [id i32] i32
(restart-case (load-asset id)
(use-placeholder [] -1)))
(defn main [] ()
(handler-bind [(AssetMissing [_] (invoke-restart 'use-placeholder))]
(print (asset-or-placeholder 7))))
```
Here a missing asset signals a typed condition. The handler chooses a restart,
so execution continues with a placeholder instead of requiring error values to
be threaded through every caller. See
[web/examples/restart.flan](web/examples/restart.flan) for a runnable version.
## Commands
Eleven of them, and the four anyone starts with:
```text
flan check <file.flan> type-check a program
flan run <file.flan> [flags] [-- args...] build and run it
flan build <file.flan> [-o out] [flags] build a native executable
flan dev <file.flan> [-s socket] [--llvm] start a live development session
```
Useful build options include `-O0`…`-O3`, `--debug`, `--sanitize`,
`--no-bounds-checks`, `--x86`, and `--target=wasm32-wasi|web`. `run` takes the
same build flags and keeps them: everything after `--` goes to the program, and
a dash-argument before it that `run` does not offer is refused rather than
guessed at. `run` is native-only; cross-built output should be run with an
appropriate WASI runtime or browser. A `.wasm` file is not a tiny native
executable in a trench coat.
`--x86` picks the hand-written backend (lib/x86.ml) instead of LLVM, and
`flan dev` is the one command that takes it unasked: a dev session is what it
was written for, it halves the `C-c C-c` round trip, and nothing it builds
outlives the session. `--llvm` is how to ask for the other one there — for a
program this backend refuses by name, and for `--debug`. Every other command here is LLVM
by default and stays that way; `emacs/MANUAL.md` lists what the dev backend
does not do.
The other seven. Four print a stage of the pipeline, which is how you find out
what the compiler thinks it was given:
```text
flan read <file.flan>... the forms the reader produced
flan parse <file.flan>... one line per declaration
flan shim <file.flan>... the C a (declare-c ...) generated
flan emit <file.flan> [--x86] [--dev] [--debug] [--no-bounds-checks]
LLVM IR, or x86-64 assembly under --x86
```
One builds a redefinition module the way `flan dev` does, for scripting the
loop without an editor:
```text
flan reload <program.flan> <forms.flan> [-o out.so] [--debug] [--x86]
```
And two are the C binding tools, which are the most useful thing here that
nothing else documents.
### `flan import-c` — read a header, print the bindings it implies
It builds nothing and writes nothing. It reads a C header (through clang's own
parser), prints the `declare-c` forms it would generate, and then prints every
function it *refused* and the reason — which is the half that earns its keep,
because "this binding is missing" and "this binding cannot exist" are different
problems:
```text
$ flan import-c test/headers/sample.h
(declare-c set-seed [seed u32] "set_seed")
(declare-c add-ints [a i32 b i32] i32 "add_ints")
(declare-c name-length [text str] i32 "name_length")
...
;; 9 imported, 12 refused, of 22 functions in test/headers/sample.h
;; refused owned-text: owned_text returns char *, which the caller owns and
;; releases through the library — a copy would leave the original with no
;; owner. Declare it with a declare-c returning (Ptr u8)
;; refused printf-like: printf_like is variadic, and a wrapper cannot forward
;; an argument list it does not know the shape of
;; refused file-time: file_time long has a width that differs between this
;; project's own targets (64 bits on x86-64, 32 on wasm32), so no single
;; Flan type is right for it
```
Hand the package's `.flan` files along with the header and it diffs against
them too: a `defstruct` whose layout no longer matches the header's record is
named, which is the failure that otherwise shows up as a wrong pixel.
```text
$ flan import-c vendor/raylib/raylib-5.5.h vendor/raylib/*.flan
```
Anything after the header that is not a `.flan` file is passed to clang, so
`-I` and `-D` work as they do anywhere else.
### `flan generate-c` — write those bindings into the package
The same machinery, committing its answer. It takes a package *directory*, not
a header: the header comes from the package's own `headers` file, at the
version its `link` file names, because which version may be read is a property
of the package and not of the command line. It reads every `.flan` in the
directory except the one it writes, so hand-written declarations keep winning,
and it writes `generated.fln`.
```text
$ flan generate-c vendor/raylib
;; refused text-format: TextFormat is variadic, and a wrapper cannot forward
;; an argument list it does not know the shape of
;; refused load-shader: LoadShader Shader is a struct the package does not
;; describe — add a defstruct for it, or keep a hand-written declare-c
```
It exits non-zero and writes nothing if the package and the header disagree.
That is the point of it: the committed file is the one thing in the package
with no second opinion, so the moment of writing it is the last moment at which
the installed library can contradict it.
## Environment
Thirteen variables the toolchain reads, none of which has to be set on a
machine with clang, LLVM and binutils on `PATH`. Each exists for a machine
where the thing is somewhere else, or is the wrong one.
| Variable | What it replaces | Read at |
|---|---|---|
| `FLAN_CLANG` | `clang`, which compiles the IR and the runtime's C | `lib/build.ml:14` |
| `FLAN_LLC` | `llc`, used only by the live loop | `lib/build.ml:886` |
| `FLAN_LD` | `ld`, used only by the live loop | `lib/build.ml:887` |
| `FLAN_AS` | `as`, used only by `--x86` | `lib/build.ml:978` |
| `FLAN_OBJDUMP` | `objdump`, used only by the disassembly verb | `lib/dev.ml:2018` |
| `FLAN_OCAMLFIND` | `ocamlfind`, which a merged `flan dev` needs **at run time** | `lib/dev.ml:2898` |
| `FLAN_EMCC` | `emcc`, for `--target=web` | `lib/build.ml:23` |
| `FLAN_LIBDIR` | where `flan.cmxa` and `flan.a` are, if not beside the binary | `lib/dev.ml:3187` |
| `FLAN_CACHE_DIR` | the object cache, default `$XDG_CACHE_HOME/flan/objcache` | `lib/build.ml:84` |
| `FLAN_WASM_SYSROOT` | the wasi-libc sysroot, default `/usr/wasm32-wasi` | `lib/build.ml:239` |
| `FLAN_WASM_BUILTINS` | `libclang_rt.builtins-wasm32.a`, which is not in clang's resource directory on Fedora | `lib/build.ml:272` |
| `FLAN_WEB_SHELL` | the HTML shell a `--target=web` build wraps the module in | `lib/build.ml:458` |
| `FLAN_DEV_LEAKS` | set (to anything) to have a `--dev` build print what it still held at exit | `runtime/flan_dev.c:1084` |
`${FLAN_RAYLIB_WEB}` is not read by the compiler: it is expanded inside
`vendor/raylib/link`, which is where a package writes a linker argument that
has to differ per target. Any `${NAME}` in a `link` file expands from the
environment and an unset one is refused by name.
**`llc` and `ld` have to match `clang`.** The live loop does not call the clang
driver at all — it goes `llc` + `ld -shared` + `dlopen`, which is what makes
`C-c C-c` cost milliseconds. So an `llc` from a different LLVM release than
`clang` breaks the dev loop while `flan build` keeps working perfectly, which
is a confusing shape of failure to meet without warning.
Variables beginning `FLAN_DEV_` other than `FLAN_DEV_LEAKS`, plus
`FLAN_AGENT_SOCKET`, `FLAN_AGENT_OWNER` and `FLAN_COMPILER_STAMP`, are
internal: `flan dev` sets them across its own `exec` to hand the merged binary
what it needs. Setting them by hand is not supported.
## Checking it
```text
dune test the suite. Seconds. Run it constantly.
dune build @checks everything else that can fail. A couple of minutes.
dune build @sanitize the corpus under ASan and UBSan.
dune build @valgrind the corpus under memcheck. Tens of minutes.
```
`dune test` means "the language still works". `@checks` — which is `@page`,
`@x86` and `@cells` — means "and everything written down about it is still
true": the reference page's examples still print what the page says, the
hand-written x86 backend still agrees with LLVM, and a `--dev` build still calls
through its indirection cells.
The two are separate on purpose. A suite that goes red because prose drifted
teaches you to skim past red. Both of this repository's silent failures — two
backend refusals that sat for a month, a page of examples that stopped
compiling for two days — were found by accident, and neither would have
survived one person typing one command.
`.github/workflows/checks.yml` is now that person: it runs `dune build`,
`dune test --force` and `dune build @checks` on every push. What it cannot run
is written down in the workflow itself rather than left to be discovered —
raylib, emscripten, wasm32-wasi and lldb are all absent from an Ubuntu runner,
and every one of them was already a self-skip, so the tick is green over less
than it is on a machine with those installed. The habit that covers the rest is
still worth keeping: a lane's handoff quotes `@checks`, the way the x86
handoffs already quote the survey's counts.
## Project map
- [web/index.html](web/index.html) — language reference and fuller examples.
- [spec-memory.md](spec-memory.md) — ownership, containers, and generics.
- [spec-conditions.md](spec-conditions.md) — conditions, handlers, and restarts.
- [emacs/MANUAL.md](emacs/MANUAL.md) — the interactive editor workflow.
- [docs/BUILT.md](docs/BUILT.md) — implementation rationale.
- [TODO.org](TODO.org) — decisions, current work, and known limits.
## License
Flan is released under the [MIT License](LICENSE). Third-party material under
`vendor/` is distributed under its own licenses.
+2609
View File
File diff suppressed because it is too large. Load diff
+585 -83
View File
@@ -4,8 +4,15 @@
still exits 1 and still opens with [file:line:col: message]; a driver that
got to the end of the file hands over everything it found, sorted, with a
count after it. Nothing here parses the message — the squiggle comes from
the span and the classification from the kind. *)
let with_errors path f =
the span and the classification from the kind.
[?x86_hint] is the one thing here that is not the same for every command.
A refusal from the dev backend is actionable in a different way depending on
how that backend was chosen: somebody who typed [--x86] already knows they
asked for it, and somebody who typed [flan dev] did not ask for anything.
The second is the common case now that it is the default, so the command
that takes it by default hands over the sentence naming the way out. *)
let with_errors ?x86_hint path f =
try f () with
| Flan.Loc.Error d ->
prerr_endline (Flan.Loc.report d);
@@ -21,8 +28,50 @@ let with_errors path f =
"did not compile". *)
| Flan.X86.Unsupported m ->
prerr_endline ("x86: " ^ m);
Option.iter prerr_endline x86_hint;
ignore path;
exit 3
(* The JS dialect's refusal, and the same status for the same reason. It is
a wider category than the x86 one — that backend is behind on a node, and
this one is a dialect that deliberately does not carry the memory model —
but a sweep counts them the same way: refused by name, not a failure. *)
| Flan.Js.Unsupported m ->
prerr_endline ("js: " ^ m);
ignore path;
exit 3
(* A refusal with no location: a combination of flags this command does not
offer, or a build step that failed. Every [failwith] this binary can reach
is one of those, and a sentence is what a user can act on where an
uncaught OCaml exception and its backtrace are not. *)
| Failure m ->
prerr_endline ("flan: " ^ m);
ignore path;
exit 1
(* A file that is not there, or that cannot be read. The exception already
carries the path and the reason the operating system gave, which is the
whole of what anybody can act on, so it is passed through as it is —
[lib/dev.ml]'s [message_of_exn] does the same for the same reason, and
the daemon has had this arm since before the CLI did. Without it
[flan check nosuch.flan] ends in [Fatal error: exception Sys_error(...)],
which is the compiler telling the user it did not expect to be asked. *)
| Sys_error m ->
prerr_endline ("flan: " ^ m);
ignore path;
exit 1
(* The backstop. Nothing in this binary reaches it today: every [Hashtbl.find]
on a path a command can take is guarded by a [find_opt]. It is here so
that the day one is not, the failure is a sentence naming the file being
worked on rather than a bare [Fatal error: exception Not_found] — which
says nothing at all, not even which file. Its own exit status, because a
compiler bug is not a refusal of the program and a script should be able
to tell the two apart. *)
| Not_found ->
prerr_endline
("flan: internal error — a lookup failed with no name to report, while \
working on " ^ path
^ ". This is a bug in the compiler and not a fault in the program; \
please report the file that provoked it.");
exit 4
let summarise (d : Flan.Ast.decl) =
let open Flan.Ast in
@@ -30,9 +79,13 @@ let summarise (d : Flan.Ast.decl) =
| Package n -> Printf.sprintf "package %s" n
| Import (a, p) -> Printf.sprintf "import %s %S" a p
| Defalias (n, _) -> Printf.sprintf "defalias %s" n
| Defstruct (n, fs) -> Printf.sprintf "defstruct %s (%d fields)" n (List.length fs)
| Defunion (n, vs) -> Printf.sprintf "defunion %s (%d cases)" n (List.length vs)
| Defvar (n, _, _) -> Printf.sprintf "defvar %s" n
| Defsymbol (n, _) -> Printf.sprintf "symbol %s" n
| Defstruct (n, fs, _) -> Printf.sprintf "defstruct %s (%d fields)" n (List.length fs)
| Defdata (n, vs) -> Printf.sprintf "defdata %s (%d cases)" n (List.length vs)
| Defunion (n, ms) ->
Printf.sprintf "defunion %s (%d members)" n (List.length ms)
| Defvar (n, _, _, Once) -> Printf.sprintf "defonce %s" n
| Defvar (n, _, _, Every) -> Printf.sprintf "def %s" n
| Defconst (n, _, _) -> Printf.sprintf "defconst %s" n
| Declare (fn, csym) ->
Printf.sprintf "declare %s (%d params) = %s" fn.name (List.length fn.params)
@@ -41,23 +94,28 @@ let summarise (d : Flan.Ast.decl) =
Printf.sprintf "declare-c %s (%d params) = %s" fn.name
(List.length fn.params) csym
| Defenum (n, ms) -> Printf.sprintf "defenum %s (%d members)" n (List.length ms)
| Defclass (n, slots) ->
Printf.sprintf "defclass %s (%d slots)" n (List.length slots)
| Defgeneric fn ->
Printf.sprintf "defgeneric %s (%d params)" fn.name (List.length fn.params)
| Defmulti fn ->
Printf.sprintf "defmulti %s (%d params)" fn.name (List.length fn.params)
| Defmethod m ->
Printf.sprintf "defmethod %s %s (%d body forms)" m.mgen
(dispatch_text m.mkey) (List.length m.mfn.fbody)
| Defn fn ->
Printf.sprintf "defn %s (%d params, %s return, %d body forms)"
fn.name (List.length fn.params)
(match fn.ret with None -> "Unit" | Some _ -> "explicit")
(List.length fn.fbody)
(* Every path past [parse] goes through [Load]: an import is resolved into the
declarations it stands for, and the package's C shim and linker arguments
come back with them. *)
(* Every driver here is the batch case, which is the one the workflow is: write
everything, compile at the end, work through the list. So every one of them
(* Every path past [parse] goes through [Front], and so through [Load]: an
import is resolved into the declarations it stands for, and the package's C
shim and linker arguments come back with them. Every driver here is the
batch case, which is the one the workflow is: write everything, compile at
the end, work through the list. So every one of them passes [~all:true] and
asks for the whole list rather than the first thing wrong. *)
let load path : Flan.Load.t =
Flan.Load.program ~file:path
~parse:Flan.Parse.program_all (Flan.Reader.read_file path)
let checked path = Flan.Check.program_all (load path).decls
let checked path = snd (Flan.Front.checked ~all:true path)
(* What the source called each parameter, per function. The typed IR refers to
locals by slot index and records no names — [Check] has them in its scope
@@ -79,12 +137,14 @@ let param_names (l : Flan.Load.t) =
l.Flan.Load.decls
(* Bounds checks are on unless a build asks for them off — the release
decision, not the optimisation level (NEXT.md, Bounds checks). *)
decision, not the optimisation level (TODO.org, "An index out of range is
a condition"). *)
let no_checks_flag = "--no-bounds-checks"
(* A dev build is the one a REPL can attach to: every call goes through a cell
so a redefinition can be installed, and the cells and globals are exported
so a loaded module can reach them (NEXT.md, the dev loop). *)
so a loaded module can reach them (TODO.org, "The dev loop, step 1: the
reload primitive"). *)
let dev_flag = "--dev"
(* Source-level debugging: DWARF in the IR, -g on the C, and -O0 forced.
@@ -109,15 +169,106 @@ let sanitize_flag = "--sanitize"
let two_process_flag = "--two-process"
(* The hand-written x86-64 backend (lib/x86.ml) instead of LLVM. The dev
backend from DISCUSS.md item 15, off by default and named explicitly:
LLVM stays the release path and the default one. It covers a subset of the
IR and refuses the rest by name, so a build that succeeds is one it really
compiled. *)
backend written up in docs/BUILT.md, "The hand-written x86 backend, and
the four measurements behind it". It covers a subset of the IR and
refuses the rest by name, so a build that succeeds is one it really
compiled.
Where it is the default and where it is not is the whole of the split: it
is the dev backend, so [flan dev] takes it unasked, and every other command
here builds something that outlives the session and stays on LLVM. That is
also what keeps [lib/x86.ml]'s calling convention licensed — a dev build is
compiled entirely by it and a release build entirely by LLVM, so the two
never meet in one process. On the commands that are still LLVM by default
this flag is how you ask for the other backend; on [flan dev] it is how you
say you meant it, which is what keeps [--x86 --debug] refused by name
rather than reached by accident. *)
let x86_flag = "--x86"
(* The other direction, and it exists because [flan dev] changed sides: a dev
session is the x86 backend now, and this is how to ask for the one that
compiles every node. The two reasons to want it are the ones the backend
itself names — a program it refuses, and [--debug], which it has no line
table for in a redefinition module.
Every other command already builds through LLVM, so there it names the
default and is accepted and ignored. That is deliberate rather than an
oversight in the flag table: a flag that states what a command was going to
do anyway is a true sentence, and refusing it would make the one spelling
mean two different things depending on which subcommand it followed. *)
let llvm_flag = "--llvm"
(* [flan emit] annotates, with --x86 or without, because it exists to be
read: each Flan form heads the code it produced. This turns that off, and
the callers who want it are the checks that compile the output both ways and
compare the objects byte for byte -- a claim that comments, and the splitting
of a [.byte] directive, are invisible to the assembler and to [llc] is worth
measuring rather than asserting. *)
let no_annotate_flag = "--no-annotate"
(* "This program carries no collector", and the way it is kept is a refusal
rather than a different lowering: every dyn left in the program is named,
with its location, and nothing downstream is told the flag was given. See
[Check.no_gc], which is a pass between checking and emission and answers
unit — an annotated program's output is byte for byte what it is without
the flag, and that is the property the flag is worth having for. *)
let no_gc_flag = "--no-gc"
(* "Which of these lines allocates?", printed and nothing else. The other side
of [--no-gc]: that flag refuses a program for holding a dyn, this one only
says where the memory goes — on the collector's heap and on an allocator the
program named, told apart by the diagnostic's kind. Off by default, because
a warning on every push in a program that means them is noise; asked for,
because a frame budget is a question you ask on purpose.
It is a warning and not an error, and it says so in the one way that
matters: the exit status does not move. See [Check.memory_sites], which is
a pass over the checked program and, like [Check.no_gc], tells nothing
downstream that it ran. *)
let warn_memory_flag = "--warn-memory"
(* [flan check --defs]: every definition the checked program holds, prelude
included. Off by default, so a file that checks prints nothing. *)
let defs_flag = "--defs"
let flags =
[ no_checks_flag; dev_flag; debug_flag; sanitize_flag; two_process_flag;
x86_flag ]
x86_flag; llvm_flag; no_annotate_flag; no_gc_flag; warn_memory_flag;
defs_flag ]
(* The warnings, where errors go. Printed one to a location in the repo's
standard [file:line:col:] shape, with the squiggle [Loc.entry] draws, so
an editor or a flycheck checker parses them exactly as it parses an error.
Not through [Loc.report_all]: that appends a count of *errors*, and these
are not errors. *)
let print_memory_warnings ~file (p : Flan.Tast.program) =
List.iter
(fun (d : Flan.Loc.diag) ->
prerr_endline
(Flan.Loc.entry ~mark:'~' ~label:"warning: " d.Flan.Loc.dloc
d.Flan.Loc.dmsg))
(Flan.Check.memory_sites ~file p)
(* Which backend a command got, from the two flags and the default it would
have taken. One function because there is one rule, and the only thing that
varies is which way it falls with neither flag given: [flan dev] true, every
other command false.
Both flags together is refused rather than resolved. Last-one-wins is what
[-O] does above, but an optimisation level is a dial and this is a fork: the
two backends disagree on every aggregate, and someone who wrote both
spellings does not know which half of their session they were asking about.
A sentence is more use than a coin flip. *)
let backend_x86 ~default args =
let x86 = List.mem x86_flag args and llvm = List.mem llvm_flag args in
if x86 && llvm then begin
prerr_endline
"flan: --x86 and --llvm together — one process is compiled by one \
backend, so this asks a question with no answer. Pick the one you \
meant.";
exit 2
end;
if x86 then true else if llvm then false else default
(* [--target=wasm32-wasi] and [--target=web], the two cross targets. Unlike
the flags above, a target
@@ -126,8 +277,43 @@ let flags =
usage error. *)
let target_prefix = "--target="
(* The optimisation level, which until now had no spelling at all: [Build.default]
pinned -O2 and [--debug] was the only route to anything else. Four levels and
not five — -Os is clang's and llc rejects it outright ("invalid optimization
level"), and the same string reaches both ([Build] at the clang compile and
again at the llc step of the live loop), so offering a level one of the two
tools does not know would be a flag that works for [flan build] and breaks
[C-c C-c].
Last one wins, which is what every compiler does with a repeated -O and the
only rule that does not need explaining. *)
let opt_levels = [ "-O0"; "-O1"; "-O2"; "-O3" ]
let opt_of args =
List.fold_left
(fun acc a -> if List.mem a opt_levels then Some a else acc)
None args
let is_flag a =
List.mem a flags || String.starts_with ~prefix:target_prefix a
List.mem a flags || List.mem a opt_levels
|| String.starts_with ~prefix:target_prefix a
(* [--debug] forces -O0 in [Build] and says why there: [llvm.dbg.declare]
describes an alloca and mem2reg deletes the alloca, so a debug build at -O2
has a line table over code whose locals are gone. That is a good rule, and
it makes [--debug -O2] a request that cannot be honoured — so it is refused
here by name instead of being quietly overruled two modules away. *)
let check_opt_against_debug ~debug ~opt =
match (debug, opt) with
| true, Some o when o <> "-O0" ->
prerr_endline
("flan: --debug and " ^ o
^ " ask for opposite things — a debug build is -O0 because \
llvm.dbg.declare describes an alloca and mem2reg at any higher level \
deletes it, leaving a line table over locals that are not there. \
Drop one of the two.");
exit 2
| _ -> ()
let target_of args =
List.find_map
@@ -144,36 +330,89 @@ let () =
List.iter
(fun path ->
with_errors path (fun () ->
Flan.Reader.read_file path
Flan.Source.read_file path
|> List.iter (fun f -> print_endline (Flan.Form.to_string f))))
files
(* The other syntax, on stdout: a .flan file printed indented, a .fln file
printed with parentheses, comments and number spellings kept both
ways. *)
| [ _; "convert"; path ] ->
with_errors path (fun () ->
let forms = Flan.Source.read_file path in
let source = In_channel.with_open_bin path In_channel.input_all in
if Flan.Source.is_indented path then
print_string (Flan.Paren_printer.program ~source forms)
else
let macros = Flan.Body_macros.table ~file:path forms in
match Flan.Indent_printer.program ~source ~macros forms with
| text -> print_string text
| exception Flan.Indent_printer.Unprintable (f, why) ->
Flan.Loc.failk "convert/unprintable" f.Flan.Form.loc
"%s has no spelling in the indented syntax, so this file cannot \
be converted. Rename it in the .flan file and convert again"
why)
| _ :: "parse" :: files when files <> [] ->
List.iter
(fun path ->
with_errors path (fun () ->
Flan.Reader.read_file path
Flan.Source.read_file path
|> Flan.Parse.program_all
|> List.iter (fun d -> print_endline (summarise d))))
files
| _ :: "check" :: files when files <> [] ->
| _ :: "check" :: args when List.exists (fun a -> not (is_flag a)) args ->
let warn_memory = List.mem warn_memory_flag args in
let defs = List.mem defs_flag args in
let files = List.filter (fun a -> not (is_flag a)) args in
List.iter
(fun path ->
with_errors path (fun () ->
let p = checked path in
List.iter
(fun (g : Flan.Tast.global) ->
Printf.printf "%s %s %s\n"
(if g.gconst then "defconst" else "defvar")
g.gname (Flan.Types.to_string g.gty))
p.globals;
List.iter
(fun (f : Flan.Tast.fn) ->
Printf.printf "defn %s : (Fn [%s] %s) %d slots\n" f.name
(String.concat " "
(List.map Flan.Types.to_string f.params))
(Flan.Types.to_string f.ret) (Array.length f.slots))
p.fns))
(* After the listing is decided and before it is printed is not a
distinction anything can see: the warnings go to stderr and the
listing to stdout. What matters is that they are asked of a
program that checked, which is what leaves the exit status
alone. *)
if warn_memory then print_memory_warnings ~file:path p;
if defs then begin
List.iter
(fun (g : Flan.Tast.global) ->
Printf.printf "%s %s %s\n"
(* The three defining forms, told apart the way the compiler
tells them apart: [gconst] is the image, and [grerun] is
what a re-run does to the storage. A listing that called
both mutable forms one name could not answer the question
someone runs [flan check] on a dev file to ask. *)
(if g.gconst then "defconst"
else if g.grerun then "def" else "defonce")
g.gname (Flan.Types.to_string g.gty))
p.globals;
List.iter
(fun (f : Flan.Tast.fn) ->
if not (Flan.Check.internal_name f.name) then
Printf.printf "defn %s : (Fn [%s] %s) %d slots\n" f.name
(String.concat " "
(List.map Flan.Types.to_string f.params))
(Flan.Types.to_string f.ret) (Array.length f.slots))
p.fns
end))
files
(* [flan copies <file> <name>]: the compiled copies of the generic NAME that
the file makes, one name to a line, and nothing when NAME is not generic
or nothing calls it. The editor's lowering view asks it with no daemon
attached; a daemon's [defs] answers the same from its session. *)
| [ _; "copies"; path; name ] ->
with_errors path (fun () ->
let s, _ = Flan.Session.create ~file:path () in
let env = s.Flan.Session.env in
if Flan.Check.is_generic env name then
List.iter
(fun c ->
if
List.exists
(fun (f : Flan.Tast.fn) -> String.equal f.Flan.Tast.name c)
s.Flan.Session.program.Flan.Tast.fns
then print_endline (Flan.Check.written_name c))
(Flan.Check.instantiations env name))
(* The generated C, for looking at. A wrong FFI binding is wrong in the
wrapper, and the wrapper is not on disk anywhere — [Build] hands the text
straight to clang — so without this the only way to read one is to catch
@@ -195,19 +434,27 @@ let () =
the header's records. *)
| _ :: "import-c" :: header :: rest ->
with_errors header (fun () ->
let pkg = List.filter (fun a -> Filename.check_suffix a ".flan") rest in
let pkg = List.filter Flan.Source.is_source rest in
let flags =
List.filter (fun a -> not (Filename.check_suffix a ".flan")) rest
List.filter (fun a -> not (Flan.Source.is_source a)) rest
in
let ds =
List.concat_map
(fun f -> Flan.Parse.program (Flan.Reader.read_file f)) pkg
(fun f -> Flan.Parse.program (Flan.Source.read_file f)) pkg
in
let structs =
List.filter_map
(fun (d : Flan.Ast.decl) ->
match d.Flan.Ast.d with
| Flan.Ast.Defstruct (n, fs) -> Some (n, fs)
| Flan.Ast.Defstruct (n, fs, _) -> Some (n, fs)
| _ -> None)
ds
in
let unions =
List.filter_map
(fun (d : Flan.Ast.decl) ->
match d.Flan.Ast.d with
| Flan.Ast.Defunion (n, ms) -> Some (n, ms)
| _ -> None)
ds
in
@@ -236,7 +483,8 @@ let () =
in
let imported, dump, env =
Flan.Cimport.header ~loc:(Flan.Loc.make header 0 0) ~header ~flags
~known_structs:(List.map fst structs) ~known_enums ~taken ~bound_syms
~known_structs:(List.map fst structs)
~known_unions:(List.map fst unions) ~known_enums ~taken ~bound_syms
~config:
(match pkg with
| f :: _ -> Flan.Load.binding_config (Filename.dirname f)
@@ -260,6 +508,14 @@ let () =
List.iter
(fun (n, why) -> Printf.printf ";; DISAGREES %s: %s\n" n why)
bad);
(match Flan.Cimport.check_unions ~env ~unions dump with
| [] ->
if unions <> [] then
Printf.printf ";; every defunion agrees with the header\n"
| bad ->
List.iter
(fun (n, why) -> Printf.printf ";; DISAGREES %s: %s\n" n why)
bad);
(* And the bindings the package already wrote by hand, against the
header's own signatures. Nothing else in the build can do this: a
wrong declare-c is wrong in the generated prototype too, so the two
@@ -339,13 +595,13 @@ let () =
| _ :: "generate-c" :: dir :: _ ->
with_errors dir (fun () ->
let loc = Flan.Loc.make dir 0 0 in
let out = Filename.concat dir "generated.flan" in
let out = Filename.concat dir "generated.fln" in
let ds =
List.concat_map
(fun f -> Flan.Parse.program (Flan.Reader.read_file f))
(fun f -> Flan.Parse.program (Flan.Source.read_file f))
(List.filter
(fun f -> not (String.equal f out))
(Flan.Load.entries dir ".flan"))
(Flan.Load.source_entries dir))
in
let config = Flan.Load.binding_config dir in
match Flan.Load.header_specs ~loc dir with
@@ -380,6 +636,16 @@ let () =
(if x.Flan.Cimport.cmapping then "UNMAPPED" else "DISAGREES")
x.Flan.Cimport.cname x.Flan.Cimport.cwhy)
r.Flan.Cimport.gconsts;
(* [Cimport.regenerate] only knows the paren syntax; when [out] is a
.fln file the paren text it wrote is read back and reprinted
indented, the same translation `flan convert` does, so the
committed file reads like every other .fln in the package. *)
if r.Flan.Cimport.gwrote && Flan.Source.is_indented out then begin
let source = In_channel.with_open_bin out In_channel.input_all in
let forms = Flan.Reader.read_all ~file:out source in
let text = Flan.Indent_printer.program ~source forms in
Out_channel.with_open_bin out (fun ch -> Out_channel.output_string ch text)
end;
if r.Flan.Cimport.gwrote then
Printf.printf
"wrote %s: %d declarations, %d refused, of %d functions in %s.\n\
@@ -415,6 +681,39 @@ let () =
"flan emit: --target is refused — the emitted IR carries no triple and \
no datalayout, and the target is chosen at build.";
exit 2
(* --x86 prints the hand-written backend's assembly where the default prints
LLVM IR. The two are the same act — here is what this program compiles to,
before an assembler or an optimiser has touched it — and reading one
against the other is the only way to check a lowering by eye. --sanitize
is refused rather than ignored: ASan is an LLVM pass and this backend has
no arm for it, so honouring the flag is impossible and dropping it
silently would print something that is not what --sanitize builds. *)
| _ :: "emit" :: args
when List.mem x86_flag args && List.exists (fun a -> not (is_flag a)) args ->
if List.mem sanitize_flag args then begin
prerr_endline
"flan emit --x86: --sanitize is an LLVM pass and this backend has no \
arm for it — emit without --x86 to see what a sanitized build compiles.";
exit 2
end;
let checks = not (List.mem no_checks_flag args) in
let dev = List.mem dev_flag args in
let debug = List.mem debug_flag args in
(* The listing is annotated because a listing is what this command is for:
a person asked to see what their program compiles to, and a wall of
[.byte] with nothing saying which form produced which run answers the
letter of that and not the question. --no-annotate is here for the one
consumer that wants the bare spelling, which is the check that says
annotation changed no byte of the object. *)
let annotate = not (List.mem no_annotate_flag args) in
let files = List.filter (fun a -> not (is_flag a)) args in
List.iter
(fun path ->
with_errors path (fun () ->
checked path
|> Flan.X86.program ~checks ~dev ~debug ~annotate
|> print_string))
files
| _ :: "emit" :: args when List.exists (fun a -> not (is_flag a)) args ->
let checks = not (List.mem no_checks_flag args) in
let dev = List.mem dev_flag args in
@@ -427,10 +726,14 @@ let () =
List.iter
(fun path ->
with_errors path (fun () ->
let l = load path in
let l, p = Flan.Front.checked ~all:true path in
let pnames = if debug then param_names l else [] in
Flan.Check.program_all l.decls
|> Flan.Emit.program ~checks ~dev ~debug ~pnames ~sanitize
(* Between checking and emission, and it hands the very same program
on: the flag is a question asked of what was checked, never a
parameter of what is emitted. *)
if List.mem no_gc_flag args then Flan.Check.no_gc p;
Flan.Emit.program ~checks ~dev ~debug ~pnames ~sanitize
~annotate:(not (List.mem no_annotate_flag args)) p
|> print_string))
files
| _ :: "build" :: path :: rest ->
@@ -438,7 +741,13 @@ let () =
let dev = List.mem dev_flag rest in
let debug = List.mem debug_flag rest in
let sanitize = List.mem sanitize_flag rest in
let x86 = List.mem x86_flag rest in
(* LLVM by default, and deliberately not moved when [flan dev] moved: a
release build is the one thing here that outlives the session, and the
argument [lib/x86.ml] rests on is exactly that a dev build is compiled
entirely by it and a release build entirely by LLVM. *)
let x86 = backend_x86 ~default:false rest in
let opt = opt_of rest in
check_opt_against_debug ~debug ~opt;
let target = target_of rest in
let out =
match List.filter (fun a -> not (is_flag a)) rest with
@@ -450,53 +759,146 @@ let () =
(* A web build is three files — the page, its JS and the module — and
the page is the one named here: emcc derives the other two from it,
and it is the one a browser opens. *)
(* The JS dialect's output is one file and it is source, so it is
named the way source is: node runs it by name. *)
(match target with
| Some t when Flan.Build.is_web t -> base ^ ".html"
| Some t when Flan.Build.is_js t -> base ^ ".js"
| Some t when String.starts_with ~prefix:"wasm32" t -> base ^ ".wasm"
| _ -> base)
| _ when String.length path > 1 && path.[0] = '-' ->
Printf.eprintf
"flan build: the file comes before the flags, and %s is a flag. \
Write it after the file, as in: flan build prog.fln %s\n"
path (String.concat " " (List.filter (fun a -> is_flag a) (path :: rest)));
exit 2
| _ ->
prerr_endline
"usage: flan build <file.flan> [-o out] [--no-bounds-checks] \
[--dev] [--debug] [--sanitize] [--target=wasm32-wasi|web]";
"usage: flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \
[--no-bounds-checks] \
[--dev] [--debug] [--sanitize] [--x86|--llvm] [--no-gc] \
[--warn-memory] [--target=wasm32-wasi|web|js]";
exit 2
in
with_errors path (fun () ->
let l = load path in
let p = Flan.Check.program_all l.decls in
(* The link follows the program, not the import list: a package nothing
reachable calls into contributes no C and no linker argument, and its
functions are not emitted either. That is what lets one file import
raylib and still be buildable for wasm32. *)
let p, csrcs, lflags = Flan.Reach.link ~dev l p in
let f =
Flan.Front.linked ~all:true ~dev path ~before_link:(fun p ->
(* Before reachability rather than after: a dyn in a function
nothing calls is still a dyn somebody wrote, and a refusal that
depended on what [main] happened to reach would come and go as
the program was edited elsewhere. *)
if List.mem no_gc_flag rest then Flan.Check.no_gc p;
(* Before reachability too, and for the same reason: a push in a
function nothing calls is still a push somebody wrote. *)
if List.mem warn_memory_flag rest then
print_memory_warnings ~file:path p)
in
Flan.Build.need_main ~file:path ~doing:"flan build has nothing to link"
f.program;
ignore (Flan.Build.executable
~opts:{ Flan.Build.default with checks; dev; debug; sanitize;
target; x86 }
~csrcs ~lflags ~pnames:(if debug then param_names l else [])
p ~out))
target; x86;
opt = Option.value opt
~default:Flan.Build.default.Flan.Build.opt }
~csrcs:f.csrcs ~lflags:f.lflags
~pnames:(if debug then param_names f.load else [])
f.program ~out))
(* The daemon an editor talks to: one session, the program it belongs to
running beside it, and a socket. Unlike [flan reload] the session persists,
so a defvar added by one evaluation is part of what the next one is checked
so a defonce added by one evaluation is part of what the next one is checked
against — and it owns the build, which is what makes its layout rules
describe the process that is actually running. *)
| _ :: "dev" :: path :: rest ->
(* With no file, an empty image: the prelude and nothing else, which files
are loaded into. The first word after [dev] is the file unless it is a
flag or the [-s] of a socket. *)
| _ :: "dev" :: args ->
let path, rest =
match args with
| p :: rest when p <> "-s" && not (is_flag p) -> (Some p, rest)
| rest -> (None, rest)
in
(* --debug builds the host *and* every module this daemon sends with DWARF,
which is one flag because it is one decision: a line breakpoint in a
.flan buffer needs a line table on the host to fire at all, and one in
each redefinition module to still be firing after C-c C-c. It implies
-O0 on both, so it is asked for rather than assumed. *)
let debug = List.mem debug_flag rest in
(* One decision for both halves of the session, which is what makes it safe
at all: the host and every module this daemon sends are compiled by the
same backend, because there is one place that says which. The two
conventions agree on every scalar and disagree on every aggregate, so a
crossed pair is correct until the first redefined function takes or
returns a struct — and [flan.abi.x86] refuses that pair at [dlopen] if
this is ever got wrong.
x86 by default, and this is the command where that is true. A dev
session is the thing the backend was written for: it turns the round
trip from 62ms to 28ms, and nothing it builds outlives the session, so
the subset it covers is a subset of one editor's afternoon rather than
of a shipped binary. [--llvm] is how to leave it — for a program it
refuses by name, and for [--debug].
[--debug] takes [--llvm]'s side on its own, because it is not a
preference: [X86.redefinition] emits no line table, so a [--debug]
session on this backend would set breakpoints that stop firing at the
first C-c C-c. Asking for the debugger is asking for the backend that
has one. Writing [--x86] as well still reaches [Dev.start]'s refusal,
which is the point of leaving it readable here — the combination stays
refused by name, it is just no longer somewhere you arrive by typing one
flag. *)
(* --sanitize takes [--llvm]'s side for the reason [--debug] does: the
sanitizers are LLVM passes. [--x86] written as well is refused by name
in [Dev.start]. *)
let sanitize = List.mem sanitize_flag rest in
let x86 = backend_x86 ~default:(not (debug || sanitize)) rest in
let asked_x86 = List.mem x86_flag rest in
let merged = not (List.mem two_process_flag rest) in
let rest = List.filter (fun a -> not (is_flag a)) rest in
let sock =
match rest with
| [ "-s"; s ] -> s
| [] -> Filename.concat (Filename.dirname path) ".flan-dev.sock"
| [] ->
(match path with
| Some path -> Filename.concat (Filename.dirname path) ".flan-dev.sock"
(* An image has no program to sit beside, so its socket goes where
a user's runtime sockets go. *)
| None ->
let dir =
match Sys.getenv_opt "XDG_RUNTIME_DIR" with
| Some d when d <> "" && Sys.file_exists d -> d
| _ -> Filename.get_temp_dir_name ()
in
Filename.concat dir
(Printf.sprintf "flan-image-%d.sock" (Unix.getpid ())))
| _ ->
prerr_endline
"usage: flan dev <program.flan> [-s socket] [--debug] [--two-process]";
"usage: flan dev [program.flan] [-s socket] [--debug] [--sanitize] \
[--llvm] [--two-process]";
exit 2
in
with_errors path (fun () -> Flan.Dev.start ~debug ~merged ~file:path ~sock ())
(* Only this command hands one over, and only when it chose the backend
itself. A refusal from a session nobody asked to be on this backend is
one flag away from building, and the flag is the half of the sentence
[X86.unsupported] cannot know to write. *)
let x86_hint =
if x86 && not asked_x86 then
Some
"flan dev: the x86 backend is the default for a dev session because \
it is about twice as fast, and it does not compile every program. \
Start this one with flan dev --llvm."
else None
in
(match path with
| Some path ->
with_errors ?x86_hint path (fun () ->
Flan.Dev.start ~debug ~sanitize ~merged ~x86 ~file:path ~sock ())
| None ->
with_errors ?x86_hint "" (fun () ->
Flan.Dev.start_image ~debug ~sanitize ~merged ~x86 ~sock ()))
(* One redefinition, built the way an editor will ask for it: a session over
the program the process was built from, and a file of the forms that
@@ -505,6 +907,12 @@ let () =
given only a list of function names could. *)
| _ :: "reload" :: prog :: forms :: rest ->
let debug = List.mem debug_flag rest in
(* The same flag, for the same reason, and it had to arrive with [flan
dev]'s: a command that could build a module for a host the other backend
compiled is how the crossed pair was reachable from the CLI at all.
Building an --x86 host with [flan build --x86 --dev] and then reloading
into it now has a spelling that produces a module it can load. *)
let x86 = backend_x86 ~default:false rest in
let rest = List.filter (fun a -> not (is_flag a)) rest in
let out =
match rest with
@@ -512,17 +920,25 @@ let () =
| [] -> Filename.remove_extension (Filename.basename forms) ^ ".so"
| _ ->
prerr_endline
"usage: flan reload <program.flan> <forms.flan> [-o out.so] [--debug]";
"usage: flan reload <program.flan> <forms.flan> [-o out.so] \
[--debug] [--x86]";
exit 2
in
with_errors forms (fun () ->
let t, _ = Flan.Session.create ~debug ~file:prog () in
let t, _ = Flan.Session.create ~debug ~x86 ~file:prog () in
let src = In_channel.with_open_bin forms In_channel.input_all in
let c = Flan.Session.eval ~origin:forms t src in
let opts = { Flan.Build.default with dev = true; debug } in
let timing = Flan.Build.shared ~opts ~ir:c.Flan.Session.ir ~out () in
Printf.eprintf "%s %s llc %.1fms ld %.1fms\n" out
(String.concat " " c.Flan.Session.fns) timing.Flan.Build.llc_ms
let opts = { Flan.Build.default with dev = true; debug; x86 } in
let timing =
if c.Flan.Session.x86 then
Flan.Build.shared_x86 ~opts ~asm:c.Flan.Session.ir ~out ()
else Flan.Build.shared ~opts ~ir:c.Flan.Session.ir ~out ()
in
(* [as] where the other path has [llc], which is the number the whole
backend exists to move. Named for what ran. *)
Printf.eprintf "%s %s %s %.1fms ld %.1fms\n" out
(String.concat " " (Flan.Session.shown_fns c))
(if x86 then "as " else "llc") timing.Flan.Build.llc_ms
timing.Flan.Build.link_ms)
(* [run] builds and execs. A .wasm is not executable, and picking a runtime
for it is a decision this command has no business making, so a cross
@@ -533,28 +949,114 @@ let () =
this host can exec. Use flan build --target=... and a wasm runtime.";
exit 2
| _ :: "run" :: path :: args ->
(* One command, two argument lists, and until now no rule saying which was
which: [flan run game.flan --debug] built at -O2 and handed the game a
[--debug] it had never heard of. Nothing reported that, because neither
side thought it had been given anything wrong.
The rule, in one line: a build flag is the build's, [--] ends the build
flags, and everything after [--] is the program's whatever it looks
like. Before [--], an argument that starts with a dash and is not a
build flag this command offers is refused by name — not guessed at,
because guessing is the failure this exists to stop, and a program of
one's own that wants [-v] has [--] to ask for it. Plain arguments need
no ceremony: they were never ambiguous and they still go straight
through, so [flan run calc-me.flan "1+2"] is unchanged. *)
(* --dev is deliberately not on this list, and its absence is the point:
a dev build's indirection cells exist so that something can attach and
redefine through them, and nothing can attach to a process this command
builds, execs, waits for and deletes. Leaving it off means it lands in
the refusal below with a sentence, rather than quietly producing a
spelling [Build] says does not exist — [flan build]'s --x86 arm records
that `flan dev` never reaches its fork because --x86 is read only by
[flan build], and an --x86 --dev route through here would have made that
sentence false. *)
let run_flags =
[ no_checks_flag; debug_flag; sanitize_flag; x86_flag; llvm_flag ]
@ opt_levels
in
let build_args, prog_args =
let rec split acc = function
| "--" :: rest -> (List.rev acc, rest)
| a :: rest when String.length a > 1 && a.[0] = '-' ->
if List.mem a run_flags then split (a :: acc) rest
else begin
prerr_endline
("flan run: " ^ a
^ " is not a flag this command offers, and it will not be \
guessed at — a build flag belongs to the build and \
anything else belongs to the program. Write it after -- to \
send it to the program: flan run "
^ Filename.basename path ^ " -- " ^ a);
exit 2
end
| a :: rest -> let l, r = split acc rest in (l, a :: r)
| [] -> (List.rev acc, [])
in
split [] args
in
let checks = not (List.mem no_checks_flag build_args) in
let debug = List.mem debug_flag build_args in
let sanitize = List.mem sanitize_flag build_args in
let x86 = backend_x86 ~default:false build_args in
let opt = opt_of build_args in
check_opt_against_debug ~debug ~opt;
with_errors path (fun () ->
let exe =
Filename.concat (Filename.get_temp_dir_name ())
(Printf.sprintf "flan-run-%d" (Unix.getpid ()))
in
let l = load path in
let p = Flan.Check.program_all l.decls in
let p, csrcs, lflags = Flan.Reach.link l p in
ignore (Flan.Build.executable ~csrcs ~lflags p ~out:exe);
let code =
Sys.command (String.concat " " (List.map Filename.quote (exe :: args)))
let f = Flan.Front.linked ~all:true path in
Flan.Build.need_main ~file:path ~doing:"flan run has nothing to run"
f.program;
ignore (Flan.Build.executable
~opts:{ Flan.Build.default with checks; debug; sanitize;
x86;
opt = Option.value opt
~default:Flan.Build.default.Flan.Build.opt }
~csrcs:f.csrcs ~lflags:f.lflags
~pnames:(if debug then param_names f.load else [])
f.program ~out:exe);
(* Spawned and waited on here rather than through [Sys.command], so a
SIGTERM or SIGHUP sent to flan reaches the program and flan still
removes the executable and its work directory: the default action
would end flan inside the wait with neither removed. SIGINT and
SIGQUIT are ignored while the program runs, as [system] does, since
the terminal sends them to the program too. A flan killed outright
takes the program with it ([Spawn.dying]). *)
let pid = Flan.Spawn.dying exe (Array.of_list (exe :: prog_args)) in
let caught = ref None in
let forward s =
Sys.Signal_handle (fun _ ->
caught := Some s;
try Unix.kill pid s with Unix.Unix_error _ -> ())
in
Sys.set_signal Sys.sigterm (forward Sys.sigterm);
Sys.set_signal Sys.sighup (forward Sys.sighup);
Sys.set_signal Sys.sigint Sys.Signal_ignore;
Sys.set_signal Sys.sigquit Sys.Signal_ignore;
let rec wait () =
match Unix.waitpid [] pid with
| _, Unix.WEXITED c -> c
| _, (Unix.WSIGNALED s | Unix.WSTOPPED s) -> 128 + Flan.Spawn.host_signal s
| exception Unix.Unix_error (Unix.EINTR, _, _) -> wait ()
in
let code = wait () in
(try Sys.remove exe with Sys_error _ -> ());
exit code)
exit (match !caught with
| Some s when s = Sys.sigterm -> 143
| Some _ -> 129
| None -> code))
| _ ->
prerr_endline
"usage: flan (read|parse|check|emit|shim) <file.flan>...\n\
"usage: flan (read|parse|check|emit|shim) <file.flan>...\n flan check <file.flan>... [--warn-memory] [--defs]\n flan copies <file.fln> <generic>\n flan emit <file.flan> [--x86] [--dev] [--debug] [--no-bounds-checks]\n\
\ flan import-c <header.h> [package.flan...] [clang flags...]\n\
\ flan generate-c <package-dir>\n\
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev] \
[--debug] [--sanitize] [--x86] [--target=wasm32-wasi|web]\n\
\ flan run <file.flan> [args...]\n\
\ flan reload <program.flan> <forms.flan> [-o out.so]\n\
\ flan dev <program.flan> [-s socket]";
\ flan build <file.flan> [-o out] [-O0|-O1|-O2|-O3] \
[--no-bounds-checks] [--dev] \
[--debug] [--sanitize] [--x86] [--warn-memory] [--target=wasm32-wasi|web|js]\n\
\ flan run <file.flan> [build flags...] [--] [program args...]\n\
\ flan reload <program.flan> <forms.flan> [-o out.so] [--x86]\n\
\ flan dev [program.flan] [-s socket] [--x86]\n\
\ flan convert <file.flan|file.fln>";
exit 2
+17 -16
View File
@@ -4,8 +4,9 @@
;;;; 3.5
;;;;
;;;; Chosen to be the smallest program that is still a real one. What it needs:
;;;; functions, recursion, structs, (Ptr T) and `addr`, byte slices, `at`/`len`,
;;;; `while`, `set` on locals and on fields, `cond`, `match`, Option, i32/u8/f64,
;;;; functions, recursion, structs, (Ptr T) and `addr`, byte slices,
;;;; `at`/`length`, `while`, `set` on locals and on fields, `cond`, `match`,
;;;; Option, i32/u8/f64,
;;;; and argv. What it deliberately does NOT need: an allocator, Vec, Map, any
;;;; generic function, any macro the user wrote, FFI beyond argv and stdout,
;;;; a window, or a frame loop. It runs headless, so it is the same test on
@@ -19,11 +20,11 @@
;; ── one by pointer. `addr` takes the address of a local; the pointer never
;; ── outlives the frame, so no allocator is involved.
(defstruct Cursor
[src [u8] ; non-owning slice into argv — calc-me never owns a byte
[src [const u8] ; non-owning slice into argv — calc-me never owns a byte
pos i32]) ; no initialiser means zeroed
(defn peek [c (Ptr Cursor)] u8
(if (< (.pos c) (len (.src c)))
(if (< (.pos c) (length (.src c)))
(at (.src c) (.pos c))
0)) ; 0 doubles as end-of-input
@@ -34,20 +35,20 @@
(while (= (peek c) \space)
(advance c)))
;; digit? was written here until the prelude grew one. There is a single
;; is-digit was written here until the prelude grew one. There is a single
;; top-level namespace, so a second definition is now an error rather than a
;; shadow — which is the rule working: two byte-identical digit? functions
;; shadow — which is the rule working: two byte-identical is-digit functions
;; that later drift apart is exactly what it exists to prevent.
;; ── number := digit+ ("." digit+)? ────────────────────────────────────
(defn parse-number [c (Ptr Cursor)] (Option f64)
(skip-spaces c)
(let [start (.pos c)]
(while (digit? (peek c))
(while (is-digit (peek c))
(advance c))
(when (= (peek c) \.)
(advance c)
(while (digit? (peek c))
(while (is-digit (peek c))
(advance c)))
(if (= start (.pos c))
None
@@ -104,7 +105,7 @@
(Some lhs)))
;; ── Whole input, or nothing. Trailing junk is an error, not ignored. ──
(defn evaluate [src [u8]] (Option f64)
(defn evaluate [src [const u8]] (Option f64)
(let [c (Cursor {.src src})] ; pos omitted: zeroed
(let [v (some (parse-expr (addr c) 1))]
(skip-spaces (addr c))
@@ -114,12 +115,12 @@
;; Entry point: (defn main [args [string]] i32). Both the parameter and the
;; return type are optional — sand.flan uses the bare (defn main []) form.
;; print and println are compiler-provided and structural: the walk over the
;; print and prin are compiler-provided and structural: the walk over the
;; argument's concrete type happens at compile time, so there is nothing to
;; dispatch on at run time and no type to name at the call site.
(defn main [args [string]] i32
(if (< (len args) 2)
(do (println "usage: calc-me \"1 + 2 * 3\"") 1)
(match (evaluate (bytes (at args 1)))
(Some v) (do (print v) (println "") 0)
None (do (println "calc-me: cannot parse") 1))))
(defn main [args [str]] i32
(if (< (length args) 2)
(do (print "usage: calc-me \"1 + 2 * 3\"") 1)
(match (evaluate (bytes-view (at args 1)))
(Some v) (do (prin v) (print "") 0)
None (do (print "calc-me: cannot parse") 1))))
+7 -7
View File
@@ -10,8 +10,8 @@
;;; Handlers cannot see the establishing function's locals yet, so anything a
;;; handler accumulates into has to be a global.
(defvar seen i64)
(defvar ticks i64)
(defonce seen i64)
(defonce ticks i64)
;;; Two frames under the restart-case, so a transfer has something to cross.
(defn probe [n i32] i32
@@ -30,16 +30,16 @@
(retry [] 7)))
(defn run-once [] ()
(print (fetch 1)) (println "")
(prin (fetch 1)) (print "")
(handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
(print (fetch 2)) (println ""))
(prin (fetch 2)) (print ""))
(handler-bind [(AssetMissing [c] (invoke-restart 'use-placeholder))]
(print (fetch 3)) (println ""))
(prin (fetch 3)) (print ""))
(print seen) (println "")
(print ticks) (println ""))
(prin seen) (print "")
(prin ticks) (print ""))
(defn main [] i32
(agent/start "/tmp/flan-conditions.sock")
+14 -1
View File
@@ -10,7 +10,7 @@ Why it is shaped this way: [[file:spec-conditions.md][spec-conditions.md]]. Some
(signal c) ; (). Handler returns -> carry on. No handler -> no-op.
(error c) ; Never. Only a transfer gets past; else the program stops.
(handler-bind [(Type [c] body ...) ...] body ...) ; match by type, no hierarchy
(handler-bind [(Type [c] body ...) ...] body ...) ; match by type or a parent's
(restart-case BODY ; BODY and every clause have the same type = the form's
(name [p T ...] CLAUSE) ...)
@@ -102,6 +102,19 @@ the agent's socket instead.
~BoundsError~ rather than ending the process, abandoning a frame and retrying
it is a real thing to do, and a non-idempotent mutation is what makes it go
wrong.
- *Four conditions come from below your program*, all with ~error~:
~StorageExhausted~ when an allocator cannot satisfy a request, ~FileError~
when a file operation fails, ~BoundsError~ for an index or slice outside its
container, and ~ArithError~ for arithmetic with no answer — a divide or
remainder by zero, ~INT64_MIN / -1~, and a float-to-integer cast that does not
fit, each of which used to be a bare ~SIGFPE~ with no message and no location.
The first two offer a ~retry~ at the failing site, because freeing something
or supplying another path makes the same operation succeed. The last two offer
*nothing*: no handler makes index 51 valid for a length-50 array or gives a
division by zero a quotient, so there is nothing to resume into. The restart
that answers those is the one your program already established — the frame
loop's ~continue~ — and it is on the stack and reachable without anything
being pushed at the failure.
- *An unknown restart name is a hard stop.* No ~find-restart~ to test with.
- *So are the wrong arguments*, and for the same reason: nothing static can
know what a name will find. The message names both signatures.
+169
View File
@@ -0,0 +1,169 @@
# Bug hunt, 2026-09-18
Five search agents swept the runtime, checker, backends, dev loop, and Emacs client.
Everything below was either executed to failure or traced to the exact line. The five
marked **DISPATCHED** have fix lanes; the rest are recorded here and wait.
## Dispatched
### 1. `borrowed` grants the borrow flag to whole subtrees — moves inside container targets vanish
**Repealed, not fixed (2026-09-18):** the flow analysis this hole lived in was removed wholesale
(spec-memory.md, "The repeal"). The trigger programs now compile by design and misbehave at run time.
`lib/check.ml:2062-2076`. The flag gates both `moved` (2003) and `global_borrow` (2033),
and is set across the entire checking of the target: the index argument of `at`, the base
of any `Field`. A move nested there is never recorded.
Confirmed by execution, two programs:
- `(print (at rows (eat w)))` then `(free w)` — double free, exit 134.
- Same shape with a move-only global `g`: accepted, `g` freed, `(length g)` reads a freed
header, exit 0 silent. Defeats the rule test/test_flan.ml:1067-1068 pins.
Fix direction: narrow the flag to the target's own read — restore `ctx.borrow` for the
index of `at`; treat `Field` as simple only when its base chain bottoms out at a `Var`.
### 2. A `while` condition is move-checked outside `in_loop` — double free on iteration two
**Fixed, then repealed (2026-09-18):** the fix merged (47cb46a) and was removed the same day with the
whole flow analysis. The trigger compiles and aborts in the allocator at run time, by design.
`lib/check.ml:1688-1691`. The condition is checked before `in_loop` is entered, but
emit re-runs it every trip (`emit.ml:1838`). A condition that moves a local frees it
once per iteration. Confirmed: glibc double-free abort, exit 134.
`dotimes`' count (2504) and `loop`'s inits (2561) are also outside but evaluate once — correct.
Fix: check the condition inside `in_loop`.
### 3. A checked declaration that fails to build or deliver leaves a NULL cell the session will call
`lib/session.ml:601` commits `t.program` before `Dev.eval` builds (`dev.ml:603` can raise)
or delivers (`dev.ml:596` can refuse — e.g. queue full at `vendor/agent/flan_agent.c:1186`,
which a parked program guarantees after 64 installs since nothing drains the ring while
parked). The session keeps the declaration; the process never got the body; the next
thunk's install prologue interns the name with `cell = NULL` (`runtime/flan_dev.c:65`)
and `body_of` calls through it with no null test (`emit.ml:1472`) — jump to address 0
on the game thread. Nearest reachable mechanism to the transient SIGSEGV of TODO.org,
"A transient signal 11 on a globals daemon"; the
stranded-Vec hypothesis there was read out and refuted (reloads never redefine a host
global; storage addresses are stable; `global_borrow` holds).
Related, same lane: the queue-full refusal says "the program is not calling agent/poll",
which is the wrong cause for a parked program.
### 4. The merged build never drains the program's stdout pipe while serving a request
fd 1 is a 64K pipe whose only reader is `accept_loop`'s select (`lib/dev.ml:2739`), not
running while `serve` handles a request. `eval_expr`'s wait (664-672) and
`run_render_thunk`'s wait (1026-1036) poll for five seconds and never drain, so a
program that prints (sand.flan does) blocks in `flan_write_stdout`, stalls the frame
thread for the whole timeout, and gets the false diagnostic "is it calling (agent/poll)?".
Same root on the exit path: `flan_merged_exit`/`park` `fflush(NULL)` before
`program_state = PROGRAM_PARKED` (dev.ml:3000/3017/3024), so a full pipe delays the park
and `rerun` refuses a finished program as "already running".
Also same family: `rt_die` (`runtime/flan_rt.c:384-388`) starts with `fflush(stdout)` —
a bounds trap can hang on the full pipe — and calls `exit(134)`, the route `die_now`
documents as unsafe (atexit/ELF destructors want the loader lock a dlopening thread may
hold); it should `_exit` like the break loop does.
### 5. x86 shifts are always 64-bit, so the count is masked to 63 instead of width−1
`lib/x86.ml:2530-2534` / `shift_cl` at 369 (`rex ~w:true` unconditionally). The comment
claims the hardware masks to operand width; it masks to 63. `emit.ml:2041` masks to
`bits-1` explicitly (TODO.org, "A shift count is bounded two different ways", records
this as the language's rule). Six
confirmed divergences, e.g. `(<< x 32)` on i32: LLVM 1, x86 0; `(>> i8min 8)`: LLVM
-128, x86 -1. Invisible because `test/survey-x86.sh:80` never globs `spike/js/*.flan`,
where `p1-int-semantics.flan` already catches it — widen the glob in the same lane.
Same wide-compute root, second divergence: float→int overflow under `--no-bounds-checks`
gives 0 on x86 (64-bit `cvttsd2si` then truncate) vs INT_MIN on LLVM. Acknowledged-UB
territory; fix or record, the lane's call.
## Recorded, not scheduled
- **Region guard never asks about elements** — **repealed, not fixed (2026-09-18)**: with the flow
analysis gone, `free` of an `at` result is no longer a checker question; the mixed-allocator
construction is legal and its misuse is a run-time matter. (`lib/check.ml:1272`, refusals 4092/4165):
a heap-backed inner Vec pushed into an arena-backed outer passes the guard; `at` then
hands out an owning header, `free` accepts it, and the later read is a confirmed UAF
(printed garbage). Breaks the premise stated in the clone note at check.ml:4152.
Candidate fixes: refuse `free` of a non-binding target, or region-check the element at
`push`/`put`. Sixth on the list; needs a deliberate mixed-allocator construction.
- **Reversed slice under `--no-bounds-checks`** — **fixed**. The `lo <= hi` test is a
representation invariant, not a bounds check, but sat behind `if f.md.checks` in both
backends (`emit.ml:1037`, `x86.ml:2164`; same for SliceFromPtr's `n >= 0`), so a
negative-length slice reached user code. Split in both backends: `hi <= len` stays
behind the flag, `lo <= hi` and `n >= 0` are now emitted in every build, through the
same slice-failure path. `flan_vec_as_slice` was the model. See "A slice's length word
is a count" in docs/BUILT.md.
- ~~**`reg leaks` lies at >3072 live blocks**~~ — **fixed**. Compaction now also asks
whether there is an eighth of a table's worth of dead to reclaim, so a table full of
live blocks stops thrashing the epoch; `flan_dev_reg_by_type` returns −1 with an unread
count rather than zero rows, and the agent refuses in a sentence; a note dropped by a
genuinely full table says so on stderr once. `test/dev_limits.c regfull` pins it under
a writer thread: 3100 live blocks, 200 asks, 199 wrong before and 200 right after.
- **Emacs framing**: a truncated frame raises wrong-type instead of the timeout message
and leaves the partial frame in the buffer, desyncing every later request by one frame
(`flan-dev.el:220`); `extract-reply` reads before it deletes, so an unreadable payload
wedges the connection permanently (:187). One ordering fix covers both.
- **Emacs poll vs watch**: `flan-dev--poll` bypasses `flan-dev-settle-hook` and consumes
the watch's reply; the two consumers stay swapped for the session (`flan-dev.el:406`).
Also `request` reconnects before running the settle hook — 30s freeze after a daemon
restart with the watch armed.
- **C-x C-e at point-min** installs an empty declaration (`flan-dev.el:1745`
`backward-sexp` no-op unchecked); same predicate fires inside strings and misjudges
narrowed buffers.
- **`flan-connect` + `flan-dev-quit` kill two sessions** (`flan-dev.el:721`): quit sends
`close` down the current connection and kills the daemon it started for another program.
- ~~**`reg at` TOCTOU**~~ — FIXED. The gate was checked three round trips before the
render thunk ran, and nothing held the break across the ~300ms build, so a `restart`
in between let the thunk chase freed memory with the gate's blessing. The sound fix is
agent-side: the render job now carries the condition it was built under. `inspect` by
address delivers its module as `stopped-only <path>`, the job header keeps the flag,
and `flan_agent_poll` drops such a job — counted, handle closed, nothing installed and
nothing called — when `depth` is 0 at the moment it is claimed. That read is sound
rather than narrower: only the game thread polls and only the game thread raises
`depth`, so `depth > 0` seen inside a poll means *this* thread is parked in the break
loop and cannot be running a frame. The daemon reads the `refusals` count either side
of the delivery and surfaces the agent's own sentence — "the program resumed while this
inspection was being built — stop it again and re-ask" — instead of the timeout's
wrong-cause "is it calling (agent/poll)?". `reg at`'s depth gate stays as the front
door. `locals`, `globals` and `inspect` by slot are deliberately *not* stopped-only:
they hold no address, re-deriving the frame through `snap_top` or binding a global by
name at thunk-run time, so they carry no blessing that can expire. Pinned in
`test_agent.ml`, where a stopped-only job delivered to a program that is definitively
running is dropped while the eval module beside it installs — one install, not two.
- ~~**defenum values never range-checked to i32**~~ — FIXED. Every resolved member value,
explicit or autoincremented, is range-checked against i32 in the parser before the collision scan,
so the scan compares the numbers the program will actually have. Out of range is refused
by name (`parse/enum-value-out-of-range`); explicit-duplicate aliasing stays legal.
- **NaN sign** — **fixed**. LLVM constant-folded `0.0/0.0` to `nan`, x86 computed `-nan`
— a stdout DIFFER on a two-line program. Resolved by canonicalizing the *printed* form
rather than the arithmetic: `flan_f64_to_bytes` and the two dev emitters render any NaN
as unsigned `nan`, which is what `format-f64` in the prelude always did. Pinned in
`test/programs/format.flan`. See docs/BUILT.md.
- **x86's slice-from-ptr refusal is the wrong sentence** — **fixed** 2026-09-25; x86 calls `flan_slice_promise_error`: `x86.ml` still reports a
negative promise through `flan_slice_error` — "slice [0 -2) is out of bounds for
length 0", naming a range and a length the caller never wrote — where `emit.ml` has
its own `flan_slice_promise_error`. Same condition and same exit on both sides, only
the text differs. The survey cannot see it: `bounds.flan` picks its case out of
`(at args 1)` and `survey.sh` runs every program with no arguments, so nothing in the
corpus reaches the `n = -2` case on the x86 path. Noticed while making the check
unconditional (which did not change what it prints); the fix is one `bounds_call` with
one extra instead of three.
- **`emit.ml:3369` transient test ignores `new_globals`**: on the `retains=false` path a
module first to intern a global gets dlclosed; zero-init makes it moot today, a literal
init would dangle.
- **x86 to-bytes helper-return clobber**: `(defn numstr [n] (string (i64->bytes n)))`
works on LLVM, garbage on x86 — documented caller's-problem UB, but the divergence
makes the documented edge invisible.
- **map-grow at log2cap>=40 quotes a stale failure** (`flan_rt.c:2438`, pre-dates
yesterday).
## JS backend (deprioritised 2026-09-18, do not schedule)
- `lib/js.ml:889` still matches 1-arg `*ToBytes`; commit 81b807f made them 2-arg, so
every number-printing program is refused — JS corpus MATCH went 24 → 3 and `@js`
stays green because a refusal counts as success. Needs a MATCH floor in strict mode.
- f32 literals keep double precision (`js.ml:671` discards the fkind; `Math.fround` it).
- The cast-range trap quotes the cast's column, not the operand's (`js.ml:1077`).
## Verified clean, don't re-hunt
`flan_map_remove` (two randomized ASan harnesses incl. non-power-of-two cells, zero
mismatches), the grow-path overflow guards, the seqlock protocol itself, the park/rerun
condvar, `to_bytes`'s 64-byte slot arithmetic, elisp multibyte framing (unibyte
throughout, tested), JS struct value semantics and 64-bit integer semantics (broad
probes), division sign/INT_MIN traps at all widths, `if`/`match` dead-set joins,
`defer`'s kill-at-registration, index widening rules.
+2409 -453
View File
File diff suppressed because it is too large. Load diff
+102 -48
View File
@@ -27,7 +27,7 @@ hypothetical.
## 1. The one thing that cannot be written at all
> **Bound, 2026-09-13.** `draw-texture-pro`, `image-from-image`, `window-ready?` and
> **Bound, 2026-09-13.** `draw-texture-pro`, `image-from-image`, `is-window-ready` and
> `Key/left-shift` are all hand-written in `vendor/raylib/raylib.flan` now. The section
> is kept as it was written, because the reasoning is the part worth having; what the
> fix cost, and what could and could not be tested, is in the box at the end of it.
@@ -74,7 +74,7 @@ Three more the same way, none of them blockers, all one line each:
| Missing | Used at | Verdict |
|---|---|---|
| `ImageFromImage` | `sprite_atlas.clj` `auto-select-tiles`; `tilemap-blob.lisp` `tile-subimages` | Annoyance — see §3 |
| `IsWindowReady` | `engine.clj` `run-game!`, `engine.lisp` `run-game` — the "window already open" guard | Annoyance; a `defvar bool` does the same thing |
| `IsWindowReady` | `engine.clj` `run-game!`, `engine.lisp` `run-game` — the "window already open" guard | Annoyance; a `defonce bool` does the same thing |
| `SetTextureFilter` | declared in `rl.clj`, called nowhere | Not needed. Point is raylib's default |
| `UpdateTexture` | declared in `rl.clj`, called nowhere | Not needed |
| `SetClipboardText` | `sprite_atlas.clj`, in a `comment` block only | Not needed |
@@ -88,7 +88,7 @@ uses — and this is one member short of what this game needs. One entry.
### What the four lines actually cost, and what a test could say about them
All four landed together: `draw-texture-pro` beside `draw-texture-rec`,
`image-from-image` beside `image-crop`, `window-ready?` beside `window-should-close?`,
`image-from-image` beside `image-crop`, `is-window-ready` beside `window-should-close`,
and `left-shift 340` at the end of the `Key` `defenum`. The three signatures were read
off a raylib header rather than remembered, and all three are unchanged across 5.1, 5.5
and the 6.0 this game vendors.
@@ -98,7 +98,7 @@ asked for "an acceptance case that makes raylib compute with the source rect so
permuted `Rectangle` goes red". `DrawTexturePro` cannot have one: it needs a GL context,
and `raylib.flan`'s own Shapes comment already says none of the drawing calls can be in
the acceptance table. What `raylib-ffi.flan` does instead is link it — the call sits
behind `(when (rl/window-ready?) …)`, which is false headless, so the shim is generated
behind `(when (rl/is-window-ready) …)`, which is false headless, so the shim is generated
and the symbol is resolved at link time and the body never runs. That catches a name or
an arity that does not exist in libraylib. It does **not** catch the argument order, and
three structs in a row is exactly where an argument order goes wrong. Only looking at
@@ -186,10 +186,23 @@ Neither implementation returns errors as values. Both use the host's exception o
condition system throughout. Porting this game exercises `signal`/`error`/`restart-case`
and never wants a `Result`.
### `handler-case` — one site, and `handler-bind` already covers it
### `handler-case` — one site, and it is now the natural spelling of it
`engine.clj`'s `reload-config!` is `(try … (catch Exception e (println e)))`. That is
"log it and carry on", which is what a `handler-bind` clause returning normally does.
`engine.clj`'s `reload-config!` is `(try … (catch Exception e (println e)))`. The ranking
below was written when the operator did not exist, and said `handler-bind` covers it,
which it does: "log it and carry on" is what a clause returning normally does. It is no
longer the closer translation. `handler-case` landed on 2026-09-19 and is Clojure's
try/catch by construction, so that site ports across as itself:
```
(handler-case (read-config path)
[(FileError [c] (do (print (.path c)) default-config))])
```
The difference that decides it is not spelling. A `handler-bind` clause is lifted into a
function of its own and cannot see `reload-config!`'s locals; a `handler-case` clause runs
at the form and can, which is what a catch block is assumed to do everywhere it is
written.
### `Handle` and pools — nothing to pool
@@ -206,14 +219,19 @@ type's shape rather than its name.
`engine.lisp` `read-data`, `game.lisp` `load-tileset` and `read-bitmask` read plists out
of `assets/*.data` at startup, and `reload-config!` re-reads the config while the game
runs. `vendor/edn/edn.flan` is a 561-line tokenizer with `int-of` / `text=?` /
`keyword=?` / `expect`, and `test/programs/edn.flan` includes a hand-written struct
reader as the worked example.
runs. `vendor/edn/edn.flan` is the tokenizer — `int-of` / `is-text-equal` / `is-keyword-equal` /
`expect` — and `vendor/edn/read.flan` is `(edn/read bytes)`, which answers a dynamic
`Value` for a document nobody declared a type for. `test/programs/edn.flan` is the
hand-written struct reader as the worked example; `test/programs/edn-read.flan` reads the
tileset file itself.
So this is writable today. It costs a hand-written reader per schema — two here, the
tileset (`:texture-path`, `:selected-cells`) and the bitmask table — because `(read-edn T
bytes)` is not built. Call it ~80 lines, once, at load time. See §3 for whether it should
be written at all.
So this is writable today, and the tileset needs no hand-written reader at all: it reads
as a `Value.Table` whose `:selected-cells` is a `Value.Set` of pairs, against an arena,
released by one `free-all`. A hand-written reader is no longer what a *struct* costs:
`(edn/defedn Tileset "assets/tileset.edn")` derives the struct from the file while the
program is compiled and emits the reader with it, so the ~80 lines this used to price are
not written at all. See §3 item 9, and `docs/BUILT.md`, "A type provider: the struct a
data file implies".
---
@@ -227,7 +245,7 @@ Ordered by how much of the game it touches.
(`engine.clj`'s `texture-cache` atom, `engine.lisp`'s `*texture-cache*` hash table), and
`engine.lisp` keeps `*buffers*` there too.
`sand.flan`'s idiom — everything in `defvar` fixed arrays, the loop functions taking no
`sand.flan`'s idiom — everything in `defonce` fixed arrays, the loop functions taking no
state — works because nothing it holds is move-only. This game's state is not like that:
`tilesets`, `src-rects`, `dst-rects` and the bitmask table all want a `Vec` or a `Map`.
@@ -237,7 +255,7 @@ host does:
1. Hold the state in a `let` in `main` and pass `(Ptr Game)` to update and draw. Costs
one parameter on three functions.
2. For the texture cache specifically, a `[8 CacheEntry]` fixed array with a count beats a
`(Map string Texture2D)` outright — the game loads five textures, and a linear scan of
`(Map str Texture2D)` outright — the game loads five textures, and a linear scan of
five path comparisons at load time is not worth a hash table. This is a case where the
restriction pushes toward the right answer.
@@ -246,7 +264,8 @@ host does:
`lib/check.ml:412` and `:317`. `game.lisp`'s shape is `tilesets` = a vector of `tileset`
structs, each of which owns `tiles`, `src-rects` and `dst-rects` vectors. That is
`(Vec Tileset)` where `Tileset` owns three `Vec`s, and it is refused — recursive teardown
waits on `drop`, which `NEXT.md:484` records as deferred, not built.
waits on `drop`, which `TODO.org`, "drop, and unwind-protect with it", records as
deferred, not built.
**Annoyance.** The fix is at the *inner* level, not the outer one: making the outer
container a fixed array changes nothing, because `[4 Tileset]` where `Tileset` owns `Vec`s
@@ -284,7 +303,7 @@ piece that needs to touch the state, and the game can supply `snapshot`/`restore
more callbacks.
> **Settled by the author, 2026-09-13: fixed arrays with counts.** The first branch below. So the
> conditional verdict in this section is now unconditional: the state fits in `defvar` globals, the
> conditional verdict in this section is now unconditional: the state fits in `defonce` globals, the
> engine takes only `(Fn [] ())` callbacks and never names a state type, generics stays off this
> game's critical path, and `drop` is not needed for this version. Nothing in the language had to be
> built for it — fixed arrays, counts and slices all already work. What is left is writing the game.
@@ -295,7 +314,7 @@ more callbacks.
**This verdict is conditional, and the condition is one decision.** Both it and the
globals workaround above hang on the same pivot, so it is worth stating once, flatly:
*nothing in this game's state needs to be move-only.* Make the inner collections fixed
arrays with counts, and then the state fits in `defvar` globals, the engine takes only
arrays with counts, and then the state fits in `defonce` globals, the engine takes only
`(Fn [] Unit)` callbacks and never names a state type, and generics stays off the critical
path. Keep `Vec`s in a let-bound `Game` struct passed as `(Ptr Game)` instead, and
`run-game` names `Game`, two programs with two state types need two engines, and generics
@@ -521,13 +540,13 @@ not compete for the same slot.
1. ~~**`declare-c draw-texture-pro`.**~~ Bound. The renderer is writable. The acceptance
case this asked for could not be written as described — see §1's closing box — so it
is a link check behind a `window-ready?` guard, and the computed test moved to
is a link check behind a `is-window-ready` guard, and the computed test moved to
`image-from-image` where raylib does the arithmetic on the CPU.
2. ~~**`left-shift` in the `Key` `defenum`.**~~ One entry, `340`. §5 records that nothing
else in the enum was missing.
3. ~~**`declare-c image-from-image` and `window-ready?`.**~~ Both bound.
3. ~~**`declare-c image-from-image` and `is-window-ready`.**~~ Both bound.
`image-from-image` carries the strongest new assertion in the table: two carves at
different `y`s out of one sheet, and the sheet re-read afterwards to show it was not
destroyed.
@@ -617,24 +636,32 @@ not compete for the same slot.
7. **`drop`, or recursive teardown.** Unblocks `(Vec T)` where `T` owns a `Vec`, which is
`game.lisp`'s tileset shape exactly. Fixed arrays sidestep it at this size, so this is
about the version of the game with several atlases, not this one. `NEXT.md:484` already
has it deferred with reasons; nothing here argues with that, only records a customer.
about the version of the game with several atlases, not this one.
`TODO.org`, "drop, and unwind-protect with it", already has it deferred with reasons;
nothing here argues with that, only records a customer.
8. **Generics.** Real, and worth building — but for this game it is five-line loops at
load time, and the reusable-engine argument for it dissolves once the state lives in the
game rather than in the engine. **Do not sequence it ahead of items 4–6 on this game's
account.**
9. **`(read-edn T bytes)`.** `vendor:edn` already makes the asset readers writable; this
removes ~80 lines of hand-written cursor walking for two schemas. Convenience, and it
competes with compile-time embedding, which may be the better answer for both files
anyway.
9. ~~**`(read-edn T bytes)`.**~~ **Built**, and not under that name: `(edn/defedn Tileset
"assets/tileset.edn")` reads the *file* while the program is compiled, derives the
struct its shape implies, and emits a reader with it — so the ~80 lines are neither
hand-written nor written by the compiler from a type that was declared by hand. It is a
macro in `vendor:edn`, not a builtin, and `defjson` is the same over `vendor:json`.
The competition with compile-time embedding was the wrong reading of it: they are the
two halves of one answer, the shape from the file at compile time and the bytes from an
`embed` beside it. See `docs/BUILT.md`, "A type provider: the struct a data file
implies".
**Not ranked, because this game does not need them:** escaping closures and capture (one
site, fixed by one parameter), `Handle` and pools (nothing to pool), `Result`/`try`
(neither host uses that discipline), `handler-case` (one site, `handler-bind` covers it),
`loop`/`recur` and tail calls (nothing recurses), user-written allocators, structural
typing.
(neither host uses that discipline), `loop`/`recur` and tail calls (nothing recurses),
user-written allocators, structural typing. `handler-case` was on this list for the same
reason and has since been built anyway — it cost one function in the checker and nothing
in either backend, being a `handler-bind` whose clause invokes a restart the form
established around itself.
---
@@ -660,8 +687,20 @@ each finding in §A names the command that produced the failure.
### A.1 `GetCodepointPrevious` cannot be called, and closing it is a checker change
> **Closed, 2026-09-14.** It was a checker change and it is made: `agrees` in
> `lib/cimport.ml` now has the pointer arm its own comment had been listing among the
> differences it accepted. `vendor/raylib/raylib.flan` carries
> `get-codepoint-previous-raw` — the exact declaration this section says it wanted — and
> a `get-codepoint-previous` wrapper over it that takes the bytes and an offset;
> `generated.flan` no longer carries the `str`-faced version at all, because a binding
> that is wrong for the only direction it reads in is worse than no binding. The example's
> `step-back` is one call to it, the continuation-byte walk is gone, and the expression
> below now prints 12356 and 3. The section is kept as written; the reasoning is the part
> worth having.
This is the real find of the round, and it is general: **a C function that reads
*backwards* from the pointer it is handed cannot be given a Flan `string`.**
*backwards* from the pointer it is handed cannot be given a Flan `str`.**
`lib/shim.ml` is explicit about why — `Pstr` is "ptr+len in, a NUL-terminated copy out",
and the wrapper builds that copy for the duration of the call. Forwards, that is invisible
@@ -675,8 +714,8 @@ It does not crash, which is the bad part. Run headless:
(let [text "いろはに"
b (bytes text)
sz 0]
(println (rl/get-codepoint-previous (string (slice b 3 (len b))) (addr sz)))
(println sz))
(print (rl/get-codepoint-previous (str (slice b 3 (length b))) (addr sz)))
(print sz))
```
That prints `0` and `0`, where the answer is 12356 (い) and 3. Zero is also what
@@ -696,15 +735,15 @@ and means it, as `load-image-from-memory-raw` and `load-font-ex-raw` do:
```
with `(addr (at b i))` for the interior pointer. It does not work, and the reason is not
the FFI. `lib/cimport.ml` maps `const char *` to `string`, and `agrees` — the function that
the FFI. `lib/cimport.ml` maps `const char *` to `str`, and `agrees` — the function that
decides whether a hand-written declaration and the header say the same thing — accepts
exactly one difference, an enum against a 32-bit integer. `(Ptr u8)` against `string` is not
exactly one difference, an enum against a 32-bit integer. `(Ptr u8)` against `str` is not
it. Adding the line above and building *anything*, not only regenerating, gives:
```
vendor/raylib/generated.flan:233:12: the declare-c of get-codepoint-previous-raw
disagrees with vendor/raylib/raylib-5.5.h: parameter text is (Ptr u8) and the
header says string (const char *)
header says str (const char *)
```
`headers` is read on every build, so the binding cannot be added without relaxing the
@@ -739,6 +778,12 @@ binding over a `const char *` cursor meets it.
### A.2 There is no cast between pointer types
> **Closed, 2026-09-25.** `((Ptr U) p)` casts a pointer's element type, spelled as `(i32 x)`
> is, and checks nothing; it may add const and may not drop it. The example's call is now
> `(rl/update-texture texture ((Ptr u8) pixels))`; `update-texture-colors` stays as a one-line defn over the cast. There is
> still no cast between a pointer and an integer, and no pointer arithmetic.
`textures_image_processing` hands the pixels `LoadImageColors` returned straight to
`UpdateTexture`. In C both are pointers and nothing has to be said. Here
`load-image-colors` answers `(Ptr Color)` and `update-texture` takes `(Ptr u8)` — the
@@ -757,7 +802,7 @@ A.1, with the same message. **Not built.**
the first element is the address of the buffer.
```flan
(rl/update-texture texture (addr (.r (at (slice-from-ptr pixels n) 0))))
(rl/update-texture texture (addr (.r (at (slice-from pixels n) 0))))
```
`Color`'s first field is `r`, a `u8`, at offset 0. It compiles, it is the right address,
@@ -768,7 +813,7 @@ often — `void *` appears thirty-odd times in raylib.h alone.
Two smaller notes on the same call. `(.data im-copy)` is already a `(Ptr u8)` over the same
bytes, so the whole round trip is avoidable; the example keeps it because it is what the C
does and because nothing else in the corpus exercises
`LoadImageColors`/`UnloadImageColors`. And `slice-from-ptr` is what makes any of this
`LoadImageColors`/`UnloadImageColors`. And `slice-from` is what makes any of this
readable — it is the one form that turns a raylib pointer plus a raylib count into
something with a length, and it was reached for three times across the two files.
@@ -778,16 +823,16 @@ something with a length, and it was reached for three times across the two files
open question and the answer is clean. The reader takes the bytes, the object file carries
them, the shim hands them over, and `LoadCodepoints` decodes the 54 codepoints of the Iroha
into the 49 distinct ones the atlas is built from. Nothing in Flan claims to know what a
character is — a `string` is bytes and `(bytes s)` / `(string b)` say so in both directions
character is — a `str` is bytes and `(bytes s)` / `(str b)` say so in both directions
at no cost — and for this job that is exactly the right amount of opinion. There is a
`valid-utf8?` in the prelude and this example never needs it.
`is-valid-utf8` in the prelude and this example never needs it.
**An interior pointer into a string has an idiom already.** `(string (slice b off (len b)))`
is the C's `char *ptr` and compiles to nothing: a `string` and a `[u8]` are the same two
**An interior pointer into a string has an idiom already.** `(str (slice b off (length b)))`
is the C's `char *ptr` and compiles to nothing: a `str` and a `[u8]` are the same two
words. Every forward-reading `const char *` entry point is reachable that way.
**`load-font-ex` needed nothing.** It takes a `[i32]` of codepoints, takes the
pointer-and-count apart itself, and uses a zeroed `defvar` as the null pointer that means
pointer-and-count apart itself, and uses a zeroed `defonce` as the null pointer that means
"the default ASCII set". It was written for this call before anything called it, and the
call fit it exactly.
@@ -795,12 +840,19 @@ call fit it exactly.
is `(and (> i 0) (continuation? (at b i)))`, and at the start of the text `i` is -1 — so if
`and` were a strict function rather than a form, the guard would not save the `(at b -1)`
beside it and the first press of LEFT would signal `BoundsError`. It is a form and it does
short-circuit; `test/programs/raylib-codepoints.flan` has a row for it, because the walk
short-circuit; `test/programs/raylib-codepoints.flan` had a row for it, because the walk
itself never reaches that offset and the claim is about the language rather than about the
example.
> **Note, 2026-09-14.** The walk this describes is gone — §A.1 above closed, and `step-back`
> is now one call to `GetCodepointPrevious`. The finding stands and the observation about
> `and` is still true; what went with the walk is the corpus's only exercise of it over an
> out-of-bounds index. The `back at start 0` row still runs and now pins the `(<= offset 0)`
> guard instead, which is the right thing for it to pin. A language claim wants a language
> test rather than a raylib example, and there is not one.
**Fixed arrays were the right shape for both.** A codepoint table with a count, a
`[9 Rectangle]` of toggle buttons, a `[9 string]` of labels. The §3 finding from the
`[9 Rectangle]` of toggle buttons, a `[9 str]` of labels. The §3 finding from the
siam-farmer report — a global cannot hold a `Vec`, and fixed arrays with counts are usually
the better answer anyway — held again, in two more programs, with no friction at all.
@@ -860,14 +912,16 @@ load a file out of `resources/` and neither file is in this tree.
- `DotGothic16-Regular.ttf` is SIL OFL 1.1 and therefore redistributable, but it is 2 MB,
and whether a compiler repository whose only binary asset is a 1 KB PNG should grow a 2 MB
font is a call about the repository rather than about this port. Left to the author;
`HANDOFF-raylib-ports.md` carries it as the open question. The example looks for it under
`handoffs/HANDOFF-raylib-ports.md` carries it as the open question. The example looks for it under
`examples/resources/`, says on screen when it is not there, and runs either way — raylib
answers a missing path with the default font, whose glyphs are ASCII, so the kana draw as
boxes and the program explains itself rather than looking broken.
**Neither example needed a language feature.** Nothing below the two refused bindings in §A
was blocked at all, and both of those are one arm in `lib/cimport.ml`'s `agrees` away from
being ordinary binding work.
was blocked at all, and both of those were one arm in `lib/cimport.ml`'s `agrees` away from
being ordinary binding work. That arm was written on 2026-09-14 and §A.1 is now ordinary
binding work; §A.2 turned out to have a second, better reason behind the first, and the
boxes at the head of each section say what happened.
## E. Testing
+92
View File
@@ -0,0 +1,92 @@
# What is in here, and what is still true
These are the documents that are written once and read occasionally: the reasons behind the code, the
reports from finished investigations, and the record of individual work sessions. The documents that are
edited every day stay at the repository root, because source comments cite them by bare filename from
dozens of places and a path that moves is a path that rots. So `TODO.org`, `plan.org`, `spec-memory.md`
and `spec-conditions.md` are one directory up, and everything here points back at them.
A note on how to read the citations below: a document in this directory that says `plan.org` means the
one at the repository root. Nothing in `docs/` is named for a file at the root, so there is no ambiguity,
and rewriting a hundred prose mentions into `../plan.org` would have cost more in readability than it
bought in precision.
## If you have thirty seconds
Read **`BUILT.md`**. It is by far the largest document here and it is the one that pays: it holds the
reason behind every part of the compiler that exists, from why nothing is ever `dlclose`d to why the
printer is a compile-time walk over a type rather than a runtime function. It is current, it is
maintained, and deleting it would mean deriving all of it again. If you want to know why a thing is the
shape it is, the answer is in here.
After that, **`../TODO.org`** at the root for every decision, open question and known gap — one heading
each, with a keyword saying where it stands — and **`REFERENCES.md`** here for
where the evidence comes from — the reference clones on this machine, what each one answers, and the
standing rule that a claim in these notes was read out of a clone rather than recalled.
## The current documents
| File | What it is |
|---|---|
| `BUILT.md` | Why the parts that exist are shaped the way they are. The longest and the most load-bearing document in the repository. **Current.** |
| `PORTING.md` | What the author's game, `siam-farmer`, needs from Flan that Flan does not have yet, measured against the code that exists rather than against a plan. It is the requirements document the language is actually steering by. **Current.** |
| `REFERENCES.md` | The reference clones under `~/Repositories`, what each one is consulted for, and the specific files and lines already cited from them. **Current.** |
## The reports
Each of these was written once, from a reading or a measurement, and is not
maintained afterwards. They are kept because the reasoning in them is the
reason the code looks the way it does, and deriving it again would cost more
than reading it.
| File | What it is |
|---|---|
| `SPIKE-GENERICS.md` | Milestone 5's parametric polymorphism, run early and out of order as a spike. The spike succeeded and generics landed, so this is the report of finished work rather than a live plan — but its findings about where the cost falls are still the reason the implementation looks the way it does. **Historical, findings still stand.** |
| `SPIKE-INFERENCE.md` | Whether the checker should infer more than it does. A reading rather than a branch: nothing was built. |
| `SPIKE-DYNAMIC.md` | The dynamic-value runtime — NaN-boxed values over a mark-sweep heap. The design the `dyn` type and `runtime/flan_dyn.c` were built from. |
| `SPIKE-DUPLICITY.md` | Where the dyn side and the native side meet, and where one of them repeats the other. The source of the rule that a capability is written once per side and never twice on the same side. |
| `SBCL-REDEFINITION-NOTES.md` | What SBCL does when a struct is redefined under a running image, read out of the clone. Written for the open question of whether Flan's hard refusal should become a warning and a kept old layout. The question is still open. |
| `BUGS-2026-09-18.md` | A five-agent sweep of the runtime, checker, backends, dev loop and Emacs client. The bugs are fixed; the file is kept because `spec-memory.md` cites one of its findings as evidence. |
`DISCUSS.md` used to be here and is gone. Its open questions are entries in
`../TODO.org` now, one heading each; the four long reports it carried — the
embedding spike and the three that built the hand-written x86 backend — are in
`BUILT.md`, under "One process" and "The hand-written x86 backend, and the four
measurements behind it", because they were reasoning rather than decisions and
a few lines could not hold them.
Three more files that used to be here are gone, and `git log` is where they
live now: `overview.md`, the first brainstorm from before the language had
S-expressions, which had said in its own first line that it was superseded;
`DIAGNOSTICS-AUDIT.md`, the worklist the diagnostics rewrite was done from,
which the rewrite closed; and `REVIEW-production-readiness.md`, a review of a
tree a thousand commits behind this one.
## `handoffs/`
One report per work session, each written by whoever held the branch at the time. They are the
operational record: what was attempted, what was measured, what was decided without being able to ask,
and what was left open for the next lane. They are all historical the moment they are written — a handoff
describes a session that has ended — but they are cited by name from source comments and from `../TODO.org`,
because the reasoning behind a guard or a calling convention is often only written down once and this is
where it was written.
Nine of them are the hand-written x86-64 backend, read in the order the work happened:
`HANDOFF-x86-rt.md` set the remaining-items list the others close, `HANDOFF-x86-redef.md`
built the redefinition emitter, `HANDOFF-x86-aggregates.md` took that across the struct boundary,
`HANDOFF-x86-guards.md` settled the two guards nothing reaches, `HANDOFF-x86-debug.md` added debug
information, and `HANDOFF-x86-cost.md` measured what the backend costs and set the survey running on its
own so a refusal cannot sit unnoticed again. Then `HANDOFF-x86-devloop.md` for making it the dev loop's
default, `HANDOFF-x86-abi-marker.md` and `HANDOFF-x86-annotate.md`, and
`HANDOFF-x86-macro-visibility.md` for why a macro module's symbols are hidden.
The rest are unrelated to each other. `HANDOFF-arith.md` is why a divide by zero is a condition
rather than a `SIGFPE`. `HANDOFF-raylib-ports.md` is the running record of the last two raylib example
ports, whose lasting findings were folded into `PORTING.md`, and `HANDOFF-cimport-ptr.md` is the arm
`cimport.ml`'s header check had been promising in a comment and not implementing — which is what those two ports
found, worked around and wrote up. `HANDOFF-devtest-noise.md` is the linker
error `dune test` used to print on every run. `HANDOFF-emacs-flake.md` is the `test_emacs` flake that
turned out to be `SIGPIPE` killing the daemon mid-reply, and it is worth reading for the two mechanisms it
rules out as much as for the one it found. `HANDOFF-dyn-m1.md` is the first milestone of the dynamic
runtime, `HANDOFF-lowering-buffer.md` the buffer that shows what a form lowered to, `HANDOFF-rot.md` a
sweep for prose that had stopped being true, and `HANDOFF-tidy.md` is this reorganisation.
+25 -2
View File
@@ -2,7 +2,7 @@
Read the source, do not recall it. Every claim in this repository's notes that came from one of these
was read out of the clone, and the ones that were recalled instead have been wrong before — see
`NEXT.md`'s corrections. When a note cites a file and line, it is because someone opened it.
the corrections `TODO.org` records. When a note cites a file and line, it is because someone opened it.
All paths are under `~/Repositories`. Dates are the last commit in the clone at the time this file was
written (2026-09-13); a stale clone is still the right source for how a thing works, just not for what
@@ -36,6 +36,15 @@ Most of what this repository copies. Places already cited in the notes:
the machinery the generics spike copied.
- `src/check_type.cpp:173-200` — `FieldFlag_using` and `FieldFlag_subtype`. Odin's answer to
"a function over anything with these fields": nominal embedding, not row polymorphism.
- `core/encoding/json/` — what `vendor/json` is written against. Three facts were read out of it
rather than recalled. `tokenizer.odin` allocates nothing and hands back the raw literal including
its quotes, while `parser.odin:320` `unquote_string` does the copy against an allocator — which is
the split `vendor/json` copies, and the reason its `string-of` is the only function in the package
that allocates. `parser.odin:388` clones even when the literal holds no escape at all, and
`types.odin:96` `destroy_value` frees every `String` it walks — together those settle that a
`Value` owns its strings unconditionally, which is what lets one `free-all` take a whole document.
And `types.odin:49` sets `DEFAULT_SPECIFICATION` to JSON5, not JSON: `vendor/json` deliberately
does not follow that one, and every refusal in it that names a dialect names this difference.
Two Odin facts worth keeping together, because conflating them has already caused one wrong note:
**`$T` procs are monomorphised, containers are type-erased.** Odin uses both and picks per case.
@@ -52,7 +61,7 @@ one borrows its memory model from.
|---|---|---|
| `farolero` | 2023-05-05 | Common Lisp conditions and restarts, portably — a smaller read than SBCL's for *semantics*. |
| `lem` | 2025-08-29 | An editor written in CL. Its own REPL and process model. |
| `cider` | 2026-09-10 | The Clojure editor protocol this repo's `flan-dev.el` is shaped after. |
| `cider` | 2026-09-10 | The Clojure editor protocol this repo's `flan.el` is shaped after. |
| `emacs` | 2026-04-18 | Overlays, faces, `pre-command-hook` — the C and Lisp behind the editor half. |
| `fennel-ls` | 2026-05-06 | A Lisp language server. |
| `Carp` | 2026-08-24 | A statically typed Lisp with no GC. **The closest thing to this language's premise.** |
@@ -62,6 +71,20 @@ one borrows its memory model from.
`Carp` deserves the note it has: statically typed, no GC, Lisp syntax, borrow-checked. Whatever it
got right or wrong is the nearest prior art to what is being built here.
## Compiling to JavaScript
Cloned 2026-09-17, for the JS backend (TODO.org, "A JS backend is a dialect, not a second machine").
| Clone | What it answers |
|---|---|
| `Fable` | **The best semantic match**: statically typed F# to readable JS, value-semantics structs, real i64/u64. `src/fable-library-ts/Long.ts`, `Int32.ts`, `BigInt.ts` for how .NET integer semantics live on JS numbers; `src/Fable.Transforms/` for how a typed AST lowers, including where structs get cloned at assignment and call sites. Large — read it for specific answers, not through. |
| `squint` | A small Clojure-syntax-to-JS compiler. `src/squint/compiler.cljc` is the emitter to read for output style and structure. |
| `clojurescript` | The full-size version of the same problem, including host interop. Consult, don't read through. |
The Lisp-to-JS pair never faces the two hard questions here — integer width and struct copy
semantics — because they are dynamic. Fable does, which is why it is on this list despite being
neither a Lisp nor small.
## Graphics and the C boundary
`raylib`, `raygui`, `glfw`, `SDL`, `freetype-gl`, `TIC-80`, `aseprite`, `macroquad`, `godot`, `bevy`.
+454
View File
@@ -0,0 +1,454 @@
# SBCL's redefinition model, as a reference point for Flan
Research notes, 2026-09-20. Untracked. Written to answer "investigate SBCL's
redefinition model — warn + keep old value until callers update", the question
behind TODO.org, "A struct version word, so a redefined layout keeps working".
Nothing here is a decision; the last section lists candidates and stops.
The question behind it: Flan's dev session hard-refuses a struct layout change
and a global type change (`lib/session.ml:304-338`, `docs/BUILT.md:1213-1219`),
and plan.org's dev/release table (~671-682) lists "Structs: version word" for dev
builds with nothing behind it. SBCL is the system the author half-remembered as
"warn, keep the old layout, complain at the stale reader". It does do something
close to that — but the shape of it is not quite what the note says, and the
difference matters for what Flan could copy.
---
## 1. `defstruct` redefinition
### It is an error, not a warning
This is the one place that half-remembered summary is wrong, and it is worth stating
first because it inverts the precedent. The note says SBCL "warns rather than
refusing". SBCL signals a **continuable error** — the default behaviour of an
incompatible `defstruct` redefinition at a REPL with no handler is to drop you
into the debugger, and in a non-interactive build it aborts. It proceeds only
because a human (or a handler) picks a restart.
From `src/code/defstruct.lisp`, `%redefine-defstruct`:
> "attempt to redefine the ~S class ~S incompatibly with the current definition"
with two restarts:
- `continue` — "Use the new definition of ~S, invalidating already-loaded code
and instances."
- `recklessly-continue` — "Use the new definition of ~S as if it were
compatible, allowing old accessors to use new instances and allowing new
accessors to use old instances." Offered only when `mutable-layout-p` holds,
i.e. when the new layout can be overwritten in place onto the old one
(compatible sizes/rawness), so it is not offered for arbitrary changes.
There is a separate genuine warning for the compatible-ish case,
`redefine-structure-warning`:
> "incompatibly redefining slots of structure class ~S~@Make sure any uses of
> affected accessors are recompiled"
The comparison that drives all of this is `compare-slots`, which returns three
lists: slots that **moved**, slots that were **retyped**, slots that were
**deleted**. So SBCL's notion of "incompatible" is finer than Flan's
`compatible_structs` all-or-nothing name+type list equality — a pure append of
trailing slots is a different case from a reorder, and SBCL distinguishes them.
Note also that ANSI leaves structure redefinition **undefined**
(CLHS `defstruct`); everything above is an SBCL extension, not a standard
protocol. SBCL is not implementing a spec here, it invented a policy.
### What happens to the live instances
Picking `continue` calls `register-layout` with `:invalidate t`, which reaches
`%invalidate-layout` in `src/code/class.lisp`:
> "Mark LAYOUT as invalid. This is called only on CONDITION and STRUCTURE
> subtypes when redefining incompatibly."
It sets the layout's `invalid` slot, zeroes `layout-clos-hash`, and unhooks the
classoid from every superclass's subclass table. **The instances themselves are
not touched, not walked, not found.** There is no heap scan. Each instance keeps
pointing at the same layout object it always pointed at; that object is now
flagged invalid.
This is the crucial mechanism, and it is the one Flan cannot have: **an SBCL
structure instance carries a pointer to its layout in its header word.** The
invalidation is one store into a shared object, and every instance in the heap
learns about it for free, because they all reach it through that pointer.
### When the error surfaces
At the next access, not at redefinition time. Two paths, and they give different
conditions, which is worth being precise about:
- **The typed-accessor path.** A `defstruct` accessor in safe code checks the
instance's layout against the expected one. With the layout invalidated the
check fails and you get an ordinary `type-error` — the value is no longer
`typep` the struct type it used to be. (I did not read the exact accessor
emission path in `src/compiler/`; treat "it is specifically a `type-error`
rather than some dedicated condition" as **unverified**.)
- **The PCL path.** When a structure-object with an invalid wrapper goes through
PCL (generic function dispatch, `slot-value`), `%obsolete-instance-trap`
fires. For a structure it signals `sb-pcl::obsolete-structure`, defined in
`src/pcl/std-class.lisp`, reported as:
> "obsolete structure error for a structure of type ~S"
This is the condition people quote when they say "SBCL tells you your instance
is stale". There is no restart on it that repairs the instance. There is no
update protocol for structures.
So the honest one-line summary of the struct story: **SBCL refuses by default,
proceeds only on an explicit human restart, and then converts your old instances
into landmines that raise at first touch.** It never silently reads at the wrong
offsets — except under `recklessly-continue`, which is exactly the "silent
argument mismatch" outcome `docs/BUILT.md:1223-1230` says Flan refuses to ship,
and SBCL offers it only behind a name chosen to shame you.
## 2. `defclass` redefinition — the part with a real protocol
This is where CL earns the reputation, and it is a standard protocol (CLHS
4.3.6, `update-instance-for-redefined-class`), not an SBCL extension.
Redefining a class does **not** error. The sequence:
1. The new class definition is installed. `make-instances-obsolete` runs —
in SBCL, the `std-class` method calls `%update-lisp-class-layout` and
`%invalidate-wrapper` with the `:obsolete` flag. Again: a flag on the shared
wrapper, no heap walk. `layout-invalid` can hold `nil`, `t`,
`(:flush <wrapper>)` or `(:obsolete <wrapper>)`; `invalid-wrapper-p` is the
predicate on the access path.
2. Nothing else happens until someone touches an instance. CLHS 4.3.6: the
update occurs "at an implementation-dependent time, but no later than the
next time a slot of that instance is read or written", and the instance's
`eq` identity is preserved across it.
3. On that first touch, `%obsolete-instance-trap` runs. It computes the
**added** slots and the **discarded** slots by name, builds a property list
of the discarded slots' values, swaps the instance's storage for the new
shape, and calls:
```lisp
(sb-sys:nlx-protect (update-instance-for-redefined-class
instance added discarded plist)
(replace-wrapper-and-slots instance owrapper oslots))
```
The `nlx-protect` is a detail worth noting: if the user's
`update-instance-for-redefined-class` method signals and the stack unwinds,
the instance is rolled back to its old wrapper and slots rather than left
half-migrated.
What the protocol guarantees:
- **Slots present in both definitions keep their values, matched by name.**
This is the whole reason it works, and it is only possible because a CLOS
instance's slots are addressed through a name→index map held in the class, not
baked into compiled call sites.
- **Added slots are initialized from their `:initform`s** — the system-supplied
primary method calls `shared-initialize` on exactly the added-slot names,
passing along the initargs it received.
- **Discarded slots' values are not lost, they are handed to you** in the
property list, so a user method can do `(getf plist 'old-name)` and derive the
new slots from the old ones (the canonical CLHS example is a cartesian→polar
coordinate change).
- **Initargs are validated**; the default method signals on an initarg not
declared valid for the class.
- The return value is ignored.
The pattern to take away: **the automatic part is name-matching, and the
interesting part is a user hook that gets the old values.** CLOS does not try to
guess what a renamed slot meant; it hands you the corpse and lets you decide.
## 3. Global variables
Trivial, as expected, and the interesting bit is that CL already draws Flan's
distinction.
- `defparameter` re-evaluated "unconditionally assigns the initial-value to the
dynamic variable named name".
- `defvar` "assigns initial-value (if supplied) to the dynamic variable named
name **only if name is not already bound**".
That is precisely `docs/BUILT.md`'s rule that a `defvar`'s *initial value* is
deliberately not in the refusal table — "edit the code, keep the sand". CL's
`defvar` is the same policy, reached by the same reasoning, thirty years earlier.
Flan's `defvar` and CL's behave the same way here: both accept a changed
initialiser and ignore it while the variable is bound. The divergence is only in
the const twin — Flan's `defconst` refuses a changed value (when the checker
consumed it), whereas CL's `defparameter` simply overwrites.
There is **no** type-change problem to speak of, because a CL special variable
has no compile-time type and no fixed-shape storage — it is one boxed word. The
entire class of failure that `lib/session.ml:304-311` is protecting against
(storage laid out to a type, reused for another) does not exist in CL. So CL
offers no precedent for Flan's typed-global refusal; it sidesteps it by being
dynamically typed. Worth saying plainly rather than pretending there is guidance
here.
`defconstant` is the exception and is famously annoying: redefining one to a
non-`eql` value is an error in SBCL, which is the same shape as Flan's
compile-time-consumed `defconst` refusal, for a related reason (the old value
may already have been folded into compiled code).
## 4. Function redefinition
Trivially supported, and the reason is the reason Flan's cell design already
works: **a global function call goes through the `fdefinition`**, a name→function
indirection, not to a body address. Redefining `foo` stores a new function
object into the symbol's function cell, and every call site compiled against
`foo` picks it up on its next call with no recompilation and no stale caller.
This is exactly plan.org's "every cross-function call goes through an
indirection cell; body redefinition is one atomic pointer store". Flan already
matches SBCL here for the body-only case.
Two nuances worth carrying over:
- **Stale callers do exist in SBCL, in one place: block compilation.** Under
`(declaim (start-block ...))` / `:block-compile`, calls within the block become
local calls resolved at compile time. Redefining a function in a block-compiled
unit leaves its in-block callers calling the old body — they were never going
through the fdefinition. SBCL's answer is not a warning or a trampoline; it is
"block compilation prevents redefinition, that is the trade you made", and the
feature is off by default. Self-recursive calls are the same story in
miniature: SBCL deliberately compiles a self-call as a full call precisely so
that a function can be redefined while running.
- **SBCL does not version signatures.** A function whose lambda list changes is
just a redefinition; there is no new version, no trampoline, no per-caller
warning. Callers compiled against the old arglist get a normal
wrong-number-of-arguments error at runtime (or a compile-time style-warning if
SBCL still has the old ftype recorded and sees the call again). plan.org's
versioned-function design with tracked caller sites is **more** than SBCL does,
not a port of it. If the author was remembering SBCL as the source of that
design, the memory is of something else — the nearest real relatives are
Erlang's two-version code loading (old code + current code, a process running
old code is killed on the third load) and Smalltalk's `become:`.
## 5. What maps to Flan and what cannot
### The structural obstacle, stated once
Every SBCL mechanism above — struct layout invalidation, CLOS wrapper
obsolescence, the lazy trap — rests on a single fact: **an instance carries a
pointer to its shape descriptor in its header.** That is what makes "invalidate
the shape" an O(1) operation that reaches every live instance, and what makes
"detect staleness at next access" a load-and-compare on a word the instance
already has.
Flan's typed side has none of that. A `defstruct` value is flat and unboxed; it
lives inline in a global, inline in an array element, in registers, spilled on a
stack frame. There is no header word, no per-instance shape pointer, and no way
to enumerate the live instances of a type. So:
- Layout invalidation as SBCL does it: **structurally unavailable.** There is
nothing to invalidate that instances reach.
- Detecting an obsolete instance at access time: **unavailable in release
shape,** because detection requires a per-instance word. It is available only
if you *add* that word, which is exactly what plan.org's "Structs: version
word, dev only" line means.
- `update-instance-for-redefined-class`-style migration: **doubly unavailable**,
because it needs both the per-instance shape tag *and* the ability to change
an instance's size in place. A flat struct embedded in an array cannot grow.
### The dev-build version word is SBCL's generation counter
The connection plan.org's table implies but does not spell out: a version word
in the dev build is the degenerate form of SBCL's layout. SBCL's instance points
at a layout object whose `invalid` flag can be flipped; a Flan dev-build struct
would carry an integer stamped at construction, compared against the type's
current generation on access. Flip the generation on redefinition, and the next
field access on an old-generation value traps. That is `layout-invalid` with the
indirection collapsed into an immediate, which is the only form that survives
"instances have no headers, but dev builds may pay for one extra word".
What it buys, honestly: **not** the ability to keep using old values. It buys a
*diagnosis* instead of a refusal — the reload is accepted, the program keeps
running, and the first read of a stale value says "this `Cursor` was built
before you changed `Cursor`" with a location, instead of the session saying
"restart" before anything runs. That is genuinely SBCL's bargain (`continue`
then trap), and it is the honest framing: the version word converts a
compile-time refusal into a runtime trap. Whether that is an improvement is a
judgement about which failure the author would rather debug.
What it costs, all of which is real:
- A word per struct instance in dev builds, so dev and release layouts differ —
which means array strides, FFI structs, and anything crossing to C differ too,
or need to be excluded. The dev/release divergence plan.org already accepts
for frames and cells gets bigger and more observable.
- A check on every field access in dev builds.
- Structs embedded in other structs, in arrays, and in `Vec`s each need a story;
an array of 4096 cells would need every element stamped and every element
checked.
- Globals are not covered by it at all. A global's storage is a fixed slot with
a shape; a version word on the *global* could detect a stale read, but there
is nothing to read — the point of the refusal is that the old bytes mean
something else now. SBCL has no precedent to offer, per section 3.
### The dyn side is where CLOS-style update is actually available
Grounding the claim in the code as landed (TODO.org, "defclass is a named dyn
map with a shape tag"; commits `8d2bf2a`,
`5af990e`, `6c6024e`; `runtime/flan_dyn.c`):
- A `defclass` instance is an ordinary dyn map with a `klass` field in the
`flan_obj` header (`flan_dyn.c:255`), set by `flan_dyn_map_new_class`
(`:1088`), read by `flan_dyn_class_of` (`:1100`). It is deliberately in the
header and not an entry, so `len`, `render` and `dyn_equal` do not see it.
- **Slots are keys.** `lib/classes.ml:150-175`: `(defclass point [x y])`
desugars to `(defn point [x dyn y dyn] dyn #point{:x x :y y})` and nothing
else. Access is `get`/`put` by keyword.
Two consequences, and the second is the one that surprised me:
1. **Name-based slot carrying — the hard half of
`update-instance-for-redefined-class` — is free here.** A dyn instance already
stores its slots by name. Any migration is a map operation.
2. **There is no class object at runtime to invalidate, because there is no class
registry at all.** The `klass` field holds an interned keyword, not a pointer
to a class descriptor. Redefining a `defclass` today is just redefining its
constructor `defn` — which the session already permits as an ordinary body
change. Old instances keep their old keys and keep working; new ones get the
new keys; `class-of` answers the same keyword for both. So the dyn side
currently has neither the refusal *nor* the protocol: it has silence.
Whether that silence is a bug depends on what the author wants. It is the
Clojure answer (a map is a map; a "class" is a tag) rather than the CLOS answer.
The CLOS answer would need, concretely:
- A runtime class registry: keyword → (slot-name list, generation counter).
Nothing like this exists today; `lib/classes.ml` is compile-time only and
emits a plain `defn`.
- A generation stamp per instance, or a cheaper trick: compare the instance's
key set against the registry's current slot list lazily on `get`/`put`. With
a generation word in the `flan_obj` header — there is room, and unlike the
typed side these objects *have* headers — the check is a compare.
- A migration step at first access after a bump: add missing slots as nil,
collect removed slots into a property list, and call a user hook. The obvious
Flan spelling of that hook is a generic function, e.g.
`(defmethod update-for-redefined point [p added discarded] ...)`, which fits
the dispatch mechanism that already exists.
- A decision on whether `put` of an unknown slot stays legal. Decided
2026-09-26: it does not; `get`, `put` and `set` refuse an unknown slot on an
instance at run time, so the registry's slot list is enforced.
This is a real, SBCL/CLOS-precedented design that Flan's runtime can actually
support. It is also a feature with no user yet, since redefinition on the dyn
side currently fails silently rather than loudly.
---
## 6. Candidate designs
Three, none recommended over the others. Cost estimates are rough.
### A. Keep the refusal for typed structs and globals; fix the message
Leave `lib/session.ml` refusing, but rewrite both messages to say what SBCL's
restarts say: name which fields moved, were retyped, or were deleted (SBCL's
`compare-slots` split — Flan compares whole field lists and can cheaply do the
same split), and state the two things the author can do instead (restart, or
rename the type and migrate by hand). Optionally accept the case SBCL's
`mutable-layout-p` accepts and Flan currently refuses: **appending** fields to a
struct no array/FFI type depends on is layout-compatible for every existing
instance, so it need not be refused at all.
- Cost: small, days. Frontend only, no runtime change, no dev/release divergence.
- Behaviour: unchanged except the append case starts working and the diagnostic
names the offending field.
- Precedent: SBCL refuses by default too — this is the `%redefine-defstruct`
error with no restart taken, plus `compare-slots`' finer classification. The
"SBCL warns instead of refusing" premise these notes were written to check
does not survive
contact with the source, so "keep refusing" is the SBCL-consistent option, not
the timid one.
### B. Dev-build struct version word — SBCL's layout generation, collapsed
Stamp every dev-build struct instance with the type's generation at
construction; bump the generation on an incompatible redefinition; check the
stamp on field access in dev builds and trap with "this value was built before
`Cursor` changed shape" instead of refusing the reload. Release builds carry
neither word nor check, which is plan.org's dev/release table as written.
- Cost: large. Touches layout, both backends, arrays and `Vec`s of structs, FFI
boundary exclusions, and every field-access site; the dev/release layout
divergence becomes observable wherever a struct crosses to C. This is the one
that wants a written design before any code.
- Behaviour: the reload is accepted and the program keeps running; stale values
trap loudly at first touch rather than reading wrong offsets. It does **not**
let old values keep working — no migration is possible for a flat unboxed
struct — so it converts a pre-run refusal into a mid-run trap. Globals are not
covered and still need option A's refusal.
- Precedent: SBCL's `%invalidate-layout` plus the obsolete-instance trap, with
the layout pointer replaced by an immediate because Flan instances have no
headers. Note that SBCL, having the same choice, still puts the error in the
redefinition path *as well* and requires a restart to reach the trap state.
### C. CLOS-style update for dyn `defclass` instances only
Give `defclass` a runtime registry (keyword → slots + generation), stamp a
generation into the `flan_obj` header beside `klass`, and on the first `get`/`put`
after a bump, migrate the instance: add new slots as nil, gather removed slots
into a list, and dispatch a user-overridable generic `update-for-redefined`
before returning. Typed structs and globals are untouched and keep option A's
refusal.
- Cost: medium and well-contained — `runtime/flan_dyn.c` header field plus a
registry, `lib/classes.ml` emitting the registration, one lazy check on the map
access path. It rides on machinery that already exists: headers, interned
keyword tags, generic dispatch, and slots that are already addressed by name.
- Behaviour: redefining a class stops being silent. Instances survive
redefinition with their shared slots intact, new slots appear as nil, and the
author can write the coordinate-change-style migration by hand. The risk is
that it is a ceremony on top of maps that are already open — if `put` of an
arbitrary key stays legal, the registry describes an intention rather than a
constraint.
- Precedent: this *is* CLHS 4.3.6 and `update-instance-for-redefined-class`, the
only part of CL's story that is a standard protocol rather than an
implementation's policy, and the only part whose prerequisites Flan already
satisfies.
B and C are independent and could both happen, or neither. A is close to free
and is compatible with both.
---
## Sources
- SBCL source, `src/code/defstruct.lisp` (`%redefine-defstruct`, `compare-slots`,
`redefine-structure-warning`, `mutable-layout-p`, restart texts) —
<https://github.com/sbcl/sbcl/blob/master/src/code/defstruct.lisp>
- SBCL source, `src/code/class.lisp` (`%invalidate-layout`, `register-layout`,
`layout-invalid`) — <https://github.com/sbcl/sbcl/blob/master/src/code/class.lisp>
- SBCL source, `src/pcl/std-class.lisp` (`obsolete-structure` condition,
`%obsolete-instance-trap`, `make-instances-obsolete`, `invalid-wrapper-p`,
`replace-wrapper-and-slots`) —
<https://github.com/sbcl/sbcl/blob/master/src/pcl/std-class.lisp>
- CLHS 4.3.6 Redefining Classes —
<https://www.lispworks.com/documentation/HyperSpec/Body/04_cf.htm>
- CLHS `update-instance-for-redefined-class` —
<https://www.lispworks.com/documentation/HyperSpec/Body/f_upda_1.htm>
- CLHS `defparameter`/`defvar` —
<https://www.lispworks.com/documentation/HyperSpec/Body/m_defpar.htm>
- SBCL Internals, Local Calls — <http://www.sbcl.org/sbcl-internals/Local-Calls.html>
- Block compilation in SBCL 2.0.2 —
<https://mstmetent.blogspot.com/2020/02/block-compilation-fresh-in-sbcl-202.html>
- Lisp journey, "Structures: lightweight records" (the user-visible restart menu)
— <https://lisp-journey.gitlab.io/blog/data-structures-structs/>
The SBCL *manual* (<https://www.sbcl.org/manual/>) does not document structure
redefinition, obsolete instances or layout invalidation at all — checked, and it
is silent. Everything in sections 1 and 4 above comes from source and mailing
list/blog material, not from the manual.
### Marked unverified
- The exact condition signalled when a *compiled typed accessor* (as opposed to
the PCL path) is applied to an instance with an invalidated layout. Stated
above as "an ordinary `type-error`"; I did not read the accessor emission in
`src/compiler/`, so treat the specific condition name as unverified. The
`sb-pcl::obsolete-structure` text is verified from `src/pcl/std-class.lisp`.
- Whether SBCL's `compare-slots` classification ever *avoids* the error for a
pure append (i.e. whether appending slots is accepted silently). The
`mutable-layout-p` gate on `recklessly-continue` strongly implies a
compatible-layout notion exists, but I did not confirm the append case is
error-free.
+604
View File
@@ -0,0 +1,604 @@
# The duplicity audit: where the two sides meet, and where one side repeats itself
> **Nothing was built.** This is a reading of `lib/check.ml`, `lib/types.ml`, `lib/emit.ml`,
> `runtime/flan_dyn.c`, `lib/prelude.ml`, `vendor/edn/read.flan`, `plan.org` and `spec-memory.md`, with
> twenty-odd probe programs run through `_build/default/bin/main.exe check|emit|run` where a probe settles
> something a reading cannot. Every claim below is either cited to a line or reproduced from a probe whose
> output is quoted. Read against `dev-loop` at `5bd25b6`. A sibling lane is implementing dyn on the x86
> backend; nothing here turns on it.
The doctrine being audited: **every capability has two sides, and the rule is never to repeat yourself on
the same one.** The dyn side is tagged, GC-heap, heterogeneous, unannotated, and wrong at run time. The
native side is untagged, allocator-managed, annotated, and wrong at compile time. A dyn vec and a `(Vec T)`
are two implementations of one idea and that is correct; two implementations on the *same* side is the
smell. SBCL is the named model for the error half: derive what is derivable at compile time, be dynamic
where it is not.
The audit found **one memory-safety hole**, **three silent acceptances that should be refusals**, **two
capabilities the typed side lacks and the runtime already implements**, and **two same-side duplications
ready to retire**. Ranked questions are in §B; the duplications are in §C.
---
## 0. The first correction: the doctrine's "no annotations" is not what shipped
The doctrine says the dyn side takes no annotations. The implementation takes no *parameter* annotations.
The return slot is mandatory and always was — `HANDOFF-dyn-m1.md:9-19` records the decision and its reason,
which is structural rather than a TODO: an optional return slot has no syntactic resolution, because
`(defn f [] (Rune {.code 65}) (bar))` is both a type application and a struct literal in the same position,
and that misparse is what removed the old optional slot.
The probe confirms it. `(defn f [x] (print x))`:
```
e4.flan:1:13: print takes no type arguments — generics are milestone 5
```
`(print x)` was read as the return *type*. Every dyn function in this document is spelled
`(defn f [x] dyn ...)`, with `dyn` written out.
**Verdict: doctrine-vs-implementation mismatch, benign but load-bearing.** It means the author's sketching
case — `(defn settle [row col] ...)` — is still not writable, which is `SPIKE-INFERENCE.md`'s answer
arrived at from the other direction (`docs/SPIKE-INFERENCE.md:17-20`: that spelling "provably cannot be
given the meaning they want"). It also flattens item 3 below: neither `[x $t]` nor `[x]` frees anybody from
writing a return type, so the choice between them is never about annotation volume.
---
## 1. Absence — Option on one side, nil on the other
**There is no nil.** Not "two absence systems"; one, plus a documented value with no producer.
```
$ flan check a1.flan # (defn f [] dyn nil)
a1.flan:1:16: unknown name nil
```
`flan_dyn_nil` is defined in the runtime (`runtime/flan_dyn.c:696`), in the stub
(`runtime/flan_dyn_stub.c:80`), declared in the ABI (`runtime/flan_dyn.h:35`), declared to LLVM
(`lib/emit.ml:3001`), and exercised eleven times in `test/dyn_ops.c`. **No path in `lib/check.ml`
constructs one.** The dyn side's absence value is reachable from C and from nothing a Flan program can
write.
The sharpest thing in the audit is what stands in its place. `box` refuses `Unit` with this
(`lib/check.ml:1436-1439`):
> `()` does not box into dyn — a value of the zero-sized type carries nothing a dyn could hold. The absent
> dyn value is **nil, which is what an `if` with no else branch answers here**.
The message describes behaviour the compiler does not implement. `(defn f [x] dyn (if x 1))` does not
answer nil; it is refused, by that very message:
```
a2.flan:1:17: () does not box into dyn — ... The absent dyn value is nil, which is what an if
with no else branch answers here
```
The sentence is a design note that reads as a description. A user following it writes the form it names and
gets the refusal that names it.
`(Option dyn)` checks clean (probe `a3`) and is coherent: `Option` is a two-case data type decided
structurally in the checker (`lib/check.ml:3025-3033`) and never desugared, so its payload type is
unconstrained; `owning` walks `Option e` into `e` (`lib/check.ml:451`) and `Dyn` owns nothing, so no region
rule fires.
**Verdict: undecided-needs-author, but simpler than the brief assumes.** There is one reachable absence
system today, not two. The conflict is prospective, and the decision is not "reconcile two systems" but
"what may produce a nil, and what does one do at a typed boundary". The three coherent answers:
1. **`nil` is never produced.** Delete the sentence at `check.ml:1436-1439`, keep the `Unit` refusal, and
let `(Option dyn)` be the dyn side's absence too. Cheapest, and loses the Clojure feel.
2. **`nil` is produced and unboxes to a trap.** Symmetrical with every other `need_*`: `flan_dyn_need_i64`
of a nil traps exactly as it traps on a text.
3. **`nil` is produced and unboxes to `None` at an `(Option T)` want.** The only answer that makes the two
absence systems one, and the only one that puts an implicit conversion at the boundary
`check.ml:1367-1380` exists to keep sharp — "nowhere else does the compiler decide a dyn is an i64 on its
own", and this would be the compiler deciding a dyn is a `None`.
Related, and not in the brief's ten: **truthiness**. `(defn f [x] dyn (if x 1 2))` checks and runs, and the
condition goes through `flan_dyn_need_bool` (`lib/check.ml:1455-1459`), so a dyn `if` is strict-bool — a nil
or an int in the condition traps. `plan.org:908-910` declares the CL-vs-Clojure truthiness question "moot
under static typing". **It is not moot on the dyn side**, and the strict-bool answer is currently the one
that fell out rather than the one that was chosen.
---
## 2. Equality and ordering — not two meanings, one side missing a case
**Landed in `daed039`** (M2 queue item 5): typed `=` and `!=` now grow the `string` case this section argues
for, bytewise, with the fast paths `runtime/flan_rt.c`'s `flan_str_eq` carries. `is_equatable` is no longer a
bare alias for `is_comparable` — `lib/types.ml` now reads `let is_equatable = function String -> true | t ->
is_comparable t` — and the transcript below, quoted as this section found it, now accepts `(= "hi" "hi")`.
Ordering was deliberately left alone: `<` and friends still refuse a string, on the same "no collation this
language has picked" grounds §2 raises, and that half of the verdict did not land. What follows is kept as
the historical argument that got the equality half moving; read the two paragraphs above as its outcome, not
as a correction of anything past this point.
Typed `=` is numbers and enums and nothing else, as this was written. `lib/types.ml:166-168` then read:
```ocaml
let is_comparable = function Enum _ -> true | t -> is_numeric t
let is_equatable = is_comparable
```
`is_equatable` is a bare alias for `is_comparable`. The dyn `=` is structural over everything and is the one
dyn operation that never traps (`SPIKE-DYNAMIC.md:285-287`), and dyn `<` orders text bytewise by `memcmp`
(`SPIKE-DYNAMIC.md:273-275`).
One probe, both operators, one line apart — as run before `daed039`; the second `print` now accepts too:
```
$ flan check b3.flan
(defn f [x] dyn x)
(defn main [] i32 (do (print (= (f "hi") (f "hi"))) ; accepted
(print (= "hi" "hi")) ; refused, at the time
0))
b3.flan:2:64: = compares machine numbers; string has no built-in comparison (plan.org, Types)
```
And the generic side inverts the same way (`d1`):
```
d1.flan:2:32: this call instantiates biggest at $t = string, and string does not answer is-ordered
```
while `(defn biggest-d [a b] dyn (if (> a b) a b))` at `"a"` and `"b"` checks clean (`c2`).
The reflex reading is "same operator, two meanings — trap". It is the wrong reading, because it treats the
dyn answer as the anomaly. The dyn answer is *right*, it is cheap, and **the code that implements it already
exists on the typed side too**: `flan_hash_str` and the map's string equality
(`runtime/flan_rt.c:1939`, and `Types.keyable` says `String -> true` at `lib/types.ml:156`). So the typed
side already has a string equality good enough to key a `(Map string V)` — and refuses to let a program call
`=` on two strings.
That is **same-side duplication in the making, on the typed side**: `keyable` and `is_equatable` are two
answers to "does this type have an equality", they disagree about `string`, and one of them is a `Map`
implementation detail rather than a language rule.
The four-predicate ceiling is where it shows. `predicate_names` is exactly `[ "is-ordered"; "is-equal";
"is-hashable"; "is-numeric" ]` (`lib/check.ml:412`), and `pred_entails` (`lib/check.ml:490-497`) admits its
table is "only sound while" ordered ⊆ equatable holds — which is only true *because* the set is so small.
`lib/prelude.ml:247-248` says the quiet part: `sort-bytes` exists as a hand-written `sort-by` because "a
`[[u8]]` is not `[is-ordered]` **and cannot be**".
**Verdict: same-side gap on the typed side, not a boundary conflict.** The author's question is not "which
meaning wins" — it is whether typed `=` and `is-ordered` grow the `string` case the runtime already implements
with `memcmp`. Growing them retires `sort-bytes`, unifies `keyable` with `is_equatable`, and makes the two
sides agree on strings at no representational cost.
---
## 3. Generics and dyn — two ways to take "any type", and they are not the same way
Both `[x $t]` and `[x]` take a value whose type is not written. The difference is *where the operations on
it are checked*, and it is total:
- A generic is checked **at the definition**. An operator over a type variable with no predicate asserting
it is refused there, at the `defn`, before any call exists (`lib/check.ml:976-990`, and
`SPIKE-INFERENCE.md:26-29` quotes the refusal: "`+` over the type variable `t` is refused"). The call site
then re-checks the predicate against the concrete type (`d1` above, and `pred_holds` at
`lib/check.ml:471-483`).
- A dyn parameter is checked **nowhere**. `(defn f [x] dyn (+ x "a"))` checks clean; it traps at run time
(probe `a6`): `dyn +: int and text, and it takes two numbers — (+ 1 "a")`.
So they overlap on exactly one function — `id` — and diverge on every function with an operator in it. The
generic is monomorphised and costs nothing; the dyn is tagged, heap-allocating and rooted
(`emit.ml:705`: a rooted alloca escapes through `flan_dyn_root_push`, so mem2reg cannot promote it, at every
optimisation level).
The user's rule falls out and needs no new machinery to state: **write `[x $t]` when the operation is the
same for every type and the caller knows which; write `[x]` when the operation *depends* on the type and
nobody knows it until the value arrives.** `sort` is the first; a config reader is the second.
**Verdict: clean.** Genuine duplicity, correctly placed. With the caveat that the four-predicate ceiling of
§2 is what makes the generic side look weaker than it is: a generic can only express the four questions the
compiler has builtins for, so every capability outside that set reads as "use dyn" when the honest answer is
"the predicate does not exist yet".
---
## 4. Conditions — the refusal is right, and its absence everywhere else is the hole
`signal` refuses a dyn condition and a condition with a dyn *field*, each by name
(`lib/check.ml:1976-2005`). Probe `f1`:
```
f1.flan:2:31: dyn does not cross into a written type yet — the field payload of the condition Oops
is one, and a payload has to stay rooted across a handler transfer, which is milestone 2
```
The reason given is exact and correct: a payload crosses a handler boundary as a pointer to a frame that is
still alive, and a dyn in it has to stay rooted across a transfer the root stack knows nothing about.
**But an ordinary `defstruct` with a dyn field is accepted, and nothing roots it.** Probes `e2`, `e5`, `e7`,
`f3` all check clean:
```lisp
(defstruct S [x dyn])
(defonce boxes (Vec S))
(defn stash [] () (push boxes (S {.x (vec-new dyn)})))
```
`flan emit f3.flan` shows what happens:
```llvm
%dr0 = alloca i64
call void @flan_dyn_root_push(ptr %dr0)
%t1 = call i64 @flan_dyn_vec_new()
```
One root — the *temporary*, for the duration of `stash`'s frame. It is popped at the `ret`. After that the
dyn vec's only reference is `S.x`, inside a `(Vec S)` whose storage belongs to an allocator the collector
never traces. The next allocation collects a live object.
Nothing in the tree refuses this. `box` has no arm for it — a `Named` type is refused only when it is being
*boxed into* dyn (`lib/check.ml:1450-1452`), not when it *contains* one. `dyn_roots`
(`lib/emit.ml:710`) counts nodes, not fields. `dyn_sites`, the `--no-gc` pass, walks globals' types,
parameters, the return type and `flan_dyn*` call nodes (`lib/check.ml:7104-7136`) — **not struct fields**, so
`(defstruct S [x dyn])` passes `--no-gc` (probe `e6`, clean). That last one is vacuous today, since filling
the field needs a `flan_dyn*` call which the pass does catch; it will stop being vacuous the moment a typed
container boxes into dyn.
`SPIKE-DYNAMIC.md:402-406` names the machinery that is missing: "a handle to a typed `(Vec Enemy)` would
need the collector to know which fields of `Enemy` are dyn, and a per-type descriptor emitted by the
compiler is how that arrives." The descriptor is M2. The *field* is M1 and is already writable.
**Verdict: conflict, and the most urgent one in this document.** Not a design conflict — an implementation
hole the design already knows how to close, protected in exactly one place and open everywhere else. Whether
the dyn side needs its own signal story is the *second* question and the easy one: typed payloads serve both
sides fine, because the answer to "I want a dyn in my condition" is the same as the answer to "I want a dyn
in my struct", and both wait on the same descriptor.
---
## 5. Collections — the dyn side is one third built, and `edn/Value` is the other two thirds
What the dyn runtime has: a vec, mutable, GC-owned (`runtime/flan_dyn.c`, `OBJ_VEC`). What it does not have:
a **map**, a **set**, and **keywords**. There are three object kinds — `OBJ_TEXT | OBJ_VEC | OBJ_INT`
(`SPIKE-DYNAMIC.md:108`) — and four spare box tags (`SPIKE-DYNAMIC.md:57`).
Keywords are not absent by oversight; they are *typed-side only, at compile time*. `plan.org:920-923`:
"`:space` resolves at compile time against the parameter's enum type... **No runtime cost.**" That is a
different thing from EDN's `Key`, which is a value that exists at run time and can be a map key nobody
declared.
Meanwhile `vendor/edn/read.flan:71-80` is a complete tagged dynamic value, hand-written in typed Flan:
```lisp
(defdata Value
[(Nil []) (Bool [b bool]) (Int [n i64]) (Float [x f64]) (Text [s string])
(Key [s string]) (List [items (Vec Value)]) (Set [items (Vec Value)])
(Table [entries (Map string Value)])])
```
with its own structural equality, `value=?` (`read.flan:99`), its own list equality (`:116`), its own set
equality (`:128`), its own table equality (`:145`). Every one of those is an operation `flan_dyn_eq`
implements (`SPIKE-DYNAMIC.md:283-300`).
Counted by *capability* rather than by implementation language, this is unambiguous: `Value` and the dyn
runtime both answer "a heterogeneous value that carries its type at run time". **One capability, two
implementations, same side.** The author's lean is right.
The price is worth stating precisely, because it is the whole of the decision:
| `Value` has | dyn has | what closing the gap costs |
|---|---|---|
| `Nil` | `flan_dyn_nil`, unreachable from source (§1) | a producer and a boundary rule |
| `Bool`/`Int`/`Float`/`Text` | all four | nothing |
| `List` | vec | nothing |
| `Key` | — | a fifth object kind or a spare tag; interning if `=` is to stay O(1) |
| `Table` | — | a fourth object kind, a structural hash over tags, ordering for iteration |
| `Set` | — | **hardest**: `keyable` refuses `Value`-shaped keys (`read.flan:57-62`), so a dyn set cannot just be a dyn map either; `Value.Set` is an O(n) vec for exactly that reason and says so (`read.flan:64-69`) |
The M2 shape that falls out: **`OBJ_MAP` on tag 3 as a fourth kind, keywords as a fifth, and a dyn set as a
dyn map to a unit** — which works on the dyn side precisely because `flan_dyn_eq` has no `keyable`
restriction to run into. The thing `Value` cannot do, dyn can, and that is the argument for the retirement
rather than against it.
Note the counterpart landed on the *other* side while this audit was being written: **the type-provider lane
has merged to `dev-loop`** — `(edn/defedn Tileset "assets/tileset.edn")` derives a struct from the file at
expansion time (`lib/parse.ml:1321`, `lib/check.ml:4702`, `lib/macro.ml:302`, `docs/BUILT.md:5810-5830`),
`defjson` is the same over a different grammar and shares "the design and not the code"
(`docs/BUILT.md:5944-5960`), and `macro-slurp` is the prelude half (`lib/prelude.ml:1822-1830`).
`docs/PORTING.md:633-637` marks `read-edn` built under that name. So the data-file capability now has both
its sides in the tree at once: `defedn` is the perf side (a struct, at compile time), and a dyn map with no
struct given is the dynamic side. **`Value` is neither.** It is the dynamic side, written before the dynamic
side existed.
**Verdict: same-side duplication, retire — sequenced behind a dyn map and dyn keywords.** Retiring it before
those land would delete the only thing in the tree that can read a config file whose keys are unknown.
---
## 6. Strings — a one-way door
Typed `string` is a builtin ptr+len, non-owning, the same shape as a slice (`lib/types.ml:22`, and the
`Allocator` comment at `:35` calls it "a builtin ptr+len"). It appears in no arm of `owning`
(`lib/check.ml:449-459`), which is the operational statement that it borrows: a `(Vec string)` is not
region-only, because the strings in it own nothing to release.
Dyn text is the opposite: immutable, length-prefixed, **copied on construction**, owned by the collector
(`SPIKE-DYNAMIC.md:118-121`).
Typed → dyn works and copies, via `flan_dyn_from_bytes` (`lib/check.ml:1430`). The copy is not a wart, it is
forced, and `vendor/edn/read.flan:41-47` already wrote the argument for the same case: a view handed back
out of the function that owns the buffer is "garbage with nothing to say so" once the caller reads the next
file into it. A dyn value can outlive any frame, so a dyn text must own its bytes. Correct.
Dyn → typed is **refused outright**. Probe `b5`:
```
b5.flan:2:20: string does not cross into a written type yet
```
`unbox` has arms for `i64`, `f64` and `bool` and a catch-all refusal for everything else
(`lib/check.ml:1450-1474`). So a program can put a string into dyn and can never get it back as a `string`.
It can get the *bytes* — `flan_dyn_at` answers a byte as an int (`SPIKE-DYNAMIC.md:304-305`) — one at a
time.
The refusal is understandable and the reason is not a representation problem: a `string` is ptr+len and the
text object's bytes are right there inline after the header. It is a **lifetime** problem, and the same one
§4 has. A `string` borrowed out of a GC object is valid until the next collection, and nothing in the
language says so.
**Verdict: conflict.** Dyn text is a one-way door today, which makes every string-shaped dyn program a
dead end at the moment it wants a typed function. Three exits: a copying `(string-of d alloc)` into a named
allocator (safe, explicit, the arena idiom already in the language); a borrowing one that is valid until the
next collection (fast, and needs a rule the language has no vocabulary for); or leave it refused and make
the dyn side's own string operations complete enough that nobody wants out. The first is the one that fits
`spec-memory.md`.
---
## 7. Allocators vs the GC — the refusal is written for the spelling nobody uses
`(vec-new dyn alloc)` is refused by name, with a good sentence (`lib/check.ml:4094-4100`):
> `(vec-new dyn)` takes no allocator — the dyn container's storage is the dyn runtime's, which is what lets
> the collector find the values inside it.
But **`(with-allocator arena (vec-new dyn))` is accepted in silence.** Probes `b4` and `c5` both check
clean. The ambient allocator is not consulted, not refused, not mentioned.
That is the wrong way round, because of what `spec-memory.md` decided. The allocator is in the *calling
convention* — `vendor/edn/read.flan:11-18` states the consequence plainly: "`read` takes no allocator and
names none. It does not need to: spec-memory.md puts the allocator in the calling convention, so every
`(vec-new)` and `(map-new)` below takes the *context*". The explicit allocator argument is the rare
spelling; `with-allocator` is the idiom the whole language is written in. The compiler refuses the spelling
a user almost never writes and says nothing about the one they always do.
`check.ml:627` and `:1289` both record that `with-allocator` "rebinds a dynamic variable, so the tier a
`(vec-new)` will meet is not a property of where the type is written" — so the checker cannot in general
know statically which allocator is ambient. It does not have to: the question here is not *which* allocator,
it is whether an enclosing `with-allocator` is a lie when the form under it ignores it.
**Verdict: conflict — silent acceptance where a refusal or a note is owed.** The user's model of
`with-allocator` is "everything allocated in here comes from this"; a dyn construction is the one exception,
and the exception is invisible. The author's call: refuse a dyn construction lexically inside a
`with-allocator`, warn, or write the exception down and accept it. Refusing is probably too strong —
`(with-allocator frame (edn/read bytes))` will want to hold a dyn somewhere — which makes this a
documentation-plus-note decision rather than a checker one.
---
## 8. `defstruct` vs `defclass` — the class is dyn with named slots, minus one thing
`plan.org:132-190` designs `defclass` and it predates dyn entirely. Read its ingredient list against what
the dyn runtime now has:
| `defclass` needs (plan.org) | dyn already is |
|---|---|
| "runtime class/shape metadata" (`:157`) | the tag, and `flan_dyn_tag_name` |
| identity, so a class instance is not a value that copies (`:135`) | a `flan_obj *`; `test_dyn`'s `sharing` mode asserts identity, not just equality (`SPIKE-DYNAMIC.md:561`) |
| "a managed allocation strategy" (`:157`) | the mark-sweep heap |
| "A small tracing GC confined to class instances remains an option" (`:161`) | built, and confined to exactly dyn values |
| live schema change, "existing instances at their old layout until an explicit migration" (`:173-176`) | a heterogeneous object has no layout to be stale |
Five of five. Building `defclass` as a third memory model — a third answer to identity, a third allocation
strategy, a third metadata scheme — would be **same-side duplication on the managed side**, and by the
doctrine it is the thing not to do.
What the dyn side genuinely lacks, and what a `defclass` therefore *adds* rather than duplicates:
1. **Named slots.** There is no record kind; a dyn vec is positional. This is the same missing `OBJ_MAP` of
§5 — a class instance is a map from slot name to value with a declared slot set, which is a map with a
shape check.
2. **Dispatch.** `(defmethod update ((e Enemy) dt) ...)` — "exact-class, single-argument dispatch" first
(`plan.org:168-170`). The tag is already the dispatch key; a class id in the object header is the
extension.
3. **A declared slot set at all**, which is what makes the compile-time half possible: a `defclass` can
report an unknown slot where a dyn map cannot.
The one thing that does **not** collapse into dyn, and should stay where it is: `plan.org:157-160`'s "but
*not necessarily* a tracing GC — a world or session arena, pool allocation behind generational `(Handle T)`
values". That is the **typed** side's answer to identity and longevity, and it is a real second side of the
same capability, not a duplicate. A pooled `(Handle Enemy)` over a `defstruct` is the perf side; a `defclass`
is the managed side. Both should exist.
**Verdict: undecided-needs-author, with the doctrine's answer close to forced.** `defclass` is dyn plus
named slots plus dispatch, sharing one heap and one collector — not a third thing. The corollary, and the
part worth deciding early: `plan.org:192-194` says "Do not add classes until ordinary `struct`, `Handle`,
and reload semantics are working." Under this reading the gating item changed. Classes are now gated on the
**dyn side's map/record kind**, which is M2 and in flight, rather than on `Handle`.
---
## 9. Print, hash, sort — one family already duplicated, two not yet
**Printing was duplicated three ways on one side, and it is not the dyn/typed split. Two of the three are
now one.** This section found three escape tables; the runtime dedupe lane took the two that were on the
same side and made them one function with two framings, which is what this section asked for. What is left
is the split it argued was correct.
The table lives once, as `flan_escape_char` (`runtime/flan_rt.c:388`), and both typed-side printers call
it: `flan_escape_bytes` (`:422`), which builds a capped slice for `print`, and `put_str`
(`runtime/flan_dev.c:302`), which streams into the inspector's fixed buffer so the REPL can parse the
printed form back. The framing is each caller's and is the part that is genuinely theirs; the switch is
shared, because two printers disagreeing about what a string looks like is two wire formats.
The dyn copy stays, as `emit_escaped` (`runtime/flan_dyn.c:472`), and says why at `:465-470`:
> This copy is deliberate, and the argument for it is docs/SPIKE-DUPLICITY.md §9's: the dyn printer lives
> inside the runtime that owns the storage it walks, which is why it prints a dyn vec structurally where
> the typed printer answers `<vec>`. The whole printer is this side's; the table it shares with the other
> side is the part that must not drift. If that table changes, change this one.
That is the defence `SPIKE-DYNAMIC.md` gives and this section accepted: a typed `Vec` prints as `<vec>`
because the typed printer will not walk storage it does not own, while a dyn vec's storage belongs to the
collector and the printer is inside the runtime that owns it. One copy per *side*, which is the doctrine.
Two tables is the answer; three was not.
**Hashing is typed-only.** `flan_hash_fn`, `flan_hash_flat`, `flan_hash_str`, `flan_hash_combine`
(`runtime/flan_rt.c:1807, 1911, 1939, 1954`), reached through `Types.keyable` and the checker's `key_pair`
walk. The dyn side has none, because it has no map. **Verdict: clean today, and the first thing M2 gets
wrong if it is careless** — a dyn map needs a structural hash over tags, and that hash must *call*
`flan_hash_mem`/`flan_hash_str` rather than re-derive them, or the family duplicates on the dyn side the way
printing duplicated on the typed one.
**Sorting is typed-only** — `sort` (`lib/prelude.ml:277`), `sort-by` (`:304`), `sort-bytes` (`:1399`).
Nothing is built on the dyn `lt`/`le`/`gt`/`ge`. `sort-bytes` is already a half-duplicate of `sort` that
exists only because `[[u8]]` cannot answer `is-ordered` (`lib/prelude.ml:247-248`) — so it is the §2 gap,
appearing in the prelude as an extra function.
---
## 10. The SBCL lesson, concretely
SBCL's mechanism is a **type lattice plus an IR1 fixpoint**. `ir1-optimize-cast`
(`src/compiler/ir1opt.lisp:3765`) intersects the value's *derived* type with the *asserted* one; where the
intersection is empty the conflict is unconditional, and the node is rewritten to
`%compile-time-type-error` (`ir1opt.lisp:3837`), whose warner is `%compile-time-type-error-warn`
(`ir1util.lisp:4351`). That is a full `WARNING` at compile time, and the form is **still emitted**, so the
program that was warned about also signals at run time. What cannot be *proved* wrong is not dropped either:
`convert-type-check` (`src/compiler/checkgen.lisp:518`), driven by `generate-type-checks` (`:684`), lowers
the unprovable assertion to a run-time check.
Measured, not paraphrased — `sbcl --noinform --non-interactive` on `(defun f () (+ 1 "a"))` then calling it:
```
; caught warning:
; Constant "a" conflicts with its asserted type number.
; See also:
; The SBCL Manual, Node "Handling of Types"
;
; compilation unit finished
; caught 1 WARNING condition
#<simple-type-error expected-type: number datum: "a">
```
Both halves in one run: a compile-time `WARNING` naming the constant and the asserted type, **and** a
function that still exists and still signals. The warning changed the diagnostics and nothing else.
Two things carry over and one does not.
**Does not carry over: the derivation.** Flan has `Types.equal` and `fits` (`lib/types.ml:173`), and `fits`
is `equal` with one exception for `Never`. No lattice, no unions, no intersection, no fixpoint. "Derive the
type of every expression and intersect it with what is wanted" is not a change to `check.ml`; it is a
different `check.ml`. Anyone reading "be like SBCL" as "add type derivation" is reading a year of work into
a sentence.
**Carries over, and is nearly free: refuse at the site where the pre-box type is still in hand.** This is
the part worth doing. `box` (`lib/check.ml:1418`) and `dyn_fold` (`lib/check.ml:3414`) both receive the
*typed* expression before it is boxed — `dyn_fold`'s own comment says "the typed side of a mixed pair is
boxed on the way in". So at `(+ dyn-x "a")` the second operand's type is `Types.String`, sitting in a
variable, at the moment the compiler decides to emit `flan_dyn_add`. Today that compiles (probe `a6`) and
traps:
```
dyn +: int and text, and it takes two numbers — (+ 1 "a")
```
The trap message is written from information the *checker* had. One match in `dyn_fold` — if an operand's
pre-box type is known and no arm of the dyn operator accepts that tag, refuse — is SBCL's empty-intersection
case exactly, with no inference, no lattice and no new pass. It catches every literal and every typed
binding flowing into a dyn operator, which is most of what a beginner writes wrong, and it catches nothing
that is genuinely dynamic, which is the whole point.
**Carries over, and is the part to resist skipping: warn, and still emit.** SBCL's discipline is that a
compile-time type warning never changes what the program does. If Flan takes the refusal route instead, it
should be a refusal only where the conflict is unconditional — a known non-numeric tag into an arithmetic
operator — and never a refusal on a heuristic, because the dyn side's contract is that it runs.
One piece of evidence about how thin the typed side's own derivation is, since it bears on how much of SBCL
is even reachable from here. Fully typed `(+ 1 "a")` refuses like this (probe `a5`):
```
a5.flan:1:35: expected string, found the integer literal 1
```
It refused because the literal `1` adopted the type of the *second* operand and failed — not because `+`
rejects text. The typed side has no statement anywhere that `+` takes numbers; it has literal adoption and
`Types.equal`. That is worth knowing before promising SBCL-shaped diagnostics.
---
## A. The two sides, as the language stands
| capability | native side | dyn side | verdict |
|---|---|---|---|
| parameter types | written | omitted | clean |
| **return type** | written | **written** (`HANDOFF-dyn-m1.md:9-19`) | doctrine mismatch (§0) |
| integers | `i8`…`i64`, `u8`…`u64` | `i64` only, 48-bit inline + box | clean |
| floats | `f32`, `f64` | `f64` only | clean |
| absence | `(Option T)`, `Some`/`None` | `nil` — **unreachable from source** (§1) | undecided |
| truthiness | n/a | strict bool via `need_bool` | undecided (§1) |
| equality | numbers + enums (`types.ml:168`); `string` refused | structural, never traps, text bytewise | typed side missing a case (§2) |
| ordering | `is-ordered` = numbers + enums | numbers + text `memcmp` | same (§2) |
| map keys | `keyable`: ints, enums, bool, **string**, arrays, structs | — no map — | typed side self-inconsistent (§2, §5) |
| "any type" | `$t` + `{:where}`, checked at the `defn` | dyn, checked nowhere | clean (§3) |
| conditions | struct payloads | refused, by name (`check.ml:1976-2005`) | clean; the hole is elsewhere (§4) |
| struct with a dyn field | **accepted, unrooted** (§4) | — | **conflict** |
| vec | `(Vec T)`, allocator-owned, moves | `(vec-new dyn)`, GC-owned | clean |
| map | `(Map K V)`, open-addressed | **absent** | gap (§5) |
| set | absent (`Value.Set` is an O(n) vec) | absent | gap (§5) |
| keywords | compile-time enum resolution (`plan.org:920`) | absent | gap (§5) |
| strings | ptr+len view, borrows, owns nothing | GC text, immutable, copies in | one-way door (§6) |
| storage | `with-allocator`, in the calling convention | GC; ambient allocator **silently ignored** (§7) | conflict |
| identity / long-lived objects | `defstruct` + pooled `(Handle T)` — planned | `flan_obj *` | clean; `defclass` is the dyn side (§8) |
| dynamic data value | `defedn`/`defjson` → a struct, at compile time — **landed** | dyn value; **plus `edn/Value`** | same-side duplication (§5) |
| printing | `flan_rt.c` + `flan_dev.c` — **two on one side** | `flan_dyn.c` | typed side duplicated (§9) |
| hashing | `flan_hash_*` | absent | clean, at risk in M2 (§9) |
| sorting | `sort`, `sort-by`, **`sort-bytes`** | absent | typed side half-duplicated (§9) |
| compile-time type errors | `Types.equal` + literal adoption | none at all | the SBCL question (§10) |
| C interop | `declare`/`declare-c` | refused, by name (`check.ml:6082`) | clean |
---
## B. The genuine conflicts, ranked, each as one question
1. **A `defstruct` may hold a `dyn` field, and nothing roots it once the frame that built it returns — should the field be refused until the per-type descriptor exists, the way a condition's field already is?** (§4; `check.ml:1976-2005` refuses one case, `box`'s `Named` arm at `:1450` permits the rest; `emit f3.flan` shows the single root popped at `ret`.)
2. **What produces a `nil`, and what does a `nil` do at a typed boundary — trap, or become `None`?** (§1; `check.ml:1436-1439` describes a producer that does not exist.)
3. **Does `with-allocator` around a dyn construction refuse, warn, or stay silent?** (§7; the explicit spelling is refused at `check.ml:4096`, the idiomatic one is accepted at probes `b4`/`c5`.)
4. **Should typed `=` and `is-ordered` grow the `string` case, given `keyable` already says `string` has an equality and `flan_hash_str` already implements it?** (§2; `types.ml:156` vs `types.ml:168`.)
5. **Can a dyn text become a typed `string` — by copy into a named allocator, by borrow until the next collection, or not at all?** (§6; `check.ml:1450-1474` has no arm.)
6. **Is `defclass` the dyn side with named slots and dispatch, sharing one heap — or a third memory model?** (§8; if the former, classes are gated on the dyn map, not on `Handle` as `plan.org:192` says.)
7. **Is a dyn `if` strict-bool because that was chosen, or because `need_bool` was the arm that existed?** (§1; `plan.org:908-910` calls the question moot and it is not.)
8. **Does `dyn_fold` refuse an operand whose pre-box type no arm of the operator accepts — SBCL's empty-intersection case, with no inference added?** (§10; the type is in hand at `check.ml:3414`.)
9. **What is M2's object-kind budget: `OBJ_MAP`, a keyword kind, both — and does a dyn set become a dyn map to unit?** (§5; four spare tags, `SPIKE-DYNAMIC.md:57`.)
10. **Does the doctrine's "no annotations" concede the mandatory return slot permanently, or is `SPIKE-INFERENCE.md`'s return-type inference the way back?** (§0.)
---
## C. Same-side duplications to retire
1. **`vendor/edn/read.flan`'s `Value`** — a tagged dynamic value hand-written on the typed side, with its
own structural `value=?` (`:99`), `items=?` (`:116`), `sets=?` (`:128`), `tables=?` (`:145`). One
capability, two implementations. **Retire — sequenced behind a dyn map and dyn keywords (§5), not
before.** `defedn`/`defjson` landing on the perf side is what makes the pair complete once dyn's half
is; `Value` is the leftover middle.
2. **The three escape tables and their number formatters** — `flan_rt.c:383`, `flan_dev.c:237` and `:551`,
`flan_dyn.c:314`, with three files each instructing the reader to change the others. The dyn copy has a
defence (§9). **The `flan_rt.c`/`flan_dev.c` pair does not, predates dyn, and should collapse to one
exported entry point the other two call.**
3. **`Types.keyable` vs `Types.is_equatable`** (`types.ml:156` vs `:168`) — two answers to "does this type
have an equality", disagreeing about `string`, one of them a `Map` implementation detail wearing a
language rule's name. **Unify** (this is question B4 by another route).
4. **`sort-bytes`** (`prelude.ml:1399`) — a hand-written `sort-by` that exists only because `[[u8]]` cannot
answer `is-ordered`. **Falls out** the moment B4 is answered; not worth touching on its own.
Not duplication, and listed so nobody retires it by mistake: a typed `Vec` printing as `<vec>` while a dyn
vec prints its elements (`SPIKE-DYNAMIC.md:334-338`); the dyn operators promoting `(+ 1 2.5)` while the
typed language has no implicit widening (`SPIKE-DYNAMIC.md:258-262`); and `flan_dyn.c` being its own
translation unit with a strictly one-way dependency on `flan_rt.c` (`SPIKE-DYNAMIC.md:427-434`), which is
what makes `--no-gc` a file-selection change rather than a dead-code-elimination wish.
+572
View File
@@ -0,0 +1,572 @@
# The dynamic-value runtime: NaN-boxed values over a mark-sweep heap
Milestone 1 of dynamic-by-default. Unannotated code computes with values that carry their type at run
time; fully annotated code compiles to exactly what it compiled to before; and a build that wants
neither a tag nor a collector can be given one that has neither. This document is the runtime half —
`runtime/flan_dyn.h`, `runtime/flan_dyn.c`, and the C tests in `test/dyn_ops.c` driven by
`test/test_dyn.ml`. The compiler half — deciding which locals are dyn, emitting the root pushes,
enforcing `--no-gc` — is a parallel lane and is built against the ABI in the header, which is fixed.
Everything here is implemented and green. `dune test --force` includes `test_dyn`, which is 29 processes
and 0.42 seconds; `dune build @sanitize` covers the same C under ASan and UBSan.
---
## 1. Value representation: NaN-boxing
`flan_dyn` is `uint64_t`, one machine word, so a dyn local is a register or a stack slot and never a
struct whose return convention two backends would have to agree about. That much was fixed by the ABI.
What was open was the tagging scheme, and the two candidates are the usual pair.
**Low-bit tagging** puts the tag in the bottom three bits of a word and requires every payload to be
either a pointer to something aligned or an integer with three bits to spare. Its cost lands on floats:
an `f64` uses all 64 of its bits, so a low-bit scheme either boxes every float on the heap or steals the
mantissa's low bits and computes with a shortened double.
**NaN-boxing** does the opposite. A double is *itself* — the bit pattern is returned unchanged — and
everything else hides inside the quiet-NaN space, which IEEE 754 leaves as 2^52 unused encodings.
This codebase decides it. `f64` is a first-class type here, not a library afterthought: `sand.flan` runs
a physics loop, `calc-me.flan` is a float calculator, the prelude carries a hand-written `format-f64`
because `%g` was not enough, and `flan_rt.c` has three paragraphs on how to print a NaN. A representation
whose float path is the expensive one is the wrong representation for this language. Under NaN-boxing
`flan_dyn_from_f64` is a `memcpy` of 8 bytes and `flan_dyn_need_f64` is the same in reverse; neither
allocates, neither branches except on the NaN canonicalisation below.
The layout:
```
63 62..52 51 50..48 47..0
1 1...1 1 tag payload
^ sign ^ quiet
```
A word is *boxed* when `(v & 0xFFF8000000000000) == 0xFFF8000000000000` — sign bit set, exponent all
ones, quiet bit set. Bits 50..48 are three tag bits; bits 47..0 are the payload, which is exactly the
width of an x86-64 user-space pointer (48-bit canonical form, top 16 bits zero for user addresses).
Anything else is a double, read straight back.
Four box tags are used and four are free:
| tag | meaning | payload |
|-----|---------|---------|
| 0 | nil | 0 |
| 1 | bool | 0 or 1 |
| 2 | int | 48-bit two's complement, sign-extended on read |
| 3 | object | pointer to a `flan_obj` (text, vec, or a wide int) |
| 4–7 | unassigned | reserved for interop; see §7 |
### The negative-NaN collision, and why canonicalising is free here
Every NaN-boxing scheme has to answer for the doubles that are already negative quiet NaNs: their bits
are indistinguishable from a box. `flan_dyn_from_f64` answers it by mapping *every* NaN to the one
positive quiet NaN on the way in.
That is not a new rule invented for this file. `flan_rt.c`'s `flan_f64_to_bytes` already renders every
NaN as `nan` with no sign, and carries the argument at length: IEEE 754 does not specify the sign of a
NaN any operation produces, LLVM's constant folder and `divsd` disagree about `(/ 0.0 0.0)`, and the same
two-line program printed `nan` through one backend and `-nan` through the other. The runtime already
decided that a NaN's sign is not a fact about the arithmetic. This takes the same line one step further
and declines to *store* it. Nothing observable is lost: NaN is not equal to itself, so no comparison can
see which NaN it is, and the printer was already refusing to say. `-0.0` is untouched and keeps its sign,
which is tested.
### Integers: 48 bits inline, the rest on the heap
`i64` is first class here and 48 bits is not 64. The two honest options were a 48-bit integer with a
64-bit name, or a box for the overflow. This takes the box.
An `i64` in ±2^47 — that is ±140,737,488,355,327 — is inline: every array index, every byte count, every
timestamp in milliseconds until the year 6429, every value any program in the corpus computes. Outside
that range `flan_dyn_from_i64` allocates a `flan_obj` of kind `OBJ_INT` holding the full 64 bits. Both
shapes report `int` from `flan_dyn_tag`, compare and print identically, and round-trip through
`flan_dyn_need_i64`; nothing above the ABI can tell them apart. The cost is one range test on the
constructor and one branch in `dyn_int_value`, and the benefit is that a dyn `i64` is an `i64`.
`u64` has no constructor in this ABI, deliberately. Typed Flan distinguishes `u64` from `i64` — that is
why `flan_u64_to_bytes` exists beside `flan_i64_to_bytes` — and a `u64` above 2^63 has no dyn spelling in
milestone 1. It is an interop question (§7), not a representation one: the box already holds 64 bits and
a seventh tag would distinguish the signedness.
### Why not a two-word value
It would have made all of the above go away: a tag word and a payload word, no NaN games, no integer
boxing. It was not available — `typedef uint64_t flan_dyn` is in the fixed ABI — and it would have been
the wrong call anyway, for the reason `flan_rt.c`'s header gives about returning structs by value: the
emitted `.ll` would then have to agree with the platform's struct-return convention, which works on
x86-64 and silently does not on wasm32, and this project has two backends and a third target.
---
## 2. Heap objects
One header, three kinds, one singly-linked list of everything allocated.
```c
typedef struct flan_obj {
struct flan_obj *next; /* every object ever allocated, newest first */
uint8_t kind; /* OBJ_TEXT | OBJ_VEC | OBJ_INT */
uint8_t mark;
int64_t len; /* bytes of a text, elements of a vec */
union {
int64_t i; /* OBJ_INT */
struct { flan_dyn *items; int64_t cap; } v; /* OBJ_VEC */
} u; /* OBJ_TEXT's bytes trail */
} flan_obj;
```
- **Text** is immutable and length-prefixed, with the bytes inline after the header — one allocation per
string, and a length that is a count and not a NUL scan. `flan_dyn_from_bytes` copies, so a literal in
`.rodata`, a frame slot, or a slice about to be dropped are all legitimate sources. Nothing in the ABI
writes into a text; `flan_dyn_set_at` on one refuses by name rather than copying on write.
- **Vec** is mutable, with its elements in a plain `malloc` block hanging off the header rather than in a
GC object of their own. Growth is then a `realloc` rather than a copy this file writes, and the
elements are never reachable except through their vec, so giving them an identity would buy nothing
and cost a header. Their bytes are counted in the live figure and freed when the vec is swept.
- **Int** is the overflow case above.
`mark` is a byte in the header and not a bit in a side table. A side table is the right answer when the
sweep is the cost, and the sweep is never going to be the cost here — see §3.
There is no free list, no size class, and no interning. Two texts built from the same bytes are two
objects, which `test_dyn` asserts directly: equality is bytewise (§5) and a test that only compared
structurally would pass against an implementation that had quietly shared.
---
## 3. The collector
**Mark-sweep, precise, non-moving, stop-the-world, in about 120 lines.**
The author's constraint was explicit — *"the answer to 'I need more performance' will never be a faster
GC, it will be to type the whole program"* — so there is no generation, no card table, no write barrier,
no incremental phase and no reference counting. The design goal is that somebody can read the whole
collector in one sitting and believe it.
### Trigger
Collection happens inside `gc_alloc` and nowhere else. The policy is the plainest one that bounds the
heap:
```
if (gc_bytes + need > gc_next) collect();
...
gc_next = gc_bytes * 2; if (gc_next < gc_floor) gc_next = gc_floor;
```
Collect when this allocation would carry the heap past a limit; afterwards set the limit to twice what
survived, with a floor (1 MiB by default) so a program with a tiny live set does not collect every other
allocation. That gives amortised O(1) collection work per byte allocated, and a heap bounded at roughly
twice the live set plus the floor — which is the property `test_dyn`'s million-allocation case asserts,
as a bound rather than as an exact figure, because pinning the high-water mark would make a tuning change
a test failure.
`flan_gc_set_floor` is an ABI extension and exists for the tests: a megabyte of floor would make the
million-allocation case a megabyte of arithmetic before it proved anything. It recomputes the trigger
from the new floor rather than only raising it to meet it — raising alone left a heap that had been given
a *lower* floor still running to the old one, which is a bug this document's first draft had.
A vec's element array growing is a `realloc` and not a collection point, deliberately: the value being
pushed may not be rooted yet. The bytes are charged to the heap immediately so the trigger sees them at
the next real allocation, but nothing is swept in the middle of a push.
### Mark
An explicit worklist, not recursion. A vec of a vec of a vec is an ordinary dyn value and its depth is
the program's, not the runtime's; a recursive marker would put the heap's depth on the C stack and a long
enough chain would overflow it *during a collection*, which is the worst available moment. The mark stack
is grown on demand and kept between collections, so a steady program stops paying for it after the first.
`test_dyn`'s `nested` mode is a chain 64 deep and would notice a marker that stopped being iterative.
Only a vec has anything to trace; a text and a boxed int are leaves and marking them is the whole visit.
### Sweep
Walk the all-objects list, unlink and free anything unmarked, clear the mark on anything else, and
subtract the freed bytes. A vec's element array is freed with its header.
### What "precise" buys
The collector never guesses whether a word is a pointer, which matters more here than usual. NaN-boxing
makes conservative scanning wrong *in both directions*: a live double is bit-identical to a boxed pointer
often enough to retain garbage indefinitely, and a payload with the box stripped is not the pointer a
scanner would recognise. Precision is cheaper than the arguments about it.
---
## 4. Roots
No conservative stack scanning. The compiler emits `flan_dyn_root_push` per dyn local on entry and one
`flan_dyn_root_pop(n)` for the lot on the way out — shadow-stack style, exactly as `flan_dev.c`'s frame
chain works and for the same reasons its header gives: a pushed record needs no agreement with the
optimiser about what a frame looks like, is the same on wasm32 as on x86-64, and needs no `.eh_frame`
walk to borrow (this language lowers every non-local exit explicitly and has none).
The root stack holds *addresses*, not values, so a local that is reassigned needs no second push. It
grows on demand, because a deep recursion over dyn locals is an ordinary program and a fixed table would
be a limit nobody could predict. Dyn globals go through the same pair, pushed once at startup and never
popped.
**The one contract the compiler lane must honour: a slot must hold a valid `flan_dyn` before it is
pushed.** The collector reads every registered address on every mark, and an uninitialised slot is a word
of stack garbage that will be decoded. Storing `flan_dyn_nil()` at the declaration is the whole of it.
`flan_dyn_root_pop` clamps at empty rather than refusing. A pop that outruns its pushes means the frame
machinery is already out of step, and the useful thing at that point is a heap that still collects.
`flan_dyn_root_reset` is an ABI extension with one caller: the merged dev build's `main`, which is
re-entered by `longjmp` and therefore pops no frame, so every root the finished run pushed still points
into stack the next run is about to write over. It is the exact counterpart of `flan_dev_frames_reset`
and `flan_condition_stacks_reset`, which exist for the same `longjmp`.
### The temporaries ring, and the hazard it closes
"Collection triggers only inside `flan_gc_alloc`, so between pushes nothing moves" protects against
*relocation*. It does not protect against *freeing*, and mark-sweep frees whatever is unreachable — which
includes an object allocated a moment ago and not yet stored anywhere the collector looks. Concretely:
```c
flan_dyn_push(v, flan_dyn_add(flan_dyn_from_bytes(p, n), other));
```
Two allocating calls in one expression. C leaves argument evaluation order unspecified, so this is not
even a window a careful emitter could close by ordering its calls.
The answer is the smallest one that does not depend on another agent having read this document: **every
object `flan_dyn.c` allocates is written into a fixed 64-slot ring, and the marker roots the whole ring
unconditionally.** Any expression making at most 64 allocations before rooting its result is safe. The
cost is one store and a masked increment per allocation, plus up to 64 objects of float in the heap,
which the trigger absorbs because the trigger is a fraction of live bytes and not a count. There is no
ABI change and no contract for the compiler lane to get wrong.
The visible consequence, and the only one: after a full collection up to 64 unreachable objects are still
alive. `test_dyn`'s `unrooted` mode asserts exactly that — "reclaimed all but the ring" — rather than
pretending the figure is zero.
An expression with 65 allocating calls and no intervening root would be a single Flan form with 65
constructors in it. If that ever exists, the compiler will have rooted its intermediates and the ring is
belt on top of braces.
---
## 5. The operations, as implemented
### Arithmetic — `+ - * / %`
Two ints answer an int; a float anywhere answers a float; anything non-numeric traps.
**The promotion is the one place dyn is more permissive than the typed language, and it is deliberate.**
Typed Flan has no implicit widening anywhere — `(print-i64 x)` used to force an explicit `(i64 x)` at
every site, and the prelude's history is full of that. But `(+ 1 2.5)` has exactly one sensible answer,
and a dynamic language that refuses it is not dynamic in any useful sense. A program that wants the
refusal annotates, which is the whole bargain of dynamic-by-default.
Integer division and remainder by zero trap, matching typed Flan, which signals `ArithError` and dies
with "divide by zero" if nothing handles it. `INT64_MIN / -1` gets its own sentence for the reason
`flan_rt.c` gives it one. The float cases are left to IEEE: `1.0/0.0` is `inf` and that is an answer.
Float `%` is computed by truncated division rather than by calling `fmod`, which would pull `math.h` into
this translation unit for one operator; the quotient is range-checked first, because casting a double
past 2^63 to an integer is undefined rather than merely wrong.
### Ordering — `< <= > >=`
Numbers against numbers (promoting across the two tags), text against text bytewise, and nothing else.
Bytewise means `memcmp` with the shorter operand first on a tie — the order a sort of `(Vec string)`
wants, and the only one needing no locale and no collation table.
A number against a text traps. The temptation is to order by tag so that everything is comparable and
sorting never fails; the reason not to is that the resulting order is an artefact of this file's tag
numbering, and a program sorting a mixed vec would get a stable answer that means nothing.
NaN is unordered in all four directions, which is what IEEE says and what `test_dyn` asserts.
### Equality — `=`
Structural, and **the only operation here that never traps**: two values of unrelated tags are unequal,
which is an answer. Making it an error would mean a dyn program could not ask "is this the string I
expected" without first checking that it is a string at all.
- Numbers compare by value across the two tags: `(= 1 1.0)` is true.
- Text is **bytewise, not by identity**. `flan_dyn_from_bytes` copies and does not intern, so two texts
built from the same bytes are two objects; the only defensible equality for an immutable byte string is
its bytes. Embedded NULs are compared like any other byte — the length is a count, not a `strlen`.
- A vec is equal element by element, with an identity shortcut first.
- NaN is not equal to itself.
Cycles: `flan_dyn_set_at` lets a vec contain itself, so the recursion is capped at 64. Past the cap two
vecs are equal only if they are the same vec, which terminates and answers correctly for the case that
actually arises — a cycle compared against itself. Two *distinct* cyclic vecs of the same shape answer
false, which is a wrong answer to a question nobody has asked yet; the honest fix is a visited set and it
can be added the day somebody needs it.
### `length`, `at`, `set-at`, `push`
`length` is bytes of a text or elements of a vec. `at` is an element of a vec or a byte of a text as an int
— which is what `(at s i)` on a `(Slice u8)` does in the typed language; codepoints are `utf8`'s job and
stay there. `set-at` and `push` are vec-only, and a `set-at` on a text says *"a text is immutable — build
another one"* rather than quietly copying.
An index that is not an int is a different sentence from a container that is not indexable, because
`(at v "1")` and `(at 3 1)` are different mistakes and one message naming both would name neither.
### `prin`
Structural, and per tag it renders what typed `prin` renders. The reference forms were **captured from a
running program**, not read off `lib/render.ml` — that file is the REPL's inspector and not necessarily
`print`'s expansion:
| tag | rendering | example |
|-----|-----------|---------|
| int | `%lld` | `42` |
| float | `%g`, NaN unsigned | `3.5`, `1`, `nan` |
| bool | the word | `true` |
| text | bare at the top level | `hi` |
| text | quoted and escaped inside a structure | `"a b"`, `"q\"\n"` |
| vec | a slice's spelling | `[ 1 2 3]` |
| nil | — | `nil` |
The leading space before every element is what `lib/render.ml`'s slice loop emits and what a Flan program
prints today; a tidier answer would diverge from an acceptance test. The escape table is the dyn side's
own copy of the typed side's. The typed side used to have two and now has one — `flan_escape_char`
(`runtime/flan_rt.c:388`), which `flan_escape_bytes` calls for `print` and `flan_dev.c`'s `put_str`
calls for the inspector, each framing the result its own way — so there are two tables in the tree, not
three, and the split that is left is the dyn/typed one argued just below. If that table changes, change
this one too.
**A typed `Vec` prints as `<vec>` and a dyn vec does not.** That looks like a mismatch and is not. The
typed printer's refusal is about walking storage it does not own — `render.ml` says so, and directs you to
`(prin (slice v))`, which borrows explicitly at the call site. A dyn vec's storage belongs to the
collector, the printer is inside the runtime that owns it, and there is nobody to ask permission from. So
a dyn vec prints the way a *slice* does, which is also the only rendering that carries any information.
`prin` has a depth cap of 16. A typed value cannot contain itself, so the typed printer needs no
run-time cap; `set-at` makes a dyn vec that can, so this one has one, and past it prints `...`, which is
the mark `render.ml` uses for the same idea.
---
## 6. The typed boundary
`flan_dyn_need_i64`, `_need_f64`, `_need_bool` are what an annotated parameter does with a dyn argument
and what a dyn expression does where the checker wants a machine value. **There is no widening and no
coercion at the boundary**: `flan_dyn_need_f64` of an int traps.
That is the asymmetry worth defending, because the operators *do* promote (§5). The two are different
questions. An operator has no stated expectation to violate — `(+ 1 2.5)` was written by somebody who
wanted a number and there is one obvious number. An annotation *is* a stated expectation, written down by
a person, and quietly turning their `i64` into an `f64` would make the boundary the one place in the
language where a *value* changed type without anybody writing it. Typed Flan does not widen a value:
```lisp
(defn g [x f64] f64 x)
(defn h [y i64] f64 (g y)) ; scratch.flan:2:24: expected f64, found i64
```
### The one divergence the compiler lane has to know about
Typed Flan does not widen a *value*, but a **literal adopts its expected type**. `(g 1)` compiles and
prints `1`, because the checker gives the literal `1` the `f64` the parameter asks for; `(vec-new i64)`
followed by `(push v 1)` works the same way.
A dyn value has no such history. Once `1` has been through `flan_dyn_from_i64` it is tagged `int`, and
nothing downstream can recover that it was written as a literal in a position that wanted a float. So:
```lisp
(g 1) ; typed: compiles, prints 1
(g some-dyn) ; dyn, where some-dyn came from the literal 1: traps, "a float was wanted"
```
That is a real divergence between the same source read as typed and read as dyn, and it is stated here
rather than discovered later. Two ways out, both the compiler's and neither this file's:
1. **Tag the literal at the constructor.** Where the checker can see that an unannotated literal flows to
a float context, emit `flan_dyn_from_f64` instead of `flan_dyn_from_i64`. This is the same inference
the checker already performs for typed literals and it keeps the boundary sharp.
2. **Emit a conversion at the call**, where both sides are visible, rather than softening
`flan_dyn_need_f64` — which sees only a tag and could not tell a literal-derived int from a computed
one.
Softening the boundary itself is the option not to take: it would accept `(g (length xs))` as readily as
`(g 1)`, and those are not the same mistake.
---
## 7. Interop, which is out of scope for M1 and not precluded
A typed `(Vec i64)` crossing into dyn code, without copying, is milestone 2. The layout leaves room for
it in three specific ways.
**Four spare tags.** Bits 50..48 give eight box tags and four are used. Tag 4 is the natural home for a
*typed handle*: payload = pointer to a typed object, with the element type recovered from a side table or
from a word in the object's own header. Tag 5 can carry `u64` above 2^63, which is the other value typed
Flan holds and this ABI currently cannot name.
**The object header is not the only thing the collector can trace.** `mark_push` dispatches on `kind`,
and a fourth kind whose tracer is "call a function pointer in a descriptor" is a local change — a handle
to a typed `(Vec Enemy)` would need the collector to know which fields of `Enemy` are dyn, and a
per-type descriptor emitted by the compiler is how that arrives. Nothing in the current layout has to
move for that: the `union` grows an arm.
**A typed vec is not a `flan_obj` and does not need to become one.** The interop that matters is the
cheap direction — a typed vec is `{ptr, len, cap}` in memory the *arena* owns, and a dyn handle to it
should be a borrow, not a copy. That means the collector must be able to trace *into* memory it did not
allocate and must never free it. The `kind` byte is what distinguishes "I own these bytes" from "I am a
window onto someone else's", and the sweep already branches on it.
What is genuinely open, and is stated here so the M2 lane does not discover it late: **a typed vec's
lifetime is its arena's, and the collector has no say in it.** A dyn value outliving the arena its handle
points into is a dangling read that the current design cannot detect. The allocation registry in
`flan_dev.c` already answers exactly that question for `Ptr` — `render.ml`'s pointer arm follows a live
one and mourns a dead one — so the shape of the answer exists. It is not built here.
---
## 8. Dropping the collector
`--no-gc` enforcement is the compiler lane's. This lane's contribution is that **it is possible**, which
is a property of the build and not a wish:
- `flan_dyn.c` is its own translation unit and always has been. It is compiled from
`Runtime_src.dyn_source` in `lib/build.ml` (both `executable` and `macro_module`) and in `lib/dev.ml`,
beside `flan_rt.c` and `flan_dev.c` and never merged with them.
- **The dependency runs one way only.** `flan_dyn.c` calls `flan_write_stdout` and `flan_trap` in
`flan_rt.c`. Nothing in `flan_rt.c` or `flan_dev.c` names a symbol in `flan_dyn.c`. One
back-reference — `flan_rt_init` calling `flan_gc_init`, say, which was the obvious thing to write —
would make the collector unconditional and the refusal a lie, so the heap initialises itself lazily on
first allocation instead.
**The linker does not drop it today, and this document will not claim it does.** `flan_dyn.o` is a named
object on the link line, not an archive member, and `ld` includes a named object in full — symbol-driven
selection is a `.a` rule. Dead-code elimination *inside* an included object needs
`-ffunction-sections -fdata-sections -Wl,--gc-sections`, which the link line does not pass. Measured
rather than assumed: build any corpus program and `nm` it, and `flan_dyn_add` and `flan_gc_collect` are
both there as defined symbols.
What the one-way dependency buys is the better mechanism anyway. The object is selected at the **file**
level: `--no-gc` is a one-line change at each of the three sites that name `Runtime_src.dyn_source` — the
same per-target selection `select_csrcs` already performs for a package's C — and once it is not
compiled, nothing has to be dropped. That only works because nothing else in the runtime refers to it;
`flan_dev.c` is compiled into every build today precisely because it *is* referred to (a package's C
names it, and package sources are collected whatever `main` does), and its own comment says so at length.
`flan_dyn.c` has no such entanglement, and keeping it that way is the constraint this section exists to
record.
The residual-dynamism refusal itself — deciding that a program is *not* fully annotated and saying which
form is not — is the compiler's, and is not attempted here.
---
## 9. Cost sketch, per operation
No microbenchmarks; these are counts of what the code does.
| operation | cost |
|-----------|------|
| `from_f64`, `need_f64` | one 8-byte copy; one compare for the NaN canonicalisation |
| `from_i64` | one range compare, then a mask and an or — or an allocation past ±2^47 |
| `need_i64` | tag test, then a shift pair to sign-extend |
| `from_bool`, `nil`, `need_bool` | mask and or; tag test |
| `tag` | one mask-compare, one shift, and a load for the object case |
| `add`/`sub`/`mul` | two tag tests, the arithmetic, one `from_i64` (which may allocate at the extremes) |
| `div`/`rem` | the above plus a zero test and an overflow test |
| `lt`/`le`/`gt`/`ge` | two tag tests and a compare; text is `memcmp` |
| `eq` | word compare first; then tags, then bytes or elements — O(size) at worst |
| `length` | tag test and a load |
| `at` | two tag tests, a bounds compare, a load |
| `set_at` | the same, plus a store; **no write barrier**, because there is no generation to have one for |
| `push` | tag test, a capacity test, amortised O(1); the doubling is a `realloc` |
| `prin` | O(size), streamed, no allocation |
| `root_push` / `root_pop` | a store and an increment; a `realloc` when the stack doubles |
| allocation | a trigger compare, a `malloc`, a header init, a ring store |
| collection | O(live) to mark, O(all) to sweep; amortised O(1) per byte allocated |
The shape to take away: **every operation is a handful of instructions plus whatever the arithmetic
costs, and the only unbounded ones are `eq`, `prin` and collection itself.** If that is not fast enough,
type the program.
---
## 10. ABI extensions
The header in `runtime/flan_dyn.h` is the agreed ABI with nothing removed and no signature changed.
Seven things are added; none is something the compiler lane must emit.
| addition | why |
|----------|-----|
| `flan_dyn_root_reset()` | the merged dev build's `longjmp` re-entry, exactly as `flan_dev_frames_reset` |
| `flan_dyn_tag()` | the break loop and the inspector; the first question anyone asks a stopped dyn value |
| `flan_dyn_tag_name()` | the same table the trap messages are written from, so a message and an inspector cannot disagree |
| `FLAN_DYN_TAG_*` | the numbers `flan_dyn_tag` answers with |
| `flan_gc_count()` | the tests: a live-bytes figure alone cannot tell a collecting heap from one with small objects |
| `flan_gc_set_floor()` | the tests: a 1 MiB floor makes the million-allocation case slow and uninformative |
| `flan_trap()` in `flan_rt.c` | a thin exported wrapper over the existing static `rt_trap`, so a dyn type error takes the *same* path as `flan_bounds_fail` and the other six rather than re-implementing the hook, the flush, the socket and `_exit(134)` |
One more thing the compiler lane should know about, which is a build fact rather than an ABI addition:
**`Build.compile_c` now drops `runtime/flan_dyn.h` into the directory it compiles each translation unit
in**, so a package's C — or an emitted shim — can `#include "flan_dyn.h"`. `flan_dyn.c` itself does not
include it and declares its own prototypes, because the object cache is keyed on source text and a header
edit would serve an object compiled against the previous one. The two copies are kept honest
mechanically: `test/dyn_ops.c` includes the header and names every function in it, so a divergence is a
compile or link error in `dune test`.
---
## 11. Traps
A dyn type error is a trap and not an abort: it takes `flan_trap`, which calls `flan_trap_hook` if one is
installed and then `rt_die`. In a dev session the hook parks the program for inspection; in a standalone
build nothing installs it and the program dies where it stands. The sentence is the same either way,
which is the point of routing through the hook rather than calling `abort` in the runtime.
The sentence names the operation, both tags as **words**, and both values:
```
dyn +: int and text, and it takes two numbers — (+ 3 "hi")
dyn at: index 9 is out of bounds for text of length 2 — "hi"
dyn f64: int, and a float was wanted — (f64 1)
```
Tag names are always words — `int`, `float`, `text`, `vec`, `nil`, `bool` — and never numbers. They come
from one table that `flan_dyn_tag_name` also reads. Values are rendered into a bounded stack buffer with
a depth cap of 2, truncated rather than allocated: a trap is the one moment when allocating would be a
second thing to go wrong.
Four trap names, because they are four different mistakes and somebody standing in one wants to know
which without reading the sentence twice:
| name | when |
|------|------|
| `DynType` | a tag that is not what the operation wanted |
| `DynRange` | an index outside a vec or a text |
| `DynArith` | division by zero, or the one quotient that overflows |
| `DynHeap` | an allocation the host refused |
The bounds case does **not** signal `BoundsError` the way typed indexing does, and that is a limitation
rather than a preference: `flan_bounds_error` takes a `loc` and a transfer channel, and a dyn operation
has neither in its hands. It is the same reason the six traps in `flan_rt.c` park rather than signal. If
the compiler lane threads a `loc` through — it knows one at every call site — the dyn bounds case can
join the condition system unchanged, and that is the obvious next increment.
---
## 12. What is tested
`test/dyn_ops.c`, driven by `test/test_dyn.ml` against `test/programs/dyn-host.flan` — a C main linked
against a Flan program with no `main`, the arrangement `dev_limits.c` and `reload_host.c` already use,
because these are C entry points with no Flan spelling. One binary, 29 runs, 0.42 s.
| mode | what it establishes |
|------|--------------------|
| `ops` | every operation's happy path; every tag and its word; f64 round trips at `0`, `-0.0`, `1e308` and NaN; i64 at both inline edges, both boxed edges, `INT64_MIN` and `INT64_MAX`; arithmetic crossing into the box; NaN unordered in all four directions; text identity vs equality; embedded NULs; the empty text; vec growth past its initial capacity; a vec that contains itself, compared and printed; every printed form as an exact string |
| `gc` | a million allocations against a hundred rooted values; heap high-water under 512 KiB where a non-collecting heap would hold ~40 MiB; the live set intact afterwards |
| `unrooted` | **the positive control** — an object nothing points at is reclaimed. Without it every other case would pass against a collector that never freed |
| `nested` | a chain of vecs 64 deep traced through one root, across real collections |
| `sharing` | one vec in three slots: identity (not just equality), a write through one path read through another, survival when the direct root goes, and a single sweep when the last reference does |
| `refuse:*` | 24 refusals, one process each, asserted on the sentence as well as the exit status — a process that died some other way is not the guard firing, and the code cannot tell them apart |
`dune build @sanitize` runs the same C under ASan and UBSan. That sweep matters more here than anywhere
else in the corpus: every other program in it allocates and never frees, which is a policy ASan can only
agree with, and this one frees — a mark-sweep collector is precisely a machine for freeing something that
is still reachable. A use-after-free is what a wrong marker looks like from outside, and it is invisible
to the assertions above, because the freed bytes are usually still the bytes that were there. Leaks stay
off, as they are for the whole file: the temporaries ring holds 64 objects alive at exit on purpose, and
every one of them would be reported.
+24 -11
View File
@@ -1,5 +1,17 @@
# The generics spike, answered: it runs, and the bill lands on the dev loop rather than on the checker
> **This is the spike report and it stopped being current when generics landed for real, on 2026-09-13.**
> It is kept because the measurements and the reasoning behind the design are still the ones that were
> acted on, but it describes a branch where `lib/prelude.ml` and the backends were untouched, and they
> are not any more: the prelude's per-type families collapsed into one function each. Two things it says
> have since been overtaken and would mislead anyone writing code from it. Its account of plan.org is out
> of date — plan.org now specifies `$t` itself rather than lowercase-with-no-sigil. And the spelling rule
> below is narrower than what shipped: `$t` is written wherever a *type* goes, including in a return type
> and nested inside `[$t]` or `(Option $t)`, and bare `t` only where a type's *name* is an argument in
> expression position, as in `(vec-new t)` and the cast `(t x)`. `(Option t)` does not compile.
> plan.org's Types section and spec-memory.md's Generics section are the current account.
> `spike/`, which held every file this report names, was deleted on 2026-09-25; the files are in git history.
Milestone 5's parametric polymorphism, run early and deliberately out of order, as a spike rather than as a
decision. **Feasible, and smaller than expected.** A generic function written in Flan goes through the ordinary
frontend, is instantiated at each concrete type its call sites ask for, is emitted as real functions and runs,
@@ -53,7 +65,7 @@ Yes, at four shapes, all of them run and checked against their output.
```lisp
(defn id [x $t] t x)
(defn main [] ()
(println (id 3)) (println (id 4.5)) (println (id 7)) (println (id true)))
(print (id 3)) (print (id 4.5)) (print (id 7)) (print (id true)))
```
prints `3 / 4.5 / 7 / true`, and emits exactly three bodies — `flan.id-i32`, `flan.id-f64`, `flan.id-bool`. Four
@@ -68,11 +80,11 @@ is the `is_polymorphic_type_assignable` walk rather than a name match.
```lisp
(defn swap! [xs [$t] i i32 j i32] () ...)
(defn sort-by! [s [$t] before? (Fn [$t $t] bool)] ()
(defn sort-by! [s [$t] is-before (Fn [$t $t] bool)] ()
(let [i 1]
(while (< i (len s))
(while (< i (length s))
(let [j i]
(while (and (> j 0) (before? (at s j) (at s (- j 1))))
(while (and (> j 0) (is-before (at s j) (at s (- j 1))))
(swap! s (- j 1) j)
(set j (- j 1))))
(set i (+ i 1)))))
@@ -211,15 +223,16 @@ calling them — dead, not stale.
## Question 4 — what the whole feature would have to lower
Against what a generic function has to survive, in DISCUSS.md item 15's buckets:
Against what a generic function has to survive under TODO.org, "Generics by monomorphisation, checked abstractly,
with where predicates", in buckets:
| | |
|---|---|
| **Done in the spike** | one or more type variables in a signature; binding through `[t]`, `(Ptr t)`, `(Option t)`, `(Fn [t] t)` and nesting; return types mentioning a variable; left-to-right binding with substitution into later parameters so an `fn` literal gets its types; the Odin-keyed instantiation cache; generic calling generic, transitively; `(vec-new t)`; the abstract refusal pass for `+`, `-`, `*`, `/`, `%`, `=`, `!=`, `<`, `<=`, `>`, `>=`, the bitwise operators and the shifts; instantiations as ordinary `Tast.fn`s with dev cells and `Reach` edges for free; a depth cap so runaway instantiation refuses instead of hanging |
| **Mechanical** | the remaining builtins that take a *type name* as an argument — `pool-new`, `map-new`, `zeroed`, `uninit`, the casts — each of which reaches `type_named`/`resolve_name` by its own path, exactly as `vec-new` did (one line there fixed `vec-new`; the others are one line each); `hash` and the map key-pair path, which must refuse a `Var` rather than assume; the `fns` expansion in `session.ml` described above |
| **Mechanical** (all three have since landed, and `test/programs/generics.flan` exercises the first two; `pool-new` was removed with the pool) | the remaining builtins that take a *type name* as an argument — `pool-new`, `map-new`, `zeroed`, `uninit`, the casts — each of which reaches `type_named`/`resolve_name` by its own path, exactly as `vec-new` did (one line there fixed `vec-new`; the others are one line each); `hash` and the map key-pair path, which must refuse a `Var` rather than assume; the `fns` expansion in `session.ml` described above |
| **Bulky, not hard** | error messages that say *where* an instantiation came from — Odin's "in instantiation of" note. Today a refusal inside an instantiated body points at the generic's source with no indication which call site asked for that type, and with three or four instantiations that is the difference between a readable refusal and a puzzle. It is a context stack in `ctx` and a `Loc.note` per frame, and it touches every `fail` under an instantiation |
| **Fiddly** | move-only and ownership. `Types.is_move_only (Var _)` is false, but the same variable at `(Vec i32)` is move-only — so the abstract pass **cannot decide ownership at all**, and the dead-set analysis is only sound per instantiation. Today that means a generic body that moves its parameter type-checks abstractly and is caught, if at all, at one instantiation and not another. The rule has to be stated: either ownership is checked only per copy (and the abstract pass skips it, so a generic may be accepted and its instantiation refused), or type variables carry a move-only constraint, which is a constraint system and plan.org says not yet. One smaller thing in the same bucket, found and left alone: the abstract pass over a generic body that calls *another* generic at a concrete type generates that copy and keeps it, so plain `flan emit` can carry a body no call site asked for. It is a valid instantiation and `Reach.link` drops it, so `flan build` and `flan run` are unaffected — but the abstract pass is meant to leave nothing behind and this is the one thing it does |
| **No plan** | (1) **Unbounded instantiation.** `(defn grow [x $t] () (grow [x x]))` asks for a copy at `[2 t]`, which asks for one at `[2 [2 t]]`, forever. Before the cap it did not fail, it *hung* — and since `Session.eval` runs this same code, the thing that hangs is `C-c C-c`, with the dev daemon wedged behind it and no error to show. That is the project's stated priority hanging on three lines of ordinary-looking Flan, so the spike stops it: a depth counter in `env`, refusing past 32 and naming the type it had reached (`spike/generics/runaway.flan`). **The number is arbitrary and the designed refusal — one that names the chain of instantiations rather than the depth it gave up at — is still open.** **Odin has no cap of its own**, so there is no implementation to copy. (2) **Generic structs and containers.** `Types.Named` is a bare string with no parameters, so `(defstruct Pair [a $t b $t])` cannot be spelled at all — a parameterised named type is a change to `Types.t` and therefore to every backend, `Render`, DWARF and the layout calculator. (3) **`println` over a type variable.** plan.org's one compiler-provided exception; the abstract pass rejects it (`no printer for t`), see question 6. (4) **Generics across packages.** `Load` flattens imports into one namespace before checking, so it happens to work here, but a package boundary that is ever a real compilation-unit boundary would need the generic's *body* to cross it — the thing separate compilation cannot do and the reason C++ puts templates in headers |
| **No plan** | (1) **Unbounded instantiation.** `(defn grow [x $t] () (grow [x x]))` asks for a copy at `[2 t]`, which asks for one at `[2 [2 t]]`, forever. Before the cap it did not fail, it *hung* — and since `Session.eval` runs this same code, the thing that hangs is `C-c C-c`, with the dev daemon wedged behind it and no error to show. That is the project's stated priority hanging on three lines of ordinary-looking Flan, so the spike stops it: a depth counter in `env`, refusing past 32 and naming the type it had reached (`spike/generics/runaway.flan`). **The number is arbitrary and the designed refusal — one that names the chain of instantiations rather than the depth it gave up at — is still open.** **Odin has no cap of its own**, so there is no implementation to copy. (2) **Generic structs and containers.** `Types.Named` is a bare string with no parameters, so `(defstruct Pair [a $t b $t])` cannot be spelled at all — a parameterised named type is a change to `Types.t` and therefore to every backend, `Render`, DWARF and the layout calculator. (3) **`print` over a type variable.** plan.org's one compiler-provided exception; the abstract pass rejects it (`no printer for t`), see question 6. (4) **Generics across packages.** `Load` flattens imports into one namespace before checking, so it happens to work here, but a package boundary that is ever a real compilation-unit boundary would need the generic's *body* to cross it — the thing separate compilation cannot do and the reason C++ puts templates in headers |
The "no plan" row's first entry is the one to take seriously: it is not a missing feature, it is a hang, and it is
reachable from three lines of ordinary-looking Flan.
@@ -261,7 +274,7 @@ Counted by name, the 27 of the family I could account for split **13 / 9 / 5**:
`map-i32!` `map-f32!`, `reduce-i32` `reduce-f32`, `filter-i32` `filter-f32`, `sort-i32-by!` `sort-f32-by!`.
- 9 collapse into 4 but change their signatures and every call site — `sort-i32!` `sort-f32!` `sort-bytes!`,
`index-of-i32` `index-of-byte`, `min-i32` `min-f32` `max-i32` `max-f32`.
- 5 do not collapse — `sum-i32` `sum-f32`, `append!` `append-i64!` `append-f64!` — because they are not one
- 5 do not collapse — `sum-i32` `sum-f32`, `small-append` `append-i64!` `append-f64!` — because they are not one
function written twice; they are functions that happen to rhyme.
So the net is on the order of **27 → 15**, not 27 → 6, and the prelude keeps a per-type layer for the numeric
@@ -277,12 +290,12 @@ a type without `+`. Getting plan.org's rule instead requires a second pass that
substituted, which is `check_generic` in this spike: one extra whole-body check per generic `defn`, discarded.
It is cheap and it is worth it, but it is *not* Odin's model and the plan should stop saying it is.
**2. The abstract pass rejects `println` over a type variable**, and plan.org names `println` as "the one
**2. The abstract pass rejects `print` over a type variable**, and plan.org names `print` as "the one
compiler-provided exception: it selects a structural printer at each concrete instantiation". Those two statements
cannot both hold as written: a pass that decides an operator's legality without substituting cannot make an
exception for the one operator whose legality is only decidable after substituting. `(defn show [x $t] () (println
exception for the one operator whose legality is only decidable after substituting. `(defn show [x $t] () (print
x))` gets `no printer for t` today. The resolution is small but it is a decision, not an oversight: the abstract
pass needs an explicit allow-list of forms it defers to instantiation, `println` being the first member. Every
pass needs an explicit allow-list of forms it defers to instantiation, `print` being the first member. Every
member of that list is a place where a refusal moves from the definition to a call site, which is exactly what
plan.org's rule was trying to avoid — so the list should stay short and be written down.
+500
View File
@@ -0,0 +1,500 @@
# The inference spike, answered: the concern was right, and not for the reason it was given
> **Nothing was built.** SPIKE-GENERICS.md is measurement-heavy because a branch existed to measure; this is a
> reading of `lib/check.ml`, `lib/session.ml`, `lib/parse.ml` and the specs, with small probe programs where a
> probe settles something a reading cannot. The one number quoted below — 1.8 ms for a whole-program check — is
> SPIKE-GENERICS.md's, and the delta an inference fixpoint would add to it **was not measured**. Every other
> claim is cited to a line.
The question: can a `defn`'s type annotations become optional, inferred from the body and its uses, without
breaking the live dev loop. The author wants `(defn settle [row col] ...)` to check while sketching.
**The answer is: don't, in that form — and the obstacle is not the dev loop.** The live loop handles a changed
inferred signature correctly the moment inference is body-local; `Session.compatible` catches it by construction
and says so in the right words. What stops `(defn settle [row col] ...)` is three things that were each decided
on purpose, for reasons written down at the time, and every one of them predates this question:
1. **The grammar.** `(defn settle [row col] ...)` is not a syntax error today. It **parses**, as one parameter
`row` of type `col`. The spelling the author wants is the one spelling that provably cannot be given the
meaning they want.
2. **Literal defaulting.** `(+ x 1)` does not say what `x` is. It says `1` is an `i32` unless something above it
says otherwise, and under inference the "something above it" is gone. Inferring `x := i32` from that is not
inference; it is a default wearing inference's clothes, and it moves its error to a call site.
3. **The generic reading is refused at the definition.** `(defn settle [row col] (+ row col))` read as generic
gets plan.org's rule applied to it, which is the refusal SPIKE-GENERICS.md quotes verbatim: *`+` over the type
variable `t` is refused*. Reusing `check_generic` does not buy inference; `check_generic` is the thing that
rejects the example.
What *is* available, cheaply, with a precedent already in the file, is **return-type inference only**. That is
the scoped recommendation and its boundary is drawn at the end.
## What the previously-voiced concern got right, and what it got wrong
The concern, paraphrased: live re-evaluation of functions makes inference difficult.
**Wrong about the mechanism.** `Session.eval` re-checks the *whole* program on every `C-c C-c` —
```ocaml
(* session.ml:586 *)
let program, env = Check.program_with_env decls in
compatible ~origin:(Check.instantiation_origin env) ~loc t.program program;
```
— and `compatible` (session.ml:181) compares the new `Tast.program` against the installed one over
`Tast.fn`'s `params` and `ret`, which are types and not source text:
```ocaml
(* session.ml:191 *)
let same =
List.length f.Tast.params = List.length g.Tast.params
&& List.for_all2 Types.equal f.Tast.params g.Tast.params
&& Types.equal f.Tast.ret g.Tast.ret
in
```
It does not know or care where a type came from. An inferred signature that changes is caught exactly as a
written one is, and the refusal already prints both tuples (session.ml:239): *"%s changes signature, from
(Fn [...] ...) to (Fn [...] ...); the calls already compiled into the running program pass the old one. Restart
to change it."* There is no lock to build and no "inferred at first definition, frozen thereafter" state to
keep: the whole-program re-check means the signature is recomputed from the current text every time, so it is a
pure function of what is in the buffer, and `compatible` is the diff. **The dev loop's machinery is already
sufficient.**
**Right about the conclusion, via a mechanism that has a name.** The soundness above holds *only while the
inferred signature depends on nothing but the edited function's own body.* The moment inference reads call
sites, the whole-program re-check turns from an asset into the hazard:
- Editing function `A` changes the types `A` passes to `B`.
- `B`'s signature is inferred from its uses, so `B`'s signature changes.
- `compatible` refuses, naming `B` — a function that appears nowhere in the form the author just evaluated —
with two type tuples they never wrote, for a reason not visible at the line they edited.
That is not a hypothetical failure mode; it is the failure mode `session.ml`'s `?origin` parameter already
exists to soften for generics, and the comment there describes it in exactly these terms:
```ocaml
(* session.ml:200 *)
The refusal then arrives about [sort!-i32], which appears
nowhere in the file being edited, for a reason invisible at the
edited line.
```
For a generic there is at least an answer to give — *this is the copy of the generic `sort!` at `i32`*. For a
use-directed inferred signature there is no such sentence. The honest one is "something else you edited changed
what this function's parameters are", and the set of things that could have done it is the program.
**So the discriminating constraint for the whole design is: inference may read the function's own body and
nothing else.** Everything below is downstream of that.
## Question 2 — direction and scope, and the two features that poison it
Full Hindley–Milner is out before the language's features are considered, because it is out by the constraint
above: HM's whole point is that a use constrains a definition. Constraining `defn` inference to the body makes
it **local/bidirectional**, which is what the checker already is.
### The checker is already bidirectional, and there is a worked precedent
`check` threads a `?want` — an expected type — through every expression, and the elaborate one-directional
inference the language already has is the `fn` literal:
```ocaml
(* check.ml:1980 *)
and check_fn ctx ~want loc (params : string list) body =
let pts, ret =
match want with
| Some (Types.Fn (ps, r)) when List.length ps = List.length params -> ps, r
```
An `fn`'s parameters carry no types at all, and they get them from position. That is the feature the author is
asking for, already in the language, at a different binding form. And the refusal for when it is unavailable is
written, in the voice a `defn` version would have to borrow:
```ocaml
(* check.ml:1993 *)
fail loc
"nothing here says what this fn's parameters are — an fn takes its \
types from the position it is written in, so it goes in an argument \
whose parameter is a (Fn [T ...] R), and a name already written as a \
defn goes anywhere"
```
Note the last clause. The `fn` literal's inference works *because* a `defn` is annotated. It is checking mode
all the way down, and `defn` is where the types enter the system.
### What a `defn` body can actually tell you about a parameter
Bidirectional inference has no synthesis rule for a variable. It can only propagate: `x` appears as the first
argument of `g`, `g`'s first parameter is `i64`, therefore `x : i64`. That is a use *inside the body*, which the
constraint permits, and it is genuinely enough for a large class of Flan functions — a body that calls the
prelude or the user's own annotated functions pins its parameters immediately.
It is not enough for the author's example, and the reason is the second poison.
### Literal defaulting: `(+ x 1)` — what is `x`?
`i32`, and not because anything inferred it.
```ocaml
(* check.ml:1803 *)
and int_literal loc ~want ?(default = Types.I32) n =
match want with
| Some (Types.Int k) -> mk loc (Types.Int k) (Tast.Int (in_range loc k n, k))
...
| _ -> mk loc (Types.Int default) (Tast.Int (in_range loc default n, default))
```
Today the annotation supplies `want` and the literal takes the parameter's type. Probed
(`scratchpad/p2.flan`): `(defn f [x i64] i64 (+ x 1))`, `(defn g [x u8] u8 (+ x 1))` and
`(defn h [x f32] f32 (+ x 1))` all check, and the same literal `1` is an `i64`, a `u8` and an `f32` in the three
bodies. The arithmetic is overloaded over sized ints and floats, and the annotation is what selects the
overload.
Remove the annotation and there is nothing to select with. `(+ x 1)` would have to make `x` an `i32` by
*default*, which is a silent monomorphisation nobody wrote — and the error it produces lands at a call site,
not at the definition. That failure is already observable in the one place the language does infer a binding
with no annotation, the `let` (probe `p6.flan`):
```
(defn f [x i64] i64 (+ x 1))
(defn main [] ()
(let [n 3]
(print (f n))))
p6.flan:4:17: expected i64, found i32
4 | (print (f n))))
| ^
```
The mistake is at line 3 — `n` defaulted — and the caret is at line 4. The same program with `f` taking `i32`
checks. TODO.org, "A let binding takes no type annotation", records this hazard already in the wild: *"a `let` has no annotation, so `[3.5 -1.0]` is an
`[f64]` and every element in `programs/algorithms.flan` is…"*.
For a `let` this is contained: the binding, the default and the use are all inside one function, usually within
a few lines, and the annotation that would fix it is an argument away. For a `defn` parameter it is not
contained at all. The default would be baked into a *signature*, published to every call site in the program and
into the running process, and the first thing it does under `C-c C-c` is make `compatible` refuse when the
author later adds the annotation they meant.
**This is the concrete answer to the question as posed.** `(+ x 1)` cannot make `x` anything. The three
principled options are all worse than an annotation:
- **Default it to `i32`.** Silent, and wrong for a body about to be called with an `i64`.
- **Refuse it** — "nothing here says what `row` is." Correct, and it refuses the author's example, which is the
one they asked for.
- **Generalise it** — make `row` a type variable. That is question 3, and it is refused at the definition.
No implicit widening (`sum-i32` returns `i64` by explicit cast, SPIKE-GENERICS.md question 5) closes the escape
where a default could be "widened later". Fixed arrays, value semantics and where-predicates are neutral —
none of them is harder to infer than to check, because none of them is inferred *from*; they are all checked
*against*. The poison is entirely in the two overloaded-by-expectation forms: the integer literal and the
arithmetic over it.
## Question 1, concluded — redefinition
Restating against the above, since it is the question the spike exists for.
**Under body-local inference, redefinition is a solved problem and needs no new machinery.** The inferred
signature is a function of the edited text; `compatible` compares it to the installed one; the refusal already
names both tuples and tells the author to restart. The only thing worth adding is one clause to the message:
that the types were inferred and from where, so the author is not hunting a signature they never wrote. That is
a `?origin`-shaped change, one call site, and `?origin` is the proof it fits.
**Under use-directed inference, it creates a new class of refusal with no good message**, for the reasons in the
section above. This is where "annotations required only for what the session already holds" would come in as a
mitigation — and it is worth saying plainly that **it is a mitigation for a problem the body-local design does
not have.** There is nowhere natural to keep the lock, either: `Check.program_with_env` builds a fresh `env`
each evaluation (SPIKE-GENERICS.md makes the same observation about the instantiation cache — *"there is no
persistent cache to invalidate and therefore no staleness to get wrong"*), so a lock would have to live in
`Session.t` as a new table of frozen signatures, with its own invalidation story, its own refusal, and its own
answer to "what does a fresh `flan check` of the same file do, which has no session." Do not build it. Pick the
design that does not need it.
## Question 3 — interaction with generics: "unannotated = generic" is refused at the definition
It is the reading that looks cheapest, because the machinery exists — `check_generic` (check.ml:5947),
`generic_call` and `instantiate` (check.ml:4964, 5043), the per-call-site instantiation cache, `bind_ty`
substituted left to right. Reading an unannotated `defn` as a generic one with fresh variables, then inferring
the `where` clauses from the operators the body uses, is a real design and someone will propose it.
**It fails on the author's example, by a rule plan.org chose deliberately.** plan.org:928:
> Generic parameters → inferred at call sites, no explicit instantiation. No type classes. An operator over a
> variable with no `where` clause asserting it is rejected at the definition; a `where` predicate is what admits
> it.
`build_program` runs the abstract pass over every generic body (check.ml:6202), and SPIKE-GENERICS.md quotes
what it says about exactly this shape:
```
spike/generics/reject.flan:1:26: + over the type variable t is refused: an unconstrained type variable supports
only what every type supports, and + is not that (plan.org, Types). Take the operation as a parameter — a
(Fn [t t] ...) — and call it here
```
So under the generic reading, `(defn settle [row col] (+ row col))` is refused at its definition. Confirmed
empirically — today's message for the author's literal example is a different one, because `col` is read as a
type name, but it lands in the same machinery (`scratchpad/p1.flan`):
```
p1.flan:1:19: generic code over the type variable col is not implemented yet — milestone 5 (see plan.org)
```
The "infer the `where` clauses from the operators used" repair is where this gets expensive rather than
impossible. Inferring `{:where (is-ordered $t)}` from a `<` in the body is a constraint-collection pass — the
predicates are not a fixed lattice (`pred_entails` already has `is-ordered` covering `is-equal`), and the predicate
set would have to be closed under the operators, which is a type-class system by another road. plan.org:928 says
*no type classes* in the same breath. And it would still not save `(+ row col)`: there is no arithmetic
predicate, by design — the language's answer is "take the operation as a parameter", which is a *longer*
signature, not a shorter one. SPIKE-GENERICS.md's question 5 already paid this bill in the prelude: *"The
functions collapse; the calls get longer."*
**So unannotated must mean monomorphic**, which hands the whole question back to literal defaulting.
One thing the generics machinery *does* contribute, and it is the good news in this section: it proves the
checker can run a body more than once, with an environment installed and restored around it
(`check_generic`'s `saved_vars`/`saved_preds`/`finish`), and that re-checking bodies is cheap — 1.8 ms
whole-program, flat, dominated by the 1665-line prelude. An inference fixpoint is the same shape of cost. It was
not measured.
## Question 4 — allocators and ownership: this question closed on 2026-09-18
It was live, and it is not any more. **Signatures carry types and nothing else.**
- **Move-only is gone.** spec-memory.md:182: *"**The move-only concept itself is gone.** Everything copies — a
container as much as an integer."* `is_move_only` does not appear anywhere in `lib/`. SPIKE-GENERICS.md's
"Fiddly" row — ownership not being decidable without the concrete type, the one analysis that did not survive
abstraction — was written before the repeal and no longer has a subject.
- **The allocator is not in the calling convention as a parameter.** It is a dynamic read: `context/allocator`
and `context/temp` resolve to runtime primitives (check.ml:1860-1865), `with-allocator` rebinds a dynamic
variable (check.ml:627), and an operation takes at most one explicitly-named allocator as an ordinary argument
of type `Alloc` (check.ml:3354). Nothing about it is derived from a signature, so there is nothing for
inference to see through.
Inference would have to infer a parameter of type `Allocator` the same way it infers any other — from a use
inside the body — and that is the ordinary case, not a special one.
## Question 5 — error quality
The repo's discipline is that a refusal names the thing and points at the line. Inference's structural problem
is that the cause and the symptom separate, and the `p6.flan` probe above is the language's own miniature of it:
the mistake is the default at line 3, the caret is at line 4.
The disciplines that keep it acceptable, in the order they matter:
1. **Body-local inference is the whole of the discipline.** It bounds the distance between cause and symptom to
one function. Nothing else on this list would rescue a design without it.
2. **Never default in signature position.** A parameter whose type is not pinned by a use in the body is refused
by name — *"nothing here says what `row` is; write its type"* — rather than silently becoming an `i32`. This
is the `fn`-literal refusal at check.ml:1993, reworded for `defn`, and it is the single most important rule
in the design. It is also the rule that refuses the author's example, and the report should not pretend
otherwise.
3. **The instantiation-origin pattern, borrowed.** `Session.compatible`'s `?origin` shows how a refusal about a
name the source does not contain gets an explanation. The inference version is smaller: the signature is
inferred, and here is the form that pinned each parameter. One `Loc.note` per parameter.
4. **`Loc.failk` keys.** Every refusal in this checker is keyed (`check/unknown-function`,
`check/predicate-not-carried`). An inference refusal gets `check/parameter-not-determined` and is testable
like every other.
The annotation ridge that makes this work is not a new idea in the repo: it is plan.org:202 — *"Annotations at
function boundaries are unavoidable"* — and the reason given there is prescient about question 3's failure:
*"because compile-time overloading is incompatible with full inference."* Overloading is planned, not built
(plan.org:387, `defmethod` appears nowhere in `lib/`), so the incompatibility is a future collision rather than a
present one; but it is a collision the plan already foresaw, and any inference design that survives today has to
be checked against it before multimethods land.
## Question 6 — the two backends, the daemon, and eldoc
**Nothing downstream of the checker knows a signature was written.** `emit.ml`, `x86.ml` and `js.ml` read
`Tast.fn`'s `params` and `ret`, which are `Types.t` — they have never seen an `Ast.texpr`. The daemon is the
same:
```ocaml
(* dev.ml:1015 *)
let signature_of_fn (f : Tast.fn) =
Printf.sprintf "%s [%s] %s" f.Tast.name
(String.concat " " (List.map Types.to_string f.Tast.params))
(Types.to_string f.Tast.ret)
```
The `defs` op (dev.ml:1025) and the disassembly header (dev.ml:2671) both go through it, and `flan.el`'s eldoc
reads the `defs` cache and nothing else. **So eldoc shows an inferred function's real signature, for free, in
the same format.** The comment right above it even removes the one objection someone would raise: *"Parameter
*names* are not in the Tast, so a signature shows types only"* (dev.ml:1014) — eldoc is already showing types
without names, so an inferred signature is indistinguishable from a written one in the echo area, which is the
correct behaviour and cost nothing to get.
The one thing that *does* read the annotation slot is the frontend, and it has a booby trap:
- `Ast.fn.ret` is already `Ast.texpr option`, and both `collect` sites read `None` as **`Unit`**
(check.ml:5569 and 5706: `match fn.Ast.ret with None -> Types.Unit | Some t -> resolve env t`).
- `shim.ml:532` *produces* `ret = None` meaning `Unit`, for the struct-return wrapper. `shim.ml:589` and
`cimport.ml:1579` both read `None` as void.
- `load.ml` maps over it in three places (314, 323, 354) and walks it for dependencies twice (637, 652).
So **`None` is taken**. "Infer me" needs a third state — `Ast.ret : Written of texpr | Unit | Infer`, or a
separate flag — and that is a small, mechanical edit across `load.ml`, `shim.ml`, `cimport.ml` and two `collect`
arms. Small, but it is not free, and anyone who reads `ret option` and concludes the slot is already optional
has misread it.
## Question 7 — cost, and the increments
### The grammar comes first, and it is not a detail
```ocaml
(* parse.ml:104 *)
let rec fields (f : Form.t) (items : Form.t list) : Ast.field list =
match items with
| [] -> []
| name :: ty :: rest ->
no_pattern name;
{ Ast.fname = sym name; fty = texpr ty; floc = name.loc } :: fields f rest
| [ odd ] ->
Loc.fail odd.loc "field %s has no type — these come in name/type pairs"
(Form.to_string odd)
```
A parameter vector is a flat list of pairs. Probed:
- `(defn settle [row col] ...)` — **parses**, as `row : col`. Not an error. The error the author sees comes much
later, from the type resolver, about a type named `col`.
- `(defn settle [row] i32 ...)` — `p4.flan:1:15: field row has no type — these come in name/type pairs`.
- `(defn settle [row col x] i32 ...)` — `p3.flan:1:23: field x has no type — these come in name/type pairs`.
So an odd count is a parse error and an even count is a silently different program. There is no count to
disambiguate by, which is precisely the finding TODO.org, "A let binding takes no type annotation", records for `let`: *"`let` is a flat list of
pairs, so it cannot disambiguate by count the way `defonce` and `defconst` do — those read `[n t v]` as three
arguments to a form, and there is no such boundary between one pair and the next."*
And the meaning that would have to be given to `[row col]` is exactly the class of change this parser was
rewritten to make impossible. The `defn` return slot was made mandatory (parse.ml:904) because the optional
version *"was wrong twice in one day, the second time parsing `(defn f [] (Rune {.code 65}) (bar))` as a
function returning a Rune with a one-form body, silently, in every file in the language. A silent misparse is
the worst failure class available, and macros now generate definitions, which widens it."* `defunion` was
refused by name (parse.ml:881) for the identical reason. Making `[row col]` mean two unannotated parameters
reintroduces the pattern in the one form both of those decisions were about, and it breaks every existing
two-parameter signature in the corpus in silence.
**So a `defn` with unannotated parameters needs new syntax** — a marker, a separate vector, or a distinct form —
and the choice is a language-design decision, not a compiler one. TODO.org, "A let binding takes no type
annotation", already sketched the three
surfaces for the `let` version of the same problem and preferred the one that avoided new binding-vector syntax.
The cheap first step below is the one that needs **no** grammar change at all.
### What a full implementation touches, honestly
| | |
|---|---|
| **Grammar** | new parameter syntax in `parse.ml`'s `fields`/`defn` arms, plus its own refusals; a language decision before it is an edit |
| **AST and frontend** | a third state on `Ast.fn.ret` and a way to spell an un-annotated `Ast.field`; mechanical fallout in `load.ml` (5 sites), `shim.ml` (3), `cimport.ml` (2) |
| **The checker's two-pass split** | the real work. `collect` (check.ml:5545-5715) builds `env.fns` from annotations *alone*, before any body is looked at; `check_fn` (check.ml:5851) opens with `Hashtbl.find env.fns fn.Ast.name`. Inference breaks the split in both directions: a signature now needs its body, and a body needs its callees' signatures. The fix is a fixpoint, and the precedent is in the same function (check.ml:5725, the untyped-`defconst` loop) — *"Also to a fixpoint, and for the same reason: one untyped constant may be defined in terms of another declared after it. A constant that still does not check once no progress is left has a real error, so the last round is run without swallowing it."* |
| **Mutual recursion** | has no fixpoint. Two inferred functions each calling the other pin nothing, and must be refused **by name**, saying which two and that one of them needs an annotation. This is a new refusal with no analogue in the file |
| **The session** | one clause on the `compatible` message saying the types were inferred, in the `?origin` shape |
| **Backends, daemon, eldoc** | **untouched**, per question 6 |
| **Does not work** | "unannotated only for non-exported defns" is not available as a boundary. `Load` flattens every import into one namespace before checking (SPIKE-GENERICS.md's last "no plan" row says the same thing about generics across packages), so there is no export boundary at check time to draw the line at |
### The cheap first step, and it is a real one
**Infer the return type only.** Parameters stay annotated. `(defn settle [row i32 col i32] (+ row col))`.
It is worth taking seriously because everything expensive above evaporates:
- **One grammar decision instead of two, and it needs a marker.** Omitting the return slot cannot be decided by
count, and the temptation to say otherwise should be resisted: `defonce` and `defconst` disambiguate by count
because they have a *fixed maximum arity* — `[n t v]` is three slots and there is no fourth. A `defn` body is
an unbounded list of forms, so `(defn f [] (a) (b))` is `ret=(a), body=(b)` or `infer-ret, body=(a) (b)` and
nothing in the shape decides which. That is the same structural fact TODO.org, "A let binding takes no type
annotation", states about `let` — *"there is
no such boundary between one pair and the next"* — moved to the boundary between the return slot and the
first body form, and it is precisely the case that sank the slot the first time: `(defn f [] (Rune {.code
65}) (bar))` is a two-form example, and a count-based rule has no rule to apply to it at all. The
one-form case, contrary to appearances, is the safe one: `(defn f [] ())` means the same either way, and
`(defn f [] Bar)` with no body is already refused by `check_fn`'s *"%s returns %s but has no body"*.
So the scoped version requires a **marker** — `(defn f [] :- (foo))`, or whatever spelling wins. That is one
new token in one parser arm, refusable by shape rather than by looking a symbol up in a table, which is the
property parse.ml:904 says the old design lacked. It is a much smaller ask than the parameter-vector surgery
optional parameters need, but it is **not free**, and this report claimed otherwise in an earlier draft.
- **No parameter defaulting.** Every parameter still has a type, so `want` still flows down into the body,
`(+ x 1)` still means what it means today, and the integer-literal hazard never arises. This is the point: the
return type is *synthesised from* a fully checked body, which is the one direction bidirectional checking
actually supports.
- **The fixpoint is the `defconst` one**, with the same shape and the same stated limit. Two mutually recursive
return-inferred functions do not converge and get refused by name in the last round — and the refusal should
name the *chain*, not just the pair, because the chain is what an author has to break.
- **`compatible` needs no change, but there is a cascade and it is smaller than the use-directed one.** The
inferred return is a function of the body, and a body contains calls: if `B`'s last form is `(A x)` then
`B`'s return *is* `A`'s return, so editing `A` changes `B`'s signature and `compatible` refuses about `B`,
which the author did not touch. This is the same shape as the hazard that rules out use-directed inference —
but with one decisive difference: the primary refusal about `A` is present and correct, and every downstream
one is *explainable in a sentence the compiler can write* — "B returns what A returns". The use-directed
version has no such sentence. Propagation follows call edges out of the body, so it is bounded and nameable;
that is the whole distinction.
- **eldoc, `defs` and both backends are unchanged**, per question 6.
It does not give the author `(defn settle [row col] ...)`. It gives them a `defn` that is shorter by one token,
and it removes the annotation that is most often mechanical — `()` on every mutating function in the corpus.
Whether that is worth a grammar decision is the author's call and this report does not make it.
## Verdict
**Do a scoped version, or do nothing. Do not build optional parameter annotations.**
The concern that was voiced was correct in its conclusion and wrong in its mechanism, and the distinction is the
useful part: **live re-evaluation is not what makes this hard.** `Session.compatible` already handles a changed
inferred signature correctly, in the right words, with no new machinery, the moment inference reads only the
edited function's body. If the dev loop had been the only obstacle, this would be a cheap feature.
What actually blocks it are three decisions the project already made on purpose:
- the parameter vector is a flat pair list, so `[row col]` already parses as something else, and giving it the
wanted meaning reintroduces the silent-misparse class that mandatory return types and the `defunion` refusal
were both introduced to close (parse.ml:881, parse.ml:904, TODO.org, "A let binding takes no type
annotation");
- integer literals default to `i32` when nothing expects otherwise (check.ml:1803), so an unannotated parameter
is either silently monomorphised or refused — and the refusal refuses the motivating example;
- an unannotated parameter read as a type variable is rejected at its definition by plan.org's
no-type-classes rule, which `check_generic` implements (check.ml:5947, plan.org:928).
**The scoped version's exact boundary:** parameter types stay mandatory; the return type becomes inferrable,
spelled with an explicit marker rather than by omission, from a body checked with every parameter already known;
inference reads the function's own body and never a call site; a return-inferred body that calls another
return-inferred function propagates along that call edge and no further; a cycle among them is refused by name,
naming the chain. Nothing downstream of `check.ml` changes. The grammar cost is one marker in one parser arm —
real, and one decision rather than the several optional parameters need.
**Top three risks**, in order:
1. **The grammar decision is the project, and it has already bitten twice.** Both times it bit, it bit
silently — a program that compiled and meant something else. Any surface for "no annotation here" must be
refusable by shape rather than by looking a symbol up in a table, or it will bite a third time. **The scoped
version does not escape this** — a `defn` body is an unbounded form list, so an omitted return slot cannot be
decided by count, which is why the marker above is a requirement and not a nicety. The difference between the
scoped version and the full one is that the scoped version needs *one* such decision.
2. **Scope creep from "return only" to "parameters too."** The scoped version is pleasant and will immediately
invite the next step, and the next step crosses from synthesis into defaulting. The line to hold is one
sentence: *nothing may be defaulted in signature position.* Write it down before building, because
the first `i32` that leaks into an inferred parameter will not announce itself — it will show up as a caller
mismatch three functions away, as `p6.flan` shows.
3. **Compile-time overloading, when it lands.** plan.org:203 already says overloading and full inference are
incompatible, and multimethods are still unbuilt. Any inference the language ships now becomes a constraint
on how overload resolution can be specified later, and that bill is not payable in this lane.
## Reproducing
`dune build` is green; nothing in the tree was changed. The probes are one-liners and are quoted inline:
```lisp
;; p1 — the author's example: "generic code over the type variable col"
(defn settle [row col] (+ row col))
;; p2 — the annotation is what selects the arithmetic overload; all three check
(defn f [x i64] i64 (+ x 1)) (defn g [x u8] u8 (+ x 1)) (defn h [x f32] f32 (+ x 1))
;; p3, p4 — an odd parameter count is the only thing the parser refuses
(defn settle [row] i32 (+ row 1)) ; field row has no type
(defn settle [row col x] i32 (+ row col)) ; field x has no type
;; p6 — defaulting moves the error away from the cause
(defn f [x i64] i64 (+ x 1))
(defn main [] () (let [n 3] (print (f n)))) ; 4:17: expected i64, found i32
```
@@ -51,7 +51,7 @@ handler after it, because `SIGFPE` cannot be caught and resumed. Once the zero t
`INT64_MIN / -1` test is nearly free on the same path.
Unsigned division gets the zero test only; there is no overflow case. Float division is **not** guarded at all:
IEEE `x / 0.0` is `inf`, which is defined and wanted — `rand-f32` in the prelude divides by a float constant.
IEEE `x / 0.0` is `inf`, which is defined and wanted — `rand` in the prelude divides by a float constant.
## The x86 backend, item 3 of `HANDOFF-x86-rt.md`
@@ -76,7 +76,7 @@ because `load_loc` has already widened both operands according to their own sign
backends used to diverge silently rather than both dying: x86 divided in 64 bits and truncated on the store, producing
`-2147483648` for an `i32`, where LLVM emitted poison. `arith.flan` has an `i32` case for exactly that reason.
`spike/x86/survey.sh` is 101 MATCH / 0 DIFFER / 0 REFUSED, with the two programs this change adds among them.
`test/survey-x86.sh` is 101 MATCH / 0 DIFFER / 0 REFUSED, with the two programs this change adds among them.
`arith.flan` carries an `i32` overflow case and an `f32` cast case on purpose, and neither is padding. The `i32`
overflow is where the two backends disagreed *silently* rather than both dying, and it is the only thing that
@@ -101,7 +101,7 @@ because the reader who reaches `(/ min -1)` has probably never had to think abou
Three things in those two functions are worth knowing:
- **Integer division only.** IEEE `x / 0.0` is an infinity and is a defined answer somebody may want — the prelude's
own `rand-f32` divides by a float constant — so guarding a float division would be refusing a result the language
own `rand` divides by a float constant — so guarding a float division would be refusing a result the language
already promises.
- **One branch, not two.** The zero test and the `INT_MIN / -1` test are `or`-ed into a single compare-and-branch, and
*which* of them fired is decided by a `select` that is dead on the fall-through path. The guard is also dropped
+138
View File
@@ -0,0 +1,138 @@
# Handoff — the pointer arm `agrees` already promised
`lib/cimport.ml`'s `agrees` decides whether a hand-written `declare-c` and the C header
say the same thing. Its comment had always listed three differences that are "expected and
are not reported"; the code implemented two of them. The missing one — *a `(Ptr T)` where
the header says `T *`* — is what `../PORTING.md` §A.1 and §A.2 ran into, and it is why
`GetCodepointPrevious` could not be bound honestly and why `UpdateTexture` had to be handed
`(addr (.r (at (slice-from-ptr pixels n) 0)))`.
The arm is built. This file is the record of what it accepts, what it still refuses, and
the one thing that turned out not to be its fault.
## What the arm is
`ptr_agrees` and `agrees_c` beside `agrees` in `lib/cimport.ml`, consulted only from the
two `declare-c` sites in `diff_bound`. It takes the **C spelling** and not only the
rendered Flan type, because by the time a parameter has been rendered the header's word is
gone: `param_ty` turns `const char *` into `string` and `value_ty` turns `void *` into
`(Ptr u8)`, and neither of those is what the header said. A hand-written line that
disagrees with a rendering is not disagreeing with the header, and that distinction is the
whole of the arm.
`agrees` itself is untouched. `check_structs` uses it for field **widths**, and a struct
field is about layout — every pointer in a struct is one word whatever it points at — so
widening it there would buy nothing and would cost the discipline the field check exists
to keep.
## What it accepts
- `(Ptr T)` against a C `T *` for the same `T`.
- `(Ptr u8)` against `char *` and `const char *`. Inside a pointer, `char` and
`unsigned char` are two spellings of one byte and the package uses `(Ptr u8)` for both.
The `i8`/`u8` tolerance is the **pointee's** and not the world's; as a scalar or a field
the two are still a real disagreement, and there is a test for that.
- `(Ptr T)` for **any** `T` against `void *` and `const void *`. This is the judgement
call and the reason is sharper than "it is opaque": `value_ty` *invents* the `(Ptr u8)`
it renders a `void *` as, so the check without this arm was enforcing cimport's own guess
as if the header had said it. What is **not** given up is that it is a pointer at all —
the Flan side must still be a `(Ptr _)`, so an `i32` or a `string` declared against a
`void *` is still a finding. raylib spells thirty-odd parameters `void *` and none of
them is a scalar.
- `(Ptr Key)` against a header's `int *`, through the existing enum arm. The same four
bytes through a pointer as beside one.
## What it refuses
- `(Ptr A)` against `B *` for different named `A` and `B`. Two structs of the same size
are still two different structs.
- Anything that is not a pointer on the Flan side against a C pointer, `void *` included.
- Everything `agrees` already refused, unchanged.
## What `const` does here: nothing, and the reason is invisible
Worth writing down because reading the code does not show it. A non-const `char *`
parameter makes `param_ty` **refuse**, and `diff_bound`'s `| None -> None` means a
parameter the importer cannot render at all is *skipped rather than compared*. So a
hand-written `(Ptr u8)` over a `char *` has always passed — unexamined, not approved. Only
`const char *` ever reaches the comparison, and that is the case the arm exists for. The
rewritten comment at the head of `diff_bound` now says so.
## The comment at `lib/cimport.ml:1291`
Rewritten from a promise into a description. The count it carried — "176 `declare-c`
lines", twenty short on the day it was read — is gone rather than corrected, and both
comments now name the `grep` that answers the question instead of recording an answer that
goes stale. `vendor/raylib/headers` carried the same rot, "425 declarations … 172
hand-written", and got the same treatment. Neither file states a number any more.
## Then it was used
**§A.1 came out entirely, and it is the arm's own case.**
`vendor/raylib/raylib.flan` has `get-codepoint-previous-raw` — `(Ptr u8)` over a
`const char *`, which is precisely what the arm was missing — and a `get-codepoint-previous`
wrapper that takes the bytes and an offset and builds the interior pointer itself.
`generated.flan` no longer carries the `string`-faced version: a binding that is wrong for
the only direction it reads in is worse than no binding, and the hand-written declaration
suppresses the generated one by C symbol. `examples/text-codepoints-loading.flan`'s
`step-back` is one call to it; the continuation-byte walk and `continuation?` are deleted.
The answer is 12356 and 3, verified by running it.
`test/programs/raylib-codepoints.flan` would have caught the old answer, verified by
perturbation rather than by argument: make `step-back` set `size` to 0 — which is exactly
what the broken string call produced — and both rows go red, because the backward walk
never reaches offset 0 and runs off the end of its array.
**§A.2's call site came out; the cast moved rather than vanished.** The header check does
accept the `(Ptr Color)` binding now. What the header check was masking is that
`lib/shim.ml:651` refuses a second `declare-c` for a C symbol the package already binds,
and that rule is right: a shim emits one C prototype per declaration, and two prototypes
for `UpdateTexture` that disagree about a parameter type is a C file that does not compile.
Its message says what to write instead — "another Flan name for it is a defn" — so
`raylib.flan` has `update-texture-colors`, a one-line defn over the generated
`update-texture`:
```flan
(defn update-texture-colors [texture Texture2D pixels (Ptr Color)] ()
(update-texture texture (addr (.r pixels))))
```
The `(Ptr u8)` face has to stay the declared one, because `(.data im-copy)` is already a
`(Ptr u8)` over the same bytes and `(Ptr u8)` → `(Ptr Color)` is the direction the language
cannot write. `(addr (.r pixels))` was checked against the bytes rather than reasoned
about: a `gen-image-color 2 1 red` through `load-image-colors`, read back through the `u8`
pointer, gives 230 41 55 255. So the honest statement is that §A.2 is half closed — the
example reads `(rl/update-texture-colors texture pixels)`, the cast exists once with a name
and a comment on it instead of once per call site, and the general gap the section is
actually about is a **pointer reinterpretation**, which is not a checker arm.
## Verification
- `dune build --root .` and `dune test --root .` clean; the dev suite is 232 checks, 0
failures, unchanged — the new checks are in `test_flan.ml`, which reports pass/fail
rather than a count.
- New checks in `test/test_flan.ml` over three new functions in `test/headers/sample.h`
(`blit`, `scratch`, `pair_len_p`): six declarations that must agree, five that must
still be reported, each asserting on the message and not on a count. All of them were
confirmed live by breaking `ptr_agrees` in three different directions and watching the
right subset go red — the negatives matter most, and they are why the arm can be widened
without the check becoming decoration.
- The raylib acceptance cases ran rather than skipped (`ldconfig` finds
`libraylib.so.550`); the only "skipping" line in the whole run is the web one, which
wants a wasm build of raylib that is not in this tree.
- `flan generate-c vendor/raylib` completes, which is itself the check: it compares every
`defstruct`, every hand-written `declare-c` and every mapped constant against
`raylib-5.5.h` and refuses to write when they disagree.
- `bash web/examples/check.sh` green.
- `test/survey-x86.sh` on the finished tree: **103 MATCH, 0 DIFFER, 0 REFUSED** (38 skip
— 28 that do not compile on purpose, 8 with no main, 2 that run forever). Expected
rather than surprising: nothing here is below the IR, and the one surveyed program that
changed is `test/programs/raylib-codepoints.flan`. Run it detached — `setsid timeout
2400 test/survey-x86.sh > log 2>&1 </dev/null` — because a foreground run takes the
signal sent to its process group and returns a spurious 143.
## Still open
- A pointer reinterpretation in the language. `(addr (.r p))` works when the first field
is the type you want and there is no general form of it. This is §A.2's real subject.
- Nothing else. The three differences the comment lists are now all three implemented.
@@ -57,7 +57,7 @@ leak into other builds. The channel was the defect, not the cause.
`error`, `Error` or `FAIL`, because the reader it is written for is scanning for exactly those words. The reprint
header names that signature rather than a count, because a step that fails *before* either chmod window leaves no
deliberate failure in the log at all, and a header promising two of them would be the wrong diagnosis NEXT.md
warns about.
(now `TODO.org`) warns about.
`test/dune` was **not** touched, and neither was anything under `lib/`. The whole change is in `test/test_dev.ml`
and this file.
@@ -98,9 +98,9 @@ should be reading on a green run.
Baseline not regressed, all three measured after the change:
- `dune test --root .` — exit 0.
- `spike/x86/survey.sh` — **101 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86**, 36 skipped (28 does-not-compile, 6 no-main,
- `test/survey-x86.sh` — **101 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86**, 36 skipped (28 does-not-compile, 6 no-main,
2 runs-forever).
- `spike/x86/cells.sh` — **4/4**, exit 0.
- `test/cells.sh` — **4/4**, exit 0.
One thing not to re-derive: a first attempt at the survey came back `SURVEY_EXIT=143` with a bare `Terminated`. That
was the harness killing the process group of a long background command, not a survey failure and nothing to do with
+157
View File
@@ -0,0 +1,157 @@
# dyn, milestone 1 — what was decided and what is left
The compiler half of dynamic-by-default. The runtime half is a sibling's, built
in parallel against `runtime/flan_dyn.h`, which is the fixed ABI and the thing
the two copies are diffed against.
## Two decisions that differ from the brief
**The return slot stays mandatory; `dyn` is written out in it.** The brief
expected `ret = None` to grow a third state meaning "unannotated", and listed
mechanical fallout in `load.ml`, `shim.ml` and `cimport.ml`. That fallout does
not exist, because the change was not made. The reason is in `parse.ml` beside
the `defn` case: an optional return slot has *no* syntactic resolution, since a
capitalised head in a list is both a type application and a struct literal —
`(defn f [] (Rune {.code 65}) (bar))` is the misparse that removed the old
optional slot, and it would come straight back. A parameter vector has no such
case, because every slot in it is a name or a type and never an expression. So
`ret = None` still means Unit and only `declare` and the shim produce it. One
token in the return position buys a decision the file paid for twice in one day.
**The parameter rule is resolved in `Check`, not in `parse.ml`.** The brief
asked for "a known type name is a type, anything else is another dyn param",
written where `parse.ml` argues its other misparse-closing decisions. That
lookup is exactly the one `parse.ml:904` records being removed for being wrong
twice in one day, and at parse time the set of type names is incomplete *by
construction* — macros generate definitions, packages are loaded later, C
headers are imported later. `cimport.ml` decides it: `named env n = tname n`
passes C type names through verbatim, so POSIX's `stat` and `timespec` are
lowercase Flan type names writable in parameter position, and no syntactic rule
("capitalised is a type") can be made sound.
So the vector is carried undecided as `Ast.pitem`s and paired in
`Check.pair_params`, after every file is loaded, every macro expanded and every
header imported. The argument is written at `parse.ml`'s `defn` case as asked.
## The residual the parent owns
The set of type names is complete at a point in time and **not across time**.
`(defn f [x y] ...)` is two dyn parameters until somebody writes
`(defstruct y ...)` — or imports a header that declares one — and then it is one
parameter of type `y`, with no edit to `f`. The signature changes underneath it,
and arity changes with it.
`Session.compatible` is where that is felt: it compares with `Types.equal` over
parameters and return, so a redefinition that changes dyn-ness is refused like
any other signature change (this falls out; it is pinned in `test_session.ml`).
But the *first* definition after such an edit is the one that changes, and
nothing warns.
## What the feature costs, and what was taken back
A parameter slot with no type used to be a syntax error. It is now a `dyn`
parameter, so **a mistyped type silently becomes an extra parameter** — the
arity changes with no diagnostic, which is the failure class `parse.ml` calls
the worst available. Two rules take most of it back, in `dyn_param_or_typo`:
- a name within one edit of a type's name gets the resolver's own "did you
mean", and
- an unknown **capitalised** name is reported as an unknown type. Not one
parameter in the corpus is capitalised, while `Form`, `Cursor` and `Vector2`
appear in these vectors constantly.
What is left uncovered is a lowercase name resembling no type: `(defn f [x
widget] ())` is two dyn parameters and nothing in the text says otherwise. That
is the feature working as specified.
**Sharp edge of the near-miss rule.** `near_miss` treats any two single-char
names as one edit apart, and it compares against every struct name in scope. So
a `(defstruct D ...)` anywhere in the program makes `(defn f [a d] ...)` a
refusal rather than two dyn parameters. The message is actionable — write the
type, or rename — but it is a refusal a user will meet without having done
anything wrong.
## Open ABI point for the integrator
**A rooted slot holding 0 is not a value, and the collector must skip it.**
This is written into `runtime/flan_dyn.h` beside the root functions, and it is
the one thing in that header decided by one side alone. Roots are pushed in the
function's entry block, before the code that fills them has run and possibly for
a branch that never runs, so the compiler zeroes every root slot and must mean
something by it — and 0 is the only pattern it can write without knowing the
encoding.
If the real runtime NaN-boxes and integer zero is the zero word, this is wrong
and the two sides need a different sentinel. Do not fix it on one side.
**Settled, by reading the other side.** The real runtime does NaN-box, and the
zero word is *not* the zero integer: an integer is boxed, and boxed means the
quiet-NaN prefix is set. `mark_value` in `runtime/flan_dyn.c` follows a value
only when `dyn_boxed` holds, which tests `(v & 0xFFF8000000000000) ==
0xFFF8000000000000`, and the zero word fails it. So a rooted slot holding 0
decodes as the double `0.0` — an ordinary value rather than a marker, and
crucially never an address the collector dereferences. Zero is safe, and the
header's sentence is true for a reason both sides can check rather than by the
two of them having guessed alike. No sentinel is needed and neither side
changes.
## Not in milestone 1, each refused by name with a location
- a typed container boxing into dyn (`(Vec i64)` → dyn): "not yet"; the
heterogeneous container is the runtime's own from `(vec-new dyn)`
- a dyn in a condition's payload, or in a field of one: milestone 2 — a payload
crosses a handler boundary and must stay rooted across the transfer
- a dyn crossing to C through `declare`/`declare-c`: it is one word and would
have passed as an integer with nothing on the other side able to ask what it
means. This one was **not** in the brief and is the dangerous one, because the
general "cannot cross to C" arm would have caught it with advice (`pass (Ptr
T)`) that is wrong for dyn.
- integer widths other than i64 and floats other than f64 unboxing from dyn:
the ABI carries one of each, and a `need_i64` plus a truncation would put an
implicit narrowing at the one boundary where the value's type was already
uncertain
- ~~the x86 dev backend, and~~ the JS dialect, refuse dyn entirely. The x86
backend does not any more: a dyn is one machine word in both calling
conventions and every operation on one is an ordinary `Tast.Rt` call, so what
the lane cost was the root discipline and not the arithmetic — a zeroed frame
slot per dyn local and per dyn-producing call, pushed in the body buffer at
entry, and one `flan_dyn_root_pop` in the epilogue that every return and
every transfer out of the frame already went through. `Emit.dyn_roots` is
called by both backends, which is what makes the counts agree rather than
merely both being written down
## Roots: what is and is not verified
Every dyn slot and every dyn-producing runtime call is rooted, pushed in the
entry block and popped at every `ret` — which is the funnel all five exits pass
through, the transfer landing block included. Pushes and pops balance **by
construction**: `dyn_roots` counts before emission, the slots are minted from
that count, and `dyn_tmp` only hands them out.
**The stub verifies none of this.** `flan_dyn_stub.c` mallocs and never frees,
so a program with entirely wrong root discipline passes every test that runs
against it. What is checked instead is the IR, and that check earned its keep —
it found a real hole. A defer appears twice in the typed IR, spliced into `body`
for the normal path and again in `fdefers` for the path a transfer leaves
through, so a dyn temporary inside one is emitted twice; `dyn_roots` counted
only the body's, and the second copy went into slots the collector had never
been told about.
Nothing failed, which is the point. `dyn_tmp` falls back to a plain unrooted
slot rather than unbalancing the stack, so the pushes and the pops still
matched, the program ran and printed the right answer, and four dyn values were
simply invisible. Under a stub that never collects there is no symptom at all.
The assertion that caught it is in `test_acceptance.ml`: a rooted slot is
spelled `%dr` and the fallback `%dx`, and no dyn program in the corpus may emit
the latter. When the real collector lands, that is the check to extend rather
than replace — it is the only one that can see a missing root before there is a
collector to lose one by.
Cost: a rooted alloca has its address escape through `flan_dyn_root_push`, so
mem2reg cannot promote it. Every dyn local and every dyn temporary is a real
stack slot with a real store, at every optimisation level. That is inherent to a
precise collector with an address-registration ABI rather than stack maps.
A function with no dyn emits nothing — no push, no pop, not a `pop(0)` — which
is what makes `--no-gc` byte-identity hold.
+152
View File
@@ -0,0 +1,152 @@
# The `test_emacs` flake — SIGPIPE, and a socket that outlived its process
**Closed.** It was neither of the two mechanisms this repository had seen
before. It was a third: `SIGPIPE`.
## The symptom
One run of `dune test --root .` exited 1; the next on the same tree exited 0.
```
FAIL the client notices a stop nobody asked about
FAIL and the modeline says so, with the condition
Error: error ("flan dev: cannot reconnect to /tmp/build_997a01_dune/flan-emacs-dev.sock: make client process failed: Connection refused, ...")
```
## Which mechanism it was
Neither of the two named in the brief.
- **Not a listen/connect ordering bug.** `bind` and `listen` are adjacent in
both `Dev.two_process` and `Dev.merged_setup`, nothing else creates that
path, and the connection that failed was one that had already been working
for forty checks.
- **Not a timeout too short for a cold build.** That failure mode is now
reproducible on demand (see *The tick budget* below) and it produces an
entirely different message.
- **It was `SIGPIPE`.** The daemon answers an editor by calling `Wire.send`,
which is `Unix.write_substring`. Nothing in the OCaml half of the daemon
ignored `SIGPIPE`, so a reply written into a socket whose reader had gone
killed the process outright. In the merged one-process build that process is
the program, the compiler and the listener at once — so the program died
without ever reporting a stop (the two `FAIL`s), and the socket file was left
on disk with nothing accepting on it (`Connection refused` on a path that
plainly exists). One event, all three lines.
It is not a contrived shape. `flan-dev--live-connection` reopens a dead
connection, Emacs tears the old process down when it does, and a reply
already on its way out lands in the gap. `flan_agent.c` had reached the same
conclusion from the other side years ago and passes `MSG_NOSIGNAL`; the half
written in OCaml never got the same treatment.
## How it was reproduced
A client that sends one request and closes before the reply arrives:
```
daemon DIED after 1 hit-and-run connections
daemon wait status: 141 (128+13=141 is SIGPIPE)
socket file left behind: YES
and a fresh client gets:
[Errno 111] Connection refused
```
That is the reported failure, exactly, in under a second and every time. With
the fix, 300 such connections leave the daemon serving.
The reproduction is now a check in `test_dev.ml`, in the robustness daemon's
block: eight hit-and-run connections, then a request proving the session is
still there and still knows what it installed. Against the parent of the fix it
fails with `killed by SIGPIPE`.
**Both transports, and only one of them has a test.** `ignore_sigpipe` is
called from two places and the suite's daemons are all merged, so the check
above covers `merged_setup` and nothing covers `two_process` — which NEXT.md
(now `TODO.org`) describes as the escape hatch for a machine that cannot build the compiler
object, so it is live code somewhere. Driven by hand: before the fix a
`--two-process` daemon dies on the *first* hit-and-run connection with status
141 and leaves its socket behind, exactly as the merged one does; after it,
a hundred of them leave it serving and answering `describe`. If that call site
is ever removed, nothing in `dune test` will say so.
## The fix
`lib/dev.ml`, three parts:
1. `ignore_sigpipe ()` where the listening socket is bound — in both
`two_process` and `merged_setup`. Ignored rather than handled, because
`serve` already treats a failed write as a closed connection.
2. The reply write is guarded. An `exception` case on a `match` covers the
*scrutinee*, not the branch body, so the `EPIPE` that ignoring the signal
produces went straight past the two handlers below it and out of the accept
loop — which ended the session just as surely, only quietly. Without this
the first fix converts `SIGPIPE` into `flan dev: Unix.Unix_error(EPIPE)` and
the daemon still stops.
3. The socket is unlinked on the ways out the daemon does not control as well
as the one it does: an `atexit` in the merged `main` for `exit(3)`, which is
what `flan_rt.c`'s `rt_die` takes on any runtime trap; by hand in
`flan_agent.c`'s `die_now`, and in `main`'s `_exit(rc)` fallback, both of
which skip the `atexit` chain deliberately. A stale socket can now only come
from a `SIGKILL`.
`emacs/flan-dev.el`: a refusal on a path that exists is reported as a leftover
socket rather than as `Connection refused`. The raw message is what sent two
investigations here the wrong way.
`test/test_emacs.ml` and `test/test_repl.ml`: the daemon's wait status is kept
instead of discarded, and the program's output is read *before* the temporary
files are removed rather than after. The old order deleted the evidence and
then printed the exit code — which is why nobody could tell, twice running,
whether the daemon had exited or been killed.
## The sibling call sites
Four files are in this family and the answer differs for each:
- `test_dev.ml` and `test_repl.ml` drive the same daemon, so `lib/dev.ml`'s fix
covers them. Neither had a separate copy of the bug.
- `test_cider.ml` has **no daemon and no running program** — it drives the
inspector and break buffer from fixtures. It is not in this family at all,
despite the name.
- `test_emacs.ml` was the only one whose harness destroyed the evidence before
reading the exit code; `test_repl.ml` had the same ordering and is fixed too.
So: one bug, one site, three harnesses — not the three-way split the last one
had.
## The tick budget — a second hazard, and not this one
`test/programs/dev-repl.flan` ran `(dotimes [i 4000] (agent/wait 5) ...)`: a
twenty-second program under a two-minute test, and `test_emacs` drives one
daemon through the whole run. On a loaded machine the client can still be
working when the program reaches its last tick.
This is real and it is reproducible — sweep the budget and the same two
break-loop checks fail at 165, 175 and 185 ticks — but it is **not** the flake
above, because it fails honestly: `the program exited; restart flan dev` at one
budget, `nothing is listening on ...` at another. Never `Connection refused`.
That is what ruled it out.
Raised to 24000, which is what `dev-robust.flan` already uses for the same
reason. The 600s watchdogs are what actually bound the run.
## Verification
- `dune test --root .` — three runs, 232 checks, 0 failures, exit 0 each time.
All three under sixteen busy loops; two of them alongside a running
`test/survey-x86.sh`.
- `test/survey-x86.sh` — 103 MATCH / 0 DIFFER / 0 REFUSED.
## Open questions for the author
- `lib/cimport.ml:1138` still spells its own `cachedir` under `$TMPDIR`, which
NEXT.md:694 (now `TODO.org`) flags as the last remnant of the cold-cache bug. Another lane
holds that file. Folding it onto `Build.cachedir` is still a one-line change.
- `Build.cachedir` is now under `XDG_CACHE_HOME`, which means it is **shared**
between concurrently running lanes rather than private per dune run. The
writes are `tmp`-then-`rename`, so nothing can read a half-written object,
and nothing in this investigation pointed at it. Noted only because "several
compiler lanes at once" is now a condition the suite is expected to survive.
- `runtime/flan_dev.c:51` reaches `abort()` on a full name table, and `abort`
runs neither the `atexit` chain nor anything else. It is a 4096-name limit
and no test comes near it, so it is left as it is.
+260
View File
@@ -0,0 +1,260 @@
# Handoff — one buffer for every lowering
Branch `dev-loop`, worktree `agent-a9d4028fa5752ba39`, from `f772162`.
`tools/dump.sh` landed at `f772162` and prints four lowerings of one function side by side: the LLVM IR
the frontend emits, what `llc` makes of it at `-O0` and at `-O2`, and what the hand-written x86 backend emits.
Reading one against another is the only way to check a lowering by eye. A shell script that prints all four
into a scrollback is not how anyone reads them, though: three of the four are noise on any given day, and the
one that is not is the one you are elbow-deep in. So the same four go into an Emacs buffer with collapsible
sections, and the buffer remembers which of them you had open.
`M-x flan-lowering`, `C-c C-l` in `flan-mode`.
## The three decisions
### 1. It compiles the file; it does not ask the daemon
`flan-disassemble` (`C-c C-a`) asks the running program what the body it is calling for a name actually came
out as. This asks a different question — what does this source compile to, through four different lowerings —
and the two cannot be merged, for a concrete reason rather than a stylistic one: **the daemon has the
installed body's `.ll` and its `.so`, and neither `llc -O2` nor the hand-written backend has ever been run
over it.** There is no answer in the daemon to give. `llc -O2` on the installed IR would be a fifth thing,
built here and never run anywhere, and the backend's output for an installed body does not exist at all
unless the daemon was started `--x86`.
So the two commands sit beside each other and each says which question it answers. `flan-disassemble` keeps
its header, whose `showing` line is the daemon's own account of how much its answer claims. This buffer's
header says the opposite thing just as plainly: it names the file and the compiler flags, and says the
listing is what the source on disk compiles to and not what the program is running. Both docstrings
cross-reference the other.
### 2. `outline-minor-mode`, not `magit-section` and not overlays of my own
Nothing under `emacs/` requires a package that is not in Emacs — the whole client is `subr-x`, `seq`,
`pcase`, `cl-lib`, `xref`, `eldoc`, `comint`, `imenu` and its own files. Taking a dependency on Magit for a
four-section buffer would be the first, and it would be for folding, which Emacs has had since before Magit
existed.
`outline-minor-mode` on GNU Emacs 30.2.50 is the whole of what was asked for: `outline-cycle` is `TAB` on a
heading, `outline-hide-body` and `outline-show-all` collapse and expand everything, and
`outline-next-visible-heading` / `outline-previous-visible-heading` are `n` and `p`. Hand-rolling it would be
a hundred lines re-implementing that badly.
`outline-regexp` is `"[▸▾] "` — the fold arrow that starts each heading, and nothing else. That is not
decoration standing in for structure, it is the one anchor available: LLVM IR has `;` comments, assembler
output has `#` and `;` comments and lines that start with `.`, and `objdump` prints `<flan.step>:` label
lines, so a regexp loose enough to match four section headers written in ordinary characters also matches
inside three of the four bodies, and folding then fragments in the middle of a listing. Neither arrow can
begin a line of any of the three. `outline-search-function` would have done the same job keyed to a text
property, at the cost of a four-argument protocol to get exactly right; the arrow is simpler and it is also
the affordance, so it earns its place twice.
### 3. The open/closed state is a global alist keyed by section, and nothing else
One `defvar`, four entries, alive for the Emacs session and no longer. Not persisted to disk: it is four
booleans, a preference file for it would one day have to be migrated, and nobody has ever wanted yesterday's
fold state back.
Keyed to the **section**, not to the function. The workflow is "I am working on the backend today", which is
a fact about the section and holds across every function the author looks at while he is doing it. Keyed to
the function it would forget on every `M-x flan-lowering` of a different name, which is exactly the thing he
asked not to happen.
The alist is the single source of truth. A toggle mutates it and then applies it to the buffer; a refresh
applies it to the newly drawn buffer. Visibility is never read back out — that would couple this to whether
outline folds with overlays or with text properties, which is a thing that has changed between Emacs
versions and is none of this file's business.
## What was built
| file | what |
|---|---|
| `emacs/flan-lower.el` | the whole feature: the four fetches, the narrowing, the renderer, the folding and the memory of it |
| `emacs/flan-mode.el` | `C-c C-l` and the autoload |
| `emacs/test-flan-dev.el` | 30 checks, after the disassembly section |
| `emacs/MANUAL.md` | its own section, plus rows in both key tables, the settings table and the file table |
## The keymap
| Key | Command | Does |
|---|---|---|
| `TAB`, `RET`, `mouse-1` | `flan-lower-toggle` | open or close the section point is in |
| `S-TAB` | `flan-lower-cycle-all` | close everything, or open everything if it is all shut |
| `c` | `flan-lower-collapse-all` | close every section |
| `e` | `flan-lower-expand-all` | open every section |
| `n` / `p` | `outline-next-visible-heading` / `outline-previous-visible-heading` | between headings |
| `g` | `flan-lower-refresh` | compile again, redraw all four |
| `r` | `flan-lower-refresh-section` | compile and redraw only this one |
| `q` | `quit-window` | |
`n` and `p` are outline's own commands bound straight into the map, which is
the same thing they mean in `flan-inspect-mode` and `flan-cnr-mode`: move
between the buffer's own units. `special-mode` binds the digits and `-` to
`digit-argument` and `negative-argument`, so the collapse and expand keys are
letters rather than the `+`/`-` pair they would otherwise have been.
`r` exists because the four are not the same price. The IR is the frontend
alone; `llc -O2` over a whole program's worth of it is the one that is felt.
The intermediates live in a scratch directory per buffer, so refreshing one
section reuses the IR the other three were made from. Refreshing the IR drops
every intermediate, because everything is downstream of it and the next `r` on
another section must not answer out of a file the old IR produced. The
directory goes with the buffer, on `kill-buffer-hook`, and there is a check
for that -- four intermediates per invocation left in `/tmp` would add up.
## Two things worth knowing
**The narrowing is `dump.sh`'s awk, ported.** The IR runs from `define
...@"flan.NAME"(` to `}`; the two assembler sections from the `flan.NAME:`
label to the `.size` that ends it; the disassembly from `<flan.NAME>:` to the
blank line after it. All four keep their last line, which is what makes a
closing brace part of the listing rather than the start of the next one. The
name is the Flan one -- `step`, not `flan.step` -- and a packaged function is
written the way the program names it, `sim/settle`, which is why every pattern
allows for LLVM's quoting.
**A section's failure is that section's text, not the buffer's.** `llc` can be
missing while the IR is perfectly readable, and a buffer that refused
altogether would be the wrong answer to that. Each fetch is wrapped, and the
error message -- which is the tool's own stderr -- becomes the body.
## Testing
30 checks in `emacs/test-flan-dev.el`, immediately after the disassembly
section and needing no daemon, which is itself the distinction between the two
commands.
Twenty-two of them drive the renderer with `flan-lower-fetch-function` rebound
to canned text. That is deliberate: folding, and the memory of what was
folded, are renderer properties, and putting four compilers and an `llc -O2`
behind every redraw would have cost seconds per redraw and proved nothing
about folding. The real fetch is exercised once at the end, guarded on `llc`,
`as` and `objdump`, and that one costs about 2.2s plus one more `llc -O2` for
the `r` check.
`r` is checked where the real fetch already ran, because the claim it makes is
about files rather than about text: the IR's mtime is unchanged after one
section is refreshed, which is the only evidence available that the cache does
what it says — a re-emitted `out.ll` would have the same contents as the one
it replaced. `g` is checked twice: from inside a listing, where point comes
back to the same line *and the same text on it*, and from the header, where
there is no section to come back to and the answer has to be the top rather
than wherever the redraw finished.
`buffer-string` is useless for any of it, because it returns hidden text too:
"all four are still named when everything is shut" would pass without anything
being shut. Every visibility check is `invisible-p`, which means what it says
whether outline folded with an overlay or with a text property.
- `dune test --root .` exit 0, every suite green, `emacs: all tests passed`.
- `dune build --root . @page` and `@cells` both exit 0.
## Transcript
`emacs -Q --batch -L emacs -l try.el -- _build/default/bin/main.exe
test/programs/dev-repl.flan`, printing the buffer *as displayed* — invisible
characters dropped, so a folded section really is absent rather than merely
unprinted.
Opened on `step`, with the remembered default (the IR open):
```
== M-x flan-lowering step ==
; every lowering of step
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▾ LLVM IR 8 lines
define i64 @"flan.step"(ptr %xfer) {
entry:
%t1 = load i64, ptr @"flan.ticks"
%t2 = add i64 %t1, 1
store i64 %t2, ptr @"flan.ticks"
%t3 = load i64, ptr @"flan.ticks"
ret i64 %t3
}
▸ LLVM -O0 13 lines
▸ LLVM -O2 10 lines
▸ x86 backend 18 lines
```
`TAB` on the x86 heading, then `TAB` on the IR heading — the state the author
described, his own backend open and the other three shut:
```
== TAB on the x86 heading, TAB on the IR heading ==
; every lowering of step
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▸ LLVM IR 8 lines
▸ LLVM -O0 13 lines
▸ LLVM -O2 10 lines
▾ x86 backend 18 lines
0000000000012d94 <flan.step>:
12d94: push %rbp
12d95: mov %rsp,%rbp
...
```
The command run again on a *different* function. The preference is the
section's, so the backend is still the open one:
```
== M-x flan-lowering main -- a different function, same preference ==
; every lowering of main
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▸ LLVM IR 48 lines
▸ LLVM -O0 63 lines
▸ LLVM -O2 58 lines
▾ x86 backend 69 lines
0000000000012df4 <flan.main>:
12df4: push %rbp
```
`c`, then `e`, then `n`/`p`. Collapsed, all four are still named with their
line counts, which is the buffer as a summary:
```
== c, collapse all ==
; every lowering of main
; file ~/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/test/programs/dev-repl.flan
; compiler /home/joe/Development/flan/.claude/worktrees/agent-a9d4028fa5752ba39/_build/default/bin/main.exe
; flags none
; showing what this file compiles to, not what a running program is
; calling for this name -- for that, C-c C-a
▸ LLVM IR 48 lines
▸ LLVM -O0 63 lines
▸ LLVM -O2 58 lines
▸ x86 backend 69 lines
== e, expand all, then n/p between headings ==
== e, expand all, then n/p between headings ==
n -> ▾ LLVM IR 48 lines
n n -> ▾ LLVM -O2 58 lines
p -> ▾ LLVM -O0 63 lines
```
## What is not here
- `C-c C-l` is not in `web/index.html`, so `web/examples/quotes.sh` does not
quote it. Its list of bindings is fixed and the page is a separate artefact;
adding a row there is a page change, not this one.
- A `desktop` or `savehist` story for the fold state, deliberately: it lives
for the Emacs session and no longer.
- The batch run leaves its scratch directory behind, because `emacs -batch`
exiting runs no `kill-buffer-hook`. Interactively the hook fires.
@@ -2,7 +2,7 @@
Two examples, `text_codepoints_loading` and `textures_image_processing`, ported into
`examples/`. This file is the running record: what is done, what was decided without being
able to ask, and what is still open. `PORTING.md` has the findings that outlive the port —
able to ask, and what is still open. `../PORTING.md` has the findings that outlive the port —
its last section, "Porting the raylib examples — the last two" — and this file has the
state of the work.
@@ -12,7 +12,7 @@ state of the work.
- [x] `examples/textures-image-processing.flan` — builds
- [x] `test/programs/raylib-codepoints.flan` and `test/programs/raylib-image-processing.flan`,
both in the acceptance table, both gated on libraylib, both run plain and at `-O0`
- [x] `PORTING.md` section for this round
- [x] `../PORTING.md` section for this round
- [x] `dune test --root .` green
Four commits, each one a working state:
@@ -39,7 +39,7 @@ Four commits, each one a working state:
reported", *"a `(Ptr T)` where the header says `T *` and the hand-written line chose
something more specific"*. The function implements the enum-against-i32 arm and nothing
else. That missing arm is the only thing standing between this round's two refused
bindings and ordinary binding work — see `PORTING.md` §A.1 and §A.2, which have the
bindings and ordinary binding work — see `../PORTING.md` §A.1 and §A.2, which have the
exact declarations and the exact error. **Not built: it is a compiler change and the
freeze is on.** It is small and it is well specified, and it is the thing to do first if
these two ports are ever revisited.
@@ -77,7 +77,7 @@ Four commits, each one a working state:
`flan generate-c`. A hand-written `declare-c` that disagrees with `raylib-5.5.h` therefore
breaks every build in the tree, not just regeneration. That is what makes open question 2
a blocker rather than an inconvenience.
- `(string (slice b off (len b)))` is the idiom for a C `char *` cursor into the middle of a
- `(string (slice b off (length b)))` is the idiom for a C `char *` cursor into the middle of a
string, and it costs nothing. It is correct for every entry point that reads forwards and
wrong for every one that reads backwards.
- `slice-from-ptr` is how a raylib pointer-plus-count becomes something with a length. Both
+208
View File
@@ -0,0 +1,208 @@
# The checks nobody runs
Twice now a script in this repository has been quietly wrong for weeks, and both
times it was found by accident rather than by anything failing. `test/survey-x86.sh`
had two programs refused by name for about a month. `web/examples/check.sh` had been
red since September 12th. This is the inventory of everything in the tree that
asserts something and is not reached by `dune test`, what each one says today, and
what was done about the general problem.
A third failure was indeed waiting. It is the interesting one, and it is not the
failure it looked like.
## The inventory
Everything executable or fixture-shaped outside `dune test`, run rather than assumed.
| What | Verifies | Run how | State |
|---|---|---|---|
| `web/examples/check.sh` | each `web/examples/*.flan` against the `.out` beside it, plus `flan shim` on shimdemo and a killed `--dev` build of breakdemo | now `@page` | **passes**, 21 checks |
| `web/examples/quotes.sh` | every block on `index.html` that is not a program — usage text, refusal messages, the LLVM excerpt, the renderer's output, the Emacs keys — re-derived and looked for on the page | now `@page` | **was red, 11 of 42**; fixed, now passes |
| `test/survey-x86.sh` | stdout, stderr and exit status of every corpus program, x86 backend against LLVM | `@x86`, and by hand | **passes**, 103 match / 0 differ / 0 refused / 38 skip |
| `test/cells.sh` | a `--dev` build really calls through its indirection cell: an `LD_PRELOAD`ed constructor stores a different body, four builds, `22 22` against `42 42` | nothing — now `@cells` | **passes**, 4 checks |
| `tools/colon-to-dot.py --check` | every `.flan` uses the dot spelling for field labels | nothing | **reports 20 hits, all false.** See below — this one is a hazard, not a check |
| `tools/unit-return.py --check` | every `defn` states a return type, `Unit` spelled `()` | nothing | **reports 30 hits, all false.** Same hazard |
| `emacs/test-flan-dape.el` | dape driving lldb-dap sets a breakpoint from a `.flan` buffer and reports Flan frames | nothing loads it | **cannot run here** — `require 'dape` fails, dape is not installed. Its own header says it is out of `dune test` on purpose |
| `emacs/test-flan-mode.el`, `test-flan-watch.el` | indentation shapes; the watch accumulator | loaded by `test-flan-cider.el` | covered by `dune test` |
| `spike/x86/bench.sh`, `cost.sh` | nothing — they measure and print tables | by hand | measurement, not checks |
| `spike/embed/run.sh`, `merged.sh`, `sig.sh`, `symbols.sh` | nothing — sizes, startup numbers, a symbol-collision report with no exit status | by hand | measurement, not checks |
| `spike/backend/run.sh`, `spike/generics/run.sh` | nothing — they drive a spike by hand | by hand | measurement, not checks |
Recorded-output fixtures: `find . -name '*.out'` outside `_build` returns twenty
files and all twenty are in `web/examples`. There is no second family of golden
files hiding anywhere.
## The red one, and why nobody saw it
`quotes.sh` failed eleven of its forty-odd checks, and the honest account is that
**nine of the eleven were the script's fault, not the page's**. This matters, because
the obvious move — "the page is stale, update the page" — would have been wrong nine
times out of eleven and would have rewritten correct prose.
**Three were an over-wide needle.** `result`, `quoted` and `i64index` compare a
compiler message against the page. The page has them, exactly. But `flan check` grew
a source excerpt with a caret under the offending form, and the script stuffed the
whole of the output into the needle, so it was comparing three lines against a page
that quotes one. The script now takes the message and nothing else.
**Six were probes for refusals that no longer happen.** `(Vec T)`, `(Map K V)`,
`(Handle T)`, an `(Fn …)` parameter, a `defer` inside a `let`, and `break` were each
refused as "not implemented yet" when this loop was written. All six work today. The
page's "Not implemented yet" table lost their rows as each one landed — correctly,
and `acda9ed` says so in its own message — but nobody went back to the probe list.
Those six were the last thing in the tree asserting a claim the page had already
stopped making, and they are deleted, with a comment saying what the list used to
cover so the next reader is not left guessing.
**And what kept this invisible is the part worth keeping.** `flan check` answers a
program that compiles with its entire symbol table. So each of those six probes
failed by printing eighty lines of prelude signatures into a report that was already
forty lines long. The author had built a guard against false greens — the empty-needle
case, with a comment calling it the one failure the script exists to catch — and the
rot arrived instead as a **two-hundred-line unreadable red**. A check whose output
nobody can read is a check nobody reads. A clean exit is now its own one-line failure:
`this program compiles now — the page still says it is refused`.
Two failures were genuine, and both were a document that had gone stale:
**`cell load`.** The page quotes an LLVM excerpt from `flan emit --dev`. A `--dev`
`main` now pushes a condition frame before anything else, which shifted the SSA
numbering by one, so the page's `%t1 = load ptr, ptr @"flan.cell.work"` is `%t2`
today. The three quoted lines are now what the compiler prints, with the frame push
marked as elided rather than silently dropped.
**`renderer`, which is the September 12th failure again and was still there.** The
check greps `NEXT.md` (now `TODO.org`) for a line of inspector output. `NEXT.md` is a rolling scratch
document; the line was rewritten out of it, the grep went empty, the empty-needle
guard fired and said "whatever this quotes has moved" — into a report nobody was
reading. Behind that noise the page was genuinely wrong in exactly the way `check.sh`
was wrong two days ago: the colon-to-dot sweep rewrote every field label in the
corpus, `lib/render.ml` writes `.field` today and `test_repl.ml`'s fixtures are dots
throughout, and the page had been showing `(V {:x 1.5 :y 0})` for a renderer that
prints `(V {.x 1.5 .y 0})`. The page is corrected; so is the identical block in
`docs/BUILT.md`, which nothing checks and which had drifted the same way. The check's
anchor moves from `NEXT.md` (now `TODO.org`) to `test/programs/raylib-imported.flan`, which `dune test`
builds and runs, so it cannot quietly stop saying it.
The design lesson is one line: **an anchor in a rolling document is not an anchor.**
## The hazard in `tools/`
This is not a rotted check, it is worse, and it deserves its own paragraph.
`tools/colon-to-dot.py` advertises itself as re-runnable — "converting an already-
converted file is a no-op, so this can be run again over files a parallel branch
wrote in the old spelling". Run `--check` today and it reports twenty field labels to
convert across six files. Every one of them is `{:where (copyable? $t)}`, the generic
constraint clause, which is not a struct literal and names no field. The generics lane
introduced a brace form the tool has never heard of. **Running that tool for real
would corrupt `test/programs/generics.flan`, `reload-generic.flan`,
`generic-map-reject.flan`, `generic-runaway.flan`, `spike/generics/prelude-shapes.flan`
and `syntax-sketch.flan`.**
`tools/unit-return.py` has the same shape of fault. It decides whether a `defn`
already states a return type by transcribing the old `parse.ml`, and that
transcription knows the prelude's types and the builtins but not a type that arrived
through `declare-c`. So it reads `(defn v2-add [a Vector2 b Vector2] Vector2 …)` as a
function with no return type and would insert `()` in front of sixteen correct
signatures in `vendor/raylib/vector.flan`.
Neither is wired to an alias and neither should be. They are finished one-shot
migrations whose `--check` mode now reports noise, and the safe move is to delete them
or to put a line at the top of each saying they are historical and must not be re-run
without being taught `{:where}` and imported type names first. **That is the author's
call and nothing here touched them beyond reading.** It is flagged because a
destructive script sitting in `tools/` looking maintained is a worse trap than a red
test.
## What got wired
Three stanzas in `test/dune`, in the shape `@x86` established — a `rule` with an
alias rather than a `test`, because a test stanza attaches to `@runtest` and offers no
way to be attached to anything else, and with `FLAN` passed in because a script that
shells out to `dune` from inside a dune action waits on a lock it cannot get.
**`@page`** runs `check.sh` then `quotes.sh`. 42 checks, eleven seconds, five of which
is breakdemo's deliberate `timeout 5`. Its comment gives a *different* reason for being
opt-in than its three neighbours do, and the difference is the point: `@sanitize`,
`@valgrind` and `@x86` are opt-in because they are slow, and this one is not slow. It
is separate because of what a failure means. These two fail when a **document** has
gone stale, not when the compiler has regressed, and a suite that goes red because
prose drifted teaches whoever runs it to skim past red. `dune test` should mean "the
language broke". `@page` should mean "the page is lying".
**`@cells`** runs `test/cells.sh`, which was a real four-check pass/fail script
that nothing in the tree ran. It is not folded into `@x86` because it asks a different
question: the survey asks whether the backend agrees with LLVM about what a program
prints, and no program can answer this one, because a dev build starts with every cell
already pointing at the body this build compiled.
**`@checks`** is `@page`, `@x86` and `@cells` in one word.
All three scripts learned to resolve `FLAN` to an absolute path, which turned out to
be the only thing actually standing between them and a dune rule: `%{workspace_root}`
expands relative to the directory the rule is written in, and every one of these
scripts `cd`s somewhere before using it.
## Item 3: what would genuinely have caught these
The honest answer first. **There is no local mechanism with teeth, and `@checks` is
not one.** It is discoverability, not enforcement. An alias nobody types is no better
than a script nobody types; it only looks more responsible. What the umbrella buys is
that *deciding to check* and *checking everything* become the same act, so the gap
between "somebody wondered" and "everything was verified" is one command instead of
five — and five names to remember is four too many, paid every time, by whoever is
least likely to know they exist. It shortens the gap. It does not close it.
**The recommendation is `@checks` plus a written convention, and nothing else.**
`README.md` now has a "Checking it" section giving the four commands, saying which
means what, and saying plainly that nothing runs `@checks` for you. The convention it
proposes is that a lane's handoff quotes `@checks`, the way the x86 handoffs already
quote the survey's counts — which is a habit this repository demonstrably already has,
and habits that already exist are the only ones worth adding a rule to.
**Against a git hook, which is the tempting answer.** `@checks` is a couple of minutes,
nearly all of it `@x86` building every program twice. A pre-commit hook costing two
minutes gets switched off within a week, and a disabled hook is strictly worse than no
hook because it reads as coverage. A pre-push hook is cheaper in principle, because
pushing is rare — but the remote is not reachable from here and this repository's work
is merged between local worktrees, so there is no push to hang it on. There is no
`.git/hooks` content and no `core.hooksPath` today; that is a reasonable state and it
was left alone.
**What would actually have caught both failures within a day** is CI on every push,
and this repository has none. That is the real answer and it should not be dressed up.
The failure mode being defended against is not "somebody ran the check and ignored the
result" — it is "a month passed and nobody ran anything" — and only a machine that
runs without being asked fixes that.
**One thing deliberately not done, because it is the author's call.** `@page` is eleven
seconds against `dune test`'s forty-eight, and five of those eleven are a fixed sleep.
That is a quarter again on top of the suite — not free, but not the reason it is out
either — and putting it in the suite is the one move that would give it actual teeth. The argument against is the one in its comment —
that mixing "the page is lying" into "the language broke" devalues both — and that is
a judgement about what a red suite should mean, not a measurement. The number is here
so the decision can be made on it rather than on an assumption.
## Verification
- `dune test --root .` — 232 checks, 0 failures, 47s. Unchanged: nothing was added to
the default suite.
- `dune build --root . @x86` — passes. 103 match, 0 differ, 0 refused, 38 skip.
- `dune build --root . @page` — passes, 42 checks. Verified both ways:
`web/examples/hello.out` was temporarily rewritten to say `goodbye world`, the build
went red with a readable diff and exit 1, and the break was reverted. The tree is
clean.
- `dune build --root . @cells` — passes, 4 checks.
## Loose ends for the author
- The two scripts in `tools/` described above. They are live hazards and the fix is a
decision, not a patch.
- `emacs/test-flan-dape.el` cannot run on this machine and nothing loads it. It is the
one check in the inventory whose state is genuinely unknown.
- Three commits — `8bd5547`, `64e4f6c`, `0251cf4` — were on this worktree's branch and
were dropped by the reset to `dev-loop` that this lane was told to perform. They are
your own unmerged edits to `web/index.html`, `plan.org`, `NEXT.md` (now `TODO.org`)
and `lib/build.ml`,
they are unrelated to anything here, and they are still reachable by hash.
+130
View File
@@ -0,0 +1,130 @@
# Tidying the repository root
A note from later: `FIX.org`, `NEXT.md`, `DISCUSS.org`, `docs/DISCUSS.md` and
the root `HANDOFF.md` have since been merged into `TODO.org` at the root, with
the reasoning that would not compress into `docs/BUILT.md`. This report is the
record of a session that moved those files around while they existed, so it
keeps naming them — including inside the grep patterns it ran. Read every
mention of them below as past tense.
The root had thirty-odd files in it, nine of them session reports sitting at the
same level as the language specification, and a README is about to point a
newcomer at that. This is the plan and, below it, what actually happened.
## The plan
1. `git mv` the nine `HANDOFF-*.md` into `docs/handoffs/`, and `../DISCUSS.md`
(since deleted — its decisions are in `TODO.org`, its reasoning in `docs/BUILT.md`),
`../BUILT.md`, `../PORTING.md`, `../SPIKE-GENERICS.md`, `../REFERENCES.md` and
`../overview.md` into `docs/`. These are written once and read occasionally.
2. Leave `plan.org`, `spec-memory.md`, `spec-conditions.md`, `NEXT.md` (now
`TODO.org`), the
`.flan` scratch programs and the build files where they are: they are cited
by bare filename from source comments in dozens of places, and moving them
would be a large rewrite for no gain.
3. Rewrite every citation of a moved file, zone by zone — a flat substitution is
wrong, because a citation inside `docs/` should stay bare while the same
citation in `lib/` needs the `docs/` prefix.
4. Verify by resolving, not by grepping: for every cited path in the repository,
check it exists relative to the citing file or to the root.
5. Write `docs/README.md` saying which of these documents are still true.
## What happened
**The moves.** Fifteen files, all with `git mv` so `git log --follow` still
reaches their history. Nothing else moved.
**The citations.** 238 of them across 52 files, rewritten by zone. The zones
matter and a repository-wide substitution would have corrupted two of them:
| Citing file lives in | `DISCUSS.md` and its siblings | `HANDOFF-*.md` |
|---|---|---|
| the root, `lib/`, `test/`, `emacs/`, `spike/`, `examples/`, `vendor/` | `docs/DISCUSS.md` | `docs/handoffs/HANDOFF-x.md` |
| `docs/` | unchanged — they are siblings | `handoffs/HANDOFF-x.md` |
| `docs/handoffs/` | `../DISCUSS.md` | unchanged — siblings again |
(The file named in the middle column has since been deleted: its decisions live in
`TODO.org` and its reasoning in `docs/BUILT.md`. The names in this table are left as
this session wrote them.)
Most of the citations are prose inside a source comment rather than a link;
there is not a single markdown link among the fifteen moved documents. So the
rewrite is a rewrite of comments, and in `lib/x86.ml`, `lib/build.ml` and
`test/test_reload.ml` — which another lane is editing at the same time — every
touched line is a comment line.
**The verification.** Grep cannot tell you a rewrite is finished, because a grep
for `DISCUSS.md` matches `docs/DISCUSS.md` too. So the check resolves instead:
every path-shaped citation in the repository is looked up relative to the file
that wrote it and relative to the root, and anything that resolves to neither is
reported. 817 citations checked. The only failures are seven mentions of
`HANDOFF-f1.md`, `HANDOFF-f2.md` and `HANDOFF-f3.md` in `../../NEXT.md` (now
`TODO.org`), which were
already dangling before this work — the prose around each one says the file was
deleted — and one false positive where the pattern caught `"$root/NEXT.md"` out
of a shell variable.
### The judgement calls
**`MY-NOTES.org` and `conditions.org` stay at the root.** `MY-NOTES.org` is the
author's scratch — questions to himself about the standard library, in the
second person. `conditions.org` is a live cheatsheet, not a report, and it is
the entry point to `conditions-play.flan` beside it. Neither is a document that
belongs in a directory of finished reports, and personal files are best left
where their owner put them.
**`conditions-play.flan` stays at the root.** The brief said to move it to
`spike/` if nothing referenced it. Three things do: `conditions.org:5` tells the
reader to run it, `NEXT.md:1306` (now `TODO.org`) lists it as the program to poke conditions
with, and `.gitignore` has an anchored `/conditions-play` for the executable it
builds to. It is also not shaped like the rest of `spike/`, which is four
self-contained experiment directories with their own runners, not a home for a
loose `.flan` file. It is a demonstration that belongs next to the cheatsheet
that points at it.
**`.flan-dev.sock` is not in this worktree.** The stale one is in the author's
main checkout, which a worktree-isolated lane cannot reach. The ignore pattern
is added here so the next one never shows up in `git status`; the existing
socket is the author's to remove.
**`.gitignore`.** Five entries at the bottom of the file — `.claude/`, `probe`,
`probe.c`, `/forms.so`, `_hist/` — were uncommented stragglers in a file where
everything else explains itself. They are grouped and explained now, and
`.flan-dev.sock` is added. No pattern was removed or changed in meaning.
### Verification
- `dune build --root .` exits 0.
- `dune test --root . --force` reports 232 checks, 0 failures.
- `test/survey-x86.sh`, run detached, reports 103 MATCH, 0 DIFFER, 0 REFUSED,
0 NOX86 and 38 SKIP — the baseline, unchanged.
The resolver reports sixteen unresolved citations and all sixteen are expected.
Seven are the deleted `HANDOFF-f1.md`, `HANDOFF-f2.md` and `HANDOFF-f3.md` in
`../../NEXT.md`, which the prose around them already calls deleted. One is a
false positive where the pattern caught `"$root/NEXT.md"` out of a shell
variable in `web/examples/quotes.sh`. The other eight are in this file: the
`HANDOFF-x.md` placeholders in the zone table above, and `DISCUSS.md` quoted
bare to explain why a grep cannot verify this work. None of them is a path.
### The branch clutter — reported, not acted on
`git branch --list 'worktree-agent-*'` returns **113** branches. **111** are
fully merged into `dev-loop`. **89** of them have a worktree currently
registered under `.claude/worktrees/`, and deleting one of those would break the
harness. The two unmerged branches are the two lanes running right now.
That leaves **24** branches that are both merged and have no live worktree, and
those are the only ones safe to delete:
```sh
comm -23 \
<(git branch --merged dev-loop --format='%(refname:short)' --list 'worktree-agent-*' | sort) \
<(git worktree list --porcelain | sed -n 's|^branch refs/heads/||p' | sort) \
| xargs -r git branch -d
```
`git branch -d` rather than `-D` on purpose: it refuses anything that turns out
not to be merged after all. The 89 with live worktrees want
`git worktree prune` and a decision about the directories first, which is the
author's call and not a cleanup to run blind.
+179
View File
@@ -0,0 +1,179 @@
# Handoff — the ABI marker symbol, so a crossed pair is refused at dlopen
Branch `dev-loop`, from `682cb74`. This closes the serious finding of `HANDOFF-x86-aggregates.md`: a
redefinition module built by one backend, dlopened into a host built by the other, links and loads and then
dies with SIGSEGV at the first call into a redefined function that takes or returns a struct. Nothing refused
it, and `flan build game.flan --x86 --dev` followed by `flan reload game.flan changed.flan` builds exactly that
pair.
**It is refused now, by the loader, before any of the new code runs, with a sentence that says why.** Both
directions are in `dune test`.
## What was built
| file | what |
|---|---|
| `lib/x86.ml` | `abi_marker = "flan.abi.x86"`. `program` defines it in `.data`, in a dev build only; `redefinition` emits a `.quad` against it |
| `lib/emit.ml` | `abi_marker = "flan.abi.llvm"`, the same two halves — a definition inside `program`'s existing `if dev` block, and an `external` plus a hidden pointer to it in `redefinition`'s |
| `vendor/agent/flan_agent.c` | `abi_mismatch`, which turns a `flan.abi.` `dlopen` failure into a sentence; the reload handler uses it |
| `test/reload_host.c` | the same function, duplicated rather than shared, and the same use at its `dlopen` |
| `test/test_reload.ml` | `agg_cross`, run both ways, asserting a nonzero exit *and* the sentence *and* which marker was missing |
`lib/build.ml` was not touched. Its option-record guard is the other half of this answer and is unchanged; the
comment there already says what it cannot catch, which is what the marker is for.
## The mechanism, and why it is a datum and not a call
A dev build defines a marker symbol naming the backend that built it. A redefinition module emits a
pointer-sized datum holding the address of the marker it was itself built for.
That datum is the whole of it. A pointer in `.data` is a relocation the dynamic loader has to resolve while it
maps the object, whatever it does about lazy binding of calls, so a host that does not define the marker fails
the `dlopen` outright. A call into an absent function would do as well under `RTLD_NOW`, which is what both
loaders in this repo pass, but the datum does not depend on that and costs eight bytes.
Both sides are gated on `dev`, for two reasons that happen to agree. A release build has no cells and nothing
to load into one, so the marker would be dead weight; and `Build.executable` passes `-rdynamic` only for a dev
build, so a release build could not export the symbol even if it emitted it. Gating also keeps a release
build's output byte-for-byte what it was, which `x86.ml`'s `.Ldwtext` comment says the repo cares about.
Measured on `test/programs/cleanup.flan`:
```
x86 --dev : 00000000004280f0 D flan.abi.x86
llvm --dev : 000000000041d3f0 B flan.abi.llvm
x86 release : (nothing)
```
Macro modules are unaffected and were checked: `Build.macro_module` calls `Emit.program` without `~dev`, so a
macro module neither defines a marker nor requires one, and the OCaml-side `dlopen` in `lib/dynload_stubs.c` —
which is the *only* other `dlopen` of a Flan-built object anywhere — never sees one. It therefore got no arm,
deliberately.
`lib/dev.ml` got no arm either, and this is worth stating because it looks like an omission. The daemon builds
the host itself and sends every module to the agent in the game process; it has no `dlopen` of a redefinition
module of its own, and `--x86` has no spelling anywhere in `dev.ml` or `session.ml`, so a `flan dev` session
builds host and modules both through LLVM and is matched by construction. The mismatch is only reachable
through `flan build --x86 --dev` plus a separately built module, and that module is loaded by the agent, which
is where the sentence lives.
## What the crossed pair does now
Verbatim, from the two new cases in `test/test_reload.ml`, captured from the host's stderr:
```
flan: the module and this host were built by different backends: the module came from LLVM and needs
flan.abi.llvm, which an --x86 host does not define. The two backends pass every struct differently. Rebuild
the host without --x86.
```
```
flan: the module and this host were built by different backends: the module came from the x86 dev backend and
needs flan.abi.x86, which this host does not define. The two backends pass every struct differently. Rebuild
the host with --x86.
```
Exit 1 in both cases, from the host refusing to install. Before this it was exit 139 — SIGSEGV, at a call site,
after `a1` and `host 54063108` had already been printed.
The agent's wording is longer than the test host's, because its reader is a person in an editor rather than a
test: it names the running program rather than "this host", says the pair would die at the first call into a
redefined function taking or returning a struct, and — in the LLVM-module direction — says why the program is
the half that has to move.
The agent's two sentences have no unit test of their own, so they were checked the one way that matters: that
they are in a binary a user would actually run. `vendor/agent/flan_agent.c` reaches a program through the
vendor package's C sources rather than through an embedded-as-a-string module like `Runtime_src`, so an edit to
it lands without regenerating anything — but that had to be confirmed rather than assumed, because a repo that
embeds one C file that way can embed another.
```
flan build test/programs/agent.flan --dev -o ahost ; strings ahost | grep -c "built by different backends" → 2
flan build test/programs/agent.flan --x86 --dev -o ahost ; strings ahost | grep -c "built by different backends" → 2
```
Two, in both configurations: one sentence per direction.
Two things about the matching that are deliberate. It matches on the *marker's name*, not on `"undefined
symbol"`, which is glibc's phrasing and glibc's to change. And `dlerror` is one-shot with a buffer the next
`dl` call may clobber, so the pointer is taken once and used for both the test and the reply; the agent's
existing code called it once and still does.
## Why it is in `dune test` now, when the segfault was not
The aggregate lane measured the crossed pair and deliberately left it out: it was undefined behaviour, what it
printed was a property of whichever LLVM was installed, and a test pinning it would have been pinning the shape
of a crash. That is no longer true. The refusal happens in the loader, at a fixed point, before a single
instruction of the new body runs, so it is deterministic and is asserted the way the retyped-global and
registry-overflow cases already were: on the exit status *and* on the message.
Both directions, because a marker only one backend emitted would refuse in one direction and say nothing in the
other — and the direction with no test is the direction that quietly stops working.
The matched pairs are unchanged and still print the transcript the aggregate lane derived:
```
a1 / host 54063108 / a1 / after1 54063108 / a2 / after2 104337044 / counter 12
```
from `Emit.redefinition` + `Build.shared` and from `X86.redefinition` + `Build.shared_x86` alike.
And "a matched pair is unaffected" is checked through the agent as well as through `reload_host.c`, which is
worth saying because the two are different code paths and only one of them is what a user meets. The daemon
tests in `dune test` — `dev-globals`, `dev-repl`, `dev-watch`, `dev-pause`, `dev-loop` — start a real `flan
dev` session, build a real host, and send real redefinition modules to the agent in it over the socket. Every
one of those `dlopen`s now has to resolve `flan.abi.llvm`, and every one of those tests passes.
## `flan reload --x86` did not land, and should not have
The brief left this to judgement. It is item 3 of `HANDOFF-x86-redef.md`, not a flag.
`flan reload` does not build a module directly; it runs a `Session` over the program and asks
`Session.eval` for one, and `Session.change` holds a single `ir : string` field filled by
`Emit.redefinition ~consts`. Three things would have to move together:
- `Session.change` would need a backend-tagged payload rather than an `ir` string, and every one of the six
`Emit.redefinition` call sites in `session.ml` would have to choose.
- `X86.redefinition` raises `Unsupported` on `~consts`, on `~call`, and on any name the host was not built
with. `Session.eval` passes `~consts` as a matter of course and the expression path passes `~call`. So the
x86 path would refuse most of what a session legitimately sends, and the refusals would surface as "this
works in LLVM and not in x86" rather than as anything a user could act on.
- The daemon is the real caller of all of this. Giving `flan reload` a flag the daemon does not have would
leave the two commands disagreeing about what a session can do.
So the deliverable here is the refusal, which is the part that matters: a silent segfault is the bug. The
remedy the messages name is the one that actually exists today — rebuild the host to match the module — rather
than a flag that does not. When item 3 does land, the marker is what makes the choice checkable rather than
merely intended, and these two crossed tests are what will catch a half-done version of it.
## Baseline
| | before | after |
|---|---|---|
| `test/survey-x86.sh` | 103 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86 | **103 / 0 / 0 / 0** |
| skip breakdown | 28 does-not-compile / 8 no-main / 2 runs-forever | **28 / 8 / 2** |
| `test/cells.sh` | 4/4 ok | **4/4 ok** |
| `dune test --root .` | exit 0, 232 checks, 0 failures | **exit 0, 232 checks, 0 failures** |
The survey is the measurement that could have moved and did not, which is the point of running it: both
backends now emit a symbol into every `--dev` build, and the survey's default is a release build on both sides,
so an unguarded marker would have shown up as 103 identical-but-different objects rather than as a wrong
answer. It agrees byte-for-byte on what the programs print.
Run it detached — `setsid timeout 2400 test/survey-x86.sh > log 2>&1 </dev/null` — or a signal to this
harness's process group comes back as `SURVEY_EXIT=143`, which is not a result. The log is block-buffered
through the redirect and stays empty until the end; that is not a hang.
## What remains
- Item 3 of `HANDOFF-x86-redef.md`: `flan dev` and `flan reload` choosing host and module backend together.
The marker is now the thing that makes that checkable.
- A per-shape crossed measurement, still unmeasured and still depended on by nothing. It is *harder* to get
now, not easier: the marker refuses the pair before any of the four `step` functions runs, so anyone who
wants the answer has to build the crossed module with the marker suppressed on purpose.
- Items 1, 2 and 5 of `HANDOFF-x86-redef.md`, untouched.
- A seam worth knowing about before anyone renames a marker. The two strings live in OCaml — `X86.abi_marker`
and `Emit.abi_marker` — and the two `abi_mismatch` functions match the same literals in C, with nothing
linking the four. Rename one and the refusal still fires, because the symbol is still missing; it just stops
being a sentence and reverts to the loader's bare "undefined symbol", which is the failure this lane was
about. The crossed tests would catch it — they assert on the marker's name — so the seam is guarded, but it
is a seam.
@@ -138,9 +138,9 @@ together, the marker is what makes the choice checkable rather than merely inten
| | before | after |
|---|---|---|
| `spike/x86/survey.sh` | 99 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86 | **99 / 0 / 0 / 0** |
| `test/survey-x86.sh` | 99 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86 | **99 / 0 / 0 / 0** |
| skip breakdown | 28 does-not-compile / 6 no-main / 2 runs-forever | 28 / **8** / 2 |
| `spike/x86/cells.sh` | 4/4 ok | **4/4 ok** |
| `test/cells.sh` | 4/4 ok | **4/4 ok** |
| `dune test --root .` | exit 0 | **exit 0** |
The `no-main` count moving from 6 to 8 is the two new fixtures existing: `survey.sh` globs
+233
View File
@@ -0,0 +1,233 @@
# Handoff — `flan emit --x86` annotated, so its output can be read
Branch `dev-loop`, from `f772162`.
The x86 backend exists so that a dev build is ours end to end and so that a reader can be told *why* each
instruction is there. Until now `flan emit --x86` printed a three-line file header and then nothing but
`.byte` blobs: no source form, no frame key, no statement of the calling convention, nothing naming the
bookkeeping. Reading it meant `as` plus `objdump`, and even that answers only *what*.
**It is annotated now.** Every run of bytes is headed by the Flan form that produced it; every function
carries a frame map and a statement of how its arguments arrived; and every piece of bookkeeping the
compiler adds has a name where it appears and an explanation once, in a legend at the top of the file.
## What was built
| file | what |
|---|---|
| `lib/loc.ml` | `Loc.snippet` — the text of a span, on one line, whitespace collapsed, or `None` where there is no readable source. The squiggle's other half: the squiggle points at a form in its own file, this quotes it somewhere the file is not |
| `lib/x86.ml` | the annotation machinery: a queue of pending comments on `buf`, the per-form hook in `lower`, `frame_map`, the bookkeeping `note`s, and the legend |
| `bin/main.ml` | `emit --x86` annotates; `--no-annotate` is the bare spelling |
| `spike/x86/annot.sh` | **new** — emits every program in the corpus both ways, assembles both, and compares every section of the two objects byte for byte, in all three of default, `--dev` and `--debug` |
| `tools/dump.sh` | its x86 section now shows the annotated listing *and* the disassembly of the object that listing assembles to — why beside what |
`lib/build.ml` was not touched. A `--x86` build emits exactly the assembly it emitted before.
## The two decisions
**Annotation is always on for `flan emit --x86` and always off for a `--x86` build.** `flan emit` exists to
be read by a person, and there is no reason to make a reader ask for the thing the command is for. A build's
`.s` is a temporary file handed straight to clang and read by nobody, so leaving it untouched costs nothing
and buys something: `survey.sh`'s 103 MATCH stays a statement about the same text it has always been about,
rather than about text this lane rewrote. `--no-annotate` exists for exactly one consumer, `annot.sh`.
**None of this belongs to the LLVM path.** `flan emit` already prints IR that names its values, carries
`!dbg` on every instruction and a `!DILocalVariable` per slot. The problem being fixed here is one this
backend has and LLVM does not.
## Mnemonics: not done, deliberately
The brief allowed a trailing mnemonic per line as a bonus and said not to write a disassembler for it. No
disassembler was written — but the honest reason is not that one would be needed. The encoder knows the
mnemonic at the moment it emits the bytes; threading a text trace through every one of its entry points,
with memory-operand formatting to match, is a larger change than the request and would touch every encoding
function in the file, which is the part of it the survey is a structural check on.
What replaces it is cheaper and arguably better: `dump.sh` now prints the annotated `.s` and `objdump`'s
disassembly of the same object side by side. The disassembly says what the instructions are; the listing
says why they exist. Neither answers the other's question, and having both is what the four-way comparison
was for.
## How the annotation is attached, and why that shape
The bytes accumulate in `buf.pend` and flush as one `.byte` directive. A comment therefore cannot simply be
written when a form starts lowering — the form may emit nothing, and a heading left standing would be read
as belonging to whatever came next.
So headings are **queued, not written**. `annote` puts one on `buf.ann` with a serial; the next byte written
flushes the pending directive and then the queue, so the comment lands immediately above the bytes it is
about; and `unannote` withdraws whatever a form queued and never spent. The serials are monotonic, so
"was mine written?" is one integer comparison against the highest serial ever written.
The hook is in `lower`, beside `dwline`, and for the same reason `dwline` is there: the recursion that
lowers a nested call also lowers its arguments, so a heading queued in `lower` spans exactly the bytes that
form and everything inside it emit. The margin moves with the nesting, so an argument's code steps in and
the call's steps back out and the shape of the expression is visible without reading a word.
**Atoms are not annotated**, and this is the single detail the acceptance test turns on. A literal, a local
or a global would steal its parent's heading: `(* n 2)` lowers as a load, a load and an `imul`, and if the
two operands each queued a heading of their own then the line standing above the `imul` would name the
literal `2`. Skipping them leaves `(* n 2)` queued until the first byte and spanning the whole run. Read the
sample below: the line above the `imul` is the one that had to be right.
A form the checker invented carries `Loc.unknown` and is not annotated either — there is no source text to
quote, and it inherits the heading of the form that contains it, which is where it really came from. A form
at the same position as the last heading written is skipped, which is what stops a macro from printing its
call site once per form of its expansion. A function whose source is not readable from here — the prelude —
gets its frame map, no form headings, and one line saying so, rather than a column of bare positions.
## The frame map
This is the half that matters most and the one no amount of disassembly recovers. LLVM's output names its
values; this backend's cannot, because every value it has is a bump-allocated frame temporary and a
temporary has no name. So `-0x20(%rbp)` is the whole vocabulary of the listing and the map is its key.
Everything in it is read out of state `emit_fn` already keeps — `f.slots` *is* what the prologue stores
through, `fn.snames` is what the source called each slot — so it cannot drift from the code it describes.
The boundary between named slots and temporaries is captured as `fixed` right after the fixed allocations
and before the body is lowered; `maxframe` would be the wrong number, because that is the high-water mark of
the temporaries rather than where they start.
What is deliberately *not* described is any individual temporary. `scoped` reclaims them and a later form
reuses the bytes, so naming an offset that holds something else half the time is worse than saying where the
region begins — the same call the DWARF above it makes about locals, and for the same reason.
## The bookkeeping, named
Five things the compiler adds that no form asked for, each named where it appears and explained once in the
file legend rather than at every site: the transfer guard after every call to Flan code, the bounds check and
its signalling slow path, the arithmetic guard and the float-to-integer range check, `rep movsb` for every
aggregate copy, and the indirection cell a `--dev` build calls through. The prologue, the epilogue, the
transfer exit and C's `main` carry a prose block each.
## A worked sample
`tools/dump.sh small.flan twice`, on
```
(defn twice [n i64] i64
(* n 2))
```
LLVM at `-O2`:
```
flan.twice:
movq %rdi, -8(%rsp)
leaq (%rdi,%rdi), %rax
retq
```
and the same function out of this backend:
```
# ────────────────────────────────────────────────────────────────────
# "flan.twice" (defn twice [n i64] i64 small.flan:1:7
#
# Arguments: n from rdi.
# Returns i64 in rax.
# The transfer channel arrives last of all, from rsi. It is a pointer to the
# cell a callee writes its target into, and reading it is what every guard
# below does.
#
# The frame is 0x30 bytes below rbp. No call in it passes an argument on the
# stack.
#
# -0x8 n i64 parameter 1, from rdi
# -0x10 <chan> ptr the transfer channel this frame passes on
# -0x18 <ret> i64 the return value the epilogue loads
# Everything below -0x18 is a temporary. They are bump-allocated and
# reclaimed at the end of the form that made them, so a later form reuses
# the bytes and no one offset down there means one thing for long.
# ────────────────────────────────────────────────────────────────────
.globl "flan.twice"
.type "flan.twice", @function
"flan.twice":
# The prologue: save rbp, take the frame in one sub, and spill every incoming
# register into its slot. rsp is written here and by leave and nowhere else,
# so rsp % 16 == 0 at every call site below is a property of that one rounded
# sub rather than an invariant each case has to keep.
.byte 0x55,0x48,0x89,0xe5,0x48,0x81,0xec,0x30,...
# (* n 2) small.flan:2:3
.byte 0x48,0x8b,0x85,0xf8,0xff,0xff,0xff,0x48,0x89,0x85,0xe0,...
# The epilogue, and every return and every transfer out of this frame arrives
# here, so the frame is torn down once.
.Lret1048:
.byte 0x48,0x8b,0x85,0xe8,0xff,0xff,0xff,0xc9,0xc3
.size "flan.twice", . - "flan.twice"
```
Three LLVM instructions against thirteen, and the listing now says where the difference went: a real frame
rather than a red zone, because rsp is written twice per function and never at a call site; a transfer
channel parameter that LLVM's `-O2` dropped as dead and this backend spills because a dev build does not
optimise; and both operands of the multiply through frame temporaries at `-0x20` and `-0x28`, because every
intermediate in this backend is a frame temporary. None of that is visible in the disassembly and all of it
is visible here.
A denser one, from `bounds.flan` — the four instructions after every call, and the bounds triple:
```
# (at args 1) bounds.flan:11:35
.byte 0x48,0xb8,0x01,...
# The bounds check. One unsigned compare catches a negative index as
# well as an oversized one, and the not-taken branch is the whole of
# the fast path.
.byte 0x48,0x63,0x85,0xa4,...,0x48,0x39,0xc8,0x0f,0x82
.long .Linb1078 - . - 4
# Out of bounds: the location string, the operands, and this frame's
# channel, then flan_bounds_error, which signals
.byte 0x48,0x8d,0x3d
.long .Lk1079 - . - 4
...
call flan_bounds_error
# The transfer guard, after every call to Flan code: load this
# frame's channel, load through it, test, and branch if it is set —
# a callee that transferred left a target there and the value in rax
# means nothing.
.byte 0x4c,0x8b,0x9d,0xd0,...,0x0f,0x85
.long .Lxfer1077 - . - 4
# ud2, where emit.ml writes unreachable. Nothing answered the signal,
# so the runtime already died inside that call and nothing falls
# through to here.
.byte 0x0f,0x0b
```
## Verification
**Byte identity — `spike/x86/annot.sh`, 342 SAME / 0 DIFFER, measured on this code.** Every program in `test/programs` and in
`spike/x86`, emitted both ways, assembled both ways, and every section of the two objects compared byte for
byte — in the default build, in `--dev`, and in `--debug`. The `--debug` case is the sharp one: a
`.debug_line` row is an address expressed as a label, and annotation issues no labels and consumes no
`uniq`, precisely so that those cannot move. The 84 SKIPs are programs that do not compile at all — the
reject corpus and the generic-milestone ones — and are the same set the survey skips.
**`dune test --root .` — 232 checks, 0 failures**, run against this code. `dune build @page` and
`dune build @cells` both exit 0; `@cells` reports `x86 --dev: 22 22` and `x86 : 42 42`, which is the check
that the indirection cells and the ABI marker still come out where they were.
**`test/survey-x86.sh` has NOT been run on this work.** Two runs were started and both were invalidated by
racing with a `dune build` that replaced `bin/main.exe` underneath them; a third was killed on instruction
before it finished. **Whoever picks this up must not assume the 103 MATCH / 0 DIFFER / 0 REFUSED baseline
still holds — it is the one check that matters and it is outstanding.** Run it detached and with the
compiler pinned, so it cannot race a rebuild:
```
FLAN=_build/default/bin/main.exe setsid timeout 2400 test/survey-x86.sh > log 2>&1 </dev/null
```
What makes an unwelcome result unlikely rather than impossible: the survey builds through `flan build --x86`,
which passes `annotate = false`, so the assembly it compiles is produced by the same code path as before with
one field added to a record and two branches that are never taken. The edits that *could* reach it are the
ones to `buf` — `flush` now writes `b.ind`, which is `""` in a build, and `u8`/`dir`/`text`/`lbl` now test
`b.ann`, which is empty in a build. `annot.sh` proves those are inert for the object at 342 SAME across the
corpus in all three of default, `--dev` and `--debug`, but it proves it about the *assembly*, and only the
survey proves it about the programs.
## What is not here
- No mnemonics, for the reason above.
- No annotation of an individual temporary, for the reason above.
- The redefinition emitter (`X86.redefinition`) builds its install stub with annotation off. It is generated
code with no Flan form behind it and a frame map of two slots; the legend would be longer than the
function. If the reload path ever wants a readable listing, the `ann` field on its context is the one
line to change.
@@ -16,7 +16,7 @@ reason: it builds every corpus program twice and runs both, which is minutes whe
dune build --root . @x86
It is a `(rule ...)` with no `(executable ...)` beside it, which is where it differs from its two neighbours. The
check already exists — `spike/x86/survey.sh` is what every handoff quotes its counts from — and a second
check already exists — `test/survey-x86.sh` is what every handoff quotes its counts from — and a second
implementation of it in OCaml would be a second thing to drift, which is exactly the failure this alias is meant
to prevent. So the rule runs the script, and the script grew three environment variables to make that possible:
@@ -212,8 +212,8 @@ multi-file case: its directory table has one entry and its file table two, `gene
| | before (`957ba07`) | after, on `957ba07` | after, rebased onto `eacf7c4` |
|---|---|---|---|
| `spike/x86/survey.sh` | 99 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86, skips 28 + 6 + 2 | **99 / 0 / 0 / 0**, skips 28 + 6 + 2 | **101 / 0 / 0 / 0**, skips 28 + 6 + 2 |
| `spike/x86/cells.sh` | 4/4 ok | 4/4 ok | **4/4 ok** |
| `test/survey-x86.sh` | 99 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86, skips 28 + 6 + 2 | **99 / 0 / 0 / 0**, skips 28 + 6 + 2 | **101 / 0 / 0 / 0**, skips 28 + 6 + 2 |
| `test/cells.sh` | 4/4 ok | 4/4 ok | **4/4 ok** |
| `dune test --root .` | exit 0 | exit 0 | **exit 0**, run without a pipe; acceptance reports **232 checks, 0 failures** |
The MATCH count went from 99 to 101 across the rebase and neither is this lane's doing: the
+260
View File
@@ -0,0 +1,260 @@
# Handoff — the x86 backend, wired to the dev loop
Branch `dev-loop`, worktree `agent-aaf84e55296df121c`, from `f459352`. Items 1, 2 and 3 of
`HANDOFF-x86-redef.md`'s "What remains": the new-name path, the transient thunk, and the wiring that lets
`flan dev` choose the backend for a host and its modules together.
**All three landed, and the numbers say the lane was worth it.** A `C-c C-c` round trip through a real daemon
is **62–66ms on LLVM and 27–30ms on `--x86`** on this machine; `C-x C-e` is **60ms and 24ms**. The build inside
those is **53ms and 17–18ms**. Details and caveats in §Measured.
There is one finding that is not good news and it is in §The merged daemon: `flan dev --x86` refuses the
*merged* daemon and needs `--two-process`, because the compiler's macro module is a third way the two backends
can meet in one process and `flan.abi.x86` does not guard it.
> **Closed.** `docs/handoffs/HANDOFF-x86-macro-visibility.md` took the recommendation in §The merged daemon —
> hidden visibility on a macro module's Flan bodies — and `flan dev --x86` starts a merged daemon now. The
> refusal in `Dev.start` is gone, `test_dev.ml` drives a merged `--x86` session through C-x C-e, C-c C-c, a
> park and a `rerun`, and the analysis below stands as the account of what the failure *was*. One correction
> to it: by the time that lane ran, `-Wl,-Bsymbolic` had already landed for an unrelated reason (a package's
> `defmacro` resolving to the merged host's copy, commit 65d14f4) and was incidentally suppressing this crash
> too. It has been removed in favour of the narrow fix, for the reason §The merged daemon gives for not
> wanting it.
## What was built
| file | what |
|---|---|
| `lib/x86.ml` — `loc`'s new `Lslot`, `fnctx.slot` | a name the host was never built with: the address lives in a module-local slot the installer fills by string. `Lgot`'s shape with `Sym` where it has `Got` |
| `lib/x86.ml` — `redefinition`'s installer | `flan_dev_cell` / `flan_dev_global` lookups, an init image per new global, the `consts` republish, and a real frame so those calls are made on an aligned stack |
| `lib/x86.ml` — `flan_reload_call`, `flan_reload_transient` | what an expression evaluation compiles to, and the claim the agent unloads on |
| `lib/x86.ml` — `string_const` | now bumps `Emit.m.nstr`, which is the third transient condition |
| `lib/session.ml` — `t.x86`, `change.x86`, `redefinition` | the backend choice, as a session setting, made in one place |
| `lib/dev.ml` — `build_module`, `module_ext`, `Dev.start ?x86` | the daemon picking builder and extension off the change, and the two refusals |
| `lib/dev.ml` — `rename_program_main_asm`, `merged_executable`'s fork | an `--x86` merged host, which builds and is currently refused (see below) |
| `bin/main.ml` | `flan dev --x86`, `flan reload --x86`, and a bare `Failure` printed as a sentence |
| `test/programs/reload-v6.flan` | a run-time-new global with a value of its own |
| `test/test_reload.ml` | the x86 section runs all four modules against the LLVM transcript; the refusal it asserted is gone |
| `test/test_dev.ml` | an `--x86` daemon driven through `C-c C-c`, `C-x C-e`, a literal, a new `defvar` and a new `defn`; plus the merged refusal |
## 1. The new-name path
A function or a `defvar` the running process has no symbol for. ELF cannot grow one, so the address is asked
for by string at install time and parked in a slot this module defines — `flan.cellp.<n>` for a function's
cell, `flan.gp.<n>` for a global's storage, spelled as `Emit.cellptr` and `Emit.globalptr` spell them so the
two sides read against each other.
The reference side is **one new `loc` case and nothing else**. `Lslot` loads the slot and answers
`Reg (scratch, d)`, which is exactly what `Lgot` already did with `Got` where this has `Sym`. Every site that
reaches a cell already double-loads, so no call site, no place expression and no `sym_loc` caller had to learn
a third case. `fnctx.slot` is a *second* predicate rather than a widened `ext` because the two answer different
questions: `ext` says "the host's, reach it through the GOT", `slot` says "nobody's yet, reach it through a
slot I filled". It defaults to `fun _ -> None`, so the whole-program path emits byte-identical output and
`survey.sh` goes on being a structural check on all of this.
Three things that would each have cost a session:
- **`flan_reload_install` had to become a function with a frame.** It was a run of loads and stores plus `ret`,
which was fine while it called nothing. The moment it calls `flan_dev_cell`, rsp is 8-mod-16 at the call and
glibc's `movaps` inside `strcmp` faults — a failure that reads as a backend bug and is an ABI bug. Its shape
is now `emit_globals_init`'s, down to owning the null transfer cell no caller hands it.
- **A new global's declared value has to travel with it.** `flan_dev_global` copies it onto the allocation the
first time the name is interned and ignores it after, which is where "a reload must not reset the program's
state" lives. `emit.ml` folds that value into an LLVM constant and this file has no folder, so the image is a
module-local `.bss` buffer written by the initialiser *lowered as ordinary code* — which is the bargain
`emit_globals_data`'s comment already makes for a whole program, and it needs no second evaluator that could
disagree with the first about what a struct literal means.
- **Order inside the installer is load-bearing and is not checkable from outside.** Every lookup resolves
before any body is published; publishing first exposes a function whose slots are still null to every call
site in the host. `emit.ml` says the same and `test_reload.ml` checks it *there* by grepping the IR text.
There is no text to grep on this side, so the guarantee is the loop order and the comment above it.
`reload-v6.flan` is new and is the reason the fixture set was not enough. v3's `extra` is declared zero, which
`calloc` also gives, so a run-time-new global whose image never arrived would still have passed. v6's `tuning`
is 42 and the host prints 88.
Republishing a `defconst` came free once the rest was there — one store of the new constant into the host's
global, which is what `emit.ml` does — so that refusal is gone too.
## 2. The transient thunk
`flan_reload_call` is `emit_main` without the argv and the exit: no caller hands it a transfer channel, so it
owns a null cell on its own frame and passes that cell's address on. Sixteen bytes of frame rather than eight,
because rsp has to be 16-aligned at the call.
The thunk is excluded from everything else the module does — no cell, no publish, no registry slot. There are
4096 slots and an expression evaluated in a loop would exhaust them, and a module with nothing pointing into it
is what lets the agent `dlclose` it at all.
`@flan_reload_transient` is that claim, under `emit.ml`'s three conditions. The third is about **data** rather
than text and is the one that can be got wrong in the dangerous direction: a string literal lives in this
module's image, an expression may store one anywhere it likes, and a global left pointing into an unmapped
image is silent garbage rather than a fault. So the count is kept where the literals are made — `string_const`
bumps the same `Emit.m.nstr` field `emit.ml` counts on. Two deliberate decisions in that:
- **A float constant is not counted.** It is a label in the same `.rodata` and it is *loaded*, never retained.
Counting rodata bytes would be the wrong test; `emit.ml` keeps frame descriptors in a separate `nfi` for
exactly this reason and says so.
- **The installer's own registry name strings are counted.** That is right rather than incidental: a module
that interned a name left something behind, and `flan_dev.c` `strdup`s it precisely because the module that
passed it may go away.
## 3. The wiring
**The choice is a session setting, not a per-command flag**, and it is spelled exactly as `debug` already is:
one field on `Session.t`, set once in `Dev.start`, carried on every `Session.change` the session emits.
`session.ml`'s own comment on `debug` gives the reason and it is the same one — the modules have to match the
process they are loaded into. `Session.redefinition` is the single place that picks a backend, so the six call
sites cannot disagree and the refusal has one home; `change.x86` rides beside `change.ir` so the text and the
builder can never come from two different answers to the same question.
**There is no fallback and there must not be one.** If `X86.redefinition` refuses a form the daemon reports the
refusal. Quietly building an LLVM module instead is precisely the crossed pair `flan.abi.x86` exists to refuse
at `dlopen`. A refusal reaches the editor as a diagnostic like any other: `X86.Unsupported` is re-raised as a
`Loc.Error` at the form it is about, because every caller already handles that and none handled the other, and
a daemon that died on the first unsupported form would be worse than one that says so and stays up.
`flan reload` got the same flag at the same time. A command that could build a module for a host the other
backend compiled is how the crossed pair was reachable from the CLI at all; `HANDOFF-x86-aggregates.md`'s
two-line reproduction no longer has a second half.
**`--x86 --debug` is refused for the daemon**, and this is a decision rather than an oversight.
`flan build --x86 --debug` stays allowed — `X86.program` emits a hand-written DWARF 4 unit — but
`X86.redefinition` emits none, so a `--debug` session would build a host with a line table and then send it
modules without one. A breakpoint set on a line in the buffer would fire before the first `C-c C-c` and stop
firing after it. Accepting the flag and ignoring it would be worse.
## The merged daemon, which is the finding
**`flan dev --x86` refuses the merged daemon and needs `--two-process`.**
A merged build is the program and the compiler in one process. The compiler expands macros by `dlopen`ing a
module `Build.macro_module` made — through `Emit.program`, always, and cached on disk under
`~/.cache/flan/objcache` by the *macro source*, not by the backend. A merged host is linked `-rdynamic` so a
redefinition module can reach its cells, and that exports every `flan.*` body it has. So the macro module's own
copy of a prelude function is **interposed by the host's**.
With an LLVM host both halves are LLVM and nobody notices. With an `--x86` host the caller is LLVM and the body
it lands in is this backend's. Measured, before the refusal went in:
```
Thread 2 "program" received signal SIGSEGV
#0 0x0000000000403884 in flan[clamp] () <- the --x86 host's body
#1 0x00007ffff6f6b58c in flan.macro[clamp] () from ~/.cache/flan/objcache/flan-macros-....so
#2 flan_macro_call (dynload_stubs.c:68)
...
#19 camlFlan__Session.create_inner (lib/session.ml:117)
#20 camlFlan__Dev.merged_setup (lib/dev.ml:3013)
```
During the **first macro expansion**, before the program had started. `flan dev --x86` printed its build line
and exited 139.
Three things worth stating about it:
- **`flan.abi.x86` does not catch this and was never meant to.** It guards a redefinition module.
`HANDOFF-x86-abi-marker.md` reasoned explicitly that macro modules are unaffected because
`Build.macro_module` calls `Emit.program` without `~dev`, so a macro module neither defines nor requires a
marker — and that reasoning was correct for what it covered. This is a *third* path, and it exists only
because the merged daemon puts the compiler and the program in one address space.
- **`-Bsymbolic` is the wrong fix**, though it is one line. The macro module links its own `flan_rt.c`, and
binding symbolically would bind those calls to a copy of the runtime `flan_rt_init` never ran on — a second
bug hiding behind a green suite — and it would change the cached object for the LLVM path, which is the
default and must not move. `-Bsymbolic-functions` has the same defect.
- **The honest fix is hidden visibility on a macro module's Flan bodies.** That leaves the C runtime symbols
binding to the host's exactly as they do now and stops only the Flan ones from being interposed. It still
changes the cached object for both backends, so it wants a lane and a suite run of its own.
`--two-process` has no such meeting: the compiler is a separate binary that LLVM built, the macro module is
loaded into *it* and never into the program, and the only thing crossing between them is a redefinition module
— which this session now builds with the same backend as the host.
`start_merged` keeps its `--x86` plumbing (`rename_program_main_asm`, the `merged_executable` fork, the
`FLAN_DEV_X86` environment variable). It is unreachable today. It is the half that is right and it is what will
be wanted the day the macro module is fixed: the `--x86` merged host does build and link, and it is the *macro*
module and nothing about the host that stops it.
## Measured
One machine, `llc`/`clang` as installed here, warm caches. **This machine's `llc` is slower than the one
`docs/BUILT.md` was written on** — 42–48ms against that file's 15–17ms — so both rows below are measured here
rather than compared against that table. The shape of the answer is what matters and it does not depend on
which machine: `llc` is replaced by `as`, and `as` is five times cheaper.
`flan reload`, five runs each, on one changed `defn`:
| | LLVM | `--x86` |
|---|---|---|
| codegen | `llc` **42–48ms** | `as` **8.0–8.4ms** |
| link | `ld -shared` 9.7–11.4ms | `ld -shared` 8.9–9.3ms |
**Read `timing.llc_ms` carefully**: `Build.shared_x86` puts the *assembler's* time in that field, because the
record is shared between the two. `flan reload` now prints `as` or `llc` according to what ran, so its output
does not mislead; anything reading the field directly still has to know.
The round trip a user feels, through a real `flan dev --two-process` daemon on `programs/dev-repl.flan`,
measured at the socket — so this includes checking the form, emitting, building, delivering to the agent and
waiting for a frame boundary:
| | LLVM | `--x86` |
|---|---|---|
| `C-c C-c` (`eval`, one `defn`) | 62, 66, 65ms | **27, 28, 30ms** |
| of which the build reports | 53.0, 52.6, 53.5ms | **17.9, 17.2, 18.2ms** |
| `C-x C-e` (`eval-expr`) | 61, 60, 60ms | **24, 24, 25ms** |
**Better than twice as fast end to end, and three times on the build.** That is the claim the lane was written
to test and it holds.
Two things the numbers say that the headline does not:
- **`ld -shared` is now the bulk of the `--x86` build.** 9ms of an 18ms build, and it did not move between the
two backends. `docs/BUILT.md` records it at 3ms on the other machine; whatever the cause, the next
millisecond in this loop is in the linker and not in codegen. Nothing here tried to move it.
- **About 9ms of the round trip is neither codegen nor link.** 27ms at the socket against an 18ms build leaves
the frontend re-checking the program, the wire, the `dlopen` and the wait for a frame boundary.
`docs/BUILT.md` puts the re-check "under 10ms" and that is consistent. It was 14% of the LLVM round trip and
is 33% of this one, which is the ordinary consequence of removing the big term: the next thing worth
measuring has changed.
## Verification
| | before (`f459352`) | after |
|---|---|---|
| `dune test --root .` | exit 0, 232 checks, 0 failures | **exit 0, 232 checks, 0 failures**, three runs |
| `test/survey-x86.sh` | 103 MATCH / 0 DIFFER / 0 REFUSED / 0 NOX86 | **103 / 0 / 0 / 0** |
| skip breakdown | 28 does-not-compile / 8 no-main / 2 runs-forever | 28 / **9** / 2 |
| `test/cells.sh` | 4/4 ok | **4/4 ok** |
| `bash web/examples/check.sh` | green | **green** |
The `no-main` count moving from 8 to 9 is `reload-v6.flan` existing: `survey.sh` globs
`test/programs/*.flan`, and a program with no `main` fails the LLVM link and is classified there before the x86
build is attempted. `reload-agg.flan` moved it from 6 to 8 for the same reason. The **MATCH** count is what
must not move, and it did not.
The survey is the measurement that could have moved and did not, which is why it is run: `Lslot` and
`fnctx.slot` default to the whole-program answer and `string_const` now mutates `md.Emit.nstr` on that path
too, harmlessly, because nothing there reads it. The survey is what says so structurally rather than by
argument.
One more trap, and it wasted a run here. `dune test` caches a test's *result*: the second and third runs of an
unchanged tree print nothing and exit 0 without having executed anything. If what you want is three real runs
— which is what the flakiness in this area asks for — it is `dune test --root . --force`.
Run `dune test --root .` **without a pipe** — piping to `tail` gives you `tail`'s exit status, and the
`dev-robust` fixture puts `ld` and `clang` failure text in the output either way. Run the survey **detached**
(`setsid timeout 2400 test/survey-x86.sh > log 2>&1 </dev/null`) or a signal to the harness's process group
comes back as 143, which is not a result. And do not run the survey while `dune test` is running: both take the
`_build` lock, and the loser reports a lock error rather than a count.
## What remains
- ~~**The macro module, so the merged daemon can be `--x86`.**~~ Done — `HANDOFF-x86-macro-visibility.md`.
- **The linker.** 9ms, unchanged between backends, now the bulk of an `--x86` build.
- **DWARF from `X86.redefinition`**, which is what `flan dev --x86 --debug` is refused for.
- **`X86.program ~macros`**, if the macro module is ever to be built by this backend rather than merely made
immune to interposition. Not needed for the fix recommended above.
- **`test/test_emacs.ml` got no `--x86` case, deliberately.** It drives the Elisp client against a daemon, and
the client neither knows nor can say which backend a session uses — the flag is `flan dev`'s and the protocol
above it is identical. The real editor path for *this* lane is `test_dev.ml`, which drives the daemon's own
verbs and is where the case went. Worth revisiting only if `--x86` ever grows a spelling in the protocol.
- Items 2–7 of `HANDOFF-x86-rt.md` §6, unchanged. And still worth doing: **run `test/survey-x86.sh` in CI**.
@@ -19,7 +19,7 @@ and the caller that matters here is `guard`, the two-load-and-branch check emitt
condition reads, in source terms: **a function that has a defer and makes no guarded call at all** — neither in the
body nor in the defers, which are spliced onto the normal exit path and so are lowered as part of the body.
That is not an exotic shape. It is any leaf function with a defer. `spike/x86/p9-dead-defers.flan` is ten lines:
That is not an exotic shape. It is any leaf function with a defer. `test/programs/x86-p9-dead-defers.flan` is ten lines:
```flan
(defn quiet [n i32] i32
@@ -94,7 +94,7 @@ named `cleanup` is the innermost one, and anything that finds the channel set ag
**Is the block emitted?** Yes, whenever a defer body contains a guarded call in a function that can unwind. `used` is
a `bool ref` set by `current_pad`, so a defer that only assigns — which is what `p6-transfer.flan`'s `middle` does —
never emits it. `objdump -d` over `spike/x86/p10-defer-transfer.flan` built with `--x86` finds two references to
never emits it. `objdump -d` over `test/programs/x86-p10-defer-transfer.flan` built with `--x86` finds two references to
`flan_transfer_fail`.
**Does anything arrive there?** Yes. The thing that makes this hard to construct is that `lib/check.ml`'s
@@ -104,7 +104,7 @@ remaining case lives: "this is the one that reaches a function through a call, w
So the probe puts the `invoke-restart` in an ordinary `defn` and has the defer call it. The checker has nothing to
object to, because from where it stands `second` is a function like any other.
`spike/x86/p10-defer-transfer.flan` is that program. `outer` establishes two nested restart-cases and a handler-bind
`test/programs/x86-p10-defer-transfer.flan` is that program. `outer` establishes two nested restart-cases and a handler-bind
and calls `middle`; `deep` signals; the handler aims a transfer at `esc-one`; the transfer unwinds `deep` and then
`middle`; `middle`'s transfer exit clears the channel and runs its defers; and the defer calls `second`, which aims a
second transfer at `esc-two`. The restart frames are both still pushed at that moment — it is `outer`'s own pad that
@@ -115,7 +115,7 @@ is why the second restart-case has to be established *outside* the first.
Both backends, verbatim and identical, at exit status 134:
```
spike/x86/p10-defer-transfer.flan:39:7: a defer invoked a restart, which a defer may not do — it is the cleanup a transfer runs on its way out
test/programs/x86-p10-defer-transfer.flan:39:7: a defer invoked a restart, which a defer may not do — it is the cleanup a transfer runs on its way out
```
The location is `Loc.to_string fn.Tast.floc` — `middle`'s own `defer` form — on both sides, which is the part a
@@ -132,14 +132,14 @@ has for the case, and the corpus simply had no program that started a transfer f
| file | state | what |
|---|---|---|
| `lib/x86.ml` — `emit_fn`, tail of the transfer exit | working | the item-5 refusal removed; one hunk, six lines out, a comment in. Nothing else in the file |
| `spike/x86/p9-dead-defers.flan` | working, MATCH | a leaf function with a defer and no call. Was the refusal |
| `spike/x86/p10-defer-transfer.flan` | working, MATCH at 134 | a defer that calls a function that invokes a restart, while a first transfer unwinds |
| `test/programs/x86-p9-dead-defers.flan` | working, MATCH | a leaf function with a defer and no call. Was the refusal |
| `test/programs/x86-p10-defer-transfer.flan` | working, MATCH at 134 | a defer that calls a function that invokes a restart, while a first transfer unwinds |
`lib/emit.ml`, `lib/check.ml` and `runtime/flan_rt.c` were not modified; no change to any of them was needed.
## The survey
`bash spike/x86/survey.sh p9 p10` — the two new probes, both sides, built and run:
`bash test/survey-x86.sh p9 p10` — the two new probes, both sides, built and run:
```
MATCH 2
@@ -153,7 +153,7 @@ Worth saying what that second row proves for `p10`, because it is not obvious: t
hand-encoded backend that got the `.rodata` label or the length register wrong would still exit 134 and would still
look like a match to anything comparing only exit statuses.
And the full run, `SURVEY_QUIET=1 spike/x86/survey.sh`, over `test/programs` and `spike/x86` together:
And the full run, `SURVEY_QUIET=1 test/survey-x86.sh`, over `test/programs` and `spike/x86` together:
```
MATCH 99
@@ -0,0 +1,149 @@
# Handoff — a macro module keeps its own prelude, so the merged daemon can be `--x86`
Branch `dev-loop`, worktree `agent-a8f1c40df2a41e094`, from `9e80147`. This closes the finding of
`HANDOFF-x86-devloop.md` §The merged daemon: `flan dev --x86` refused the merged daemon and demanded
`--two-process`, because the compiler's macro module is a third way the two backends can meet in one process
and `flan.abi.x86` does not guard it.
**`flan dev --x86` starts a merged daemon now.** One process, a program that uses macros, C-x C-e, C-c C-c, a
park and a `rerun`, all in `dune test`. The LLVM path is byte-identical — see §Verification, which is the one
section of this file that had to be written before anything else was believed.
## What was built
| file | what |
|---|---|
| `lib/emit.ml` — `program ?hidden`, `emit_global ?hidden` | every Flan definition in the module emitted `hidden`, except the `flan.macro.*` thunks. Defaults to `false`, so no other caller moves |
| `lib/build.ml` — `macro_module` | passes `~hidden:true`, and drops the `-Wl,-Bsymbolic` that was doing the same job bluntly |
| `lib/dev.ml` — `Dev.start` | the `x86 && merged` refusal is gone; the comment in its place records what made the combination safe |
| `test/programs/dev-macro.flan` | new: `dev-loop.flan`'s shape with a prelude macro in it, and a bounded run so it parks on the clock |
| `test/test_dev.ml` | the assertion that the merged daemon refuses `--x86` is now a merged `--x86` session driven through both editor verbs, a park and a `rerun` |
| `NEXT.md`, `HANDOFF-x86-devloop.md` | the open finding, pointed at this file — `NEXT.md` is now `TODO.org` |
## The correction that came first
`HANDOFF-x86-devloop.md` says `-Bsymbolic` is the wrong fix and should not be reached for. By the time this
lane opened it was **already in the tree**: commit 65d14f4, for a different bug — a package's `defmacro`
compiled into the merged host, whose body was qualified after the quasiquote had become a string literal, so
expanding through the host's copy produced an unqualified `begin-drawing` the checker refused. That flag binds
*every* reference locally, so it had been incidentally suppressing the SIGSEGV as well.
So the first thing done here was to delete the refusal and change nothing else. `flan dev --x86` on
`test/programs/dev-repl.flan` and on `test/programs/sand-headless.flan` — which imports `sand.flan` and so
expands `rl/with-drawing` — both came up merged, served a C-c C-c and a C-x C-e, parked and reran. **The crash
was already gone.** That is worth stating plainly, because it changes what the rest of this lane is: not
"reproduce and fix a segfault" but "replace a blunt fix that works with a narrow one that works for a reason,
and prove both properties the blunt one was covering".
`-Bsymbolic` is worth replacing rather than leaving alone, and the reason is not tidiness. The macro module
links its own `flan_rt.c`. Binding symbolically aims its calls at that copy rather than the host's — a copy
`flan_rt_init` never ran on, and one whose `flan_exit_hook` is null. That hook is how a merged build parks
instead of exiting: a runtime trap raised inside a macro expansion would have called `exit(3)` and taken the
session down with it. Nothing in the suite goes there, which is exactly the shape of a bug that hides behind a
green run.
## The fix
`Emit.program ~hidden:true` writes `define hidden` on every Flan function and `hidden global` on every Flan
global. The thunks stay at default visibility, and that is the whole of the boundary: `flan_macro_call`
(`lib/dynload_stubs.c`) reaches a macro by `dlsym`, a hidden symbol is not in the table `dlsym` searches, and
nothing else in the module is anybody's to call. Measured on the module the suite builds:
```
$ nm -D --defined-only ~/.cache/flan/objcache/flan-macros-<key>.so | grep 'flan\.'
0000000000006490 T flan.macro.clamp
00000000000064b0 T flan.macro.unless
00000000000064d0 T flan.macro.into
```
Three symbols, and they are the three the compiler asks for by name. The 155 `flan_*` C symbols are still
exported and still bind to the host's when there is a host, which is the property `-Bsymbolic` was taking
away.
The mechanism was already in the file: `emit_fn ?hidden` exists for `Emit.redefinition`, whose comment records
the mirror-image failure — a redefinition's own body interposed by the host's, so the installer publishes the
function it was replacing and the reload appears to do nothing. `emit_global` needed the parameter added.
## What still depends on `-rdynamic`, audited
The export exists so a redefinition module can reach a running program's cells, and none of that moved: the fix
is on the module that is *loaded*, not on the process loading it. `Build.executable` still passes `-rdynamic`
for a dev build and `Emit.program` still defaults `hidden` to `false`, so a merged host's dynamic symbol table
is what it was. Item by item, with what says so:
- **A redefinition module reaching the host's cells and globals.** `Emit.redefinition` emits an `external` for
a global the host has and reaches a function through the host's cell symbol. Both are host exports. Covered
by the merged `--x86` C-c C-c in `test_dev.ml` and by every LLVM case above it.
- **`flan_dev_cell` / `flan_dev_global`.** C symbols in `runtime/flan_dev.c`, linked into the host, reached
from an installer by the same export. They are the new-name path. `test_dev.ml`'s `--two-process` `--x86`
case still does a new `defvar`, a new `defn` and a round trip through both.
- **`Emit.cellptr` / `Emit.globalptr`.** Module-local slots in the *redefinition* module that the installer
fills by string. Nothing in a macro module emits either, and `Emit.redefinition` is untouched.
- **`flan_reload_transient`.** Defined by a redefinition module and `dlsym`'d by `vendor/agent/flan_agent.c`
to decide whether the module can be unloaded. Also `Emit.redefinition`'s, also untouched — and note it is a
symbol looked up in a *dlopened object*, which is the one place hidden visibility would have broken
something had it been applied there.
- **The weak-symbol pattern in `lib/dynload_stubs.c`.** `flan_agent_request`, `flan_merged_rerun` and
`flan_merged_program_state` are `__attribute__((weak))` declarations resolved when the *compiler* is linked
into the merged executable — a static link, decided before any `dlopen` happens. A macro module has no
bearing on them. `rerun` working in the new test is the end-to-end proof: it is that exact pattern.
The one thing that did change is stated in §The correction above, and it changed *back*: the macro module's
calls into the C runtime bind to the host's copy again, as they did before 65d14f4.
**The invariant that keeps it that way: `~hidden:true` is passed by exactly one caller, `Build.macro_module`,
and it never passes `~dev`.** `Emit.program` refuses the pair outright now, because nothing in the types stops
someone writing it and the consequence would be a merged host that links, runs, and silently installs nothing:
a dev build's cells, globals and `flan.abi.*` marker are in the dynamic symbol table *on purpose*, for a
redefinition module to bind against. The other four call sites (`Build.executable`, `Dev.merged_executable`,
`bin/main.ml`'s `emit`, and the tests) take the default.
## Verification
| | before (`9e80147`) | after |
|---|---|---|
| `dune test --root . --force` | exit 0, 232 checks | **exit 0, 232 checks, `dev: all tests passed`** |
| emitted IR, 540 files | — | **byte-identical** |
| `flan dev --x86` merged, `sand-headless.flan` | SIGSEGV / refused | **up, parked, reran, same grid hash as LLVM** |
| `flan dev` merged, `sand-headless.flan` | up | **up, `rl/with-drawing` still expands** |
**The IR comparison is how "the LLVM path did not move" was verified, and it is the check worth repeating.**
Every `test/programs/*.flan` plus `sand.flan` and `calc-me.flan`, emitted twice — once release, once `--dev` —
by the compiler at `9e80147` and by this one: 540 files, `diff -rq` reports nothing. `hidden` defaults to
`false` and the only other edit to a format string adds an empty interpolation, so for those two paths this is
the argument made mechanical rather than a sample.
Be clear about what that diff does *not* say: it covers `Emit.program`'s default, which is every path but the
new one. `macro_module` deletes its `.ll` unless `opts.keep`, so the `hidden:true` output is not in it. What
stands behind the new path is `nm -D` on the linked object above — three thunks and nothing else of Flan's,
which is the property being claimed, read off the artefact rather than off the text that made it.
`test/survey-x86.sh` was **not** run here — it is forty minutes and this lane changes no lowering. It will
report one more `runs-forever` when it next runs: `dev-macro.flan` waits on an agent that is not there, which
is what `dev-loop.flan` and `dev-repl.flan` already do.
The two properties that had to hold together, in one run each:
- **LLVM merged, `sand-headless.flan`.** This is the one that checks 65d14f4, and the fixture is chosen for
it: `sand-headless.flan` imports `sand.flan` as a package, `sand.flan` imports `vendor:raylib` and calls
`rl/with-drawing` — a *package's* `defmacro`, reached through an import, which is exactly the shape the
reproduced bug had. A prelude macro would not have covered it, because the prelude's macros are not the ones
whose bodies were qualified after the quasiquote became a literal. If hidden visibility did not cover that
bug the daemon would have died with the checker refusing an unqualified `begin-drawing`. It came up and ran.
- **`--x86` merged, same program.** Both runs print the same grid hash, `15595743031174623232`, which is the
simulation having produced identical output through two backends in a process that also expanded macros.
## Notes for whoever is next
- **`dev-macro.flan` parks on a clock, not on a delivery**, and that is deliberate. `dev-loop.flan` waits for
a delivery per `while`, which means a test has to pace its ops to match the fixture's count; `dev-repl.flan`
runs for two minutes. This one runs 400 frames of 5ms and then returns, so the park is going to happen
whether or not `agent/wait` reported the thing the test sent. Two seconds of the default suite's 44.
- **The C-x C-e in that case is retried, not asked once.** `merged_setup` binds the socket and the program's
thread starts after it returns, so the first ask can land before there is an agent to reach. That is a race
with the startup and not a result.
- **`X86.program ~macros` is still unbuilt**, and is still not needed. A macro module is LLVM's and immune to
the host rather than matched to it, which is the cheaper of the two answers and the one that works when the
host is a backend the macro path has never heard of.
- `--x86 --debug` is still refused for the daemon, for the unrelated reason in `Dev.start`:
`X86.redefinition` emits no DWARF.
@@ -10,8 +10,8 @@ module publishes a new body into the host's cell, and the host's un-rebuilt call
| | before (`b1cc67b`) | after |
|---|---|---|
| `spike/x86/survey.sh` | 97 MATCH / 0 DIFFER / 0 refused | (see §"After", below) |
| `spike/x86/cells.sh` | 4/4 ok | 4/4 ok |
| `test/survey-x86.sh` | 97 MATCH / 0 DIFFER / 0 refused | (see §"After", below) |
| `test/cells.sh` | 4/4 ok | 4/4 ok |
| `dune test --root .` | exit 0 | exit 0 |
`dune test` prints two `ld` / `clang` failures from inside the `dev-robust` fixture and still exits 0. Check the
@@ -61,7 +61,8 @@ So a reference to a symbol the object does not define goes through the GOT. Meas
`call_flan`'s `` `Cell `` target loaded **one** level: `mov r11, cell@GOTPCREL(%rip)` then `call *%r11`. That
reads as "call through the cell" in a disassembly and in fact *calls the cell*. The GOT slot holds the cell's
**address**; reading what the cell holds is two loads, not one. Found by the segfault, not by reading — which is
DISCUSS.md item 15's claim, now with a sixth instance. `load_sym` is the fix and is the only helper that
../DISCUSS.md item 15's claim (now docs/BUILT.md, "The bugs, and the shape they share"), now with a sixth
instance. `load_sym` is the fix and is the only helper that
double-loads; `Lgot` gets it right for free because `lmem` already had the shape.
## Scope, deliberately narrower than `Emit.redefinition`
@@ -97,14 +98,14 @@ That is `C-c C-c` on an existing `defn`, which is the demo, and it is what `test
path; add a fixture beside it.
5. Items 2–7 of `HANDOFF-x86-rt.md` §6, unchanged.
Also still true and still worth doing: **run `spike/x86/survey.sh` in CI**.
Also still true and still worth doing: **run `test/survey-x86.sh` in CI**.
## After, measured
| | before (`b1cc67b`) | after |
|---|---|---|
| `spike/x86/survey.sh` | 97 MATCH / 0 DIFFER / 0 refused | **97 / 0 / 0**, skips `28 does-not-compile / 6 no-main / 2 runs-forever` — identical |
| `spike/x86/cells.sh` | 4/4 ok | **4/4 ok** |
| `test/survey-x86.sh` | 97 MATCH / 0 DIFFER / 0 refused | **97 / 0 / 0**, skips `28 does-not-compile / 6 no-main / 2 runs-forever` — identical |
| `test/cells.sh` | 4/4 ok | **4/4 ok** |
| `dune test --root .` | exit 0 | **exit 0**, run without a pipe |
The skip breakdown is the line that matters beyond the MATCH count, because the no-`main` relaxation is the one
@@ -1,7 +1,8 @@
# Handoff — the x86 backend's last two correctness items
Branch: `dev-loop`, worktree `agent-af9064091c0602dc8`. Three commits plus this one; nothing is half-written and
nothing is reverted. The long-form report is **DISCUSS.md item 18**; this file is the operational version.
nothing is reverted. The long-form report is **../DISCUSS.md item 18** (now docs/BUILT.md, "The hand-written x86 backend, and the four
measurements behind it"); this file is the operational version.
## 1. The `flan_vec_as_slice` answer — got it, and it is complete
@@ -48,7 +49,7 @@ repeated it. Verified with the survey: `Vec`/`Map` via `vec.flan`, `vec-of-vec.f
## 2. Survey counts, measured
`spike/x86/survey.sh`, unchanged in what it compares (stdout + stderr + exit status, same bounds-check setting both
`test/survey-x86.sh`, unchanged in what it compares (stdout + stderr + exit status, same bounds-check setting both
sides).
| | brief said | measured before | after |
@@ -62,7 +63,7 @@ sides).
two refusals — `slice-from-ptr.flan` and `bounds.flan` — both reported as `x86: primitive with 2 arguments`. Fixing
that is commit 1 and is reported separately so the "after" number is not misread as this lane's work.
Also measured, opt-in and new: `SURVEY_FLAGS=--dev spike/x86/survey.sh` → **97 MATCH / 0 DIFFER**.
Also measured, opt-in and new: `SURVEY_FLAGS=--dev test/survey-x86.sh` → **97 MATCH / 0 DIFFER**.
## 3. What was built, file by file
@@ -80,11 +81,11 @@ All **working and verified**; nothing in this list is unverified or reverted.
| `lib/x86.ml` — `program` | working | now `~checks ?dev`; emits cells and the `flan_dev_reg_enable` ctor when `dev` |
| `lib/x86.ml` — `layout_ctx` | working | now `~checks ~dev`; `Emit.m.dev` is no longer hardcoded `false` |
| `lib/build.ml` | working | `--dev` removed from the `--x86` refusal list; `~dev:opts.dev` threaded to `X86.program` |
| `spike/x86/survey.sh` | working | `SURVEY_FLAGS`, given to **both** sides. Default unchanged |
| `spike/x86/p7-slice-from-ptr.flan` | working, MATCH | negative length through a parameter and a `restart-case` |
| `spike/x86/p8-cell.flan` | working, MATCH | direct call + function value, for `cells.sh` |
| `spike/x86/cell-override.c` | working | `dlsym("flan.cell.twice")` + store, in a constructor |
| `spike/x86/cells.sh` | working, 4/4 ok | the only test of the cell that can exist |
| `test/survey-x86.sh` | working | `SURVEY_FLAGS`, given to **both** sides. Default unchanged |
| `test/programs/x86-p7-slice-from-ptr.flan` | working, MATCH | negative length through a parameter and a `restart-case` |
| `test/programs/x86-p8-cell.flan` | working, MATCH | direct call + function value, for `cells.sh` |
| `test/cell-override.c` | working | `dlsym("flan.cell.twice")` + store, in a constructor |
| `test/cells.sh` | working, 4/4 ok | the only test of the cell that can exist |
`lib/emit.ml` was **not** modified. No change to it was needed.
@@ -93,10 +94,10 @@ All **working and verified**; nothing in this list is unverified or reverted.
Nothing fought for an hour. Four short false starts, all mine and all one-line:
- `handler-bind` clause syntax guessed as an `fn` literal:
`spike/x86/p7-slice-from-ptr.flan:30:18: a handler-bind clause is (Type [name] body ...)`.
`test/programs/x86-p7-slice-from-ptr.flan:30:18: a handler-bind clause is (Type [name] body ...)`.
The form is `(BoundsError [c] body ...)`; `test/programs/bounds-condition.flan:112` is the model.
- `(defn show [name [u8] ...])` for a literal argument:
`spike/x86/p7-slice-from-ptr.flan:37:14: expected [u8], found string`. A string literal wants `string`, not `[u8]`,
`test/programs/x86-p7-slice-from-ptr.flan:37:14: expected [u8], found string`. A string literal wants `string`, not `[u8]`,
even though they are the same two words at the machine level.
- `cmp_imm` takes `~dst` and an `int`, not `~reg` and an `Int64`.
- `cell-override.c`: `error: 'NULL' undeclared` — needs `<stddef.h>` beside `<dlfcn.h>`.
@@ -111,7 +112,7 @@ fixtures did **not** fail here — `/tmp` had room throughout (6% used at start
**Yes, reached and tested.** And the test is the interesting part, because *the corpus cannot do it*: a dev build
starts with every cell pointing at the body that build compiled, so it prints exactly what a release build prints
whether or not anything reads the cell. `spike/x86/cells.sh` preloads a `.so` whose constructor `dlsym`s
whether or not anything reads the cell. `test/cells.sh` preloads a `.so` whose constructor `dlsym`s
`flan.cell.twice` (the cells are in `.dynsym` — a dev build is `-rdynamic`) and stores a different body there. Four
builds; the two release rows are the control that says the effect is the indirection and not symbol interposition:
@@ -161,5 +162,5 @@ returned a struct. `cells.sh` does not reach it: the body it installs is `(i64,
bounds check, every intermediate in memory, `rep movsb` block copies, and now an extra load per call site in a dev
build — which is the one item `emit.ml` pays too.
**Also worth doing and not a backend item: run `spike/x86/survey.sh` in CI.** The 2 refusals this lane found were a
**Also worth doing and not a backend item: run `test/survey-x86.sh` in CI.** The 2 refusals this lane found were a
month-old lane's new prim, and nothing noticed. A backend that refuses by name does not rot quietly, but it does rot.
+912 -124
View File
File diff suppressed because it is too large. Load diff
+987 -200
View File
File diff suppressed because it is too large. Load diff
+28 -9
View File
@@ -1,6 +1,25 @@
;;; flan-dape.el --- Debug a Flan program with dape and lldb-dap -*- lexical-binding: t; -*-
;; The other half of the dev loop. flan-dev.el is about a program that keeps
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; The other half of the dev loop. flan.el is about a program that keeps
;; running while you change it; this is about stopping one and reading it.
;;
;; There is no DAP implementation here and there should not be. `flan build
@@ -46,7 +65,7 @@
(defcustom flan-dape-command "flan"
"The flan executable used to build a program for debugging.
Its own option rather than flan-dev.el's `flan-dev-command', because this
Its own option rather than flan.el's `flan-command', because this
file is meant to load without that one: editing Flan, debugging Flan and
attaching to a running Flan are three independent things to want."
:type 'string)
@@ -70,14 +89,14 @@ does and does not buy."
:type '(repeat string))
(defun flan-dape--source ()
"The .flan file this session is about.
"The .flan or .fln file this session is about.
The buffer's own file, or the nearest one up from it — so M-x flan-debug
from a *compilation* buffer or a dired still has an answer."
(or (and buffer-file-name
(string-suffix-p ".flan" buffer-file-name)
(string-match-p "\\.fla?n\\'" buffer-file-name)
buffer-file-name)
(car (directory-files default-directory t "\\.flan\\'"))
(user-error "No .flan file here to debug")))
(car (directory-files default-directory t "\\.fla?n\\'"))
(user-error "No .flan or .fln file here to debug")))
(defun flan-dape--binary (source)
"Where the debug build of SOURCE goes.
@@ -107,7 +126,7 @@ one made by `flan build'."
;; can just read `buffer-file-name'. `dape' itself expects an already
;; evaluated config, so `flan-debug' below must not hand it the raw entry.
(defconst flan-dape-config
'(modes (flan-mode)
'(modes (flan-base-mode)
ensure dape-ensure-command
command-cwd dape-command-cwd
compile (flan-dape--compile-command (flan-dape--source))
@@ -159,11 +178,11 @@ common case is one command rather than a config prompt."
;; thing entirely.
;;;###autoload
(with-eval-after-load 'flan-mode
(define-key (symbol-value 'flan-mode-map) (kbd "C-c C-g") #'flan-debug))
(define-key (symbol-value 'flan-base-mode-map) (kbd "C-c C-g") #'flan-debug))
;;; --dev and --debug are different builds
;;
;; `flan dev' — what flan-dev.el connects to — builds with --dev: every
;; `flan dev' — what flan.el connects to — builds with --dev: every
;; cross-function call goes through a cell so a redefinition can be installed,
;; and -rdynamic exports those cells. `flan build --debug' is a different
;; axis: -O0, DWARF, and no cells unless --dev is also passed.
-2016
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1215 -126
View File
File diff suppressed because it is too large. Load diff
+864
View File
@@ -0,0 +1,864 @@
;;; flan-lower.el --- Every lowering of one function, in one buffer -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; `tools/dump.sh' prints four lowerings of one function side by side --
;; the LLVM IR the frontend emits, what `llc' makes of it at -O0 and at -O2,
;; and what the hand-written x86 backend emits. Reading one against another is
;; the only way to check a lowering by eye, and the whole reason the second
;; backend is trustworthy is that the two agree. This is that script with a
;; buffer around it.
;;
;; A scrollback is the wrong shape for it. Three of the four are noise on any
;; given day and the fourth is the one being worked on, so the four are
;; sections that fold, and which of them is open is remembered -- keyed to the
;; section and not to the function, because "I am on the backend this week" is
;; a fact about the backend and holds across every function looked at while it
;; is true.
;;
;; This is *not* `flan-disassemble', and the two cannot be merged. `C-c C-a'
;; asks the running program what the body it is calling for a name actually
;; came out as; only the daemon can answer that, because the daemon compiled
;; the module and still has both the .ll and the .so. Neither `llc -O2' nor
;; the hand-written backend has ever been run over that body, so there is no
;; four-way answer in the daemon to give. This command asks the other
;; question -- what does the source on disk compile to -- by compiling it, and
;; its header says so in as many words so that a listing never implies the
;; stronger claim.
;;
;; The folding is `outline-minor-mode', which is Emacs' own and has been since
;; long before Magit: `outline-flag-region' hides a subtree and
;; `outline-next-visible-heading' walks between them. Nothing under emacs/
;; requires a package that is not in Emacs and this does not start.
;;
;; Headings are found by the arrow that starts them, `▸' or `▾' at column
;; zero. That is not decoration standing in for structure: a regexp loose
;; enough to match four section headers written in ordinary characters also
;; matches inside three of the four bodies -- LLVM IR has `;' comments,
;; assembler output has `#' and `;' comments and lines beginning `.', and
;; objdump prints `<flan.step>:' label lines -- and folding then fragments in
;; the middle of a listing. Neither arrow can begin a line of any of the
;; three, so the arrow is both the affordance and the anchor.
;;; Code:
(require 'subr-x)
(require 'seq)
(require 'outline)
(require 'flan)
(defgroup flan-lower nil
"Every lowering of one Flan function, side by side."
:group 'flan
:prefix "flan-lower-")
(defcustom flan-lower-buffer "*flan-lowering*"
"Buffer `flan-lowering' writes into."
:type 'string)
(defcustom flan-lower-program "flan"
"The Flan compiler this shells out to.
A buffer under this editor is usually being worked on beside the compiler
that built it, so the useful value is often an absolute path into a build
directory rather than whatever is first on PATH."
:type 'string)
(defcustom flan-lower-flags nil
"Flags passed to `flan emit', and so to both the IR and the x86 section.
`(\"--dev\")' compares the four dev lowerings rather than the four release
ones. Only `flan emit' understands them: `llc' is handed the IR that came
out, so the two LLVM sections follow whatever this chose without being told."
:type '(repeat string))
(defcustom flan-lower-llc "llc"
"The LLVM static compiler the -O0 and -O2 sections are made with."
:type 'string)
;; The four, in the order they lower: what the frontend wrote, what LLVM makes
;; of it twice, and what this project's own backend makes of it. The symbols
;; are what the remembered state is keyed by, so they are the one part of this
;; list that is not free to change.
(defconst flan-lower--sections
'((ir . "LLVM IR")
(O0 . "LLVM -O0")
(O2 . "LLVM -O2")
(x86 . "x86 backend"))
"Section ids and the names they are shown under, in order.")
(defconst flan-lower--heading-re "^[▸▾] "
"What a section heading looks like from the start of a line.")
;; Session state and nothing more. Four booleans do not want a preference
;; file: it would be a thing to migrate the first time a section is renamed,
;; and nobody has ever wanted yesterday's fold state back. The IR is open to
;; begin with because a buffer that opens with everything shut reads as though
;; the command failed.
(defvar flan-lower--state
'((ir . t) (O0 . nil) (O2 . nil) (x86 . nil))
"Which sections are open, for this Emacs session.
Keyed by section and not by function on purpose -- see the header. This is
the single source of truth: a toggle changes this and then applies it to the
buffer, and a redraw applies it to the buffer it has just drawn. Visibility
is never read back out, which would tie this to whether outline folds with
overlays or with text properties.")
(defvar flan-lower-fetch-function #'flan-lower--fetch
"How one section's text is obtained.
Called with (SECTION FILE NAME FLAGS) and answers a string, or signals an
error whose message becomes the section's body. A variable rather than a
call so that the renderer can be driven from a test without four subprocesses
and an `llc -O2' per redraw.")
(defvar-local flan-lower--file nil "Source file the sections were made from.")
(defvar-local flan-lower--name nil "Flan name the sections were narrowed to.")
(defvar-local flan-lower--flags nil "Flags `flan emit' was given.")
(defvar-local flan-lower--texts nil "Alist of section id to its text.")
(defvar-local flan-lower--dir nil "Scratch directory the intermediates live in.")
;;; Faces
(defface flan-lower-heading '((t :inherit bold))
"Face for a section heading.")
(defface flan-lower-count '((t :inherit shadow))
"Face for the line count beside a section heading.")
;;; Getting the four
(defun flan-lower--scratch ()
"The scratch directory for this buffer, made on first use.
The intermediates are kept rather than piped so that refreshing one section
does not recompile the file: `llc -O2' is the slow one and the IR it reads
has not changed."
(unless (and flan-lower--dir (file-directory-p flan-lower--dir))
(setq flan-lower--dir (make-temp-file "flan-lower-" t)))
flan-lower--dir)
(defun flan-lower--clean ()
"Remove this buffer's scratch directory."
(when (and flan-lower--dir (file-directory-p flan-lower--dir))
(delete-directory flan-lower--dir t))
(setq flan-lower--dir nil))
(defun flan-lower--run (out program &rest args)
"Run PROGRAM with ARGS, stdout to OUT, and answer OUT.
A failure is signalled with the program's own stderr, because every one of
these is a compiler and the compiler's complaint is the whole of what a
reader needs."
(let ((err (expand-file-name "stderr" (flan-lower--scratch))))
(unless (executable-find program)
(error "no %s on PATH" program))
(let ((code (apply #'call-process program nil (list (list :file out) err)
nil args)))
(unless (eq code 0)
(error "%s exited %s: %s" program code
(string-trim
(with-temp-buffer (insert-file-contents err) (buffer-string)))))
out)))
(defun flan-lower--slurp (file)
"The contents of FILE as a string."
(with-temp-buffer (insert-file-contents file) (buffer-string)))
(defun flan-lower--ll (file flags &optional debug)
"The IR for FILE under FLAGS, made once and kept.
Every section is downstream of this one: `llc' reads it twice and the x86
section is the same frontend asked for a different back half.
With DEBUG, the same IR with a line table in it, which is what `llc' is
given: its output then says which source position each instruction came
from, and `flan-lower--annotate-llc' turns those into the forms. A line
table changes no instruction LLVM emits, so the two LLVM sections are still
what the IR section compiles to."
(let ((ll (expand-file-name (if debug "out.dbg.ll" "out.ll")
(flan-lower--scratch))))
(unless (file-exists-p ll)
(apply #'flan-lower--run ll flan-lower-program "emit" file
(if (and debug (not (member "--debug" flags)))
(append flags '("--debug"))
flags)))
ll))
;;; Source lines
;; Every listing here is headed by the Flan forms it came from. The IR carries
;; them itself: `flan emit' writes a `; form file:line:col' comment above the
;; first instruction of each form. The other three lose comments on the way
;; through a compiler, so each gets them back from what survives: `llc' keeps
;; the line table's positions as `.loc' directives, and the x86 backend ends
;; each function with a map from byte offsets to forms, which the assembler
;; ignores and which is read here out of the assembly it was given.
(defun flan-lower--split-where (where)
"WHERE, a heading's \"file:line:col\", as (FILE LINE COL), or nil.
The file is everything before the last two colons, so a name holding a colon
or a space survives."
(when (string-match "\\`\\(.*\\):\\([0-9]+\\):\\([0-9]+\\)\\'" where)
(list (match-string 1 where)
(string-to-number (match-string 2 where))
(string-to-number (match-string 3 where)))))
(defun flan-lower--shown (where)
"WHERE as a listing shows it: the file by its base name."
(pcase (flan-lower--split-where where)
(`(,file ,line ,col) (format "%s:%d:%d" (file-name-nondirectory file) line col))
(_ where)))
(defun flan-lower--comment-text (s)
"S with its control characters escaped, as `flan emit' writes a file name."
(replace-regexp-in-string
"[\x00-\x1f\x7f]"
(lambda (c)
(pcase (aref c 0)
(?\n "\\\\n") (?\r "\\\\r") (?\t "\\\\t")
(ch (format "\\\\x%02x" ch))))
s t))
(defun flan-lower--same-file (table heading)
"Whether TABLE, a path from a line table, names the file HEADING names.
The line table's path may have been made absolute where the heading's is the
one the program was given, so a trailing run of whole components counts."
(let ((table (flan-lower--comment-text table)))
(or (equal table heading)
(and (< (length heading) (length table))
(string-suffix-p (concat "/" heading) table)))))
(defun flan-lower--headings (ir)
"The headings in IR, as a table of (LINE . COL) to a list of (FILE . FORM).
A heading is `; form', padding, a tab and its position; the tab is the one
character neither half can hold."
(let ((tbl (make-hash-table :test #'equal)))
(dolist (l (split-string ir "\n"))
(when (string-match "\\`[ \t]*; \\([^\t]*\\)\t\\(.*\\)\\'" l)
;; Both taken before `string-trim', which matches a regexp of its own.
(let* ((where (match-string 2 l))
(form (string-trim (match-string 1 l))))
(pcase (flan-lower--split-where where)
(`(,file ,line ,col)
(push (cons file form) (gethash (cons line col) tbl)))))))
tbl))
(defun flan-lower--unoctal (s)
"S, a string from an assembler directive, with its octal escapes decoded."
(replace-regexp-in-string
"\\\\\\([0-7]\\{3\\}\\|\\\\\\|\"\\)"
(lambda (m)
(let ((e (substring m 1)))
(if (string-match-p "\\`[0-7]\\{3\\}\\'" e)
(string (string-to-number e 8))
e)))
s t t))
(defun flan-lower--files (asm)
"The `.file' table of ASM, `llc' output: file number to path."
(let ((tbl (make-hash-table)))
(dolist (l (split-string asm "\n"))
(when (string-match
"\\`[ \t]*\\.file[ \t]+\\([0-9]+\\)[ \t]+\"\\(\\(?:[^\"\\]\\|\\\\.\\)*\\)\"\\(?:[ \t]+\"\\(\\(?:[^\"\\]\\|\\\\.\\)*\\)\"\\)?" l)
(let* ((n (string-to-number (match-string 1 l)))
(a (flan-lower--unoctal (match-string 2 l)))
(b (and (match-string 3 l) (flan-lower--unoctal (match-string 3 l)))))
(puthash n (cond ((null b) a)
((file-name-absolute-p b) b)
(t (concat (file-name-as-directory a) b)))
tbl))))
tbl))
(defun flan-lower--annotate-llc (text headings files)
"TEXT, `llc' output with a line table, with each position written as its form.
A `.loc' whose file (looked up in FILES, the `.file' table) and position have
a form in HEADINGS becomes a comment quoting it, unless it names the form just
quoted; the directives, and the labels the line table alone needed, are
dropped."
(let ((last nil) (out nil))
(dolist (l (split-string text "\n"))
(cond
((string-match "\\`[ \t]*\\.loc[ \t]+\\([0-9]+\\)[ \t]+\\([0-9]+\\)[ \t]+\\([0-9]+\\)" l)
(let* ((path (gethash (string-to-number (match-string 1 l)) files))
(line (string-to-number (match-string 2 l)))
(col (string-to-number (match-string 3 l)))
(hit (and path
(seq-find (lambda (h) (flan-lower--same-file path (car h)))
(gethash (cons line col) headings))))
(form (cdr hit)))
(when (and form (not (equal form last)))
(setq last form)
(push (format "\t# %s%s%s" form
(make-string (max 1 (- 56 (length form))) ?\s)
(format "%s:%d:%d"
(file-name-nondirectory (car hit)) line col))
out))))
((string-match-p "\\`\\(\\.Ltmp[0-9]+\\|\\.Lfunc_begin[0-9]+\\):\\'" l))
((string-match-p "\\`[ \t]*\\.file[ \t]+[0-9]" l))
(t (push l out))))
(string-join (nreverse out) "\n")))
(defun flan-lower--srcmap (asm name)
"The x86 backend's source map for NAME out of ASM, as (OFFSET DEPTH WHERE FORM)."
(let ((sym (concat "flan." name)) (out nil))
(dolist (l (split-string asm "\n"))
(let ((f (split-string (string-trim l) "\t")))
(when (and (>= (length f) 6)
(equal (nth 0 f) "#@")
(equal (nth 1 f) sym))
(push (list (string-to-number (substring (nth 2 f) 2) 16)
(string-to-number (nth 3 f))
(nth 4 f)
(string-join (nthcdr 5 f) "\t"))
out))))
(nreverse out)))
(defun flan-lower--annotate-x86 (text srcmap)
"TEXT, objdump's listing of one function, with SRCMAP's forms placed in it.
Each form is written above the first instruction at or past its offset from
the function's first byte, indented by how deeply it is nested."
(let ((base nil) (out nil) (pending srcmap))
(dolist (l (split-string text "\n"))
(cond
((string-match "\\`\\([0-9a-f]+\\) <[^>]+>:" l)
(setq base (string-to-number (match-string 1 l) 16)))
((and base (string-match "\\`[ \t]*\\([0-9a-f]+\\):" l))
(let ((off (- (string-to-number (match-string 1 l) 16) base)))
(while (and pending (<= (car (car pending)) off))
(pcase-let* ((`(,_ ,depth ,where ,form) (pop pending))
(head (format "\t%s# %s" (make-string (* 2 depth) ?\s)
form)))
(push (concat head (make-string (max 1 (- 62 (length head))) ?\s)
(flan-lower--shown where))
out))))))
(push l out))
(string-join (nreverse out) "\n")))
(defun flan-lower--fetch (section file name flags)
"The text of SECTION for NAME in FILE, compiled with FLAGS.
Narrowed to the one function, which is almost always what is wanted: the
prelude is emitted too, so a two-line program is ten thousand lines of IR."
(let ((sym (concat "flan\\." (regexp-quote name))))
(pcase section
('ir
;; LLVM spells the name `@"flan.step"' when it needs quoting and
;; `@flan.step' when it does not, and a packaged name always needs it.
(flan-lower--narrow (flan-lower--slurp (flan-lower--ll file flags))
(concat "^define .*@\"?" sym "\"?(") "^}"))
((or 'O0 'O2)
(let ((s (expand-file-name (format "out.%s.s" section) (flan-lower--scratch)))
(ll (flan-lower--ll file flags t)))
(unless (file-exists-p s)
(flan-lower--run s flan-lower-llc
(if (eq section 'O0) "-O0" "-O2")
ll "-o" s))
;; `.size' ends the function in GAS output, and it is the last line
;; of it rather than the first line of the next.
(let* ((asm (flan-lower--slurp s))
(text (flan-lower--narrow asm (concat "^\"?" sym "\"?:")
"\\.size")))
(and text
(flan-lower--annotate-llc
text (flan-lower--headings (flan-lower--slurp ll))
(flan-lower--files asm))))))
('x86
(let ((dis (expand-file-name "out.x86.dis" (flan-lower--scratch))))
(unless (file-exists-p dis)
(let ((s (expand-file-name "out.x86.s" (flan-lower--scratch)))
(o (expand-file-name "out.x86.o" (flan-lower--scratch))))
;; The IR is asked for first even though this path does not read
;; it, so that a frontend error is reported once, in whichever
;; section is fetched first, rather than twice in two dialects.
(flan-lower--ll file flags)
(apply #'flan-lower--run s flan-lower-program
"emit" "--x86" file flags)
;; The backend writes machine code, not mnemonics, so its .s is
;; `.byte' blobs. Assembling and disassembling is what makes it
;; readable -- and it is also a check that the bytes are well
;; formed, which reading them never would be.
(flan-lower--run o "as" "--64" "-o" o s)
(flan-lower--run dis "objdump" "-d" "--no-show-raw-insn" o)))
(let ((text (flan-lower--narrow (flan-lower--slurp dis)
(concat "<" sym ">:") "^$"))
(s (expand-file-name "out.x86.s" (flan-lower--scratch))))
(and text
(flan-lower--annotate-x86
text (and (file-exists-p s)
(flan-lower--srcmap (flan-lower--slurp s) name)))))))
(_ (error "no such section: %s" section)))))
(defun flan-lower--narrow (text start end)
"The run of TEXT from the line matching START to the line matching END.
Both lines are kept, which is what makes a function's closing brace and its
`.size' part of the listing rather than the start of the next one. Answers
nil when START never matches, which is what a name the file does not define
looks like."
(with-temp-buffer
(insert text)
(goto-char (point-min))
(when (re-search-forward start nil t)
(let ((beg (line-beginning-position)))
(forward-line 1)
(if (re-search-forward end nil t)
(buffer-substring-no-properties beg (line-beginning-position 2))
(buffer-substring-no-properties beg (point-max)))))))
(defun flan-lower--copies-from-compiler (name file)
"The copies of generic NAME that FILE makes, asked of `flan copies', or nil.
For when no daemon is attached to say; it is the same compiler run the other
sections already make, over the same file."
(when (and file (executable-find flan-lower-program))
(with-temp-buffer
(when (eq 0 (call-process flan-lower-program nil (list (current-buffer) nil)
nil "copies" file name))
(sort (split-string (buffer-string) "\n" t) #'string<)))))
(defun flan-lower--copies (name &optional file)
"The names of the copies of generic NAME the connected program has, or nil.
A generic has no code under its own name: each type it is called at makes a
copy named for the types, `selection-sort-i32'. The daemon lists the generic
with its signature as written, `$' and all, and lists each copy with the
generic's own location -- which is what tells a copy from an unrelated
function that happens to share the prefix, `sort-bytes' beside `sort'.
With no daemon that knows NAME, FILE is asked of the compiler instead."
(let ((g (assoc name flan--defs)))
(if g
(when (string-match-p "\\$" (nth 2 g))
(let ((prefix (concat name "-")))
(sort (mapcar #'car
(seq-filter
(lambda (d)
(and (string-prefix-p prefix (car d))
(equal (nth 1 d) "fn")
(equal (nth 3 d) (nth 3 g))))
flan--defs))
#'string<)))
(flan-lower--copies-from-compiler name file))))
(defun flan-lower--fetch-copies (section name)
"SECTION for each copy of generic NAME, each under a line naming it, or nil.
The copies are the session's, and the file may call the generic at fewer
types than the session does; a copy the file does not make is named as such
rather than left out."
(let ((copies (flan-lower--copies name flan-lower--file)))
(and copies
(mapconcat
(lambda (c)
(format ";; %s\n%s" c
(or (funcall flan-lower-fetch-function
section flan-lower--file c flan-lower--flags)
"not made by this file: nothing in it calls at these types\n")))
copies "\n"))))
(defun flan-lower--gather (sections)
"Fetch each of SECTIONS into `flan-lower--texts', replacing what was there.
One section's failure is that section's text and not the buffer's: `llc' can
be missing while the IR is perfectly readable, and a buffer that refuses
altogether would be the wrong answer to that."
(dolist (s sections)
(let ((text (condition-case err
(or (funcall flan-lower-fetch-function
s flan-lower--file flan-lower--name
flan-lower--flags)
(flan-lower--fetch-copies s flan-lower--name)
(format "nothing for `%s' here\n" flan-lower--name))
(error (concat (error-message-string err) "\n")))))
(setf (alist-get s flan-lower--texts) text))))
;;; Drawing it
(defun flan-lower--header (label text)
"Insert a header line naming LABEL with TEXT, wrapped under the label.
Wrapped by hand rather than with `fill-region', which is what the sibling
buffer uses: filling canonicalises runs of spaces, and every value here is a
path or a flag list whose columns are the point of it."
(let* ((start (point))
(indent "; ")
(lead (format "; %-11s " label))
(col (length lead))
(fresh t))
(insert lead)
(dolist (word (split-string (or text "") "[ \t\n]+" t))
(cond
(fresh (setq fresh nil))
((> (+ col 1 (length word)) 78)
(insert "\n" indent)
(setq col (length indent)))
(t (insert " ") (setq col (1+ col))))
(insert word)
(setq col (+ col (length word))))
(insert "\n")
(put-text-property start (point) 'face 'font-lock-comment-face)))
;; Enough highlighting to tell the three languages apart at a glance, and no
;; more. A major mode for each would be three modes to keep, and what is
;; wanted here is only that a register not read like a mnemonic.
(defconst flan-lower--ir-keywords
(regexp-opt '("define" "declare" "ret" "load" "store" "call" "br" "add"
"sub" "mul" "sdiv" "udiv" "srem" "icmp" "fcmp" "alloca"
"getelementptr" "phi" "select" "switch" "bitcast" "zext"
"sext" "trunc" "inttoptr" "ptrtoint" "unreachable" "tail"
"and" "or" "xor" "shl" "lshr" "ashr" "label")
'symbols))
(defconst flan-lower--ir-types
(regexp-opt '("i1" "i8" "i16" "i32" "i64" "ptr" "void" "float" "double")
'symbols))
(defun flan-lower--rules (section)
"Highlighting rules for SECTION, as an alist of regexp to face."
(pcase section
('ir `((,flan-lower--ir-keywords . font-lock-keyword-face)
(,flan-lower--ir-types . font-lock-type-face)
("[%@][-A-Za-z0-9_.\"$/]+" . font-lock-variable-name-face)
("^\\s-*;.*$" . font-lock-comment-face)))
('x86 `(("^ *[0-9a-f]+:" . shadow)
("<[^>\n]+>" . font-lock-function-name-face)
("%[a-z0-9]+" . font-lock-variable-name-face)
("\\$0x[0-9a-f]+" . font-lock-constant-face)
("#.*$" . font-lock-comment-face)))
(_ `(("^\\s-*\\.[a-zA-Z_0-9.]+" . font-lock-preprocessor-face)
("^[^ \t\n#][^ \t\n]*:" . font-lock-function-name-face)
("%[a-z0-9]+" . font-lock-variable-name-face)
("\\$-?[0-9]+\\|\\$0x[0-9a-f]+" . font-lock-constant-face)
("#.*$" . font-lock-comment-face)))))
(defun flan-lower--fontify (beg end section)
"Highlight BEG..END as SECTION's language.
Earlier rules win, which is why comments come last in every list: a `#' to
the end of the line is a comment whatever it holds, so it is painted over
whatever the rules above it found in there."
(save-excursion
(dolist (rule (flan-lower--rules section))
(goto-char beg)
(while (re-search-forward (car rule) end t)
(let ((s (match-beginning 0)) (e (match-end 0)))
;; An anchored rule can match the empty string, and a search that
;; does not move is a loop that does not end.
(if (= s e)
(unless (eobp) (forward-char 1))
(unless (get-text-property s 'face)
(put-text-property s e 'face (cdr rule)))))))))
(defun flan-lower--insert-section (id label)
"Insert the heading and body for section ID under LABEL."
(let* ((raw (or (alist-get id flan-lower--texts) ""))
;; Trimmed, because the blank line that ends an objdump function is
;; the narrowing's terminator rather than part of the listing, and a
;; count that included it would be one instruction out.
(text (if (string-empty-p (string-trim raw)) raw
(concat (string-trim-right raw "\n+") "\n")))
(lines (if (string-empty-p text) 0
(1- (length (split-string text "\n")))))
(open (alist-get id flan-lower--state))
(hbeg (point)))
(insert (if open "▾ " "▸ "))
(insert (propertize label 'face 'flan-lower-heading))
(insert (propertize (format " %d line%s" lines (if (= lines 1) "" "s"))
'face 'flan-lower-count))
(insert "\n")
(put-text-property hbeg (point) 'flan-lower-section id)
;; The blank line after the listing belongs to the body and not to the
;; gap between sections. That is what makes an open section breathe and a
;; buffer with everything shut four adjacent lines -- a summary, which is
;; the shape it is meant to have when it is closed.
(let ((bbeg (point)))
(insert text)
(unless (string-suffix-p "\n" text) (insert "\n"))
(flan-lower--fontify bbeg (point) id)
(insert "\n")
(put-text-property bbeg (point) 'flan-lower-section id))))
(defun flan-lower--draw ()
"Erase this buffer and write the header and the four sections into it."
(let ((inhibit-read-only t))
(erase-buffer)
(let ((start (point)))
(insert (format "; every lowering of %s\n" flan-lower--name))
(put-text-property start (point) 'face 'font-lock-comment-face))
(flan-lower--header "file" (abbreviate-file-name (or flan-lower--file "")))
(flan-lower--header "compiler" flan-lower-program)
(flan-lower--header "flags" (if flan-lower--flags
(string-join flan-lower--flags " ")
"none"))
;; The counterpart of `flan-disassemble's `showing' line, and it has to be
;; here for the same reason: four listings of a name look exactly like an
;; answer about the program, and this one is an answer about the file.
(flan-lower--header
"showing" "what this file compiles to, not what a running program is \
calling for this name -- for that, C-c C-a")
(insert "\n")
(pcase-dolist (`(,id . ,label) flan-lower--sections)
(flan-lower--insert-section id label))
(flan-lower--apply-state)
(set-buffer-modified-p nil)))
;;; Folding
(defun flan-lower--heading-bounds ()
"Bounds of the section whose heading point is on, as (HEAD-END . SUB-END)."
(let ((hend (line-end-position))
(send (save-excursion
(forward-line 1)
(if (re-search-forward flan-lower--heading-re nil t)
(1- (match-beginning 0))
(point-max)))))
(cons hend (max hend send))))
(defun flan-lower--apply-state ()
"Fold every section the way `flan-lower--state' says, arrows and all."
(let ((inhibit-read-only t)
(modified (buffer-modified-p)))
(save-excursion
(goto-char (point-min))
(while (re-search-forward flan-lower--heading-re nil t)
(goto-char (match-beginning 0))
(let* ((id (get-text-property (point) 'flan-lower-section))
(open (alist-get id flan-lower--state))
(props (text-properties-at (point))))
;; The arrow is rewritten rather than redrawn with the rest, so the
;; properties that make it a heading have to be put back by hand:
;; inserted text inherits nothing the caller did not arrange.
(delete-char 1)
(insert (if open "▾" "▸"))
(set-text-properties (1- (point)) (point) props)
(goto-char (match-beginning 0))
(pcase-let ((`(,hend . ,send) (flan-lower--heading-bounds)))
(outline-flag-region hend send (not open))
;; Onwards from the end of this heading, so the next search does
;; not find the arrow that was just rewritten.
(goto-char hend)))))
(set-buffer-modified-p modified)))
(defun flan-lower--section-at-point ()
"The section point is in, heading or body, or nil above the first one."
(get-text-property (point) 'flan-lower-section))
(defun flan-lower--goto-section (id)
"Put point on section ID's heading, if it is here."
(let ((pos (save-excursion
(goto-char (point-min))
(catch 'found
(while (re-search-forward flan-lower--heading-re nil t)
(when (eq (get-text-property (match-beginning 0)
'flan-lower-section)
id)
(throw 'found (match-beginning 0))))
nil))))
(when pos (goto-char pos))))
(defun flan-lower-toggle (&optional event)
"Open or close the section point is in, or the one EVENT was over.
Above the first heading there is nothing to fold, so this moves to the first
section instead of doing nothing -- the same bargain `flan-cnr-tab' makes."
(interactive (list last-nonmenu-event))
(when (and event (listp event) (mouse-event-p event)) (mouse-set-point event))
(let ((id (flan-lower--section-at-point)))
(if (null id)
(outline-next-visible-heading 1)
(setf (alist-get id flan-lower--state) (not (alist-get id flan-lower--state)))
;; Point may be inside a body that is about to be hidden, and a point
;; in invisible text is a cursor that appears to have vanished.
(flan-lower--goto-section id)
(flan-lower--apply-state))))
(defun flan-lower--set-all (open)
"Open every section if OPEN, else close every one."
(pcase-dolist (`(,id . ,_) flan-lower--sections)
(setf (alist-get id flan-lower--state) open))
(let ((id (flan-lower--section-at-point)))
(when (and id (not open)) (flan-lower--goto-section id)))
(flan-lower--apply-state))
(defun flan-lower-collapse-all ()
"Close every section, leaving the four names and their line counts."
(interactive)
(flan-lower--set-all nil))
(defun flan-lower-expand-all ()
"Open every section."
(interactive)
(flan-lower--set-all t))
(defun flan-lower-cycle-all ()
"Close everything if anything is open, and otherwise open everything."
(interactive)
(flan-lower--set-all
(not (seq-some (lambda (s) (alist-get (car s) flan-lower--state))
flan-lower--sections))))
;;; Refreshing
(defun flan-lower--position ()
"Where point is, as (SECTION . LINE-WITHIN-IT), for putting it back."
(let ((id (flan-lower--section-at-point)))
(when id
(cons id (count-lines
(or (save-excursion (flan-lower--goto-section id) (point))
(point-min))
(line-beginning-position))))))
(defun flan-lower--restore (pos screen-line)
"Put point back at POS and the window back at SCREEN-LINE."
(if pos
(when (flan-lower--goto-section (car pos))
(forward-line (cdr pos)))
;; Point was in the header, above every section. The top is where it was
;; and the top is where it goes back to; leaving it wherever the redraw
;; finished would put it at the end of the buffer, which is a worse jump
;; than the one this whole function exists to avoid.
(goto-char (point-min)))
;; Guarded because under `emacs -batch' there is no window at all, and an
;; unguarded `set-window-start' would be a failure with nothing to do with
;; what was being refreshed.
(when-let ((win (and screen-line (get-buffer-window (current-buffer)))))
(set-window-start win (save-excursion
(vertical-motion (- screen-line))
(point)))))
(defun flan-lower--redraw (sections)
"Re-fetch SECTIONS and draw the buffer again, keeping point where it was."
(let* ((pos (flan-lower--position))
(win (get-buffer-window (current-buffer)))
;; How far down the window point is sitting, so that a redraw is a
;; redraw and not a jump to the top of a listing being read.
(screen-line (and win (count-screen-lines (window-start win) (point)))))
(flan-lower--gather sections)
(flan-lower--draw)
(flan-lower--restore pos screen-line)))
(defun flan-lower-refresh ()
"Compile the file again and redraw all four sections."
(interactive)
(flan-lower--clean)
(setq flan-lower--texts nil)
(flan-lower--redraw (mapcar #'car flan-lower--sections)))
(defun flan-lower-refresh-section ()
"Compile and redraw only the section point is in.
Worth its own key because the four are not the same price: the IR is the
frontend alone, and `llc -O2' over a whole program's worth of it is the one
that is felt."
(interactive)
(let ((id (flan-lower--section-at-point)))
(unless id (user-error "flan: point is not in a section"))
;; Only this section's intermediate goes; the IR everything is downstream
;; of stays, which is the whole saving.
;; The IR is what the other three are made from, so refreshing it drops
;; their intermediates too -- they are not redrawn here, but the next `r'
;; on one of them must not answer out of a file the old IR produced.
(dolist (f (pcase id
('ir '("out.ll" "out.dbg.ll" "out.O0.s" "out.O2.s"
"out.x86.s" "out.x86.o" "out.x86.dis"))
('O0 '("out.O0.s"))
('O2 '("out.O2.s"))
('x86 '("out.x86.s" "out.x86.o" "out.x86.dis"))))
(let ((p (expand-file-name f (flan-lower--scratch))))
(when (file-exists-p p) (delete-file p))))
(flan-lower--redraw (list id))))
;;; The mode
(defvar flan-lower-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "TAB") #'flan-lower-toggle)
(define-key map (kbd "RET") #'flan-lower-toggle)
(define-key map [mouse-1] #'flan-lower-toggle)
(define-key map [backtab] #'flan-lower-cycle-all)
(define-key map "c" #'flan-lower-collapse-all)
(define-key map "e" #'flan-lower-expand-all)
;; `n' and `p' move between the buffer's own units, the way they do in
;; `flan-inspect-mode' and `flan-cnr-mode'. Here the unit is a section.
(define-key map "n" #'outline-next-visible-heading)
(define-key map "p" #'outline-previous-visible-heading)
(define-key map "g" #'flan-lower-refresh)
(define-key map "r" #'flan-lower-refresh-section)
(define-key map "q" #'quit-window)
map)
"Keys in `flan-lower-mode'.")
(flan-evil-own-keys 'flan-lower-mode-map)
(define-derived-mode flan-lower-mode special-mode "flan-lower"
"Every lowering of one Flan function, in foldable sections.
\\{flan-lower-mode-map}"
(setq buffer-read-only t)
(setq-local truncate-lines t)
;; Set before the minor mode goes on, because that is when it reads them.
(setq-local outline-regexp "[▸▾] ")
(setq-local outline-level (lambda () 1))
(outline-minor-mode 1)
;; Minor-mode bindings take precedence over this mode's map. Outline owns
;; TAB by default, so give this buffer its intended folding keys back
;; without changing Outline's bindings anywhere else.
(let ((map (copy-keymap outline-minor-mode-map)))
(define-key map (kbd "TAB") #'flan-lower-toggle)
(define-key map [backtab] #'flan-lower-cycle-all)
(setq-local minor-mode-overriding-map-alist
(cons (cons 'outline-minor-mode map)
(assq-delete-all 'outline-minor-mode
minor-mode-overriding-map-alist))))
(add-hook 'kill-buffer-hook #'flan-lower--clean nil t))
;;;###autoload
(defun flan-lowering (name &optional file)
"Show every lowering of NAME in FILE: the IR, -O0, -O2, and the x86 backend.
FILE defaults to the file the current buffer is visiting. NAME is the Flan
one -- `step', not `flan.step' -- and a packaged function is written the way
the program names it, `sim/settle'.
What this shows is what FILE compiles to today, which is not the same claim
as `flan-disassemble' (\\[flan-disassemble]): that one asks the running program what the body
it is calling for a name actually came out as, and only the daemon can
answer it. The two cannot be merged, because the daemon has the installed
body's IR and its object and has never run either `llc -O2' or the x86
backend over it.
Which sections are open is remembered for the session, by section rather
than by function: asking for a different name leaves whichever of the four
was being read open."
(interactive
(list (or (thing-at-point 'symbol t)
(completing-read
"Lowerings of: "
(flan--compiled-names)
nil nil nil nil
(and (fboundp 'flan-current-defun-name)
(flan-current-defun-name))))
(when current-prefix-arg
(read-file-name "Lowerings from file: " nil nil t))))
(let ((src (or file buffer-file-name)))
(unless src
(user-error "flan: no file to compile; visit one or give it a file"))
(with-current-buffer (get-buffer-create flan-lower-buffer)
(unless (derived-mode-p 'flan-lower-mode) (flan-lower-mode))
;; A new name means new intermediates only if the file changed; the
;; narrowing is done here, so the same file's IR is reused across names.
(unless (equal flan-lower--file (expand-file-name src))
(flan-lower--clean))
(setq flan-lower--file (expand-file-name src)
flan-lower--name name
flan-lower--flags flan-lower-flags
flan-lower--texts nil)
(flan-lower--gather (mapcar #'car flan-lower--sections))
(flan-lower--draw)
(goto-char (point-min)))
(display-buffer flan-lower-buffer)))
(provide 'flan-lower)
;;; flan-lower.el ends here
+489 -62
View File
@@ -1,5 +1,24 @@
;;; flan-mode.el --- Major mode for Flan -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; Derived from `prog-mode', borrowing `lisp-mode''s machinery for the parts
;; that are simply s-expressions: sexp motion, paren matching and
;; `beginning-of-defun' already do the right thing.
@@ -52,48 +71,148 @@
;; The keymap binds them, but do not load the client merely to edit a file.
;; These must be real autoloads, not just `declare-function`s: otherwise a
;; user who has loaded only flan-mode cannot invoke M-x flan-dev at all.
(autoload 'flan-eval-defun "flan-dev" nil t)
(autoload 'flan-eval-buffer "flan-dev" nil t)
(autoload 'flan-eval-last-sexp "flan-dev" nil t)
(autoload 'flan-connect "flan-dev" nil t)
(autoload 'flan-disconnect "flan-dev" nil t)
(autoload 'flan-describe "flan-dev" nil t)
(autoload 'flan-show-output "flan-dev" nil t)
;; user who has loaded only flan-mode cannot invoke M-x flan at all.
(autoload 'flan-eval-defun "flan" nil t)
(autoload 'flan-eval-buffer "flan" nil t)
(autoload 'flan-eval-last-sexp "flan" nil t)
(autoload 'flan-connect "flan" nil t)
(autoload 'flan-disconnect "flan" nil t)
(autoload 'flan-describe "flan" nil t)
(autoload 'flan-how-to-rebuild "flan" nil t)
(autoload 'flan-repl "flan-repl" nil t)
(autoload 'flan-break "flan-dev" nil t)
(autoload 'flan-repl-clear "flan-repl" nil t)
(autoload 'flan-repl-clear-output "flan-repl" nil t)
(autoload 'flan-break "flan" nil t)
;; The two CIDER-shaped buffers. They reach the daemon through an indirection
;; of their own so that fixtures can drive them, so autoloading is all the
;; wiring they need.
(autoload 'flan-inspect "flan-inspect" nil t)
(autoload 'flan-cnr-show "flan-cnr" nil t)
(autoload 'flan-doc "flan-dev" nil t)
(autoload 'flan-dev "flan-dev" nil t)
(autoload 'flan-dev-quit "flan-dev" nil t)
(autoload 'flan-dev-restart-program "flan-dev" nil t)
(autoload 'flan-step-defun "flan" nil t)
(autoload 'flan-doc "flan" nil t)
(autoload 'flan "flan" nil t)
(autoload 'flan-quit "flan" nil t)
;; The images: several at once, one current, and a list to pick from.
(autoload 'flan-switch-image "flan" nil t)
(autoload 'flan-list-images "flan" nil t)
(autoload 'flan-run "flan" nil t)
;; And the cheap counterpart, which needs an autoload for the reason above and
;; more than most: it is the command someone reaches for the moment a window
;; closes, which can be the first thing they ever ask the client to do.
(autoload 'flan-rerun "flan" nil t)
(autoload 'flan-interrupt "flan" nil t)
;; Bound below, like the rest, and it was the one missing an autoload.
(autoload 'flan-disassemble "flan-dev" nil t)
(autoload 'flan-disassemble "flan" nil t)
;; The other question about the same function, and the reason it is a second
;; command rather than a fifth argument to the first: `flan-disassemble' asks
;; the running program, and this compiles the file.
(autoload 'flan-lowering "flan-lower" nil t)
;; C-c C-m and the half of it that is findable by name rather than by a
;; modifier. Real autoloads for the reason stated above: a `declare-function'
;; would leave M-x with nothing to load.
(autoload 'flan-macroexpand "flan-dev" nil t)
(autoload 'flan-macroexpand-all "flan-dev" nil t)
(autoload 'flan-macroexpand "flan" nil t)
(autoload 'flan-macroexpand-all "flan" nil t)
(defgroup flan nil
"Editing and evaluating Flan."
:group 'languages
:prefix "flan-")
;; Both lists are read off `Parse.decl' and `Parse.form' (lib/parse.ml), which
;; are where the two questions are actually decided: a head `decl' has an arm
;; for introduces a name, and a head `form' has an arm for is never a call.
;; Kept whole rather than topped up one name at a time — the list that is
;; patched only when somebody notices a gap is the list that is always a
;; release behind the parser.
(defconst flan--definers
'("defn" "defvar" "defconst" "defstruct" "defunion" "defenum" "defalias"
"declare" "import" "package")
'("defn" "defn-" "defmacro" "def" "defonce" "defconst" "defstruct" "defdata" "defunion"
"defenum" "defalias"
;; The object and dispatch heads (lib/parse.ml:1277-1334).
"defclass" "defgeneric" "defmulti" "defmethod"
"declare" "declare-c" "import" "package")
"Forms that introduce a top-level name.")
(defconst flan--special
'("let" "if" "do" "while" "until" "dotimes" "loop" "match" "set" "return"
"defer" "some" "none" "try" "zeroed" "uninit" "slice" "at" "len" "addr"
"bytes" "cast" "true" "false" "nil" "print" "println")
"Forms with meaning to the checker.")
'("quote" "do" "let" "if" "if-let" "when" "cond" "and" "or"
"while" "until" "break" "continue" "return" "set"
"array" "array-fill" "array-gen" "the" "match" "fn" "dotimes" "loop" "recur"
"defer" "some" "try" "signal" "error"
"handler-bind" "handler-case" "restart-case" "invoke-restart")
"The heads `Parse.form' dispatches on — the forms with a meaning of their own.
Not functions, which is the line this list draws: `and' does not evaluate its
second argument unless it has to and `quote' evaluates none of its, while
everything in `flan--builtins' below is an ordinary call. They used to be one
list and were drawn alike, which said they were the same kind of thing.
Two groups of real heads are deliberately left out. `quasiquote', `unquote'
and `unquote-splicing' are never written as words — the reader makes them out
of \=`, ~ and ~@, and the sigils are not symbols for a keyword rule to reach.
`find-restart', `compute-restarts', `errdefer' and `await' the parser
recognises only in order to refuse them, and drawing those as keywords would
advertise four forms that cannot be used.
`until' is a macro in the prelude and not a head of `Parse.form'. It stays
here because it is control flow a reader takes for `while', which is what this
face says.")
(defconst flan--builtins
'(;; arithmetic, comparison, bits
"+" "-" "*" "/" "%" "=" "!=" "<" "<=" ">" ">=" "not"
"bit-and" "bit-or" "bit-xor" "bit-not" "&&" "||" "^^" "<<" ">>"
"rotate-left" "rotate-right" "popcount" "leading-zeros" "trailing-zeros"
"min" "max"
;; the fill patterns
"zeroed" "filled" "dead-beef"
;; allocators
"make-allocator" "allocator-from" "allocator" "heap-allocator"
"arena-new" "arena-destroy" "free-all" "free-temp" "can-free" "can-free-all"
"alloc-epoch" "alloc-id" "alloc-budget" "set-alloc-budget"
"alloc-live-blocks" "with-allocator"
;; Vec
"vec-new" "reserve" "free" "clone"
;; String
"string-new" "bytes->string" "append" "runes"
"char-count" "byte-length" "char-at" "byte-at" "byte-slice"
;; Map
"map-new" "map-next"
;; collections
"put" "get" "remove" "remove-at" "insert" "pop" "pop-front" "remove-last" "clear" "copy" "fill"
;; dyn
"class-of" "type-of" "keyword"
;; compile time
"embed" "embed-dir" "compile-error"
;; files
"slurp" "barf" "delete-file" "make-directory" "rename-file"
;; containers and memory
"length" "at" "slice" "slice-from" "addr" "deref"
;; options, bytes, the host
"Some" "bytes" "bytes-view" "str" "char"
"bytes->f64" "bytes->i64" "f64->bytes" "i64->bytes"
"write-stdout" "print" "prin" "exit" "argv")
"The functions the compiler provides, from `lib/check.ml''s `builtins' table.
Ordinary calls — nothing here is special to the parser — so they are drawn as
builtins and not as keywords. `str' and `char' are in this list and in the
type rule below and mean a different thing in each: `(str b)' converts and a
bare `str' names a type, which the rules tell apart by the paren.
`destructure~nth' is in the table and not here: the compiler writes it into a
destructuring `let' and nobody types it.
The randomness functions, the string and sequence functions and everything
else in `lib/prelude.ml' are deliberately absent. They are ordinary Flan
written in Flan, the running program answers for them by name, and listing
them here would be a second copy of the prelude to keep in step.")
(defconst flan--constants
'("true" "false" "nil" "None" "context/allocator" "context/temp" "uninit")
"Names that stand for themselves rather than being called.
Matched as bare symbols, which is how they are written: nobody types `(true)',
so a paren-anchored rule would never see one. `uninit' is here for the same
reason and is the odd one — it is legal only as the last item of a `def' or a
`defonce', where it says the storage is left as it was found.")
(defvar flan-font-lock-keywords
`((,(concat "(" (regexp-opt flan--definers t) "\\_>"
@@ -101,21 +220,66 @@
(1 font-lock-keyword-face)
(2 font-lock-function-name-face nil t))
(,(concat "(" (regexp-opt flan--special t) "\\_>") 1 font-lock-keyword-face)
;; The builtins are deliberately not drawn. clojure-mode leaves `map',
;; `filter' and the rest of the core library plain and spends
;; `font-lock-builtin-face' only on the earmuffed dynamic variables, and a
;; buffer reads better for it: `at', `push' and `print' are the ordinary
;; vocabulary of a program, and colouring the ordinary leaves nothing for
;; the unusual. `flan--builtins' is still the list completion and the
;; dynamic rules check against, so nothing else has to know this.
;;
;; Ahead of the qualified-name rule below, which would otherwise take
;; `context/' in `context/allocator' for a package alias. It is not one:
;; there is no package called `context', and the slash is part of the name.
(,(concat "\\_<" (regexp-opt flan--constants t) "\\_>")
1 font-lock-constant-face)
;; A keyword resolves against an enum at the call site, so it reads as a
;; constant rather than as a string.
;; constant rather than as a string. `:where', the one key a `defn''s
;; constraint map accepts, is covered by the same rule.
("\\_<:\\(?:\\sw\\|\\s_\\)+" . font-lock-constant-face)
;; A field. The label in a struct literal — `{.x 1.0}' — and the accessor
;; `(.x v)' are the same name and are drawn the same way. Without this
;; rule every field label in the corpus is unfontified, which is what the
;; colon-to-dot change left behind: the keyword rule above used to cover
;; them and no longer does, because the colon belongs to keywords now.
("\\_<\\.\\(?:\\sw\\|\\s_\\)+" . font-lock-constant-face)
;; The machine types, which are ordinary symbols but never anything else.
("\\_<\\(?:[iu]\\(?:8\\|16\\|32\\|64\\)\\|f\\(?:32\\|64\\)\\|bool\\|string\\|Unit\\|Never\\|Ptr\\|Option\\)\\_>"
;; A field — the label in `{.x 1.0}' and the accessor in `(.x v)' — is left
;; plain, for the reason the builtins are: it is part of reading the
;; program, not part of reading the language. clojure-mode draws Java
;; interop the same way.
;;
;; The package half of a qualified name — the `rl/' of `rl/draw-text'.
;; Drawn as a type the way clojure-mode draws a namespace, so the eye can
;; split the package from the name without reading either. The leading
;; letter keeps `:foo/bar' keywords and a bare `/' out of it. It sits
;; after the constants rule above on purpose: `context/allocator' has a
;; slash and is not a qualified name — there is no package called
;; `context' — and font-lock leaves text that is already drawn alone.
("\\_<\\([a-zA-Z][a-zA-Z0-9!?*+=<>._-]*/\\)" 1 font-lock-type-face)
;; The types the compiler knows without being told: every primitive in
;; `Types.primitive_names', plus the four applied ones the checker
;; resolves and the two function types, `Fn' and `CFn'. `dyn' is
;; lowercase on purpose — it is a primitive beside `i64' and `bool', not
;; a container over something.
;; `int' and `float' are builtin aliases for `i32' and `f32'. `const'
;; is the reserved word of the read-only slice type, `[const u8]', and is
;; drawn as part of the type it spells.
;;
;; `Unit' is deliberately absent, though `Types.primitive_names' has it.
;; The resolver answers to the name because `Cimport' builds one for C's
;; void, but nothing anyone writes reaches that: `Parse.texpr' refuses the
;; word outright — unit is spelled `()'. Drawing it as a valid type would
;; advertise a spelling the parser rejects, which is the same reason
;; `find-restart' and `await' are left out of `flan--special'.
("\\_<\\(?:[iu]\\(?:8\\|16\\|32\\|64\\)\\|f\\(?:32\\|64\\)\\|bool\\|char\\|str\\|dyn\\|const\\|int\\|float\\|Never\\|Allocator\\|String\\|Ptr\\|Option\\|Vec\\|Map\\|C?Fn\\)\\_>"
. font-lock-type-face)
("\\_<\\(?:0x[0-9a-fA-F]+\\|-?[0-9]+\\(?:\\.[0-9]+\\)?\\)\\_>"
. font-lock-constant-face))
"Font lock for `flan-mode'.")
;; A type variable, `$t', which is what a generic `defn' names its
;; parameter types with and what `{:where (is-ordered $t)}' constrains.
("\\_<\\$\\(?:\\sw\\|\\s_\\)*" . font-lock-type-face))
;; A number is not drawn. clojure-mode has no rule for one either: a
;; literal is already unmistakable by shape, and a colour on every integer
;; in a program full of indices is noise.
"Font lock for `flan-mode'.
Every rule here is about the language itself, so a file that has never been
near a running program is drawn completely. What a *particular program*
defines is a separate question, and `flan.el' answers it — see
`flan-font-lock-dynamically' — by adding rules after these ones. After, and
never over: a name that is a special form or a builtin keeps the face these
rules gave it whatever the program happens to call its own functions.")
(defconst flan--name-re "\\(\\(?:\\sw\\|\\s_\\)+\\)"
"A Flan name, as one group.
@@ -127,14 +291,34 @@ below — and not again here.")
;; `defn' nested inside a `let' is not a definition of anything, and a match
;; that ignored the column would offer one.
(defvar flan-imenu-generic-expression
`(("Functions" ,(concat "^(defn\\s-+" flan--name-re) 1)
("Types" ,(concat "^(def\\(?:struct\\|union\\|enum\\|alias\\)\\s-+"
flan--name-re)
;; `defgeneric', `defmulti' and `defmethod' are here with `defn': all four
;; introduce something you call, and which of them declared a name is not
;; the question an index is being asked. A method is listed by the generic
;; it implements, which is the name in the same place, so a file of several
;; methods shows that name several times — the honest answer, and better
;; than listing none of them as it did.
`(("Functions" ,(concat "^(def\\(?:n-?\\|generic\\|multi\\|method\\)\\s-+"
flan--name-re)
1)
("Variables" ,(concat "^(def\\(?:var\\|const\\)\\s-+" flan--name-re) 1)
;; Its own heading rather than a second `Functions' entry: a macro runs at
;; compile time and a function at run time, and an index that drew them
;; alike would be hiding the one difference that matters about them.
("Macros" ,(concat "^(defmacro\\s-+" flan--name-re) 1)
;; `defclass' with the other type declarations: it names a shape, and a
;; reader looking for where `Sprite' is defined does not first have to
;; decide whether it was spelled as a struct or as a class.
("Types"
,(concat "^(def\\(?:struct\\|data\\|union\\|enum\\|alias\\|class\\)\\s-+"
flan--name-re)
1)
;; `def', `defonce' and `defconst'. `def' has to be matched as itself —
;; `\_>' keeps it from swallowing every other definer's prefix.
("Variables" ,(concat "^(def\\(?:once\\|const\\)?\\_>\\s-+" flan--name-re) 1)
;; A forward declaration is not a definition, and a file with both would
;; otherwise show the same name twice with nothing to tell them apart.
("Declared" ,(concat "^(declare\\s-+" flan--name-re) 1))
;; `declare-c' as well as `declare': it declares a name the same way and
;; differs only in generating the C shim that reaches it.
("Declared" ,(concat "^(declare\\(?:-c\\)?\\s-+" flan--name-re) 1))
"Imenu index for `flan-mode', by what each form introduces.")
(defun flan-current-defun-name ()
@@ -157,6 +341,18 @@ line is off screen."
(modify-syntax-entry ?/ "_" table)
(modify-syntax-entry ?. "_" table)
(modify-syntax-entry ?- "_" table)
;; `$t' is a type variable and `&' is the rest marker in an array pattern.
;; The reader's rule is that a name is anything up to a delimiter (see
;; `is_delimiter' in `lib/reader.ml'), and neither of these is one, so both
;; are part of the name they sit in and `M-.' on `$t' should not stop at
;; the sigil.
(modify-syntax-entry ?$ "_" table)
(modify-syntax-entry ?& "_" table)
;; A comma is whitespace, exactly as it is in Clojure and for the same
;; reason: `lib/reader.ml' skips it wherever a space would go, so `[a 1, b
;; 2]' and `[a 1 b 2]' are the same vector. Saying so here is what lets
;; sexp motion and the indenter step over one without a case for it.
(modify-syntax-entry ?, " " table)
;; [ ] and { } are brackets, not symbol characters: every binding list and
;; every type is written with them.
(modify-syntax-entry ?\[ "(]" table)
@@ -166,16 +362,114 @@ line is off screen."
table)
"Syntax table for `flan-mode'.")
(defvar flan-mode-map
;;; The discard reader macro
;; `#_' reads the form after it and throws it away (lib/reader.ml:16-24,
;; 202-262), which is how a form is commented out without counting its closing
;; parens. The table above knows nothing about it — it comes from
;; `lisp-mode-syntax-table', and Common Lisp has no such construct — so a
;; discarded form used to render as ordinary code, which is the one thing it
;; must not look like.
;;
;; The fix is `clojure-mode's, which has the same reader macro and the same
;; problem: a `syntax-propertize-function' that gives the span the comment
;; syntax class. Font lock then draws it as a comment with no rule of its own,
;; and every other thing that reads syntax classes — `forward-comment',
;; `comment-only-p', a package that skips comments — agrees for free.
;;
;; Generic comment fences (the `!' class) rather than a line comment, because a
;; discarded form is a region and may be one line or twenty. The `#' is the
;; opening fence and the last character of the form is the closing one, so
;; everything in between is inside a comment and is no longer read as
;; structure. That is the reader's own answer, and it leaves the *enclosing*
;; list's parens alone: only the discarded form's own last delimiter changes
;; class, so `(a #_(b c) d)' still balances.
(defun flan--discard-end (pos)
"Where the discard whose form starts at POS ends, or nil.
The reader's rule rather than a count: a discard reads *a form*, and reading
one skips leading discards of its own, so `#_#_ a b' throws away both a and b
with nothing ever counting to two (lib/reader.ml:202-262). Written as the
loop that recursion amounts to — every `#_' seen owes one more form — since
the walk over the buffer is the same either way.
Nil when a form is unfinished, which is every buffer halfway through being
typed: nothing is propertized then, rather than `#_(' greying out the rest of
the file while the parenthesis is still open."
(let ((owed 1)
;; The scan must not consult the properties it is being run to
;; compute: with `parse-sexp-lookup-properties' on, `forward-sexp'
;; would ask for the syntax of positions this pass has not reached.
;; The table alone is enough — the nesting is counted here, and
;; strings and escapes are the table's own business.
(parse-sexp-lookup-properties nil))
(ignore-errors
(save-excursion
(goto-char pos)
(while (> owed 0)
(forward-comment (buffer-size))
(if (looking-at-p "#_")
(progn (forward-char 2) (setq owed (1+ owed)))
(forward-sexp)
(setq owed (1- owed))))
;; `forward-sexp' at the end of the buffer stays put rather than
;; signalling, so a trailing `#_' with nothing after it would otherwise
;; come back as a span covering only itself — a two-character comment
;; drawn over the reader's one real discard error. It is unfinished
;; like any other, and answers nil like any other.
(and (> (point) pos) (point))))))
(defun flan--syntax-propertize (start end)
"Mark every `#_' discard between START and END as a comment.
For `syntax-propertize-function'."
(goto-char start)
(while (search-forward "#_" end t)
;; Both ends of the match are read off point before anything else runs, and
;; the `syntax-ppss' below is wrapped: it moves point and clobbers the
;; match data, so asking it first and reading the match afterwards is how
;; this would come to scan from the `#' instead of from the form — and how
;; a `#_' it declined would be found again, forever, at the same place.
(let* ((beg (- (point) 2))
(from (point))
;; `#_' written inside a string, or inside a `;' comment, is text
;; and not a reader macro. One inside a span an earlier discard
;; already covers is not reached at all: that discard's own scan
;; consumed it, and point jumps past the whole span below.
(quoted (save-excursion (nth 8 (syntax-ppss beg))))
(fin (and (not quoted) (flan--discard-end from))))
(when fin
(put-text-property beg (1+ beg)
'syntax-table (string-to-syntax "!"))
(put-text-property (1- fin) fin
'syntax-table (string-to-syntax "!"))
;; So that a change *inside* a discarded form re-propertizes from the
;; `#_' rather than from the line it was typed on; the other half is
;; `syntax-propertize-multiline' in the mode body.
(put-text-property beg fin 'syntax-multiline t))
;; Past the span when there was one, and past the `#_' when there was
;; not. Either way forward, which is the whole of why this terminates.
(goto-char (or fin from)))))
;; The keys both syntaxes share: everything that talks to the running program
;; about a name, a value or the session rather than about a piece of the text.
;; The keys that pick text out of the buffer -- which form C-c C-c means --
;; are each child mode's own, because what a form is differs between them.
(defvar flan-base-mode-map
(let ((map (make-sparse-keymap)))
;; Autoloaded from flan-dev.el, so the client loads on first use.
(define-key map (kbd "C-c C-c") #'flan-eval-defun)
;; Autoloaded from flan.el, so the client loads on first use.
(define-key map (kbd "C-c C-k") #'flan-eval-buffer)
(define-key map (kbd "C-x C-e") #'flan-eval-last-sexp)
;; The stepper: the defn at point, installed to stop before each form.
(define-key map (kbd "C-c C-s") #'flan-step-defun)
(define-key map (kbd "C-c C-z") #'flan-connect)
(define-key map (kbd "C-c C-q") #'flan-disconnect)
(define-key map (kbd "C-c C-d") #'flan-describe)
(define-key map (kbd "C-c C-o") #'flan-show-output)
;; The REPL's two clears, reachable from the file being edited: results
;; land in *flan-repl* whichever buffer the send came from, so the keys
;; that take them down belong here too. CIDER's pair: C-c C-o for the
;; last send's output, C-c M-o for the whole transcript.
(define-key map (kbd "C-c C-o") #'flan-repl-clear-output)
(define-key map (kbd "C-c M-o") #'flan-repl-clear)
(define-key map (kbd "C-c C-r") #'flan-repl)
;; C-c C-b opens the buffer rather than the minibuffer prompt: it shows the
;; same restarts plus the condition and the stack, and — the reason it
@@ -195,35 +489,77 @@ line is off screen."
;; IR it was built from. C-u for the IR rather than a second key: it is
;; the same question asked of the same body.
(define-key map (kbd "C-c C-a") #'flan-disassemble)
;; Every lowering of the name at point -- the IR, `llc' at -O0 and at -O2,
;; and the hand-written x86 backend -- in one buffer of folding sections.
;; Beside C-c C-a and not under it: that one answers for the program that
;; is running, this one for the file on disk, and no prefix argument can
;; make one of those into the other.
(define-key map (kbd "C-c C-l") #'flan-lowering)
;; What the macro call before point expands to. CIDER's key, and `C-u' for
;; the fixpoint rather than a second one — the same question asked of the
;; same form, which is the rule `C-c C-a' above already follows. One step
;; is the bare key because it is the one that can name the macro that ran:
;; a full expansion is stamped with the outermost name only.
(define-key map (kbd "C-c C-m") #'flan-macroexpand)
;; The way out when a reload is refused: rebuild, relaunch, reconnect.
(define-key map (kbd "C-c C-x") #'flan-dev-restart-program)
;; Run the image's `main' again, in the process that is already there,
;; with the globals as the finished run left them.
(define-key map (kbd "C-c C-M-x") #'flan-rerun)
;; Stop the running image where it is (SLIME's interrupt). C-c C-b is the
;; conditions buffer here, so the interrupt sits with the other C-M keys.
(define-key map (kbd "C-c C-M-i") #'flan-interrupt)
;; An image has no file of its own to rebuild from, so this key only says
;; how a rebuild is done. Bound rather than left free, because hands that
;; learned it would otherwise get "C-c C-x is undefined" and nothing more.
(define-key map (kbd "C-c C-x") #'flan-how-to-rebuild)
map)
"Keymap for every Flan source buffer, `flan-mode' and `flan-fln-mode'.")
(defvar flan-mode-map
(let ((map (make-sparse-keymap)))
(set-keymap-parent map flan-base-mode-map)
(define-key map (kbd "C-c C-c") #'flan-eval-defun)
;; The same command on the binding SLIME and CIDER put it on. Emacs binds
;; C-M-x to eval-defun only in `emacs-lisp-mode-map', so a mode derived
;; from `lisp-mode' inherits nothing and the key is undefined — which
;; reads as the client being broken rather than as the key being free.
(define-key map (kbd "C-M-x") #'flan-eval-defun)
(define-key map (kbd "C-x C-e") #'flan-eval-last-sexp)
map)
"Keymap for `flan-mode'.")
;; The parent of both source modes. Everything the dev loop asks of a buffer
;; -- is this Flan, set up eldoc and completion, draw the program's names,
;; paint watched values -- asks it of this mode, so a .fln buffer gets it the
;; same way a .flan buffer does. What each syntax reads as a form is its
;; child's business.
(define-derived-mode flan-base-mode prog-mode "Flan"
"Parent mode of the Flan source modes, `flan-mode' and `flan-fln-mode'."
(setq-local comment-start ";")
(setq-local comment-start-skip ";+ *")
(setq-local comment-add 1)
;; Spaces. The whole corpus is written with them, and alignment that is
;; correct here is alignment under a specific *column* — a tab makes that
;; depend on a setting the file cannot carry. In a .fln file a tab in the
;; indentation is an error besides.
(setq-local indent-tabs-mode nil))
;;;###autoload
(define-derived-mode flan-mode prog-mode "Flan"
(define-derived-mode flan-mode flan-base-mode "Flan"
"Major mode for editing Flan.
\\{flan-mode-map}"
:syntax-table flan-mode-syntax-table
(setq-local comment-start ";")
(setq-local comment-start-skip ";+ *")
(setq-local comment-add 1)
(setq-local font-lock-defaults '(flan-font-lock-keywords))
(setq-local indent-line-function #'lisp-indent-line)
;; Spaces. The whole corpus is written with them, and alignment that is
;; correct here is alignment under a specific *column* — a tab makes that
;; depend on a setting the file cannot carry.
(setq-local indent-tabs-mode nil)
(setq-local lisp-indent-function #'flan-indent-function)
(setq-local outline-regexp ";;;;+[ \t]*")
(setq-local imenu-generic-expression flan-imenu-generic-expression)
;; `#_' — see the section above the syntax table.
(setq-local syntax-propertize-function #'flan--syntax-propertize)
;; A discarded form spans as many lines as it likes, so the region handed to
;; the propertizer has to be widened back to the `#_' that owns it.
(add-hook 'syntax-propertize-extend-region-functions
#'syntax-propertize-multiline nil t)
;; Buffer-locally, because this answers for Flan and nothing else.
(add-hook 'which-func-functions #'flan-current-defun-name nil t))
@@ -244,11 +580,20 @@ line is off screen."
("let" . 1)
("loop" . 1)
("dotimes" . 1)
;; An anonymous function: the parameter vector, then the body. `defn'
;; without the name, and it indents like `let' rather than like `defn'
;; because there is no optional return type to be vague about.
("fn" . 1)
;; The clause vector, then the protected body. Same shape as `let'.
("handler-bind" . 1)
;; `handler-case' is the other way round — the body first and the clause
;; vector after it — but the entry is the same number, because what it
;; says is that one argument is special and the rest indent as a body,
;; and that is true of both orders.
("handler-case" . 1)
;; Test first, body after.
("if" . 1)
("if-let" . 1)
("when" . 1)
("unless" . 1)
("while" . 1)
@@ -260,6 +605,9 @@ line is off screen."
;; on the head's line — `(restart-case (middle n)' — would drag every
;; clause out to align under it.
("restart-case" . 1)
;; The condition, then the struct literal that carries its fields.
("signal" . 1)
("error" . 1)
;; All body.
("do" . 0)
("cond" . 0)
@@ -269,17 +617,54 @@ line is off screen."
;; the parameters and the body is optional; a count would have to know
;; whether one is there, and `:defn' does not care.
("defn" . :defn)
("defn-" . :defn)
;; `(declare-c NAME [params] RET "CSymbol")'. The name is the one special
;; argument; everything after it is written down the page in one column.
("declare" . 1)
("declare-c" . 1))
"How each form indents, by name.
Anything not named here that begins with `def' is treated as `:defn' by
`flan-indent-function'; anything else indents as a function call.")
A qualified name falls back to the entry for its unqualified part. Anything
still unnamed that begins with `def' (but not `default') or `with-' indents as
`:defn' in `flan-indent-function'; anything else indents as a function call.")
(defun flan--indent-spec (name)
"The indent spec for the form called NAME, or nil."
(and name (cdr (assoc name flan-indent-specs))))
"The indent spec for the form called NAME, or nil.
A qualified name falls back to its unqualified part, so `rl/when' would find
the entry for `when' — `clojure--get-indent-method' does the same."
(and name
(cdr (or (assoc name flan-indent-specs)
(and (string-match "/\\([^/]+\\)\\'" name)
(assoc (match-string 1 name) flan-indent-specs))))))
(defun flan--definer-p (name)
"Non-nil if NAME indents as a definition or a `with-' form.
Either may be qualified: `rl/with-drawing' is a `with-' form. This is
`clojure-indent-function''s fallback for a head with no spec, regexp and all;
`default…' is excluded there because it is not a definer, and here too."
(and name
(string-match "\\`\\(?:\\S +/\\)?\\(def[a-z]*\\|with-\\)" name)
(not (string-match-p "\\`default" (match-string 1 name)))))
(defconst flan--labelled-forms '("dotimes" "while" "until")
"Loops that may carry a label, which `break' and `continue' name.
`loop' is deliberately not here: it refuses a label, because a loop answers
with the value of its body and there is nothing for a jump out of one to
give. See `lib/parse.ml'.")
(defun flan--label-p (name)
"Non-nil if the form called NAME, at point, carries a label.
Point is on the head. A label is a keyword written where the binding vector
or the test would otherwise go — `(dotimes :outer [i 3] …)' — and it pushes
everything after it along by one, so the count of special arguments has to
know about it. Without this a labelled loop indented its body under its own
binding vector, which is where `test/programs/loops.flan' would have said so
if anything had been indenting it."
(and (member name flan--labelled-forms)
(save-excursion
(ignore-errors
(flan--forward-sexp 1) ; over the head
(skip-chars-forward " \t\n\r")
(eq (char-after) ?:)))))
(defun flan--non-logical-sexp-p ()
"Non-nil if what follows point is read but produces no form.
@@ -421,12 +806,15 @@ decision to `calculate-lisp-indent'."
(head-column (1- (current-column))))
(cond
((integerp method)
(flan--count-indent method indent-point last-sexp head-column))
(flan--count-indent (if (flan--label-p name) (1+ method) method)
indent-point last-sexp head-column))
((eq method :defn) (+ lisp-body-indent head-column))
;; No spec. Anything else spelled `def…' is a definition and indents
;; like one, which covers `defstruct', `defunion', `defenum', `defvar',
;; `defconst' and `defalias' without naming them.
((and name (string-match-p "\\`def" name))
;; like one, which covers `defstruct', `defdata', `defunion',
;; `defenum', `defonce', `defconst' and `defalias' without naming
;; them. A `with-' form is a body too — `rl/with-drawing',
;; `rl/with-mode-2d camera' — whatever it takes before the body.
((flan--definer-p name)
(+ lisp-body-indent head-column))
;; A clause: `(name [params] body…)'. `handler-bind', `handler-case'
;; and `restart-case' all write their clauses this way, and the head is
@@ -445,6 +833,45 @@ decision to `calculate-lisp-indent'."
;;;###autoload
(add-to-list 'auto-mode-alist '("\\.flan\\'" . flan-mode))
;; The indented syntax's mode lives in its own file; a buffer of it is the first
;; thing that loads it.
;;;###autoload
(autoload 'flan-fln-mode "flan-fln-mode" nil t)
;;;###autoload
(add-to-list 'auto-mode-alist '("\\.fln\\'" . flan-fln-mode))
;;; The other Flan buffers under Evil
;; Evil's normal state binds most single keys — the digits, RET, TAB, n, p, q,
;; g — above any major mode's map, so in a buffer of Flan's own a key its mode
;; binds would move point, start a count or record a macro instead. Each such
;; buffer's mode gives the keys its own map binds to Evil's normal and motion
;; states, so they do the same thing under Evil as without it. Only those
;; keys: the maps inherit `special-mode-map', and making one an overriding map
;; would carry h, SPC, < and - over from there as well. Every key a mode does
;; not bind itself stays Evil's.
(declare-function evil-define-key* "evil-core" (state keymap key def &rest bindings))
(defun flan-evil-own-keys (name)
"Give the keys the map named NAME binds itself to Evil's states.
Normal and motion. Takes effect when Evil is loaded, or at once if it
already is.
The map is passed by name because `eval-after-load' drops a function
`equal' to one it already holds, and two maps with the same bindings are
`equal': given the maps, only the first of them would be set up."
(with-eval-after-load 'evil
(when (fboundp 'evil-define-key*)
;; Collected first, and without the parent's bindings, because
;; `evil-define-key*' writes into the map being walked.
(let ((map (symbol-value name))
(own nil))
(map-keymap-internal (lambda (key def)
(when (commandp def) (push (cons key def) own)))
map)
(dolist (b own)
(evil-define-key* '(normal motion) map (vector (car b)) (cdr b)))))))
(provide 'flan-mode)
;;; flan-mode.el ends here
+312 -28
View File
@@ -1,5 +1,24 @@
;;; flan-repl.el --- A prompt for a running Flan program -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; A buffer to type expressions at, sent to the program `flan dev' is running
;; and answered with the value they had *there*. It adds no protocol and no
;; compiler support: every line goes through the same `eval-expr' request that
@@ -18,31 +37,68 @@
;; for it, because the file says which package it belongs to; a prompt has no
;; file and nothing to derive it from.
;;
;; - **A value and the program's output are different things** and arrive by
;; different routes. The value of the expression appears at the prompt;
;; anything the program printed while evaluating it goes to *flan-output*,
;; riding along on the same reply. Showing them in one place would be
;; convenient and wrong.
;; - **A value and the program's output are different things**, and both are
;; here. The value of the expression appears at the prompt; anything the
;; program printed while evaluating it — riding along on the same reply —
;; is inserted above it, output first, then the value, the way a terminal
;; REPL reads. The daemon's buffer (*flan: NAME*) mirrors the output, so it is
;; still somewhere when no prompt is open.
;;
;; When a compile fails, the prompt gets one line — "1 error — see
;; *flan-diagnostics*" — and the message itself goes to the diagnostics
;; list, which pops up. The full Elm-style error is worth a buffer with
;; navigation; the prompt is not that buffer.
;;; Code:
(require 'comint)
(require 'flan-mode)
(require 'flan-dev)
(require 'flan-fln-mode)
(require 'flan)
(defcustom flan-repl-buffer "*flan-repl*"
"Name of the Flan REPL buffer."
"Name of the Flan REPL buffer.
Each image has its own, named after it: \"*flan-repl: NAME*\"."
:type 'string
:group 'flan)
(defcustom flan-repl-history-file (locate-user-emacs-file "flan-repl-history")
"File the prompt's input history is kept in between sessions.
`locate-user-emacs-file' rather than a path of this file's own choosing: it
follows `user-emacs-directory', which is what `no-littering' and anyone who
has moved their state elsewhere have already redirected. nil keeps history
for the session only."
:type '(choice file (const :tag "Do not keep history between sessions" nil))
:group 'flan)
(defcustom flan-repl-history-size 500
"How many inputs the prompt remembers."
:type 'integer
:group 'flan)
(defconst flan-repl--history-separator "\n\f\n"
"What separates two entries in the history file.
Comint separates them with a newline, which is also what is *inside* a form
typed over several lines: written that way, one such form comes back as
several broken ones. A form feed alone on a line cannot occur inside a form,
so the file survives the round trip.
Let-bound around each call rather than set in the buffer, because
`comint-write-input-ring' reads this variable inside a temporary buffer where
a buffer-local value is not visible.")
(defvar flan-repl-prompt "flan> "
"Prompt shown in `flan-repl-mode'.")
(defvar flan-repl-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c C-o") #'flan-show-output)
;; The two clears, on CIDER's keys: C-c C-o takes down what the last send
;; printed, C-c M-o takes the whole transcript.
(define-key map (kbd "C-c C-o") #'flan-repl-clear-output)
(define-key map (kbd "C-c M-o") #'flan-repl-clear)
(define-key map (kbd "C-c C-d") #'flan-describe)
(define-key map (kbd "C-c C-q") #'flan-disconnect)
(define-key map (kbd "<up>") #'flan-repl-previous-input)
(define-key map (kbd "<down>") #'flan-repl-next-input)
map)
"Keymap for `flan-repl-mode'.")
@@ -50,7 +106,7 @@
"Type expressions; they are evaluated in the running program.
\\{flan-repl-mode-map}"
:syntax-table flan-mode-syntax-table
:syntax-table flan-fln-mode-syntax-table
(setq-local comint-prompt-regexp (concat "^" (regexp-quote flan-repl-prompt)))
(setq-local comint-prompt-read-only t)
(setq-local comint-input-sender #'flan-repl--send)
@@ -59,15 +115,134 @@
;; The prompt gets completion, eldoc and M-. for the same names a buffer
;; does, and against the same program: they read the client's cache, which
;; is program-scoped, which is exactly what a prompt is.
(flan-dev-setup)
(setq-local font-lock-defaults '(flan-font-lock-keywords)))
(flan-setup)
(setq-local font-lock-defaults '(flan-fln-font-lock-keywords))
(setq-local comint-input-ring-size flan-repl-history-size)
(setq-local comint-input-ring-file-name
(and flan-repl-history-file
(expand-file-name flan-repl-history-file)))
;; comint made the ring before this body ran, sized from the global value.
(setq-local comint-input-ring (make-ring comint-input-ring-size))
(let ((comint-input-ring-separator flan-repl--history-separator))
;; Silent: a first session has no file yet, and saying so is not news.
(comint-read-input-ring t))
(add-hook 'kill-buffer-hook #'flan-repl--save-history nil t))
;;; History
(defun flan-repl--history-entries (file)
"How many entries FILE holds.
Counted rather than trusted to the ring, because the file may have been
written by another Emacs since this buffer read it."
(if (not (file-readable-p file))
0
(with-temp-buffer
(insert-file-contents file)
(let ((n 0))
(goto-char (point-min))
(while (search-forward flan-repl--history-separator nil t)
(setq n (1+ n)))
n))))
(defun flan-repl--save-history ()
"Write this buffer's input ring to `comint-input-ring-file-name'.
Refused in the two cases where writing loses more than it keeps: a ring with
nothing in it, which would empty the file, and a ring shorter than the file
already is, which is what a second Emacs having saved a longer history since
this buffer started looks like. A write replaces the file whole, so either
would throw away somebody's session."
(when (and comint-input-ring-file-name
(ring-p comint-input-ring)
(not (ring-empty-p comint-input-ring))
(>= (ring-length comint-input-ring)
(flan-repl--history-entries comint-input-ring-file-name)))
(let ((comint-input-ring-separator flan-repl--history-separator))
(comint-write-input-ring))))
(defun flan-repl--save-all-history ()
"Save every REPL buffer's history.
`kill-buffer-hook' does not run for a buffer Emacs is exiting out from under,
so quitting without killing the prompt first would otherwise lose the day."
(dolist (buf (buffer-list))
(with-current-buffer buf
(when (derived-mode-p 'flan-repl-mode)
(flan-repl--save-history)))))
;; Not in batch. A batch Emacs exits through `kill-emacs' like any other, and
;; the prompt a test opened is nobody's session: the sweep would write the
;; person running the test their own history file, out of a ring made of
;; fixtures.
(unless noninteractive
(add-hook 'kill-emacs-hook #'flan-repl--save-all-history))
;;; Recall
;; `<up>' at a prompt means history to nearly everyone who has used a shell,
;; and `M-p' means it to nearly nobody who has not used Emacs. But the text
;; above the prompt is a transcript people scroll back through, and a blunt
;; binding would make that impossible — so the key is history only where there
;; is input for it to replace, and plain line motion everywhere else. Inside a
;; form typed over several lines it still moves between them, and reaches
;; history from the first one.
;;
;; The search is prefix-filtered by what is already typed, which is comint's
;; `...-from-input' pair and the same thing a shell's up-arrow does.
(defun flan-repl--input-start ()
"Where the text being typed begins, or nil if there is no process."
(let ((proc (get-buffer-process (current-buffer))))
(and proc (marker-position (process-mark proc)))))
(defun flan-repl--on-first-input-line-p ()
"Is point on the first line of the input, with history above it?"
(let ((start (flan-repl--input-start)))
;; `forward-line' rather than `line-beginning-position': the prompt is a
;; field, and field-constrained motion answers this question by accident
;; rather than by the rule meant here.
(and start (<= start (point))
(<= (save-excursion (forward-line 0) (point)) start))))
(defun flan-repl--on-last-input-line-p ()
"Is point on the last line of the input?"
(let ((start (flan-repl--input-start)))
(and start (<= start (point))
(save-excursion (forward-line 1) (eobp)))))
(defun flan-repl-previous-input (n)
"Recall the previous matching input, or move up a line above the input."
(interactive "p")
(if (flan-repl--on-first-input-line-p)
(progn
;; comint tells a continued search from a fresh one by looking at
;; `last-command' for its own name, which a wrapper's press never
;; leaves there: without the hand-off the second `<up>' searches again
;; from the entry the first one inserted and never moves.
(setq this-command 'comint-previous-matching-input-from-input)
(comint-previous-matching-input-from-input n))
;; Same reason in the other direction: `line-move' keeps the column it is
;; aiming for only while the line-motion commands follow each other.
(setq this-command 'previous-line)
;; `previous-line' and not `forward-line': the byte compiler's advice
;; against it is for code moving over text, and this is a keypress asking
;; for what the key does everywhere else — visual lines, kept column.
(with-no-warnings (previous-line n))))
(defun flan-repl-next-input (n)
"Recall the next matching input, or move down a line above the input."
(interactive "p")
(if (flan-repl--on-last-input-line-p)
(progn
(setq this-command 'comint-next-matching-input-from-input)
(comint-next-matching-input-from-input n))
(setq this-command 'next-line)
(with-no-warnings (next-line n))))
(defun flan-repl--complete-p (text)
"Is TEXT a whole form?
Parens balanced and not inside a string or comment. RET on a half-typed form
should open a line, not send something the reader will reject."
(let ((state (with-temp-buffer
(set-syntax-table flan-mode-syntax-table)
(set-syntax-table flan-fln-mode-syntax-table)
(insert text)
(parse-partial-sexp (point-min) (point-max)))))
(and (<= (nth 0 state) 0) ; depth
@@ -79,23 +254,109 @@ should open a line, not send something the reader will reject."
(let ((proc (get-buffer-process (current-buffer))))
(comint-output-filter proc (concat text "\n" flan-repl-prompt))))
(defun flan-repl--insert-output (text)
"Insert TEXT, the program's own output, before the prompt.
Two moments call this and the same rule serves both. During a send there
is no prompt yet — the process mark sits at the end of the input just sent —
so the text goes at the mark, before the value and the prompt that follow.
Between sends the mark sits after the prompt, and the text goes above the
prompt's line, so the prompt and whatever is being typed at it do not move."
(let ((proc (get-buffer-process (current-buffer))))
(when (process-live-p proc)
(let ((mark (process-mark proc))
(text (if (string-suffix-p "\n" text) text (concat text "\n")))
(inhibit-read-only t))
(save-excursion
(goto-char mark)
(forward-line 0)
(let ((at (point)))
(insert text)
;; Inserting at the mark itself leaves the mark *before* the
;; text — a plain marker does not advance — and the value the
;; reply carries would then land above the output it caused.
(when (>= at (marker-position mark))
(set-marker mark (point)))))
(flan--trim-lines)))))
(defun flan-repl--buffer ()
"The REPL buffer, for the two clear commands, from wherever they are run.
From a source buffer, the current image's."
(let ((buf (if (derived-mode-p 'flan-repl-mode)
(current-buffer)
(get-buffer (flan--repl-buffer-name)))))
(unless (buffer-live-p buf)
(user-error "flan: no REPL buffer; C-c C-r opens one"))
buf))
;;;###autoload
(defun flan-repl-clear ()
"Erase the whole REPL transcript and leave a fresh prompt.
The input history is untouched: it is the screen that goes, not the ring."
(interactive)
(with-current-buffer (flan-repl--buffer)
(let ((proc (get-buffer-process (current-buffer)))
(inhibit-read-only t))
(erase-buffer)
(when (process-live-p proc)
(set-marker (process-mark proc) (point-max))
(comint-output-filter proc flan-repl-prompt)))))
;;;###autoload
(defun flan-repl-clear-output ()
"Erase what the last send produced — output, value or error line.
The input that produced it stays, and so does the prompt; this is for a
send whose output buried the transcript, not for starting over — that is
`flan-repl-clear'."
(interactive)
(with-current-buffer (flan-repl--buffer)
(let* ((proc (get-buffer-process (current-buffer)))
(mark (and (process-live-p proc) (process-mark proc)))
(start (and (marker-position comint-last-input-end)
(marker-position comint-last-input-end)))
(end (and mark (save-excursion (goto-char mark)
(forward-line 0) (point))))
(inhibit-read-only t))
(if (not (and start end (< start end)))
(message "flan: nothing to clear")
(delete-region start end)))))
(defun flan-repl--send (_proc text)
"Evaluate TEXT in the running program and show what it was."
"Evaluate TEXT in the running program and show what it was.
An image's REPL is that image's prompt, so sending from it makes its image
the current one first."
(let ((code (string-trim text)))
(cond
((string-empty-p code) (flan-repl--output ""))
(t
(let ((reply (condition-case err
(flan-dev--request
(list :op "eval-expr" :code code :file "<repl>"))
(error (list :status "error"
(progn
;; The REPL of an image that has stopped refuses
;; rather than sending to whichever image is current.
(flan--use-image flan--buffer-image)
(flan--request
(list :op "eval-expr" :code code :file "<repl>"
:syntax "indented")))
;; :client marks a failure of the *connection* — no
;; daemon, no reply — which is not a compiler message
;; and has no place in the diagnostics list.
(error (list :status "error" :client t
:message (error-message-string err))))))
(flan-repl--output
(if (equal (plist-get reply :status) "ok")
(or (plist-get reply :value) "")
(concat "error: " (or (plist-get reply :message) "rejected")
(let ((loc (plist-get reply :loc)))
(if loc (concat " (" loc ")") ""))))))))))
(cond
((equal (plist-get reply :status) "ok")
(flan-repl--output (or (plist-get reply :value) "")))
((plist-get reply :client)
(flan-repl--output
(concat "error: " (or (plist-get reply :message) "rejected"))))
(t
;; A rejection. The message goes where every compiler message
;; goes — the diagnostics list, which `flan--record-diagnostic'
;; shows — and the prompt gets the one-line pointer.
(ignore-errors
(flan--record-diagnostic (plist-get reply :loc)
(or (plist-get reply :message)
"rejected")))
(flan-repl--output
(format "1 error — see %s" flan-diagnostics-buffer)))))))))
(defun flan-repl-return ()
"Send the input if it is a whole form, otherwise open a line."
@@ -104,22 +365,45 @@ should open a line, not send something the reader will reject."
(process-mark (get-buffer-process (current-buffer)))
(point-max))))
(if (flan-repl--complete-p input)
(comint-send-input)
(progn
;; `comint-send-input' sends the process mark to *point*, and
;; `comint-eol-on-send' carries it no further than the end of one
;; line. Completeness above was judged over the whole input, so the
;; whole input is what goes: otherwise RET in the middle of a form
;; typed over several lines, or on one just recalled — history leaves
;; point where the typing stopped — sends a fragment the reader will
;; reject.
(goto-char (point-max))
(comint-send-input))
(insert "\n"))))
(define-key flan-repl-mode-map (kbd "RET") #'flan-repl-return)
;;;###autoload
(defun flan-repl ()
"Open a prompt on the program `flan dev' is running.
Connects first if it has to."
"Open the current image's prompt.
With no image, start one: `flan' opens its REPL when it is ready."
(interactive)
(unless (and flan-dev--connection (process-live-p flan-dev--connection))
(call-interactively #'flan-connect))
(let ((buf (get-buffer-create flan-repl-buffer)))
(if (null flan--image)
(call-interactively #'flan)
(unless (process-live-p flan--connection)
(flan--live-connection))
(flan-repl--open)))
(defun flan-repl--open ()
"Show the current image's REPL buffer, making it if there is none."
(let ((buf (get-buffer-create (flan--repl-buffer-name flan--image)))
(image flan--image))
(with-current-buffer buf
;; On every open, not only the first: a new image started where a
;; stopped one was has its name, and so this buffer.
(setq flan--buffer-image image)
(unless (derived-mode-p 'flan-repl-mode)
(flan-repl-mode)
(setq flan--buffer-image image)
;; Killing an image's REPL asks, and a yes stops that image.
(add-hook 'kill-buffer-query-functions
#'flan--query-kill-image-buffer nil t)
;; comint wants a process to hang a prompt and a process mark off.
;; There is nothing to run, so this one does nothing and is never
;; written to; every request goes over the daemon's socket instead.
+417
View File
@@ -0,0 +1,417 @@
;;; flan-step.el --- The stepper, shown in the source -*- lexical-binding: t; -*-
;; `C-c C-s' installs a defn so that each call stops before each form of its
;; body. A step stop is not a condition anybody has to answer, so it does not
;; open *flan-conditions*: the form about to run is highlighted where it is
;; written, point goes there, the value of the form that just ran is shown
;; after it (CIDER's "=> 42"), and the keys are edebug's: `flan-step-mode' is on
;; in the stopped source buffer only, binds n/i/o/c/q (and e, l) there, and
;; lists them in the buffer's header line. Every other key, and every other
;; buffer, is untouched, so the conditions buffer and the REPL work while
;; stopped. The buffer is read-only while the mode is on, as edebug's is, so
;; that a plain letter can be a command. `C-c C-b' goes back to the stop.
;;
;; The daemon half: the stop's frame, place and value ride on the `break'
;; reply (`:step-frame', `:step-fn', `:step-loc', `:step-value',
;; `:step-value-at', `:step-gen'); `step-into' installs a callee to step for one
;; call; `unstep' installs every stepped function plain again.
;;; Code:
(require 'flan)
(require 'flan-cnr)
(defface flan-step-face '((t :inherit secondary-selection :extend nil))
"The form the stepper is stopped before: a quiet background, no underline,
from the theme's own face so it suits a light and a dark theme."
:group 'flan)
(defface flan-step-value-face '((t :inherit shadow :slant italic))
"The value of the form that just ran, shown after it."
:group 'flan)
(defface flan-step-key-face '((t :inherit help-key-binding))
"A key in the stepper's header line."
:group 'flan)
(defcustom flan-step-wait 1.0
"Seconds a step waits for the program to stop again.
A step that runs off the end of the stepped call stops nowhere until the next
call steps, and the marks come down when this runs out; they go up again at
that stop."
:type 'number
:group 'flan)
(defconst flan-step-condition "StepPoint"
"The condition a step stop reports: `lib/prelude.fln''s `step-point'.")
(defvar flan-step--stop nil
"The `break' reply of the step shown now, or nil.")
(defvar flan-step--image nil
"The image the step shown now stopped in. The keys act on it,
whichever image is current when they are pressed.")
(defvar flan-step--source nil
"The buffer `flan-step-mode' is on for the step shown now, or nil.")
(defvar flan-step--overlays nil
"The form and value overlays the step shown now put up.")
;;; Showing a stop
(defun flan-step--clear ()
(mapc #'delete-overlay flan-step--overlays)
(setq flan-step--overlays nil)
(when (buffer-live-p flan-step--source)
(with-current-buffer flan-step--source (flan-step-mode -1)))
(setq flan-step--source nil))
(defun flan-step--fln-p ()
(or (derived-mode-p 'flan-fln-mode)
(and buffer-file-name (string-suffix-p ".fln" buffer-file-name))))
(defun flan-step--form-end (pos)
"Where the form starting at POS ends: its closing paren, or in .fln the end
of its first line, which is the statement a step runs."
(save-excursion
(goto-char pos)
(if (flan-step--fln-p)
(progn (end-of-line) (skip-chars-backward " \t") (max pos (point)))
(or (ignore-errors (forward-sexp) (point))
(line-end-position)))))
(defun flan-step--value-end (start next)
"Where to show the value of the form at START, which ran before NEXT.
The end of the form in parens. In .fln, the end of the last line of code
before NEXT, which is the form's last line: the two are siblings in one body."
(save-excursion
(if (not (flan-step--fln-p))
(progn (goto-char start)
(min next (or (ignore-errors (forward-sexp) (point)) next)))
(goto-char next)
(if (<= (line-beginning-position) start)
(progn (skip-chars-backward " \t") (point))
(forward-line -1)
(while (and (> (point) start)
(looking-at-p "[ \t]*\\(;.*\\)?$"))
(forward-line -1))
(end-of-line)
(max start (point))))))
(defun flan-step--truncate (s)
(let ((w (max 20 (- (window-width) 20))))
(if (> (length s) w) (concat (substring s 0 (1- w)) "…") s)))
(defun flan-step--keep-p (win)
"Whether WIN is one the source must not take: the REPL's, the conditions
buffer's or another side or dedicated window."
(let ((b (window-buffer win)))
(or (with-current-buffer b (derived-mode-p (quote flan-cnr-mode)))
(window-dedicated-p win)
(window-parameter win 'window-side)
(with-current-buffer b (derived-mode-p 'flan-repl-mode)))))
(defun flan-step--display (buf _alist)
"A `display-buffer' action: BUF in a window that is not the REPL's or the
conditions buffer's, the selected one first; else a new window."
(let ((win (seq-find (lambda (w) (not (flan-step--keep-p w)))
(cons (selected-window)
(window-list nil 'nomini)))))
(if win
(progn (set-window-buffer win buf) win)
(display-buffer-pop-up-window buf nil))))
(defun flan-step--buffer (file)
"The buffer visiting FILE, visited when it is not, shown and selected.
Never in the REPL's window, which is where a person is often typing."
(let* ((buf (or (flan--buffer-visiting file) (find-file-noselect file)))
(win (display-buffer buf '((display-buffer-reuse-window
flan-step--display)))))
(when (and win (not (flan-step--keep-p win))) (select-window win))
buf))
(defun flan-step--show (stop)
"Put STOP, a `break' reply at a step, into its source."
(flan-step--clear)
(setq flan-step--stop stop
flan-step--image (and (boundp 'flan--image) flan--image))
(let ((parts (ignore-errors
(flan--visitable-loc (plist-get stop :step-loc) "the step"))))
(when parts
(with-current-buffer (flan-step--buffer (nth 0 parts))
(flan-step-mode 1)
(setq flan-step--source (current-buffer))
(let* ((pos (flan--position (nth 1 parts) (nth 2 parts)))
(form (make-overlay pos (flan-step--form-end pos))))
(overlay-put form 'face 'flan-step-face)
(overlay-put form 'priority 60)
(overlay-put form 'flan-step t)
(push form flan-step--overlays)
(goto-char pos)
(let ((win (get-buffer-window (current-buffer))))
(when win (set-window-point win pos)))
(let ((value (plist-get stop :step-value))
(at (flan--parse-loc (plist-get stop :step-value-at))))
(when (and value at)
(let* ((start (flan--position (nth 1 at) (nth 2 at)))
(end (flan-step--value-end start pos))
(ov (make-overlay end end)))
(overlay-put ov 'flan-step t)
(overlay-put ov 'after-string
(propertize
(concat " => " (flan-step--truncate value))
'face 'flan-step-value-face))
(push ov flan-step--overlays)))))))))
(defun flan-step--fetch ()
"The `break' reply when the program is stopped at a step, else nil."
(let ((r (flan--request '(:op "break"))))
(and (equal (plist-get r :status) "ok")
(plist-get r :stopped)
(equal (plist-get r :condition) flan-step-condition)
(plist-get r :step-loc)
r)))
;;;###autoload
(defun flan-step-on-stop ()
"The program stopped at a step: show it, with the keys on in its source.
Called by `flan--auto-break' on the stop's edge, and by a step that waited
for the next one. A stop already shown is shown again only if it moved."
(let ((stop (flan-step--fetch)))
(when stop
(unless (and flan-step--stop
(equal (plist-get stop :step-gen)
(plist-get flan-step--stop :step-gen))
flan-step--overlays)
(flan-step--show stop)))))
;;; Taking a step
(defun flan-step--gen () (plist-get flan-step--stop :step-gen))
(defun flan-step--use-image ()
"Make the image the step stopped in current, for one of the stepper's keys."
(if (and flan-step--image (memq flan-step--image flan--images))
(flan--use-image flan-step--image)
(setq flan-step--image nil)))
(defun flan-step--need-stop ()
(flan-step--use-image)
(unless (and flan--stopped (equal flan--stopped flan-step-condition)
flan-step--stop)
(user-error "flan: the program is not stopped at a step")))
(defun flan-step--take (restart)
"Take the stepper's RESTART and wait for the next step."
(flan-step--need-stop)
(let ((was (flan-step--gen))
(r (flan--request (list :op "restart" :name restart))))
(unless (equal (plist-get r :status) "ok")
(user-error "flan: %s" (or (plist-get r :message) "refused")))
(flan-step--wait was)))
(defun flan-step--wait (was)
"Wait for the stop after the one numbered WAS, and show it.
Answers whether the program stopped at a step again. A stop on a condition
ends the wait at once and is left to the conditions buffer; no stop at all
means it is running, and the marks come down either way."
(setq flan--stopped nil)
(flan--forget-break-stack)
(let ((deadline (+ (float-time) flan-step-wait))
(stop nil) (other nil))
(while (and (null stop) (null other) (< (float-time) deadline))
(sleep-for 0.02)
(let* ((d (flan--request '(:op "describe")))
(c (and (plist-get d :stopped) (plist-get d :condition))))
(cond
((equal c flan-step-condition)
(let ((b (flan-step--fetch)))
(when (and b (not (equal (plist-get b :step-gen) was)))
(setq stop b))))
(c (setq other c)))))
(force-mode-line-update t)
(if stop
(progn (flan-step--show stop) t)
(flan-step--clear)
(setq flan-step--stop nil)
(if other
(message "flan: stopped on %s — C-c C-b to choose a restart" other)
(message "flan: running; the next call of a stepped function steps again"))
nil)))
(defun flan-step-next ()
"Run the form the stepper is stopped before, and stop before the next."
(interactive)
(flan-step--take "next"))
(defun flan-step--name-at-point ()
"The function named at point, when point is on the form about to run."
(let ((form (seq-find (lambda (o) (overlay-get o 'face)) flan-step--overlays))
(sym (thing-at-point 'symbol t)))
(and form sym
(eq (overlay-buffer form) (current-buffer))
(<= (overlay-start form) (point) (overlay-end form))
(equal (nth 1 (assoc sym flan--defs)) "fn")
sym)))
(defun flan-step-in ()
"Step into the call the form about to run makes.
The function named at point when point is on that form, else the first call
it enters. The callee is installed to step for that one call, and only its
own entry can take it."
(interactive)
(flan-step--need-stop)
(let* ((name (flan-step--name-at-point))
(r (flan--request (append '(:op "step-into")
(and name (list :name name))))))
(unless (equal (plist-get r :status) "ok")
(user-error "flan: %s" (or (plist-get r :message) "refused")))
;; The daemon took `enter' itself, with the callee's id.
(flan-step--wait (flan-step--gen))))
(defun flan-step-out ()
"Run the rest of this call, and stop in the caller if it is stepping."
(interactive)
(flan-step--take "out"))
(defun flan-step-continue ()
"Run on. The next call of a stepped function steps again."
(interactive)
(flan-step--take-and-go "continue"))
(defun flan-step--take-and-go (restart)
(flan-step--need-stop)
(let ((r (flan--request (list :op "restart" :name restart))))
(unless (equal (plist-get r :status) "ok")
(user-error "flan: %s" (or (plist-get r :message) "refused")))
(setq flan--stopped nil)
(flan--forget-break-stack)
(force-mode-line-update t)
(flan-step--clear)
(setq flan-step--stop nil)))
(defun flan-step-quit ()
"Stop stepping: install every stepped function plain, and run on."
(interactive)
(flan-step--use-image)
;; Before the request, which signals when nothing is listening: the keys
;; and the read-only buffer must not outlive a stop nobody can end.
(flan-step--clear)
(let ((r (flan--request '(:op "unstep"))))
(unless (equal (plist-get r :status) "ok")
(user-error "flan: %s" (or (plist-get r :message) "refused")))
;; The marks C-c C-s put over each stepped defn.
(dolist (b (buffer-list))
(with-current-buffer b
(when (derived-mode-p 'flan-mode 'flan-fln-mode)
(remove-overlays (point-min) (point-max) 'flan-step t))))
(if (and flan--stopped (equal flan--stopped flan-step-condition))
(flan-step--take-and-go "continue")
(flan-step--clear))
(if (plist-get r :none)
(message "flan: nothing is stepping")
(message "flan: stepping off; %s installed plain"
(mapconcat #'identity (plist-get r :names) ", ")))))
(defun flan-step-eval (code)
"Evaluate CODE in the stepped frame and show its value."
(interactive (list (read-string "Eval in frame: ")))
(flan-step--need-stop)
(let ((r (flan--request (list :op "eval-expr" :code code
:frame (plist-get flan-step--stop :step-frame)))))
(unless (equal (plist-get r :status) "ok")
(user-error "flan: %s" (or (plist-get r :message) "refused")))
(message "=> %s" (or (plist-get r :value) (plist-get r :note) ""))))
(defun flan-step-locals ()
"Show the stepped frame's locals in *flan-conditions*, beside the source.
`i' there opens the inspector on one."
(interactive)
(flan-step--need-stop)
(let ((frame (plist-get flan-step--stop :step-frame)))
(save-selected-window
(with-current-buffer (flan-cnr-show)
(setq flan-cnr--open nil)
(goto-char (point-min))
(while (and (not (eobp))
(not (eql (get-text-property (point) 'flan-cnr-frame) frame)))
(forward-line 1))
(unless (eobp) (flan-cnr-toggle-frame))))))
;;; The keys
(defconst flan-step--keys
'(("n" "next") ("i" "into") ("o" "out") ("c" "continue") ("q" "quit")
("e" "eval") ("l" "locals"))
"The keys of `flan-step-mode', in the order its header line lists them.")
(defun flan-step--header ()
"The header line of the stopped source: its keys, then where it is stopped."
(let ((s flan-step--stop))
(concat
" "
(mapconcat (lambda (k)
(concat (propertize (car k) 'face 'flan-step-key-face)
" " (cadr k)))
flan-step--keys " ")
(when s
(let ((at (flan--parse-loc (plist-get s :step-loc))))
(format " | %s, line %s" (plist-get s :step-fn)
(if at (nth 1 at) "?")))))))
(defvar flan-step-mode-map
(let ((map (make-sparse-keymap)))
(define-key map "n" #'flan-step-next)
(define-key map "i" #'flan-step-in)
(define-key map "o" #'flan-step-out)
(define-key map "c" #'flan-step-continue)
(define-key map "q" #'flan-step-quit)
(define-key map "e" #'flan-step-eval)
(define-key map "l" #'flan-step-locals)
map)
"Keymap of `flan-step-mode'.")
;; Evil's normal state would otherwise take n, i, o, c and q before this map
;; saw them; its own per-state bindings in the map come first.
(declare-function evil-define-key* "evil-core"
(state keymap key def &rest bindings))
(with-eval-after-load 'evil
(dolist (k flan-step--keys)
(evil-define-key* '(normal motion) flan-step-mode-map
(car k) (lookup-key flan-step-mode-map (car k)))))
(defvar-local flan-step--saved nil
"The read-only flag and header line this buffer had before `flan-step-mode'.")
(define-minor-mode flan-step-mode
"Edebug's model for the stepper: single keys in the stopped source buffer.
On only in the buffer a step is stopped in, read-only while it is, with its
keys in the header line.
\{flan-step-mode-map}"
:lighter " Step"
:keymap flan-step-mode-map
(cond
(flan-step-mode
(setq flan-step--saved (list buffer-read-only header-line-format))
(setq buffer-read-only t
header-line-format '(:eval (flan-step--header)))
(when (fboundp 'evil-normalize-keymaps) (evil-normalize-keymaps)))
(flan-step--saved
(setq buffer-read-only (nth 0 flan-step--saved)
header-line-format (nth 1 flan-step--saved)
flan-step--saved nil)
(when (fboundp 'evil-normalize-keymaps) (evil-normalize-keymaps)))))
;;;###autoload
(defun flan-step-menu ()
"Go to the step the program is stopped at, with the stepper's keys on.
`C-c C-b' calls this at a step; the keys are those of `flan-step-mode'."
(interactive)
(let ((stop (flan-step--fetch)))
(unless stop (user-error "flan: the program is not stopped at a step"))
(flan-step--show stop)))
(provide 'flan-step)
;;; flan-step.el ends here
+133 -139
View File
@@ -1,5 +1,24 @@
;;; flan-watch.el --- Watched values, in a pinned buffer or inline -*- lexical-binding: t; -*-
;; Author: Joseph Ferano <joseph@ferano.io>
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1"))
;; Keywords: languages, lisp, tools
;; The headers above are what make this directory installable. M-x
;; package-install-file on it reads them, and a file with no Version: is not a
;; package as far as package.el is concerned -- until now the client was
;; reachable only by adding it to load-path by hand, which is a thing to
;; explain to every person who wants to try it.
;;
;; 29.1 is the floor because it is the oldest Emacs any of this has been run
;; against, not because some function here is known to need it. dape, which
;; flan-dape drives, asks for 29.1 as well and is a soft dependency: it is
;; reached through declare-function, so the rest of the client loads and works
;; without it and it is deliberately not listed above. The compiler this talks
;; to is not an Emacs package and cannot be listed here either -- emacs/MANUAL.md
;; says what has to be on PATH.
;; A HUD for a running Flan program: a buffer that always shows the current
;; frame's values and nothing else. Ported from the author's Clojure
;; `clj-watch', with one thing kept and one thing inverted.
@@ -28,38 +47,36 @@
;; halves are cheap for opposite reasons, and two things fall out that a poll
;; could not have given:
;;
;; the values update at *frame rate* rather than at the timer's rate — the
;; timer only decides how often the picture is repainted, not how fresh it
;; is;
;; the values update at *frame rate* rather than at the repaint rate — the
;; interval only decides how often the picture is repainted, not how fresh
;; it is;
;;
;; and the last frame's values are still there while the program is
;; *stopped*. A break loop is precisely when no thunk can run at a frame
;; boundary, because there are no more frames, and precisely when you want to
;; see what the last one held.
;;
;; Also kept from the original, and for its stated reasons: the request is
;; **async**, because a synchronous call on a timer blocks Emacs's UI every
;; tick; and the paint is `replace-buffer-contents' rather than erase-and-
;; insert, because it diffs, so point and scroll survive a repaint instead of
;; being yanked to the top five times a second.
;; The daemon reads the table and sends it on the editor's connection, as it
;; sends the program's output, so Emacs never waits on a timer for it. The
;; paint is `replace-buffer-contents' rather than erase-and-insert, because it
;; diffs, so point and scroll survive a repaint instead of being yanked to the
;; top five times a second.
;;
;; What a program writes today, with no compiler change:
;;
;; (declare-c watch-i64 [name string x i64] i32 "flan_dev_watch_i64")
;; (declare-c watch-f64 [name string x f64] i32 "flan_dev_watch_f64")
;; What a program writes:
;;
;; (defn step [] i64
;; (set ticks (+ ticks 1))
;; (watch-i64 "ticks" ticks)
;; (watch "ticks" ticks)
;; (watch "player" player)
;; ticks)
;;
;; Scalars only, so far. A struct or a slice needs a compile-time walk over
;; its type — a `(watch "hp" hp)' form in the checker — and that is a file this
;; change does not own. See BUILT.md.
;; `watch' renders any value the way `print' does — a struct, a slice, a dyn
;; value — into the table. The scalar entry points underneath it,
;; `flan_dev_watch_i64' and the rest, are reachable through `declare-c' too.
;;; Code:
(require 'flan-dev)
(require 'flan)
(require 'subr-x)
(defgroup flan-watch nil
@@ -75,19 +92,17 @@
"Seconds between repaints.
This is the *repaint* rate, not the watch rate. The program writes its values
every frame whatever this is; all this decides is how often the picture is
refreshed, which is why a slow value here costs freshness and nothing else."
every frame whatever this is; all this decides is how often the daemon sends
the table, which is why a slow value here costs freshness and nothing else.
Taken when the table is armed, so a change applies from the next `flan-watch'."
:type 'number)
(defvar flan-watch--timer nil)
(defvar flan-watch--pending nil
"Non-nil while a watch request is out and its reply has not been read.")
(defvar flan-watch--rows nil
"The last table painted, as a list of (NAME . VALUE).")
(defvar flan-watch--consumers nil
"Which pictures of the table are currently wanted: `buffer', `ghost', or both.
There is one table, one arming message and one timer, and more than one way to
There is one table, one arming message and one push, and more than one way to
look at what they produce. Holding the subscription here rather than in the
watch buffer is what lets ghost text outlive that buffer being closed — the
original design made the buffer *be* the subscription, which was right when it
@@ -99,11 +114,9 @@ was the only consumer and wrong the moment it was not.")
"The buffer's text for ROWS. OVERFLOW means some name found no slot."
(if (null rows)
(concat "nothing is being watched\n\n"
"The program decides what is shown. Call into the watch table\n"
"The program decides what is shown. Write into the watch table\n"
"from your own loop:\n\n"
" (declare-c watch-i64 [name string x i64] i32 \"flan_dev_watch_i64\")\n"
" ...\n"
" (watch-i64 \"ticks\" ticks)\n")
" (watch \"ticks\" ticks)\n")
(let ((w (apply #'max (mapcar (lambda (r) (length (car r))) rows))))
(concat
(mapconcat (lambda (r)
@@ -153,9 +166,8 @@ was the only consumer and wrong the moment it was not.")
;; both are painted from the same reply, in `flan-watch--absorb'.
;;
;; WHERE A VALUE ATTACHES. The table carries a name and a rendered string and
;; no source location, and it is not going to grow one here — that needs a
;; `(watch ...)' form in the checker, which is a file this does not own. But a
;; location is not actually missing: `(watch-i64 "hp" hp)' is *in the buffer*,
;; no source location. A location is not actually missing: `(watch "hp" hp)'
;; or `(watch-i64 "hp" hp)' is *in the buffer*,
;; and the name in the table is the string literal in it. So the anchor is
;; found by searching the text rather than by being told, which costs one
;; regexp scan per displayed buffer per repaint and needs nothing new from the
@@ -169,12 +181,11 @@ was the only consumer and wrong the moment it was not.")
;; the string literal and not the head is what makes that safe: a name in the
;; table came from one of these call sites by construction.
;;
;; WHEN IT UPDATES. On the same timer, from the same reply. The two pictures
;; therefore cannot disagree — they are one table read, painted twice — and
;; there is no second `:op "watch"' in flight, which is the invariant
;; `flan-dev-settle-hook' exists to keep. As with the buffer, the timer decides
;; how often the picture is repainted and not how fresh it is: the program
;; writes every frame regardless.
;; WHEN IT UPDATES. On the same push, from the same frame. The two pictures
;; therefore cannot disagree — they are one table read, painted twice. As
;; with the buffer, the push interval decides how often the picture is
;; repainted and not how fresh it is: the program writes every frame
;; regardless.
;;
;; Overlays are deleted and re-placed from scratch on every repaint rather than
;; being tracked across edits. That is the whole answer to the invalidation
@@ -211,9 +222,9 @@ Matched against the symbol after an open paren, with the watched name as a
string literal after it. The default covers `watch', `watch-i64', `watch-f64',
`watch-str' and the two-segment accumulator names `watch-num-i64' and
`watch-num-f64' — hence the `*\=' rather than a `?\=', which stopped at one
hyphenated segment and so found no site for a numeric watch at all. It is a setting because the Flan name is the program author's
own `declare-c' binding — only the C symbol behind it is fixed, and the editor
never sees that."
hyphenated segment and so found no site for a numeric watch at all. It is a
setting because the Flan name is the program author's own `declare-c' binding
— only the C symbol behind it is fixed, and the editor never sees that."
:type 'regexp)
(defface flan-watch-ghost-face '((t :inherit shadow))
@@ -238,15 +249,19 @@ inline it has no modeline beside it to say so.")
(let (bufs)
(dolist (w (window-list-1 nil 'nomini t))
(let ((b (window-buffer w)))
(when (and (eq (buffer-local-value 'major-mode b) 'flan-mode)
(when (and (provided-mode-derived-p (buffer-local-value 'major-mode b)
'flan-base-mode)
(not (memq b bufs)))
(push b bufs))))
bufs))
(defun flan-watch--ghost-sites ()
"Watch call sites in the current buffer, as a list of (NAME . END-OF-LINE)."
(let ((re (concat "(\\s-*" flan-watch-ghost-call-regexp
"\\s-+\"\\([^\"\n]*\\)\""))
;; Either syntax: `(watch "name" v)' in a .flan file, `watch("name", v)' in
;; a .fln one, where the call is the name glued to its parenthesis.
(let ((re (concat "\\(?:(\\s-*\\(?:" flan-watch-ghost-call-regexp "\\)\\s-+"
"\\|\\_<\\(?:" flan-watch-ghost-call-regexp "\\)(\\s-*\\)"
"\"\\([^\"\n]*\\)\""))
(sites nil))
(save-excursion
(goto-char (point-min))
@@ -293,7 +308,7 @@ Guarded on the row's length so a short name cannot be claimed by accident."
(defun flan-watch--ghost-paint (rows overflow)
"Place inline overlays for ROWS. OVERFLOW means some name found no slot."
(flan-watch--ghost-clear)
(let ((stale (and flan-dev--stopped t)))
(let ((stale (and flan--stopped t)))
(dolist (buf (flan-watch--ghost-buffers))
(with-current-buffer buf
(let* ((sites (flan-watch--ghost-sites))
@@ -321,10 +336,10 @@ Guarded on the row's length so a short name cannot be claimed by accident."
(overlay-put ov 'flan-watch-ghost t)
(push ov flan-watch--ghost-overlays))))))))))
;;; The tick
;;; The push
(defun flan-watch--absorb (reply)
"Paint REPLY, a watch answer from the daemon."
"Paint REPLY, a watch table from the daemon."
(pcase (plist-get reply :status)
("ok"
(setq flan-watch--rows
@@ -343,103 +358,88 @@ Guarded on the row's length so a short name cannot be claimed by accident."
(flan-watch--paint
(format "error:\n%s\n" (or (plist-get reply :message) "refused"))))))
(defun flan-watch--settle ()
"Collect an outstanding watch reply, blocking if it has not arrived.
;; The daemon sends the table on the connection every `flan-watch-interval'
;; while it is armed, changed or not, the same way it sends the program's
;; output. Nothing here sends a request on a timer, so nothing here can leave
;; a reply in flight for another request to read.
;;
;; Each push closes the numeric slots' accumulation window, so their count,
;; range and mean are "since the last push" rather than since the program
;; started — except while the program is stopped, when the window is left open
;; and the numbers from the moment it stopped stay on the screen. The daemon
;; decides that, since it knows whether the program is stopped when it reads
;; the table.
Hung on `flan-dev-settle-hook', so an ordinary request never reads the watch
timer's reply as its own. Blocking here is fine and blocking in the tick is
not: this runs inside something a person asked for, which already waits, and
what it waits for is a table read with nothing compiled behind it."
(when flan-watch--pending
(setq flan-watch--pending nil)
(when-let* ((proc flan-dev--connection))
(when (process-live-p proc)
(ignore-errors (flan-watch--absorb (flan-dev--read-reply proc)))))))
(defun flan-watch--on-push (kind frame)
"Paint FRAME when KIND says it is the watch table."
(when (and (equal kind "watch") flan-watch--consumers)
(flan-watch--absorb frame)))
(defun flan-watch--tick ()
"Collect the last reply if it has come, then ask again. Never blocks.
(defun flan-watch--on-connect ()
"Arm the table again on a new connection, if anything is watching.
A restarted daemon has a new program, and the new program's table is off."
(when flan-watch--consumers
(let ((r (ignore-errors (flan-watch--arm t))))
(unless (equal (plist-get r :status) "ok")
(flan-watch--paint
(format "error:\n%s\n"
(or (plist-get r :message) "the table could not be armed")))))))
Deliberately not `flan-dev--request', which waits for its answer: a
synchronous call on a 0.2s timer stalls Emacs's UI every tick, and a timer is
the one caller that must not. So this takes whatever has already arrived and
sends the next question, leaving at most one request in flight — the invariant
`flan-dev-settle-hook' exists to keep."
(cond
;; Killing the buffer cancels the buffer's half of the subscription, and
;; only that. When it was the only consumer this stops the timer and
;; disarms the table, so a program nobody is watching is back to paying a
;; load and a branch per watch call; when ghost text is also on, the table
;; stays armed and the ticks carry on feeding it.
((and (memq 'buffer flan-watch--consumers)
(not (get-buffer flan-watch-buffer)))
(flan-watch--drop 'buffer))
((not (process-live-p flan-dev--connection))
(flan-watch--paint "error:\nnot connected to a running program\n")
(flan-watch-stop))
;; Something else owns the connection this instant — an evaluation is
;; mid-flight. Skipping is right: its `flan-dev-settle-hook' has already
;; taken any reply of ours, and the next tick is 0.2s away.
(flan-dev--busy nil)
(t
(when flan-watch--pending
(when-let* ((reply (flan-dev--take-reply flan-dev--connection)))
(setq flan-watch--pending nil)
(flan-watch--absorb reply)))
(unless flan-watch--pending
(condition-case nil
;; `:reset t' opens a new accumulation window for the numeric
;; slots, so their count, range and mean are "since the last tick"
;; rather than since the program started. That is the whole of the
;; accumulator decision as the editor sees it: a min and a max over
;; a session go dead within seconds of play, and this tool exists to
;; show a number while you drag the mouse. The reset happens after
;; the read, on the daemon's side, so this tick's numbers are the
;; last tick's window and nothing is lost between the two.
;;
;; Except while the program is stopped, when the read still happens
;; and the reset does not. A stopped program takes no samples, so
;; there is no window for a reset to close and none for it to open:
;; the runtime clears a slot lazily, on its next sample, which is
;; what keeps a paused program showing the numbers from the moment
;; you paused it — the whole reason to pause. Resetting anyway
;; would ask for a new window five times a second that nothing can
;; fill. The guard lives here rather than in the runtime because
;; "since you last looked" is the editor's policy, not the table's.
;; `flan-dev--stopped' is the one place that state is tracked, and
;; `flan-dev.el''s background poll keeps it current whether or not
;; anyone is evaluating.
(progn (flan-dev--send flan-dev--connection
(if flan-dev--stopped
'(:op "watch")
'(:op "watch" :reset t)))
(setq flan-watch--pending t))
(error (flan-watch-stop)))))))
(defun flan-watch--on-disconnect ()
"Say that the table is no longer live."
(when flan-watch--consumers
(flan-watch--ghost-clear)
(flan-watch--paint "error:\nnot connected to a running program\n")))
(defun flan-watch--arm (on)
"Ask the daemon to arm the table and push it, or to disarm it when ON is nil."
(flan--request (if on
`(:op "watch-enable" :on t :interval ,flan-watch-interval)
'(:op "watch-enable" :on nil))))
;;; Commands
(defvar flan-watch-mode-map
(let ((map (make-sparse-keymap)))
;; As `flan-doc-mode-map'.
(define-key map "q" #'quit-window)
map)
"Keys in `flan-watch-mode'.")
(flan-evil-own-keys 'flan-watch-mode-map)
(define-derived-mode flan-watch-mode special-mode "flan-watch"
"Major mode for the pinned watch buffer."
(setq-local truncate-lines t))
(setq-local truncate-lines t)
;; Killing the buffer cancels the buffer's half of the subscription, and only
;; that. When it was the only consumer the table is disarmed, so a program
;; nobody is watching is back to paying a load and a branch per watch call;
;; when ghost text is also on, the table stays armed.
(add-hook 'kill-buffer-hook #'flan-watch--buffer-killed nil t))
(defun flan-watch--buffer-killed ()
"Drop the buffer consumer, the watch buffer being killed."
(when (memq 'buffer flan-watch--consumers)
(flan-watch--drop 'buffer)))
(defun flan-watch--subscribe (consumer)
"Arm the table for CONSUMER and make sure the shared timer is running.
"Arm the table for CONSUMER.
Arming is a message, not something the daemon infers. The program is the
writer, so it has to be told somebody is looking — and while nobody is, nothing
writes the table at all, which is what makes a watch call in a program nobody is
debugging a load and a not-taken branch. It is sent once for the first
consumer: two of them looking at one table is still one table."
(flan-dev--live-connection)
(flan--live-connection)
(unless flan-watch--consumers
(let ((r (flan-dev--request '(:op "watch-enable" :on t))))
(let ((r (flan-watch--arm t)))
(unless (equal (plist-get r :status) "ok")
(user-error "flan: %s" (or (plist-get r :message) "watch refused")))))
(unless (memq consumer flan-watch--consumers)
(push consumer flan-watch--consumers))
(add-hook 'flan-dev-settle-hook #'flan-watch--settle)
(when flan-watch--timer (cancel-timer flan-watch--timer))
(setq flan-watch--timer
(run-with-timer 0 flan-watch-interval #'flan-watch--tick)))
(add-hook 'flan-push-functions #'flan-watch--on-push)
(add-hook 'flan-connected-hook #'flan-watch--on-connect)
(add-hook 'flan-disconnected-hook #'flan-watch--on-disconnect))
(defun flan-watch--drop (consumer)
"Stop painting CONSUMER, and tear everything down if it was the last one."
@@ -454,7 +454,7 @@ consumer: two of them looking at one table is still one table."
(flan-watch--subscribe 'buffer)
(with-current-buffer (get-buffer-create flan-watch-buffer)
(unless (eq major-mode 'flan-watch-mode) (flan-watch-mode)))
;; Painted before the first reply, rather than left blank until one arrives.
;; Painted before the first push, rather than left blank until one arrives.
;; An empty buffer is the same picture as a broken one, and the likeliest
;; reason for it here is the honest one — the program is not calling into the
;; table — which is worth saying in words rather than by showing nothing.
@@ -488,21 +488,15 @@ Tears down both consumers. `flan-watch--drop' is the way to stop one of them."
;; `flan-watch--drop' and back into here.
(setq flan-watch-ghost-mode nil)
(flan-watch--ghost-clear)
(when flan-watch--timer
(cancel-timer flan-watch--timer)
(setq flan-watch--timer nil))
;; Settle before disarming, or the disarm request reads the tick's reply.
(flan-watch--settle)
(remove-hook 'flan-dev-settle-hook #'flan-watch--settle)
;; Only on a connection that is already live, and this is the important half.
;; `flan-dev--request' *reconnects* — which is right for something a person
;; did and wrong here, because this is also called from the tick, and the
;; reason the tick calls it is that the connection has gone. Reconnecting
;; from a timer would quietly erase the `lost' state that exists to be seen,
;; which `flan-dev.el' already forbids for its own poll timer. And there is
;; nothing to disarm anyway: the table went with the program.
(when (process-live-p flan-dev--connection)
(ignore-errors (flan-dev--request '(:op "watch-enable" :on nil)))))
(remove-hook 'flan-push-functions #'flan-watch--on-push)
(remove-hook 'flan-connected-hook #'flan-watch--on-connect)
(remove-hook 'flan-disconnected-hook #'flan-watch--on-disconnect)
;; Only on a connection that is already live. `flan--request' reconnects,
;; which is right for something a person did and wrong here: there is
;; nothing to disarm when the connection has gone, because the table went
;; with the program.
(when (process-live-p flan--connection)
(ignore-errors (flan-watch--arm nil))))
;;; What ghost text still cannot show
@@ -527,13 +521,13 @@ Tears down both consumers. `flan-watch--drop' is the way to stop one of them."
;; - The module in the running program can be older than the buffer. A site
;; you have typed but not yet installed with `C-c C-c' has no row, and one
;; you deleted but not yet installed still writes. Deliberately unreported:
;; the stale-caller machinery in `flan-dev.el' already tracks what needs
;; the stale-caller machinery in `flan.el' already tracks what needs
;; re-evaluating, and a second opinion about it here would be a worse one.
;;
;; Still genuinely blocked on the checker, and unchanged by any of this: a
;; **struct or a slice**. Rendering one is a compile-time walk over its type,
;; the runtime has scalar entry points only, and no amount of searching the
;; buffer produces a value the table does not hold. See BUILT.md.
;; buffer produces a value the table does not hold. See docs/BUILT.md.
(provide 'flan-watch)
;;; flan-watch.el ends here
+4287
View File
File diff suppressed because it is too large. Load diff
+1512 -92
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+460
View File
@@ -0,0 +1,460 @@
;;; test-flan-fln-live.el --- The .fln keys against a real daemon -*- lexical-binding: t; -*-
;; Loaded by test-flan.el, near its end, with `flan-command' already the
;; compiler under test. test-flan-fln.el checks which text each key picks;
;; this checks the reader accepts that text as a whole form, on both backends:
;; an image of its own with a .fln file with no main loaded into it, once on
;; x86 and once on LLVM, each key sent at least once, and a pause mark the
;; daemon must find.
;;; Code:
(require 'flan-fln-mode)
(declare-function test-flan--check "test-flan" (name ok))
(declare-function test-flan--result "test-flan" ())
(defvar test-flan-fln-live-dir)
(defvar test-flan-fln-live-socket)
(defconst test-flan-fln-live--program
"fn fib(n: i64) -> i64
if n < 2
n
else
fib(n - 1) + fib(n - 2)
fn twice(n: i64) -> i64 = n * 2
fn total(n: i32) -> i32
let t = 0
for i in 0..n
t = t + i
t
fn sign(n: i64) -> i64
if n < 0
-1
elif n == 0
0
else
1
enum Dir
north
south
east
fn pick(d: Dir) -> i64
match d
:north -> 10
:south -> 20
:east -> 1 +
2
_ ->
twice(3)
fn sign2(k: i64)
if k < 0 then -1
elif k == 0 then 0
else 1
fn size(k: i64) -> i64
if k < 10
1
else 2
fn lam(k: i64) -> i64
let add = fn(a: i64, b: i64) -> i64 => a + b
let dbl = fn(a: i64) -> i64 =>
a * 2
add(dbl(k), 1)
fn app(x: i64, f: Fn(i64) -> i64) -> i64 = f(x)
fn lam2(k: i64) -> i64
let a = 1
let b = app(k, fn(x) =>
let y = x + a
y)
let c = fn(z: i64) -> i64 =>
z * 2
c(b)
fn rs() -> i64
restart-case
3
restart retry() \"Try again\"
4
fn t2(k: i64) -> i64
let a = 1
let b = 2
let c: i64 = 3
a + b + c + k
fn dir(d: Dir) -> i64
match d
Dir.north -> 7
_ -> 8
struct Oops :parent Error
code: i64
type Count = i64
let speed: i64 = 3
fn speed-of() -> i64 = speed
struct Pair(a: i64, b: i64)
fn pair-sum(p: Pair) -> i64 = p.a + p.b
class shape(w, h)
generic area(s) -> dyn
method area(s: shape) = get(s, :w) * get(s, :h)
multi kind(v) -> dyn = type-of(v)
method kind(v) when :int = 1
method kind(v) when :else
0
fn counted(n: Count) -> Count = n + 1
fn oops-code() -> i64
handler-case
error(Oops{.code 7})
on Oops(c)
c.code
macro dbl-of(x, ...more)
quote
~x + ~x
fn use-mac(k: i64) -> i64 = dbl-of(k)
fn gcd(a: i64, b: i64) -> i64
let x = a
let y = b
while y != 0
let r = x % y
x = y
y = r
x
fn sum-to(n: i64) -> i64
let i = 0
let acc = 0
until i > n
acc += i
i += 1
acc
comment():
if 1 < 2 and
3 < 4
twice(1)
elif 1 > 2 or
3 > 4
0
app(5, fn(a) =>
a * 3
)
if 2 < 1 then 5
elif 2 == 1 then 6
else 7
twice(4)
if 2 > 1
twice(2)
else
0
let x = 3
twice(x) + 1
fn grp(k: i64) -> i64
let a = k + 1
b = a * 2
a + b
comment():
let u = 2
v = u * 5
u + v
")
(defun test-flan-fln-live--run (backend args)
(let* ((file (expand-file-name (format "fln-live-%s.fln" backend)
test-flan-fln-live-dir))
(flan-daemon-args args)
(name (lambda (s) (format "%s: %s" backend s)))
(value (lambda (code)
(plist-get (flan--request
(list :op "eval-expr" :code code :file "<test>"))
:value)))
(goto (lambda (needle &optional after)
(goto-char (point-min))
(search-forward needle)
(unless after (goto-char (match-beginning 0)))))
(shows (lambda (v)
(let ((r (test-flan--result)))
(prog1 (and r (string-match-p (concat "=> " (regexp-quote v) "\\'")
(string-trim r)))
(flan-clear-result))))))
(with-temp-file file (insert test-flan-fln-live--program))
(flan test-flan-fln-live-dir)
(with-current-buffer (find-file-noselect file) (flan-eval-buffer))
(test-flan--check (funcall name "a .fln file loads into an image")
(process-live-p flan--connection))
(unwind-protect
(with-current-buffer (find-file-noselect file)
(test-flan--check (funcall name "which opens in flan-fln-mode")
(eq major-mode 'flan-fln-mode))
;; C-c C-c, from inside a fn changed in the buffer.
(funcall goto "n * 2")
(delete-char 5)
(insert "n * 3")
(flan-fln-eval-defun)
(test-flan--check (funcall name "C-c C-c installs the fn at point")
(equal (funcall value "(twice 7)") "21"))
;; C-x C-e at the end of a column-0 declaration.
(funcall goto "n * 3")
(delete-char 5)
(insert "n * 4")
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e at the end of a column-0 fn installs it")
(equal (funcall value "(twice 7)") "28"))
;; C-x C-e at the end of an inner statement: text from column 3.
(funcall goto "twice(4)" t)
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e sends the statement ending at point")
(funcall shows "16"))
;; ...and inside a line, the term before point.
(funcall goto "twice(4)")
(let ((at (point)))
(insert "fib(10) + ")
(goto-char (+ at (length "fib(10)")))
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e inside a line sends the term before point")
(funcall shows "55"))
(delete-region at (+ at (length "fib(10) + "))))
;; C-c C-e on a clause: the whole if, from column 3, clauses and all.
(funcall goto "else\n 0")
(flan-fln-eval-statement)
(test-flan--check (funcall name "C-c C-e on a clause sends its if, and it reads")
(funcall shows "8"))
;; A bare let: it and the rest of its block, which is its scope.
(funcall goto "let x = 3")
(flan-fln-eval-statement)
(test-flan--check (funcall name "C-c C-e on a bare let sends its scope with it")
(funcall shows "13"))
;; A let's bindings on the lines under it go with it, and read.
(funcall goto "v = u * 5")
(flan-fln-eval-statement)
(test-flan--check (funcall name "C-c C-e on a binding line sends its let's group and scope")
(funcall shows "12"))
;; A region of several statements reads as one (do ...).
(funcall goto "twice(4)")
(transient-mark-mode 1)
(set-mark (point))
(funcall goto "else\n 0" t)
(flan-fln-eval-statement)
(test-flan--check (funcall name "C-c C-e on a region of statements evaluates them in order")
(funcall shows "8"))
;; C-c C-n sends and moves on.
(funcall goto "twice(4)")
(flan-fln-eval-statement-and-next)
(test-flan--check (funcall name "C-c C-n sends the statement")
(funcall shows "16"))
(test-flan--check (funcall name "and moves to the next")
(looking-at "if 2 > 1"))
;; A one-line if with its clauses on the lines under it.
(funcall goto "if 2 < 1 then 5" t)
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e at the end of a one-line if that goes on evaluates the whole if")
(funcall shows "7"))
(funcall goto "elif 2 == 1")
(flan-fln-eval-statement)
(test-flan--check (funcall name "C-c C-e on a clause under a one-line if sends its if")
(funcall shows "7"))
;; The newer forms install from the buffer and run.
(pcase-dolist (`(,needle ,call ,want)
'(("fn sign2" "(sign2 0)" "0")
("fn size" "(size 20)" "2")
("fn lam" "(lam 3)" "7")
("fn lam2" "(lam2 3)" "8")
("fn rs" "(rs)" "3")
("fn dir" "(dir :north)" "7")
("struct Oops" "(oops-code)" "7")
("type Count" "(counted 1)" "2")
("struct Pair" "(pair-sum (Pair {.a 1 .b 2}))" "3")
("fn pair-sum" "(pair-sum (Pair {.a 1 .b 2}))" "3")
("class shape" "(i64 (area (shape 2 3)))" "6")
("generic area" "(i64 (area (shape 2 3)))" "6")
("method area" "(i64 (area (shape 2 3)))" "6")
("multi kind" "(i64 (kind 3))" "1")
("method kind(v) when :else" "(i64 (kind :x))" "0")
("fn counted" "(counted 1)" "2")
("fn oops-code" "(oops-code)" "7")
("macro dbl-of" "(use-mac 5)" "10")
("fn use-mac" "(use-mac 5)" "10")
("fn gcd" "(gcd 1071 462)" "21")
("fn sum-to" "(sum-to 4)" "10")))
(funcall goto needle)
(flan-fln-eval-defun)
(test-flan--check (funcall name (format "C-c C-c installs %s" needle))
(equal (funcall value call) want)))
;; A top-level let is a global, installed and re-run by C-c C-c.
(funcall goto "let speed: i64 = 3")
(end-of-line)
(delete-char -1)
(insert "9")
(flan-fln-eval-defun)
(test-flan--check (funcall name "C-c C-c on a top-level let installs the global")
(equal (funcall value "(speed-of)") "9"))
;; A macro changed in the buffer and installed again reaches the
;; function installed after it.
(funcall goto "~x + ~x")
(delete-char 7)
(insert "~x * 3")
(funcall goto "macro dbl-of")
(flan-fln-eval-defun)
(funcall goto "fn use-mac")
(flan-fln-eval-defun)
(test-flan--check (funcall name "C-c C-c on a changed macro takes effect")
(equal (funcall value "(use-mac 5)") "15"))
;; The pause mark. What is sent is a line and column, and the daemon
;; answers `:pause' only when a form the reader made starts exactly
;; there (`Ast.mark_pause'). Each kind of target once, and one position
;; a column off to show the answer can be no.
;; C-x C-e on an arm's value, and on a condition line.
(funcall goto ":south -> 20" t)
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e at the end of a match arm evaluates its value")
(funcall shows "20"))
(funcall goto ":east -> 1 +" t)
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e on an arm's wrapped value evaluates all of it")
(funcall shows "3"))
(funcall goto "if 1 < 2 and" t)
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e on a wrapped condition evaluates all of it")
(funcall shows "true"))
;; An error on the wrapped line of a condition or value cut out
;; mid-line is reported where it is in the buffer, column and all.
(let ((refused
(lambda (needle bad fix)
(funcall goto needle t)
(let ((line (1+ (line-number-at-pos))) col)
(save-excursion
(forward-line 1)
(search-forward fix (line-end-position))
(replace-match bad t t)
(setq col (1+ (- (point) (line-beginning-position)
(length (car (last (split-string bad " "))))))))
(prog1 (list (condition-case err (progn (flan-fln-eval-last) nil)
(user-error (error-message-string err)))
(format ":%d:%d)" line col))
(save-excursion
(goto-char (point-min))
(search-forward bad)
(replace-match fix t t)))))))
(pcase-dolist (`(,what ,needle ,bad ,fix)
'(("an if condition" "if 1 < 2 and" "3 < 4 4" "3 < 4")
("an elif condition" "elif 1 > 2 or" "3 > 4 4" "3 > 4")
("an arm's value" ":east -> 1 +" "2 2" "2")))
(let ((r (funcall refused needle bad fix)))
(test-flan--check
(funcall name (format "an error on the wrapped line of %s is reported at its column" what))
(and (car r) (string-suffix-p (cadr r) (car r))))
(unless (and (car r) (string-suffix-p (cadr r) (car r)))
(message " want ...%s\n got %S" (cadr r) (car r))))))
(flan-clear-errors)
;; A lambda's block inside a call's brackets: the call goes whole
;; from its header line, and reads.
(funcall goto "app(5")
(flan-fln-eval-statement)
(test-flan--check (funcall name "C-c C-e on a call with a block lambda sends it whole, and it reads")
(funcall shows "15"))
(funcall goto "app(5, fn(a) =>" t)
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e at the end of its header line too")
(funcall shows "15"))
(funcall goto "if 2 > 1" t)
(flan-fln-eval-last)
(test-flan--check (funcall name "C-x C-e at the end of an if line evaluates the condition")
(funcall shows "true"))
(dolist (c '(("n - 1)" "a call, from its name")
("elif n" "an elif, at its condition")
("else\n 1" "an else, at its block")
(":south -> 20" "a match arm, at its value")
("_ ->" "a match arm, at its block")
("n < 2" "an if statement")
("t = 0" "a let")
("i in 0..n" "a for")
("+ i" "an assignment")
("if k < 0 then" "a one-line if")
("elif k" "a one-line elif, at its condition")
("else 1" "a one-line else, at its value")
("else 2" "a one-line else after a block, at its value")
("a: i64, b" "a typed lambda, from its fn")
("a * 2" "a typed lambda's block")
("x) =>" "a block lambda in a call, from its fn")
("let y = x" "a statement in a block lambda's block")
("y)" "the block's last statement, less the call's closer")
("let b = app" "a merged let's call with a block lambda, at its value")
("let c = fn" "a merged let's block lambda, at its value")
("restart retry" "a restart with a report, at its block")
("Dir.north" "an enum member's arm, at its value")
("let b = 2" "a let the let above takes in, at its value")
("let c: i64" "a typed one, at its value")
("while y != 0" "a while, at its word")
("let r = x % y" "a while's block")
("until i > n" "an until, at its word")
("acc += i" "an until's block")
("b = a * 2" "a binding under a let, at its value")))
(funcall goto (car c))
(let ((reply (flan-fln-eval-defun '(4))))
(test-flan--check (funcall name (format "C-u C-c C-c marks %s where the reader starts it"
(cadr c)))
(plist-get reply :pause)))
(flan-fln-eval-defun))
(test-flan--check (funcall name "and a plain C-c C-c takes the mark down")
(null (flan--pause-overlays)))
(funcall goto "fib(n - 1)")
(let* ((b (flan-fln--toplevel-bounds (point)))
(off (condition-case nil
(plist-get (flan--eval (flan--text (car b) (cdr b)) "defn"
nil nil (cons (1+ (point)) (+ 3 (point))))
:pause)
(user-error nil))))
(test-flan--check (funcall name "a position one column off the call is not taken")
(null off)))
(flan-fln-eval-defun)
(set-buffer-modified-p nil)
(kill-buffer))
;; Stopped whatever happened above: a daemon this started is its own to end.
(flan-quit))
(ignore-errors (delete-file file))))
(message "\nthe .fln keys, against a daemon on each backend")
(test-flan-fln-live--run "x86" nil)
(test-flan-fln-live--run "llvm" '("--llvm"))
;;; test-flan-fln-live.el ends here
File diff suppressed because it is too large. Load diff
+556 -14
View File
@@ -111,15 +111,15 @@
;; there.
(test-flan-mode--check
"a defn with a return type indents its body by two"
"(defn look [n i64 label string] i64
(print label)
"(defn look [n i64 label str] i64
(prin label)
n)")
(test-flan-mode--check
"and a defn without one indents it the same"
"(defn show-trim [s string] ()
(print s)
(println \"\"))")
"(defn show-trim [s str] ()
(prin s)
(print \"\"))")
;; A parameter list that wraps is the binding-vector rule again: it is a
;; vector, so it aligns under its first element and not under its second.
@@ -168,7 +168,7 @@
;; one column.
(test-flan-mode--check
"declare-c puts everything after the name in one column"
"(declare-c mouse-button-pressed?
"(declare-c is-mouse-button-pressed
[button MouseButton] bool
\"IsMouseButtonPressed\")")
@@ -206,13 +206,180 @@
"a discarded binding does not disturb the pairs around it"
"(let [#_a #_1 b 2
c 3]
(print c))")
(prin c))")
;; A labelled loop, from `test/programs/loops.flan'. The label is a keyword
;; written where the binding vector would otherwise go, so it pushes everything
;; along by one — and until the indenter was told, the body of a labelled loop
;; was dragged out under its own binding vector.
(test-flan-mode--check
"a labelled dotimes indents its body by two, not under its vector"
"(dotimes :outer [a 3]
(dotimes [b 3]
(when (= b 2) (break :outer))
(prin b) (print \"\")))")
;; `until' takes one on the same rule, and there the special argument is a test
;; rather than a vector — which is the case that shows the rule is about the
;; label and not about brackets.
(test-flan-mode--check
"and so does a labelled until, whose special argument is a test"
"(until :count (> n 10)
(set n (+ n 1))
(when (= n 3) (break :count)))")
;; The unlabelled form must not have moved, which is the other half of the
;; same claim.
(test-flan-mode--check
"an unlabelled dotimes is where it always was"
"(dotimes [b 3]
(prin b)
(print \"\"))")
;; `dotimes' binding vectors now hold up to four elements — name, start, stop,
;; step — and the indenter must not have learned a count. The vector is one
;; sexp whatever is in it, so the body is two in from the head in every arity,
;; and a vector that wraps aligns under its own first element. From
;; `test/programs/dotimes-range.flan'.
(test-flan-mode--check
"a three-element dotimes vector indents its body by two"
"(dotimes [i 2 5]
(prin i)
(print \"\"))")
(test-flan-mode--check
"and a four-element one, counting down, does the same"
"(dotimes [i 9 -1 -1]
(prin i)
(print \"\"))")
(test-flan-mode--check
"and a binding vector that wraps aligns under its first element"
"(dotimes [i 0
(length items)
2]
(prin i))")
;; The object and dispatch forms all begin `def', so they reach the `:defn'
;; fallback and indent their bodies by two. `defmethod' is the one with two
;; names before the parameter vector — the generic and the dispatch value —
;; which is exactly the case a count would have got wrong. From
;; `test/programs/dev-classes.flan'.
(test-flan-mode--check
"a defmethod indents its body by two, past generic and dispatch value"
"(defmethod area point [p]
(* (get p :x)
(get p :y)))")
(test-flan-mode--check
"a defclass puts its slot vector in the body column"
"(defclass point
[x y])")
(test-flan-mode--check
"and a defmulti indents its body by two"
"(defmulti describe [thing] dyn
(class-of thing))")
;; `fn' is `defn' with no name and no return type: the parameter vector, then
;; the body. From `test/programs/higher-order.flan', written down the page.
(test-flan-mode--check
"an fn indents its body under its parameters by two"
"(sort-by s (fn [a b]
(< a b)))")
;; A macro body indents by two like any `def…' form. It reaches that through
;; the `\\`def' fallback rather than through an entry, which is worth pinning:
;; the fallback is what covers every definer nobody listed.
(test-flan-mode--check
"a defmacro indents its body by two"
"(defmacro both [& args]
`(do ~@args))")
;; A macro's parameter list destructures now, so it holds names and a `&'
;; rest marker rather than one blob. It is still a vector, so a list that
;; wraps aligns name under name like every other vector here — and `&' is a
;; name character, so it does not split the marker off from what follows.
(test-flan-mode--check
"a wrapped destructuring parameter list aligns under its first parameter"
"(defmacro guard [test message
& body]
`(when ~test ~@body))")
;; A generic `defn' writes its constraint map between the return type and the
;; body — `test/programs/generic-map-reject.flan'. It is a brace, so it aligns
;; under its first element like every other brace, and the body after it is
;; still two in from `defn'.
(test-flan-mode--check
"a where clause sits in the body column and does not move the body"
"(defn is-seen [k $t] bool
{:where (is-hashable $t)}
(let [m (map-new t i32)]
(put m k 1)))")
;; A comma is whitespace, so a vector written with commas is the same vector
;; and lines up the same way. It reads as one now because the syntax table
;; says so; before, a comma was punctuation and sat in the middle of a name.
(test-flan-mode--check
"commas between bindings change nothing"
"(let [vel 1.0,
y 2.0]
(prin y))")
;; A `with-' form is a body, qualified or not, whatever it takes on the head's
;; line — `clojure-indent-function''s fallback. From `examples/core-2d-camera.flan'.
(test-flan-mode--check
"a qualified with- form indents its body by two past an argument"
"(rl/with-mode-2d camera
(rl/draw-rectangle-rec player rl/red)
(rl/draw-grid 10 1.0))")
(test-flan-mode--check
"a qualified def form indents its body by two"
"(m/defthing name
(body))")
;; A qualified name finds the spec of its unqualified part.
(test-flan-mode--check
"a qualified when keeps when's spec"
"(rl/when (ready?)
(go))")
;; `default…' begins with `def' and is not a definer.
(test-flan-mode--check
"a default-prefixed call aligns its arguments"
"(default-color a
b)")
;; A cond result on its own line sits under its test, not deeper.
(test-flan-mode--check
"a cond result on its own line aligns with its test"
"(cond
(= k 1)
(one)
:else
(other))")
(test-flan-mode--check
"a match result on its own line aligns with its pattern"
"(match v
(Int n) n
(List items)
(length items))")
;; A trailing argument of an ordinary call aligns under the first argument,
;; even when it is long; only a `with-', `def' or specced head gives a body.
(test-flan-mode--check
"an ordinary call's arguments align under the first one"
"(push missing
`(when (ok) (go)))")
;;; Font lock
;; The colon-to-dot change left every field label in the corpus unfontified:
;; the keyword rule used to cover them, and the colon belongs to keywords now.
;; What is drawn is the language; what is drawn plain is the program. A field
;; label, a builtin and a number are all part of reading a program and none of
;; them is coloured, which is how clojure-mode treats the same three.
(defun test-flan-mode--face-at (text needle)
"The face on the first character of NEEDLE in TEXT, under `flan-mode'."
@@ -226,14 +393,22 @@
(message "\n-- font lock")
;; The keyword rule is what is left of the constants: an enum member resolves
;; against a type and is worth marking.
(test-flan--check "an enum member is a constant"
(eq (test-flan-mode--face-at
"(rl/is-mouse-button-down :mouse-left)" ":mouse-left")
'font-lock-constant-face))
(dolist (case '(("(rl/Vector2 {.x 1.0 .y 2.0})" ".x" "a struct literal's field label")
("(rl/Vector2 {.x 1.0 .y 2.0})" ".y" "and the second one")
("(set total (.bytes c))" ".bytes" "a field accessor")
;; The keyword rule is still there: an enum member is one.
("(rl/mouse-button-down? :left)" ":left" "an enum member")))
(test-flan--check (format "%s is a constant" (nth 2 case))
(eq (test-flan-mode--face-at (nth 0 case) (nth 1 case))
'font-lock-constant-face)))
("(put v 42)" "42" "a number")
("(set mask 0xFF)" "0xFF" "a hex number")
("(put v x)" "put" "a builtin")
("(print (length xs))" "length" "and another")))
(test-flan--check (format "%s is plain" (nth 2 case))
(null (test-flan-mode--face-at (nth 0 case) (nth 1 case)))))
;; A dot inside a number is not a label, and neither is the dot in a name that
;; has one in the middle.
@@ -242,5 +417,372 @@
(test-flan--check "a dot inside a name does not start a label"
(null (test-flan-mode--face-at "(f alpha.beta)" ".beta")))
;; The pass over the static table. Each row is a piece of the corpus and the
;; face the name in it should carry; several of these were drawn as nothing at
;; all until the table was brought back into line with `lib/parse.ml' and
;; `lib/check.ml'.
(dolist (case '(;; A definer that was missing: the head was not a keyword and
;; the name after it was not a function name.
("(defmacro both [& args] `(do ~@args))" "defmacro"
font-lock-keyword-face "defmacro's head")
("(defmacro both [& args] `(do ~@args))" "both"
font-lock-function-name-face "and the macro's name")
("(declare-c mouse-down? [b i32] bool \"X\")" "declare-c"
font-lock-keyword-face "declare-c's head")
;; The defining forms that arrived with the object system and
;; with the def/defonce/defconst trio.
("(def ticks i64 0)" "def" font-lock-keyword-face "def's head")
("(def ticks i64 0)" "ticks" font-lock-function-name-face
"and the name it introduces")
("(defonce seed i64 1)" "defonce" font-lock-keyword-face
"defonce")
;; A private defn: the head as a whole, not `defn' and a
;; stray `-', and the name after it.
("(defn- mix [a i32] i32 a)" "defn-" font-lock-keyword-face
"defn-'s head")
("(defn- mix [a i32] i32 a)" "mix"
font-lock-function-name-face "and the private function's name")
("(defclass point [x y])" "defclass" font-lock-keyword-face
"defclass")
("(defgeneric area [self] dyn)" "defgeneric"
font-lock-keyword-face "defgeneric")
("(defmethod area point [p] 1)" "defmethod"
font-lock-keyword-face "defmethod")
;; `handler-case' is implemented now and is a keyword like the
;; other three condition forms.
("(handler-case (go) [(E [c] 1)])" "handler-case"
font-lock-keyword-face "handler-case")
;; Special forms the old list had never heard of.
("(cond (= a 1) 2)" "cond" font-lock-keyword-face "cond")
;; until is a prelude macro, drawn as the control flow it is.
("(until (> i 3) (go))" "until" font-lock-keyword-face "until")
("(and a b)" "and" font-lock-keyword-face "and")
("(handler-bind [(E [c] 1)] (go))" "handler-bind"
font-lock-keyword-face "handler-bind")
("(restart-case (go) (retry [] 1))" "restart-case"
font-lock-keyword-face "restart-case")
("(signal ArithError {.op 1})" "signal"
font-lock-keyword-face "signal")
("(dotimes [b 3] (break :outer))" "break"
font-lock-keyword-face "break")
;; The two array constructors, which the parser reads itself
;; because their dimensions are in brackets — see
;; `test/programs/array-fill.flan'.
("(array-fill [rows cols] 255)" "array-fill"
font-lock-keyword-face "array-fill")
("(array-gen [n] f)" "array-gen"
font-lock-keyword-face "array-gen")
;; A builtin is a call and not a form the parser knows, and it
;; is left plain — the rows for that are above, with the field
;; labels and the numbers.
;;
;; Types.
("(defn f [x dyn] dyn x)" "dyn" font-lock-type-face "dyn")
("(def v (Vec u8))" "Vec" font-lock-type-face "Vec")
("(declare apply [(Fn [i64] i64)] i64)" "Fn"
font-lock-type-face "Fn")
("(declare call [(CFn [i64] i64)] i64)" "CFn"
font-lock-type-face "CFn")
("(defn is-seen [k $t] bool 1)" "$t" font-lock-type-face
"a type variable")
;; The package alias of a qualified name, `clojure-mode''s
;; rule for a namespace.
("(rl/draw-text \"hi\" 1 2 3)" "rl" font-lock-type-face
"a package alias")
("(defn f [x int] float 1.0)" "int" font-lock-type-face
"int, the builtin alias")
("(defn f [s [const u8]] 1)" "const" font-lock-type-face
"const, in a read-only slice type")
;; Constants that stand for themselves.
("(set done true)" "true" font-lock-constant-face "true")
("(= o None)" "None" font-lock-constant-face "None")
("(set x nil)" "nil" font-lock-constant-face "nil")
("(def buf (Vec u8) uninit)" "uninit" font-lock-constant-face
"uninit, which is only ever written here")))
(test-flan--check (format "%s is drawn" (nth 3 case))
(eq (test-flan-mode--face-at (nth 0 case) (nth 1 case))
(nth 2 case))))
;; `Unit' is a name the resolver answers to and the parser refuses — unit is
;; written `()'. Drawing it as a type would advertise a spelling that does not
;; compile, which is the same rule that keeps `await' out of the keyword list.
(test-flan--check
"Unit is not drawn as a type, because the parser refuses the word"
(null (test-flan-mode--face-at "(defn f [] Unit 1)" "Unit")))
;; `context/allocator' has a slash in it and is not a qualified name: there is
;; no package called `context'. The constants rule runs first for exactly this
;; reason, and this is what would notice if it stopped.
(test-flan--check
"context/allocator is one constant and not an alias and a name"
(eq (test-flan-mode--face-at "(free-all (context/allocator))" "context/")
'font-lock-constant-face))
;; The name after a package alias is left for the running program to speak for
;; — see the dynamic section below. With no program it is undrawn, and that is
;; the state a file opened on its own is in.
(test-flan--check
"the name after the alias is left alone"
(null (test-flan-mode--face-at "(rl/draw-text \"hi\" 1 2 3)" "draw-text")))
;;; Font lock from the running program
;; `flan.el' adds a second set of rules that draw what the program *defines* —
;; a macro as a macro, a function as a function — and everything below drives
;; them from a fixture `flan--defs' rather than from a daemon. The live half,
;; where an evaluation is accepted and the name lights up without anyone asking
;; for it, is in test-flan.el, which has a program to evaluate against.
(require 'flan)
(defconst test-flan-mode--defs
;; The shape `Dev.defs' answers with: (NAME KIND SIGNATURE LOC DOC).
'(("settle" "fn" "settle [i32 i32] ()" "sand.flan:3" "")
("ticks" "var" "ticks i64" "" "")
("gravity" "const" "gravity f32" "" "")
("with-retry" "macro" "with-retry [args] Form" "" "")
("Pixel" "struct" "Pixel" "" "")
("Shape" "data" "Shape [Empty (Dot [x f64 y f64])]" "" "")
("Shape.Empty" "case" "Shape.Empty" "" "")
("Shape.Dot" "case" "(Shape.Dot [x f64 y f64])" "" "")
("point" "class" "point [x y]" "sand.flan:20:1" "")
("Key" "enum" "Key" "" "")
("sim/step" "fn" "sim/step [] ()" "sand.flan:9" "")
("a/draw" "fn" "a/draw [] ()" "" "")
("b/draw" "fn" "b/draw [] ()" "" "")
;; The program is allowed to define a name the language already uses. It
;; does not get to repaint it.
("length" "fn" "length [i32] i32" "" "")
("if" "fn" "if [i32] i32" "" "")
;; And a kind this editor has never heard of, which the daemon is allowed
;; to add.
("somenewthing" "sigil" "somenewthing" "" ""))
"A `defs' reply to draw against.")
(defun test-flan-mode--dyn-face (text needle &optional defs)
"The face on NEEDLE in TEXT with DEFS as what the program defines."
(let ((flan--defs (if (eq defs 'none) nil (or defs test-flan-mode--defs))))
(flan--dynamic-rebuild)
(with-temp-buffer
(insert text)
(flan-mode)
(flan--dynamic-install)
(font-lock-ensure)
(goto-char (point-min))
(search-forward needle)
(get-text-property (- (point) (length needle)) 'face))))
(message "\n-- font lock from the program")
;; `flan-font-lock-dynamically' is a list of kinds and defaults to macros
;; alone, so a macro is drawn and everything else the program defines is not.
;; A macro is the one kind a reader cannot work out from the call, because its
;; arguments are not evaluated; a function drawn as a function says only that
;; the name exists. cider-mode's default is the same shape.
(test-flan--check
"a macro the program defines is drawn as one"
(eq (test-flan-mode--dyn-face "(with-retry (go))" "with-retry")
'flan-macro-face))
(dolist (case '(("(settle 1 2)" "settle" "a function")
("(set ticks 0)" "ticks" "a global")
("(prin gravity)" "gravity" "a constant")
("(def p (Vec Pixel))" "Pixel" "a struct")
("(defn f [s Shape] () 1)" "Shape" "a data type")
("(def k Key)" "Key" "an enum")))
(test-flan--check
(format "%s the program defines is left plain" (nth 2 case))
(null (test-flan-mode--dyn-face (nth 0 case) (nth 1 case)))))
;; Asked for by name, the other kinds still draw: the default is a default and
;; not a removal.
(test-flan--check
"a function is drawn when the setting asks for one"
(let ((flan-font-lock-dynamically '(macro fn)))
(eq (test-flan-mode--dyn-face "(settle 1 2)" "settle")
'flan-function-face)))
(test-flan--check
"and t still draws every kind"
(let ((flan-font-lock-dynamically t))
(eq (test-flan-mode--dyn-face "(set ticks 0)" "ticks")
'flan-global-face)))
;; A macro and a function must not look alike — the whole point — so this is
;; asserted as a difference and not only as two faces.
(test-flan--check
"a macro and a function are not drawn the same"
(let ((flan-font-lock-dynamically '(macro fn)))
(not (eq (test-flan-mode--dyn-face "(with-retry (go))" "with-retry")
(test-flan-mode--dyn-face "(settle 1 2)" "settle")))))
;; A macro is compiled, so it has a body to disassemble and to lower — and the
;; lists that offer "a thing with a body" used to filter on kind `fn' alone.
;; Giving macros a kind of their own would have quietly dropped them out of
;; `flan-disassemble', `flan-disassemble-ir' and `flan-lowering' unless every
;; one of those learned the second word.
(test-flan--check
"a macro is offered by the completion table for a compiled body"
(let ((flan--defs test-flan-mode--defs))
(and (member "with-retry" (flan--compiled-names))
(member "settle" (flan--compiled-names)))))
(test-flan--check
"and a global is not, because it has no body"
(let ((flan--defs test-flan-mode--defs))
(not (member "ticks" (flan--compiled-names)))))
;; A constructor is `Type.Case' and is one symbol. The daemon lists each
;; case under that name, so the whole symbol is drawn, and it is drawn when
;; the data type is: a case is part of its type, not a kind to ask for apart.
(test-flan--check
"a constructor is drawn whole, case half included"
(let ((flan-font-lock-dynamically '(data)))
(eq (test-flan-mode--dyn-face "(match s (Shape.Dot) 1)" ".Dot")
'flan-type-face)))
(test-flan--check
"and not when data types are not asked for"
(null (test-flan-mode--dyn-face "(match s (Shape.Dot) 1)" "Shape.Dot")))
(test-flan--check
"a case the program does not have draws only its type half"
(let ((flan-font-lock-dynamically t))
(and (eq (test-flan-mode--dyn-face "(match s (Shape.Nope) 1)" "Shape")
'flan-type-face)
(null (test-flan-mode--dyn-face "(match s (Shape.Nope) 1)" ".Nope")))))
(test-flan--check
"a class the program defines is drawn when classes are asked for"
(let ((flan-font-lock-dynamically '(class)))
(eq (test-flan-mode--dyn-face "(point 1 2)" "point") 'flan-type-face)))
(test-flan--check
"a name the program has never heard of is left alone"
(null (test-flan-mode--dyn-face "(never-defined 1)" "never-defined")))
(test-flan--check
"and so is a kind this editor does not know"
(null (test-flan-mode--dyn-face "(somenewthing)" "somenewthing")))
;; The static rules win. `length' is not the test it once was — a builtin is
;; drawn plain now, so both answers are plain and the row proves nothing — so
;; the claim rests on `if', which the language draws and the program may not
;; repaint. Asked for every kind, so the program's own rule is live.
(test-flan--check
"a program function named if is still drawn as the special form"
(let ((flan-font-lock-dynamically t))
(eq (test-flan-mode--dyn-face "(if a 1 2)" "if") 'font-lock-keyword-face)))
;; A buffer inside a package writes `step' for what the program calls
;; `sim/step'. One package offers it, so it resolves — the rule `flan--lookup'
;; already follows for eldoc and `M-.'.
(test-flan--check
"an unqualified name that one package offers is drawn"
(let ((flan-font-lock-dynamically '(macro fn)))
(eq (test-flan-mode--dyn-face "(step)" "step") 'flan-function-face)))
(test-flan--check
"and one that two packages offer is not, because that would be a guess"
(let ((flan-font-lock-dynamically '(macro fn)))
(null (test-flan-mode--dyn-face "(draw)" "draw"))))
;; A number is never a name, even when a definition's last segment is one
;; (a closure's environment, `env/fn/lam/0').
(test-flan--check
"a literal number does not resolve to a definition ending in it"
(let ((flan--defs '(("env/fn/lam/0" "struct" "env/fn/lam/0" "" ""))))
(and (null (flan--lookup "0")) (null (flan--lookup "-0")) (null (flan--ambiguous "0"))
(flan--lookup "env/fn/lam/0"))))
;; With no session there is nothing to say, and the buffer has to be drawn
;; exactly as `flan-mode' alone draws it.
(test-flan--check
"with no session a program name is undrawn"
(null (test-flan-mode--dyn-face "(settle 1 2)" "settle" 'none)))
(test-flan--check
"and the language is drawn as it always was"
(eq (test-flan-mode--dyn-face "(let [a 1] a)" "let" 'none)
'font-lock-keyword-face))
;; Whole-buffer, because "nothing changes" is a claim about every character and
;; not about one name: a corpus file drawn with no session must come out
;; character for character and face for face as it did before any of this.
(defun test-flan-mode--faces (text &optional defs)
"Every (POS . FACE) in TEXT, with DEFS as what the program defines."
(let ((flan--defs (if (eq defs 'none) nil defs)))
(flan--dynamic-rebuild)
(with-temp-buffer
(insert text)
(flan-mode)
(flan--dynamic-install)
(font-lock-ensure)
(let (acc)
(dotimes (i (1- (point-max)))
(push (get-text-property (1+ i) 'face) acc))
(nreverse acc)))))
;; The text carries a macro call, because the default draws macros and nothing
;; else: a body of plain functions and globals is the same picture either way
;; and the last of the three claims below would be asserting nothing.
(let ((text "(defn settle [row i32] ()\n (let [vel gravity]\n (with-retry (set ticks vel))))\n"))
(test-flan--check
"a buffer with no session is drawn exactly as the mode alone draws it"
(equal (test-flan-mode--faces text 'none)
(let ((flan-font-lock-dynamically nil))
(test-flan-mode--faces text test-flan-mode--defs))))
;; ...and turning the setting off with a session up is the same picture,
;; which is what makes it an off switch rather than a reconnect.
(test-flan--check
"and turning the option off gives that same picture back"
(equal (test-flan-mode--faces text 'none)
(let ((flan-font-lock-dynamically nil))
(test-flan-mode--faces text test-flan-mode--defs))))
(test-flan--check
"while with one it is drawn differently"
(not (equal (test-flan-mode--faces text 'none)
(test-flan-mode--faces text test-flan-mode--defs)))))
;; Installing twice must not leave two copies of the rule behind: a refresh
;; happens after every accepted evaluation, and a rule added each time would
;; make redisplay slower for as long as the session lasted.
(test-flan--check
"installing repeatedly leaves one copy of the rule"
(let ((flan--defs test-flan-mode--defs))
(flan--dynamic-rebuild)
(with-temp-buffer
(flan-mode)
(font-lock-ensure)
(dotimes (_ 5) (flan--dynamic-install))
(= 1 (seq-count (lambda (k) (equal k (car flan--dynamic-keywords)))
font-lock-keywords)))))
;;; Electric pairs
;; `electric-pair-mode' reads the syntax table and nothing else, so the three
;; bracket pairs Flan writes work without a word being said about them — and
;; that is exactly why it wants a test: the table gained entries in this pass,
;; and a mistake in one of them would show up here first.
(message "\n-- electric pairs")
(defun test-flan-mode--pair (open)
"Type OPEN in a Flan buffer with `electric-pair-mode' on, and read the line."
(with-temp-buffer
(flan-mode)
(electric-pair-local-mode 1)
(let ((last-command-event open))
(call-interactively #'self-insert-command))
(buffer-string)))
(dolist (case '((?\( "()" "a paren")
(?\[ "[]" "a bracket")
(?{ "{}" "a brace")
(?\" "\"\"" "a string")))
(test-flan--check (format "%s closes itself" (nth 2 case))
(equal (test-flan-mode--pair (nth 0 case)) (nth 1 case))))
(provide 'test-flan-mode)
;;; test-flan-mode.el ends here
+249
View File
@@ -0,0 +1,249 @@
;;; test-flan-repl.el --- The prompt's history, at a stub prompt -*- lexical-binding: t; -*-
;; Loaded by test-flan-cider.el, which runs under `dune test', for the reason
;; test-flan-mode.el gives: `emacs/*.el' is already a dependency of that
;; stanza, so a file here needs no build change to be run.
;;
;; Nothing here needs a daemon, and that is not a shortcut. History is a
;; function from *a ring and where point is* to *what the buffer says*, and the
;; failures worth catching are all on that side: an up-arrow that recalls where
;; someone meant to scroll, a second press that does not move because comint
;; cannot see its own name in `last-command', a form typed over several lines
;; that comes back out of the file in pieces. Evaluation is the other half and
;; test_emacs drives it against a real program.
;;
;; The one thing that must be borrowed from the real thing is the process: the
;; input line is wherever the process mark is, so a prompt with no process
;; behind it has no input line to be on. It is the same `cat' stub
;; `flan-repl' starts, for the same reason its comment gives.
;;; Code:
(require 'flan-repl)
(require 'ring)
(declare-function test-flan--check "test-flan-cider" (name ok))
(declare-function test-flan--caught "test-flan-cider" (thunk))
(defvar test-flan-repl--history
(make-temp-name (expand-file-name "flan-repl-history-"
temporary-file-directory))
"Where these checks keep history.
Never the user's own file: `kill-emacs-hook' saves every REPL buffer, and a
batch run exits through `kill-emacs' like any other.")
(defun test-flan-repl--buffer (&optional inputs)
"A live `flan-repl-mode' buffer at a fresh prompt, with INPUTS in its ring.
INPUTS are given oldest first, the order they would have been typed in."
(let ((buf (get-buffer-create "*test-flan-repl*")))
(with-current-buffer buf
(let ((flan-repl-history-file test-flan-repl--history))
(flan-repl-mode))
(let ((proc (start-process "test-flan-repl" buf "cat")))
(set-process-query-on-exit-flag proc nil)
(comint-output-filter proc flan-repl-prompt))
(dolist (input inputs) (ring-insert comint-input-ring input))
(setq comint-input-ring-index nil)
;; The keypresses below are checked for what they do, so they are run
;; through the map the same way a keypress reaches them.
(setq last-command nil))
buf))
(defun test-flan-repl--press (key)
"Run KEY's binding in the current buffer the way a keypress would.
Through `call-interactively' and with `last-command' carried across, because
both are what the commands under test read: the binding is half the claim, and
comint's search reads the command that ran before it."
(let ((cmd (key-binding (kbd key)))
;; Quiet: comint says which history item it landed on, and in batch
;; that lands in the middle of the line a result is printed on.
(inhibit-message t))
(setq this-command cmd)
(call-interactively cmd)
(setq last-command this-command)))
(defun test-flan-repl--input ()
"The text currently typed at the prompt."
(buffer-substring-no-properties
(process-mark (get-buffer-process (current-buffer)))
(point-max)))
(defun test-flan-repl--done (buf)
"Finish with BUF, writing no history on the way out.
The hook has to come off the buffer rather than be bound away around the kill:
it is buffer-local, and a `let' outside the buffer does not reach it. Saving
here would make every check below read a file the one above it happened to
leave, which is a test of the order they are written in."
(with-current-buffer buf
(remove-hook 'kill-buffer-hook #'flan-repl--save-history t)
(let ((proc (get-buffer-process buf)))
(when proc (delete-process proc))))
(kill-buffer buf))
;;; Up-arrow at the prompt
(let ((buf (test-flan-repl--buffer '("(+ 1 2)" "(foo 1)"))))
(with-current-buffer buf
(test-flan-repl--press "<up>")
(test-flan--check "up at the prompt recalls the last input"
(equal (test-flan-repl--input) "(foo 1)"))
(test-flan-repl--press "<up>")
;; The press that discriminates: comint decides whether a press continues
;; the search by looking for its own command name in `last-command', and a
;; wrapper that does not hand it over searches again from the entry it just
;; inserted, so the first press looks perfect and the second does nothing.
(test-flan--check "and a second press reaches the one before it"
(equal (test-flan-repl--input) "(+ 1 2)"))
(test-flan-repl--press "<down>")
(test-flan--check "down comes back up the ring"
(equal (test-flan-repl--input) "(foo 1)")))
(test-flan-repl--done buf))
(let ((buf (test-flan-repl--buffer '("(+ 1 2)" "(foo 1)" "(bar 2)"))))
(with-current-buffer buf
(insert "(f")
(test-flan-repl--press "<up>")
(test-flan--check "what is already typed filters the search, as a shell does"
(equal (test-flan-repl--input) "(foo 1)")))
(test-flan-repl--done buf))
;;; Up-arrow above the prompt
(let ((buf (test-flan-repl--buffer '("(+ 1 2)"))))
(with-current-buffer buf
;; The transcript above the prompt is read by scrolling back through it,
;; which a blunt history binding would make impossible.
(comint-output-filter (get-buffer-process buf)
(concat "3\n" flan-repl-prompt))
(goto-char (point-min))
(let ((line-move-visual nil)
(where (point)))
(test-flan-repl--press "<down>")
(test-flan--check "down above the input moves a line, and recalls nothing"
(and (> (point) where)
(string-empty-p (test-flan-repl--input))))
(test-flan-repl--press "<up>")
(test-flan--check "and up moves back"
(and (= (point) where)
(string-empty-p (test-flan-repl--input))))))
(test-flan-repl--done buf))
;;; A form typed over several lines
(let ((buf (test-flan-repl--buffer)))
(with-current-buffer buf
(let ((line-move-visual nil))
(insert "(defn f [x]")
(test-flan-repl--press "RET")
(test-flan--check "RET on a half-typed form opens a line instead of sending"
(equal (test-flan-repl--input) "(defn f [x]\n"))
;; The line RET just opened is empty and is the last one, so `<down>'
;; does go to history — with a prefix that spans the newline and matches
;; nothing, which comint refuses rather than answering. What matters is
;; that the half-typed form is still there afterwards.
(test-flan--check "down on the opened line leaves the half-typed form alone"
(and (test-flan--caught
(lambda () (test-flan-repl--press "<down>")))
(equal (test-flan-repl--input) "(defn f [x]\n")))
(insert " (+ x 1))")
(test-flan-repl--press "<up>")
;; Inside the form the key is line motion, not history: the lines of one
;; input are as much a thing to move around in as the transcript is.
(test-flan--check "up inside the form moves between its lines"
(and (equal (test-flan-repl--input) "(defn f [x]\n (+ x 1))")
(flan-repl--on-first-input-line-p)))))
(test-flan-repl--done buf))
(let* ((buf (test-flan-repl--buffer '("(defn f [x]\n (+ x 1))")))
(sent nil))
(with-current-buffer buf
(setq-local comint-input-sender (lambda (_proc text) (setq sent text)))
(test-flan-repl--press "<up>")
;; History leaves point where the typing stopped, which for an empty prompt
;; is the start of what was just recalled. `comint-send-input' sends only
;; as far as point, so a recalled form is exactly the case where sending
;; the line under point would send nothing at all.
(test-flan-repl--press "RET")
(test-flan--check "a recalled multi-line form is sent whole"
(equal sent "(defn f [x]\n (+ x 1))")))
(test-flan-repl--done buf))
;;; The file between sessions
(let ((buf (test-flan-repl--buffer '("(+ 1 2)" "(defn f [x]\n (+ x 1))"))))
(with-current-buffer buf (flan-repl--save-history))
(test-flan-repl--done buf)
(test-flan--check "the history file is written"
(file-readable-p test-flan-repl--history))
(let ((buf (test-flan-repl--buffer)))
(with-current-buffer buf
(test-flan--check "a new session reads back what the last one typed"
(and (= (ring-length comint-input-ring) 2)
(equal (ring-ref comint-input-ring 1) "(+ 1 2)")))
;; Comint separates entries with a newline, which is also what is inside
;; a form typed over several lines; with that separator this entry comes
;; back as two broken ones.
(test-flan--check "including a form written over several lines, in one piece"
(equal (ring-ref comint-input-ring 0)
"(defn f [x]\n (+ x 1))"))
(test-flan-repl--press "<up>")
(test-flan--check "and up recalls it"
(equal (test-flan-repl--input) "(defn f [x]\n (+ x 1))")))
(test-flan-repl--done buf)))
(let ((buf (test-flan-repl--buffer)))
(with-current-buffer buf
(flan-repl--save-history)
(test-flan--check "a session that typed nothing does not empty the file"
(= (flan-repl--history-entries test-flan-repl--history) 2)))
(test-flan-repl--done buf))
(let ((buf (test-flan-repl--buffer)))
(with-current-buffer buf
;; What a second Emacs having saved a longer history looks like from here.
;; A write replaces the file whole, so the short ring would throw the other
;; session away.
(setq comint-input-ring (make-ring flan-repl-history-size))
(ring-insert comint-input-ring "(only-this)")
(flan-repl--save-history)
(test-flan--check "nor does a ring shorter than the file replace it"
(= (flan-repl--history-entries test-flan-repl--history) 2)))
(test-flan-repl--done buf))
(when (file-exists-p test-flan-repl--history)
(delete-file test-flan-repl--history))
;; And the hook itself, which is what makes any of the above happen without
;; being asked: this is the only check that kills a REPL buffer the way a
;; person does.
(let* ((test-flan-repl--history
(make-temp-name (expand-file-name "flan-repl-killed-"
temporary-file-directory)))
(buf (test-flan-repl--buffer '("(kept)"))))
(kill-buffer buf)
(test-flan--check "killing the prompt saves what was typed at it"
(= (flan-repl--history-entries test-flan-repl--history) 1))
(when (file-exists-p test-flan-repl--history)
(delete-file test-flan-repl--history)))
;; The prompt reads the indented syntax: the request says so, and a whole
;; form is one whose brackets balance by .fln's own syntax table.
(let ((sent nil))
(with-current-buffer (test-flan-repl--buffer)
(cl-letf (((symbol-function 'flan--request)
(lambda (form) (setq sent form) (list :status "ok" :value "9")))
((symbol-function 'flan--use-image) #'ignore))
(flan-repl--send nil "state.memory[0x200..0x220]"))
(test-flan--check "the prompt's text goes to the daemon as indented syntax"
(and (equal (plist-get sent :op) "eval-expr")
(equal (plist-get sent :syntax) "indented")
(equal (plist-get sent :code)
"state.memory[0x200..0x220]")))
(test-flan--check "a bracket left open is not a whole form"
(not (flan-repl--complete-p "f(a[0x1..0x2]")))
(test-flan--check "a closed one is"
(flan-repl--complete-p "f(a[0x1..0x2])"))
(kill-buffer (current-buffer))))
(provide 'test-flan-repl)
;;; test-flan-repl.el ends here
+59
View File
@@ -0,0 +1,59 @@
;;; test-flan-session.el --- Marks and logs the session leaves, from fixtures -*- lexical-binding: t; -*-
;; Loaded by test-flan-cider.el. No daemon: each check calls what a reply's
;; handler calls.
;;; Code:
(require 'flan)
(declare-function test-flan--check "test-flan-cider" (name ok))
(message "\nthe session's marks and log")
(let* ((file (make-temp-file "flan-session" nil ".fln"))
(buf (progn (with-temp-file file (insert "fn a() = 1\nfn b() = 2\nfn c() = 3\n"))
(find-file-noselect file))))
(unwind-protect
(with-current-buffer buf
;; A load that installed some forms and refused one: the warning of
;; the install, then the refusal marked.
(flan--show-warning file 1 1 "unused")
(flan--show-error (format "%s:2:1" file) "does not compile")
(test-flan--check "a refusal's mark leaves the same reply's warning mark up"
(and (= (length (flan--warning-overlays)) 1)
(= (length (flan--error-overlays)) 2)))
(flan--show-error (format "%s:3:1" file) "again")
(test-flan--check "and a second refusal replaces the first, not the warning"
(and (= (length (flan--warning-overlays)) 1)
(= (length (flan--error-overlays)) 2)))
(flan-clear-errors)
(test-flan--check "any command still takes every mark down"
(null (flan--error-overlays))))
(kill-buffer buf)
(delete-file file)))
(let ((flan-diagnostics-buffer " *flan-diagnostics-test*"))
(unwind-protect
(cl-letf (((symbol-function 'display-buffer) #'ignore))
(flan--record-diagnostic "a.fln:1:1" "first run")
(flan--render-memory-sites nil)
(flan--record-diagnostic "a.fln:2:1" "first run too")
(flan--clear-diagnostic-log)
(flan--record-diagnostic "a.fln:3:1" "second run")
(let ((text (with-current-buffer (flan--diagnostics-buffer)
(buffer-substring-no-properties (point-min) (point-max)))))
(test-flan--check "a load's log holds that load's messages only"
(and (string-match-p "second run" text)
(not (string-match-p "first run" text)))))
(flan--clear-diagnostic-log)
(test-flan--check "a clean load leaves nothing above the memory section"
(not (string-match-p
"run"
(with-current-buffer (flan--diagnostics-buffer)
(buffer-string))))))
(when (get-buffer flan-diagnostics-buffer)
(kill-buffer flan-diagnostics-buffer))))
(provide 'test-flan-session)
;;; test-flan-session.el ends here
+106
View File
@@ -0,0 +1,106 @@
;;; test-flan-step.el --- The stepper's keys and marks, from a fixture stop -*- lexical-binding: t; -*-
;; Loaded by test-flan-cider.el. A step stop is a `break' reply; showing one
;; needs no daemon, only a file for its location to name. Taking a step
;; (n, i, o, c) is test-flan.el's, which has a program to step.
;;; Code:
(require 'flan-step)
(declare-function test-flan--check "test-flan-cider" (name ok))
(message "\nthe stepper's keys")
(let* ((file (make-temp-file "flan-step" nil ".fln"))
(stop (list :step-loc (concat file ":2:3") :step-fn "f" :step-gen 1
:step-value "42" :step-value-at (concat file ":2:3")))
(other (generate-new-buffer " *flan-step-other*")))
(unwind-protect
(progn
(with-temp-file file (insert "fn f()\n g()\n h()\n"))
(let ((buf (find-file-noselect file)))
(with-current-buffer buf
(setq header-line-format "mine"))
(switch-to-buffer buf)
(flan-step--show stop)
(with-current-buffer buf
(test-flan--check "a stop turns the keys on in its source buffer only"
(and flan-step-mode
(not (buffer-local-value 'flan-step-mode other))))
(test-flan--check "n i o c q e l are the stepper's there"
(equal (mapcar (lambda (k) (key-binding (kbd k)))
'("n" "i" "o" "c" "q" "e" "l"))
'(flan-step-next flan-step-in flan-step-out
flan-step-continue flan-step-quit
flan-step-eval flan-step-locals)))
(test-flan--check "the source is read-only while stopped"
buffer-read-only)
;; Called, not format-mode-line'd: a batch frame has no header
;; line to format it for.
(let ((h (flan-step--header)))
(test-flan--check "the header line lists the keys and the place"
(and (string-match-p "n next" h)
(string-match-p "i into" h)
(string-match-p "q quit" h)
(string-match-p "f, line 2" h)
(equal header-line-format
'(:eval (flan-step--header))))))
(test-flan--check "the form to run has the quiet face, one overlay"
(let ((o (seq-filter
(lambda (o) (overlay-get o 'face))
flan-step--overlays)))
(and (= (length o) 1)
(eq (overlay-get (car o) 'face)
'flan-step-face)
(memq (face-attribute 'flan-step-face
:underline nil t)
'(nil unspecified))))))
(with-current-buffer other
(test-flan--check "another buffer has none of the keys"
(not (eq (key-binding (kbd "n")) 'flan-step-next))))
(flan-step--clear)
(with-current-buffer buf
(test-flan--check "clearing turns the keys off"
(not flan-step-mode))
(test-flan--check "and puts the read-only flag and header line back"
(and (not buffer-read-only)
(equal header-line-format "mine"))))
(kill-buffer buf)))
(kill-buffer other)
(delete-file file)))
;; The stop ending by any route but the stepper's own keys: the keys go, the
;; buffer is writable again.
(let* ((file (make-temp-file "flan-step" nil ".fln"))
(stop (list :step-loc (concat file ":2:3") :step-fn "f" :step-gen 1)))
(unwind-protect
(progn
(with-temp-file file (insert "fn f()\n g()\n h()\n"))
(let ((buf (find-file-noselect file))
(flan--image nil))
(switch-to-buffer buf)
(let ((show (lambda () (flan-step--show stop))))
(funcall show)
(cl-letf (((symbol-function 'flan--append-output) #'ignore))
(flan--absorb (list :status "ok")))
(test-flan--check "a poll that finds the stop gone turns the keys off"
(with-current-buffer buf
(and (not flan-step-mode) (not buffer-read-only))))
(funcall show)
(flan--sentinel nil nil)
(test-flan--check "a lost connection turns the keys off"
(with-current-buffer buf (not flan-step-mode)))
(funcall show)
(setq flan-step--stop stop)
(cl-letf (((symbol-function 'flan--request)
(lambda (_) (error "flan dev: nothing is listening"))))
(ignore-errors (flan-step-quit)))
(test-flan--check "q with nothing listening still turns them off"
(with-current-buffer buf
(and (not flan-step-mode) (not buffer-read-only)))))
(kill-buffer buf)))
(delete-file file)))
(provide 'test-flan-step)
;;; test-flan-step.el ends here
+76 -49
View File
@@ -56,7 +56,7 @@ what bounds its cost; a `with-temp-buffer' would be scanned by nothing."
(concat "(defn step [] i64\n"
" (watch-i64 \"ticks\" ticks)\n"
" (watch-f64 \"hp\" hp)\n"
" (println \"(watch-i64 \\\"fake\\\" x)\")\n"
" (print \"(watch-i64 \\\"fake\\\" x)\")\n"
" (watch \"hp\" hp))\n"))))
(with-current-buffer buf
(let ((sites (flan-watch--ghost-sites)))
@@ -141,7 +141,7 @@ what bounds its cost; a `with-temp-buffer' would be scanned by nothing."
"(watch-i64 \"ticks\" ticks)\n"
"(watch-i64 \"gone\" g)\n")))
(flan-watch--consumers '(ghost))
(flan-dev--stopped nil))
(flan--stopped nil))
(flan-watch--ghost-paint '(("ticks" . "42")) nil)
(test-flan--check
"a site whose name is in the table gets an overlay, and one that is not does not"
@@ -163,14 +163,14 @@ what bounds its cost; a `with-temp-buffer' would be scanned by nothing."
(equal (nth 2 (test-flan-watch--strings))
" => no slot: the table is full"))
(setq flan-dev--stopped "BoundsError")
(setq flan--stopped "BoundsError")
(flan-watch--ghost-paint '(("ticks" . "42")) nil)
(test-flan--check
"stopping the program marks every overlay, not the buffer's modeline only"
(equal (test-flan-watch--strings)
'(" => 42 one slot, 2 sites last frame"
" => 42 one slot, 2 sites last frame")))
(setq flan-dev--stopped nil)
(setq flan--stopped nil)
;; The whole answer to invalidating an overlay whose line moved: no overlay
;; outlives a repaint, so there is nothing to invalidate.
@@ -210,54 +210,81 @@ what bounds its cost; a `with-temp-buffer' would be scanned by nothing."
(test-flan--check "and the last consumer out disarms the table"
(and (null flan-watch--consumers) torn))))
;; --- The reset is guarded on the stop ------------------------------------
;; --- Pushes and replies on one connection ---------------------------------
;;
;; `flan-watch--tick' asks for "since you last looked" by sending `:reset t'
;; beside the read. A stopped program takes no samples, so there is no window
;; for a reset to close and none for it to open — and the runtime's lazy clear
;; is what keeps a paused program showing the numbers from the moment you
;; paused it. So the tick must drop `:reset' while stopped and keep reading.
;;
;; Asserted at the level the rest of this file works at: no daemon, no socket.
;; The tick is a function from `flan-dev--stopped' to the form it puts on the
;; wire, and that is the whole claim, so `flan-dev--send' and `process-live-p'
;; are stubs. What this cannot reach is the daemon actually honouring the
;; absent field; `test/test_dev.ml' drives a real program for that.
;; The daemon writes two kinds of frame: replies, and pushes it sends without
;; being asked — output as it is printed, the watch table while it is armed.
;; The filter handles a push as it arrives and queues a reply for whoever is
;; waiting. Frames are written into a pipe process's buffer by hand, split
;; mid-frame the way a socket may deliver them, so what is claimed is the
;; reader and not the daemon.
(defun test-flan-watch--tick-form (stopped)
"The form `flan-watch--tick' sends with the program STOPPED or not."
(let ((sent nil)
(flan-dev--stopped stopped)
(flan-dev--connection 'a-process)
(flan-dev--busy nil)
(flan-watch--pending nil)
;; Not `buffer': that consumer checks for a live watch buffer first and
;; would drop the subscription instead of ticking.
(flan-watch--consumers '(ghost)))
(cl-letf (((symbol-function 'process-live-p) (lambda (_) t))
((symbol-function 'flan-dev--take-reply) (lambda (_) nil))
((symbol-function 'flan-dev--send)
(lambda (_proc form) (setq sent form))))
(flan-watch--tick)
(list sent flan-watch--pending))))
(defun test-flan-watch--frame (form)
"FORM framed as the daemon frames it."
(let ((payload (encode-coding-string (prin1-to-string form) 'utf-8 t)))
(format "%d\n%s" (length payload) payload)))
(let ((running (test-flan-watch--tick-form nil))
(stopped (test-flan-watch--tick-form "BoundsError")))
(test-flan--check
"a tick while the program runs asks for the window it is closing"
(equal (nth 0 running) '(:op "watch" :reset t)))
(test-flan--check
"a tick while the program is stopped sends no reset"
(null (plist-get (nth 0 stopped) :reset)))
;; Skipping the tick outright would be worse than resetting: the watch would
;; freeze at whatever it held when the program stopped, and a break loop is
;; exactly when the numbers are being read.
(test-flan--check
"but it still reads the table"
(equal (plist-get (nth 0 stopped) :op) "watch"))
(test-flan--check
"and still has a reply in flight, so the cycle survives the pause"
(and (nth 1 running) (nth 1 stopped))))
(let* ((buf (generate-new-buffer " *flan-push-test*"))
(proc (make-pipe-process :name "flan-push-test" :buffer buf
:noquery t))
(painted nil)
(appended nil)
(flan-watch--consumers '(ghost)))
(with-current-buffer buf (set-buffer-multibyte nil))
(unwind-protect
(cl-letf (((symbol-function 'flan-watch--absorb)
(lambda (r) (push r painted)))
((symbol-function 'flan--append-output)
(lambda (text) (push text appended))))
(let ((flan-push-functions '(flan-watch--on-push))
(stream
(concat
(test-flan-watch--frame '(:push "output" :status "ok"
:output "héllo\n"))
(test-flan-watch--frame '(:status "ok" :value "42"))
(test-flan-watch--frame '(:push "watch" :status "ok"
:watch (("ticks" "7"))
:overflow nil)))))
;; In three pieces, the first ending inside the first frame's body.
(flan--filter proc (substring stream 0 12))
(test-flan--check "a push is not handled before all of it has arrived"
(null appended))
(flan--filter proc (substring stream 12 40))
(flan--filter proc (substring stream 40))
(test-flan--check "an output push reaches the output as it arrives"
(equal appended
(list (decode-coding-string
(encode-coding-string "héllo\n" 'utf-8)
'utf-8))))
(test-flan--check "a watch push behind a reply is painted without waiting for the reply to be read"
(equal (plist-get (car painted) :watch)
'(("ticks" "7"))))
(test-flan--check "and the reply is kept for the request that asked"
(equal (flan--take-reply proc)
'(:status "ok" :value "42")))
(test-flan--check "and taken once"
(null (flan--take-reply proc)))
(let ((flan-watch--consumers nil))
(flan--filter proc (test-flan-watch--frame
'(:push "watch" :status "ok" :watch nil)))
(test-flan--check "a watch push with nothing watching paints nothing"
(= 1 (length painted))))))
(delete-process proc)
(kill-buffer buf)))
;; The daemon's buffer keeps `flan-output-maximum-lines' and loses the oldest.
(let ((flan-daemon-buffer " *flan-trim-test*")
(flan-output-maximum-lines 10))
(unwind-protect
(progn
(dotimes (i 30) (flan--append-output (format "line %d\n" i)))
(with-current-buffer flan-daemon-buffer
(test-flan--check "the daemon's buffer is kept to its line limit"
(<= (count-lines (point-min) (point-max)) 10))
(test-flan--check "and keeps the newest lines"
(and (string-match-p "line 29" (buffer-string))
(not (string-match-p "line 0\n" (buffer-string)))))))
(kill-buffer flan-daemon-buffer)))
(provide 'test-flan-watch)
;;; test-flan-watch.el ends here
+3039
View File
File diff suppressed because it is too large. Load diff
+102
View File
@@ -0,0 +1,102 @@
;;;; raylib [core] example - 2d camera mouse zoom
;;;;
;;;; examples/core/core_2d_camera_mouse_zoom.c at the raylib 5.5 tag. It
;;;; needed rlgl's matrix stack: the grid is a 3D grid on the XZ plane, and the
;;;; C stands it up in the XY plane by pushing a matrix, translating, rotating
;;;; ninety degrees about X and drawing. vendor/rlgl binds that stack.
;;;;
;;;; One difference from the C. The mouse read-out is `TextFormat("[%i, %i]",
;;;; ...)` through DrawTextEx with a spacing of 2. TextFormat is variadic and
;;;; unbound, so the read-out is drawn in pieces with draw-text and
;;;; digits.fln's draw-int, which space the glyphs raylib's default way.
import rl "vendor:raylib"
import rlgl "vendor:rlgl"
import d "digits.fln"
const screen-width = 800
const screen-height = 450
;; 0 zooms with the mouse wheel, 1 with a right-button drag.
once zoom-mode: i32
once camera: rl/Camera2D
;; Put the world point under the mouse at the camera's origin, and the origin
;; under the mouse, so a zoom that follows keeps that point still.
fn anchor-at-mouse() -> ()
let mouse-world = rl/get-screen-to-world-2d(rl/get-mouse-position(), camera)
camera.offset = rl/get-mouse-position()
camera.target = mouse-world
;; One zoom step: `amount` is how far the input moved, and the direction is its
;; sign. The zoom stays between 1/8 and 64.
fn zoom-by(amount: f32, rate: f32) -> ()
let factor = 1.0 + rate * abs-f32(amount)
let factor = if amount < 0.0 then 1.0 / factor else factor
camera.zoom = clamp(camera.zoom * factor, 0.125, 64.0)
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - 2d camera mouse zoom")
defer rl/close-window()
;; A zero zoom makes the camera's transform singular.
camera = rl/Camera2D{.zoom 1.0}
zoom-mode = 0
rl/set-target-fps(60)
until rl/window-should-close()
;; Update
if rl/is-key-pressed(:key-one)
zoom-mode = 0
elif rl/is-key-pressed(:key-two)
zoom-mode = 1
;; Drag to move: the mouse's screen delta, scaled back into world units.
if rl/is-mouse-button-down(:mouse-left)
let delta = rl/v2-scale(rl/get-mouse-delta(), -1.0 / camera.zoom)
camera.target = rl/v2-add(camera.target, delta)
if zoom-mode == 0
let wheel = rl/get-mouse-wheel-move()
if wheel != 0.0
anchor-at-mouse()
zoom-by(wheel, 0.25)
else
if rl/is-mouse-button-pressed(:mouse-right)
anchor-at-mouse()
if rl/is-mouse-button-down(:mouse-right)
zoom-by(rl/get-mouse-delta().x, 0.01)
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/begin-mode-2d(camera)
;; The 3D grid, rotated ninety degrees and centred on the origin, so it
;; lies in the XY plane the 2D camera looks at.
rlgl/push-matrix()
rlgl/translate(0.0, 25.0 * 50.0, 0.0)
rlgl/rotate(90.0, 1.0, 0.0, 0.0)
rl/draw-grid(100, 50.0)
rlgl/pop-matrix()
;; A reference circle.
rl/draw-circle(rl/get-screen-width() / 2, rl/get-screen-height() / 2,
50.0, rl/maroon)
rl/end-mode-2d()
;; The mouse, and where it is in screen pixels.
let mouse = rl/get-mouse-position()
x = i32(mouse.x) - 44
y = i32(mouse.y) - 24
rl/draw-circle-v(mouse, 4.0, rl/darkgray)
rl/draw-text("[", x, y, 20, rl/black)
let x = x + rl/measure-text("[", 20)
let x = x + d/draw-int(rl/get-mouse-x(), x, y, 20, rl/black)
rl/draw-text(", ", x, y, 20, rl/black)
let x = x + rl/measure-text(", ", 20)
let x = x + d/draw-int(rl/get-mouse-y(), x, y, 20, rl/black)
rl/draw-text("]", x, y, 20, rl/black)
rl/draw-text("[1][2] Select mouse zoom mode (Wheel or Move)", 20, 20, 20,
rl/darkgray)
if zoom-mode == 0
rl/draw-text("Mouse left button drag to move, mouse wheel to zoom", 20,
50, 20, rl/darkgray)
else
rl/draw-text("Mouse left button drag to move, mouse press and move to zoom",
20, 50, 20, rl/darkgray)
-137
View File
@@ -1,137 +0,0 @@
;;;; raylib [core] example - 2d camera
;;;;
;;;; examples/core/core_2d_camera.c. Needed no new binding at all, which is
;;;; the reason to port it: Camera2D, begin-mode-2d and end-mode-2d have been
;;;; in vendor/raylib/raylib.flan since the beginning and nothing in the
;;;; corpus had ever passed a whole Camera2D across the FFI on a frame's path.
;;;; Two Vector2s, a float and a float, by value, sixty times a second — the
;;;; acceptance table pins that layout with get-screen-to-world-2d, and this
;;;; is the same struct going into the call that actually draws with it.
;;;;
;;;; The hundred buildings are a `defvar` of fixed arrays rather than a Vec:
;;;; a global cannot hold a Vec (PORTING.md §3) and does not need to here,
;;;; because the count is a constant in the C too. `(array n T)` is the zeroed
;;;; fixed array, and the C's `= { 0 }` is exactly that.
;;;;
;;;; get-random-value comes from the generated half of the bindings. It is on
;;;; no frame's path — it runs once, before the loop — and its Flan face is
;;;; the C one, so PORTING.md §1's rule does not reach it.
;;;;
;;;; One deliberate difference from the C: `(- camera.rotation 1.0)` and the
;;;; clamp after it are written with the prelude's `clamp` macro rather than
;;;; two ifs. It is the same arithmetic; the C spells it out because C has no
;;;; clamp.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defconst max-buildings 100)
(defvar buildings [100 rl/Rectangle])
(defvar build-colors [100 rl/Color])
(defvar player rl/Rectangle)
(defvar camera rl/Camera2D)
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - 2d camera")
(defer (rl/close-window))
(set player (rl/Rectangle {.x 400.0 .y 280.0 .width 40.0 .height 40.0}))
(set buildings (array max-buildings rl/Rectangle))
(set build-colors (array max-buildings rl/Color))
;; The skyline: each building is as wide as the last one left room for, so
;; the spacing accumulates and the row starts 6000 units to the left.
(let [spacing 0]
(dotimes [i max-buildings]
(let [w (f32 (rl/get-random-value 50 200))
h (f32 (rl/get-random-value 100 800))]
(set (at buildings i)
(rl/Rectangle {.x (+ -6000.0 (f32 spacing))
.y (- (- (f32 screen-height) 130.0) h)
.width w
.height h}))
(set spacing (+ spacing (i32 w)))
(set (at build-colors i)
(rl/Color {.r (u8 (rl/get-random-value 200 240))
.g (u8 (rl/get-random-value 200 240))
.b (u8 (rl/get-random-value 200 250))
.a 255})))))
;; A fresh Camera2D is all zeroes and a zoom of 0 makes the transform
;; singular, so the zoom is the one field that must be set — raylib.flan's
;; own comment on the struct says so.
(set camera (rl/Camera2D {.offset (rl/Vector2 {.x (/ (f32 screen-width) 2.0)
.y (/ (f32 screen-height) 2.0)})
.target (rl/Vector2 {.x (+ (.x player) 20.0)
.y (+ (.y player) 20.0)})
.rotation 0.0
.zoom 1.0}))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update
;; The C's `if (RIGHT) ... else if (LEFT) ...`: both held cancels to the
;; right, which is the else-if and not an accident.
(if (rl/key-down? :right)
(set (.x player) (+ (.x player) 2.0))
(when (rl/key-down? :left) (set (.x player) (- (.x player) 2.0))))
;; The camera follows the player's centre.
(set (.target camera) (rl/Vector2 {.x (+ (.x player) 20.0)
.y (+ (.y player) 20.0)}))
(if (rl/key-down? :a)
(set (.rotation camera) (- (.rotation camera) 1.0))
(when (rl/key-down? :s)
(set (.rotation camera) (+ (.rotation camera) 1.0))))
(set (.rotation camera) (clamp (.rotation camera) -40.0 40.0))
(set (.zoom camera) (+ (.zoom camera) (* (rl/get-mouse-wheel-move) 0.05)))
(set (.zoom camera) (clamp (.zoom camera) 0.1 3.0))
(when (rl/key-pressed? :r)
(set (.zoom camera) 1.0)
(set (.rotation camera) 0.0))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
;; Everything inside with-mode-2d is in world space and goes through
;; the camera. The ground and the crosshair lines are drawn far outside
;; the window on purpose: at zoom 0.1 they still have to reach the edges.
(rl/with-mode-2d camera
(rl/draw-rectangle -6000 320 13000 8000 rl/darkgray)
(dotimes [i max-buildings]
(rl/draw-rectangle-rec (at buildings i) (at build-colors i)))
(rl/draw-rectangle-rec player rl/red)
(rl/draw-line (i32 (.x (.target camera))) (* screen-height -10)
(i32 (.x (.target camera))) (* screen-height 10) rl/green)
(rl/draw-line (* screen-width -10) (i32 (.y (.target camera)))
(* screen-width 10) (i32 (.y (.target camera))) rl/green))
;; And everything after it is in screen space again — the frame drawn
;; around the window is how the example shows the difference.
(rl/draw-text "SCREEN AREA" 640 10 20 rl/red)
(rl/draw-rectangle 0 0 screen-width 5 rl/red)
(rl/draw-rectangle 0 5 5 (- screen-height 10) rl/red)
(rl/draw-rectangle (- screen-width 5) 5 5 (- screen-height 10) rl/red)
(rl/draw-rectangle 0 (- screen-height 5) screen-width 5 rl/red)
(rl/draw-rectangle 10 10 250 113 (rl/fade rl/skyblue 0.5))
(rl/draw-rectangle-lines 10 10 250 113 rl/blue)
(rl/draw-text "Free 2d camera controls:" 20 20 10 rl/black)
(rl/draw-text "- Right/Left to move Offset" 40 40 10 rl/darkgray)
(rl/draw-text "- Mouse Wheel to Zoom in-out" 40 60 10 rl/darkgray)
(rl/draw-text "- A / S to Rotate" 40 80 10 rl/darkgray)
(rl/draw-text "- R to reset Zoom and Rotation" 40 100 10 rl/darkgray))))
+106
View File
@@ -0,0 +1,106 @@
;;;; raylib [core] example - 2d camera
;;;;
;;;; examples/core/core_2d_camera.c. Needed no new binding at all, which is
;;;; the reason to port it: Camera2D, begin-mode-2d and end-mode-2d have been
;;;; in vendor/raylib/raylib.fln since the beginning and nothing in the
;;;; corpus had ever passed a whole Camera2D across the FFI on a frame's path.
;;;; Two Vector2s, a float and a float, by value, sixty times a second — the
;;;; acceptance table pins that layout with get-screen-to-world-2d, and this
;;;; is the same struct going into the call that actually draws with it.
;;;;
;;;; The hundred buildings are a `once` of fixed arrays rather than a Vec:
;;;; a global cannot hold a Vec (docs/PORTING.md §3) and does not need to here,
;;;; because the count is a constant in the C too. `array(n, T)` is the zeroed
;;;; fixed array, and the C's `= { 0 }` is exactly that.
;;;;
;;;; get-random-value comes from the generated half of the bindings. It is on
;;;; no frame's path — it runs once, before the loop — and its Flan face is
;;;; the C one, so docs/PORTING.md §1's rule does not reach it.
;;;;
;;;; One deliberate difference from the C: `camera.rotation - 1.0` and the
;;;; clamp after it are written with the prelude's `clamp` macro rather than
;;;; two ifs. It is the same arithmetic; the C spells it out because C has no
;;;; clamp.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
const max-buildings = 100
once buildings: [100 rl/Rectangle]
once build-colors: [100 rl/Color]
once player: rl/Rectangle
once camera: rl/Camera2D
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - 2d camera")
defer rl/close-window()
player = rl/Rectangle{.x 400.0, .y 280.0, .width 40.0, .height 40.0}
buildings = array(max-buildings, rl/Rectangle)
build-colors = array(max-buildings, rl/Color)
;; The skyline: each building is as wide as the last one left room for, so
;; the spacing accumulates and the row starts 6000 units to the left.
let spacing = 0
for i in 0..max-buildings
let w = f32(rl/get-random-value(50, 200))
h = f32(rl/get-random-value(100, 800))
buildings[i] = rl/Rectangle{.x -6000.0 + f32(spacing), .y (f32(screen-height) - 130.0) - h, .width w, .height h}
spacing += i32(w)
build-colors[i] = rl/Color{.r u8(rl/get-random-value(200, 240)) .g u8(rl/get-random-value(200, 240)) .b u8(rl/get-random-value(200, 250)) .a 255}
;; A fresh Camera2D is all zeroes and a zoom of 0 makes the transform
;; singular, so the zoom is the one field that must be set — raylib.fln's
;; own comment on the struct says so.
camera = rl/Camera2D{.offset rl/Vector2{.x f32(screen-width) / 2.0, .y f32(screen-height) / 2.0} .target rl/Vector2{.x player.x + 20.0, .y player.y + 20.0} .rotation 0.0 .zoom 1.0}
rl/set-target-fps(60)
until rl/window-should-close()
;; Update
;; The C's `if (RIGHT) ... else if (LEFT) ...`: both held cancels to the
;; right, which is the else-if and not an accident.
if rl/is-key-down(:key-right)
player.x += 2.0
elif rl/is-key-down(:key-left)
player.x -= 2.0
;; The camera follows the player's centre.
camera.target = rl/Vector2{.x player.x + 20.0, .y player.y + 20.0}
if rl/is-key-down(:key-a)
camera.rotation -= 1.0
elif rl/is-key-down(:key-s)
camera.rotation += 1.0
camera.rotation = clamp(camera.rotation, -40.0, 40.0)
camera.zoom += rl/get-mouse-wheel-move() * 0.05
camera.zoom = clamp(camera.zoom, 0.1, 3.0)
if rl/is-key-pressed(:key-r)
camera.zoom = 1.0
camera.rotation = 0.0
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
;; Everything inside with-mode-2d is in world space and goes through
;; the camera. The ground and the crosshair lines are drawn far outside
;; the window on purpose: at zoom 0.1 they still have to reach the edges.
rl/with-mode-2d(camera):
rl/draw-rectangle(-6000, 320, 13000, 8000, rl/darkgray)
for i in 0..max-buildings
rl/draw-rectangle-rec(buildings[i], build-colors[i])
rl/draw-rectangle-rec(player, rl/red)
rl/draw-line(i32(camera.target.x), screen-height * -10,
i32(camera.target.x), screen-height * 10, rl/green)
rl/draw-line(screen-width * -10, i32(camera.target.y),
screen-width * 10, i32(camera.target.y), rl/green)
;; And everything after it is in screen space again — the frame drawn
;; around the window is how the example shows the difference.
rl/draw-text("SCREEN AREA", 640, 10, 20, rl/red)
rl/draw-rectangle(0, 0, screen-width, 5, rl/red)
rl/draw-rectangle(0, 5, 5, screen-height - 10, rl/red)
rl/draw-rectangle(screen-width - 5, 5, 5, screen-height - 10, rl/red)
rl/draw-rectangle(0, screen-height - 5, screen-width, 5, rl/red)
rl/draw-rectangle(10, 10, 250, 113, rl/fade(rl/skyblue, 0.5))
rl/draw-rectangle-lines(10, 10, 250, 113, rl/blue)
rl/draw-text("Free 2d camera controls:", 20, 20, 10, rl/black)
rl/draw-text("- Right/Left to move Offset", 40, 40, 10, rl/darkgray)
rl/draw-text("- Mouse Wheel to Zoom in-out", 40, 60, 10, rl/darkgray)
rl/draw-text("- A / S to Rotate", 40, 80, 10, rl/darkgray)
rl/draw-text("- R to reset Zoom and Rotation", 40, 100, 10, rl/darkgray)
-153
View File
@@ -1,153 +0,0 @@
;;;; raylib [core] example - 3d picking
;;;;
;;;; examples/core/core_3d_picking.c. The one `core` example in this batch,
;;;; and it is here for surface and not for category: it is the only example
;;;; anywhere upstream that uses Ray and RayCollision without also loading a
;;;; Model, so it is the only way those two structs get a caller at all.
;;;;
;;;; Picking is the round trip the corpus was missing. core-world-screen.flan
;;;; already runs get-world-to-screen — a point in the scene to a pixel. This
;;;; is the inverse and it is a different shape: a pixel does not name a
;;;; point, it names a *line* through the scene, which is what a Ray is, and
;;;; the answer to "what did I click on" is where that line first meets
;;;; something, which is what a RayCollision is. Two structs the package could
;;;; not describe, for one operation that cannot be written without them.
;;;;
;;;; What was added, all in vendor/raylib/raylib.flan:
;;;;
;;;; - `Ray` — two Vector3s, origin and direction.
;;;; - `RayCollision` — a C `bool`, a float and two Vector3s. This is the
;;;; one of the three new structs a layout check can actually fail:
;;;; `hit` is one byte and `distance` is four, so there are three padding
;;;; bytes between them that a permutation destroys. BoundingBox's two
;;;; Vector3s are interchangeable and nothing would catch swapping them.
;;;; - `BoundingBox` — shared with examples/models-box-collisions.flan.
;;;; - get-screen-to-world-ray and draw-ray, hand-written beside
;;;; get-world-to-screen and the cube draws for the reasons `bindings`
;;;; gives: the first reads the same camera as its inverse, and the second
;;;; is inside a frame.
;;;;
;;;; get-ray-collision-box came free out of the generated half the moment Ray
;;;; and BoundingBox existed, along with the sphere, triangle and quad forms.
;;;; get-ray-collision-mesh is still refused and will stay refused: Mesh owns
;;;; seven arrays and a GPU handle, and describing it is a different job.
;;;;
;;;; raylib 5.5 renamed GetMouseRay to GetScreenToWorldRay and left the old
;;;; name as a #define. A #define is not a symbol, so there is nothing to bind
;;;; it to and nothing lost by not trying.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defvar camera rl/Camera3D)
(defvar cube-position rl/Vector3)
(defvar cube-size rl/Vector3)
;; The picking ray, kept between frames because draw-ray draws it every frame
;; whether or not it hit anything — that is how the example shows where the
;; click went.
(defvar ray rl/Ray)
(defvar collision rl/RayCollision)
;; The same centre/size to min/max conversion as in
;; examples/models-box-collisions.flan. Written out here rather than shared
;; because an example is a single file the reader can follow end to end.
;;
;; It used to be eight expressions of field arithmetic; it is the half-extent
;; subtracted and added, which is what the C means, now that the package
;; carries vector arithmetic — see vendor/raylib/vector.flan. That file
;; exists because raymath is `static inline` and has no symbol to bind, so
;; v3-sub and v3-add are Flan and not C.
(defn box-around [centre rl/Vector3 size rl/Vector3] rl/BoundingBox
(let [half (rl/v3-scale size 0.5)]
(rl/BoundingBox {.min (rl/v3-sub centre half)
.max (rl/v3-add centre half)})))
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - 3d picking")
(defer (rl/close-window))
(set camera (rl/Camera3D {.position (rl/Vector3 {.x 10.0 .y 10.0 .z 10.0})
.target (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0})
.up (rl/Vector3 {.x 0.0 .y 1.0 .z 0.0})
.fovy 45.0
.projection :perspective}))
(set cube-position (rl/Vector3 {.x 0.0 .y 1.0 .z 0.0}))
(set cube-size (rl/Vector3 {.x 2.0 .y 2.0 .z 2.0}))
;; Both start zeroed, which is the C's `= { 0 }`. A zero Ray draws as a
;; degenerate line at the origin and a zero RayCollision has hit false, so
;; neither needs a "not yet" flag beside it.
(set ray (rl/Ray {.position (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0})
.direction (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0})}))
(set collision (rl/RayCollision {.hit false .distance 0.0
.point (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0})
.normal (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0})}))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update. The first-person controls only run while the cursor is
;; captured, which is what the right button toggles — otherwise the mouse
;; could not be used to aim at anything.
(when (rl/cursor-hidden?) (rl/update-camera (addr camera) :first-person))
(when (rl/mouse-button-pressed? :right)
(if (rl/cursor-hidden?) (rl/enable-cursor) (rl/disable-cursor)))
(when (rl/mouse-button-pressed? :left)
(if (not (.hit collision))
;; The pixel under the pointer, as a line through the scene, tested
;; against the cube's bounds.
(do
(set ray (rl/get-screen-to-world-ray (rl/get-mouse-position) camera))
(set collision
(rl/get-ray-collision-box ray (box-around cube-position cube-size))))
;; A second click deselects. Only `hit` is cleared; the ray stays put
;; and keeps being drawn, as in the C.
(set (.hit collision) false)))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/with-mode-3d camera
(if (.hit collision)
(do
(rl/draw-cube cube-position (.x cube-size) (.y cube-size) (.z cube-size)
rl/red)
(rl/draw-cube-wires cube-position (.x cube-size) (.y cube-size)
(.z cube-size) rl/maroon)
;; A slightly larger wireframe around the selection, which is the
;; whole visual feedback of the example.
(rl/draw-cube-wires cube-position (+ (.x cube-size) 0.2)
(+ (.y cube-size) 0.2) (+ (.z cube-size) 0.2)
rl/green))
(do
(rl/draw-cube cube-position (.x cube-size) (.y cube-size) (.z cube-size)
rl/gray)
(rl/draw-cube-wires cube-position (.x cube-size) (.y cube-size)
(.z cube-size) rl/darkgray)))
;; raylib draws the ray a thousand units long, so it reads as a line
;; crossing the scene rather than as a segment with an end.
(rl/draw-ray ray rl/maroon)
(rl/draw-grid 10 1.0))
(rl/draw-text "Try clicking on the box with your mouse!" 240 10 20
rl/darkgray)
(when (.hit collision)
(rl/draw-text "BOX SELECTED"
(/ (- screen-width (rl/measure-text "BOX SELECTED" 30)) 2)
(i32 (* (f32 screen-height) 0.1)) 30 rl/green))
(rl/draw-text "Right click mouse to toggle camera controls" 10 430 10
rl/gray)
(rl/draw-fps 10 10))))
+130
View File
@@ -0,0 +1,130 @@
;;;; raylib [core] example - 3d picking
;;;;
;;;; examples/core/core_3d_picking.c. The one `core` example in this batch,
;;;; and it is here for surface and not for category: it is the only example
;;;; anywhere upstream that uses Ray and RayCollision without also loading a
;;;; Model, so it is the only way those two structs get a caller at all.
;;;;
;;;; Picking is the round trip the corpus was missing. core-world-screen.fln
;;;; already runs get-world-to-screen — a point in the scene to a pixel. This
;;;; is the inverse and it is a different shape: a pixel does not name a
;;;; point, it names a *line* through the scene, which is what a Ray is, and
;;;; the answer to "what did I click on" is where that line first meets
;;;; something, which is what a RayCollision is. Two structs the package could
;;;; not describe, for one operation that cannot be written without them.
;;;;
;;;; What was added, all in vendor/raylib/raylib.fln:
;;;;
;;;; - `Ray` — two Vector3s, origin and direction.
;;;; - `RayCollision` — a C `bool`, a float and two Vector3s. This is the
;;;; one of the three new structs a layout check can actually fail:
;;;; `hit` is one byte and `distance` is four, so there are three padding
;;;; bytes between them that a permutation destroys. BoundingBox's two
;;;; Vector3s are interchangeable and nothing would catch swapping them.
;;;; - `BoundingBox` — shared with examples/models-box-collisions.fln.
;;;; - get-screen-to-world-ray and draw-ray, hand-written beside
;;;; get-world-to-screen and the cube draws for the reasons `bindings`
;;;; gives: the first reads the same camera as its inverse, and the second
;;;; is inside a frame.
;;;;
;;;; get-ray-collision-box came free out of the generated half the moment Ray
;;;; and BoundingBox existed, along with the sphere, triangle and quad forms.
;;;; get-ray-collision-mesh is still refused and will stay refused: Mesh owns
;;;; seven arrays and a GPU handle, and describing it is a different job.
;;;;
;;;; raylib 5.5 renamed GetMouseRay to GetScreenToWorldRay and left the old
;;;; name as a #define. A #define is not a symbol, so there is nothing to bind
;;;; it to and nothing lost by not trying.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
once camera: rl/Camera3D
once cube-position: rl/Vector3
once cube-size: rl/Vector3
;; The picking ray, kept between frames because draw-ray draws it every frame
;; whether or not it hit anything — that is how the example shows where the
;; click went.
once ray: rl/Ray
once collision: rl/RayCollision
;; The same centre/size to min/max conversion as in
;; examples/models-box-collisions.fln. Written out here rather than shared
;; because an example is a single file the reader can follow end to end.
;;
;; It used to be eight expressions of field arithmetic; it is the half-extent
;; subtracted and added, which is what the C means, now that the package
;; carries vector arithmetic — see vendor/raylib/vector.fln. That file
;; exists because raymath is `static inline` and has no symbol to bind, so
;; v3-sub and v3-add are Flan and not C.
fn box-around(centre: rl/Vector3, size: rl/Vector3) -> rl/BoundingBox
let half = rl/v3-scale(size, 0.5)
rl/BoundingBox{.min rl/v3-sub(centre, half) .max rl/v3-add(centre, half)}
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - 3d picking")
defer rl/close-window()
camera = rl/Camera3D{.position rl/Vector3{.x 10.0 .y 10.0 .z 10.0} .target rl/Vector3{.x 0.0 .y 0.0 .z 0.0} .up rl/Vector3{.x 0.0 .y 1.0 .z 0.0} .fovy 45.0 .projection :projection-perspective}
cube-position = rl/Vector3{.x 0.0 .y 1.0 .z 0.0}
cube-size = rl/Vector3{.x 2.0 .y 2.0 .z 2.0}
;; Both start zeroed, which is the C's `= { 0 }`. A zero Ray draws as a
;; degenerate line at the origin and a zero RayCollision has hit false, so
;; neither needs a "not yet" flag beside it.
ray = rl/Ray{.position rl/Vector3{.x 0.0 .y 0.0 .z 0.0} .direction rl/Vector3{.x 0.0 .y 0.0 .z 0.0}}
collision = rl/RayCollision{.hit false .distance 0.0 .point rl/Vector3{.x 0.0 .y 0.0 .z 0.0} .normal rl/Vector3{.x 0.0 .y 0.0 .z 0.0}}
rl/set-target-fps(60)
until rl/window-should-close()
;; Update. The first-person controls only run while the cursor is
;; captured, which is what the right button toggles — otherwise the mouse
;; could not be used to aim at anything.
if rl/is-cursor-hidden()
rl/update-camera(addr(camera), :camera-first-person)
if rl/is-mouse-button-pressed(:mouse-right)
if rl/is-cursor-hidden() then rl/enable-cursor() else rl/disable-cursor()
if rl/is-mouse-button-pressed(:mouse-left)
if not collision.hit
;; The pixel under the pointer, as a line through the scene, tested
;; against the cube's bounds.
ray = rl/get-screen-to-world-ray(rl/get-mouse-position(), camera)
collision = rl/get-ray-collision-box(ray,
box-around(cube-position, cube-size))
;; A second click deselects. Only `hit` is cleared; the ray stays put
;; and keeps being drawn, as in the C.
else
collision.hit = false
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/with-mode-3d(camera):
if collision.hit
rl/draw-cube(cube-position, cube-size.x, cube-size.y, cube-size.z,
rl/red)
rl/draw-cube-wires(cube-position, cube-size.x, cube-size.y,
cube-size.z, rl/maroon)
;; A slightly larger wireframe around the selection, which is the
;; whole visual feedback of the example.
rl/draw-cube-wires(cube-position, cube-size.x + 0.2,
cube-size.y + 0.2, cube-size.z + 0.2, rl/green)
else
rl/draw-cube(cube-position, cube-size.x, cube-size.y, cube-size.z,
rl/gray)
rl/draw-cube-wires(cube-position, cube-size.x, cube-size.y,
cube-size.z, rl/darkgray)
;; raylib draws the ray a thousand units long, so it reads as a line
;; crossing the scene rather than as a segment with an end.
rl/draw-ray(ray, rl/maroon)
rl/draw-grid(10, 1.0)
rl/draw-text("Try clicking on the box with your mouse!", 240, 10, 20,
rl/darkgray)
if collision.hit
rl/draw-text("BOX SELECTED",
(screen-width - rl/measure-text("BOX SELECTED", 30)) / 2,
i32(f32(screen-height) * 0.1), 30, rl/green)
rl/draw-text("Right click mouse to toggle camera controls", 10, 430, 10,
rl/gray)
rl/draw-fps(10, 10)
@@ -6,26 +6,24 @@
;;;;
;;;; The one structural difference from the C is the loop: the C writes
;;;; `while (!WindowShouldClose())` and Flan has `until`, which is the same
;;;; thing without the negation. sand.flan does likewise.
;;;; thing without the negation. sand.fln does likewise.
;;;;
;;;; `defer` closes the window. It is function-scoped — it runs when `main`
;;;; returns and not at the end of any inner block — which is exactly what the
;;;; C's trailing CloseWindow() means here.
(import rl "vendor:raylib")
import rl "vendor:raylib"
(defconst screen-width 800)
(defconst screen-height 450)
const screen-width = 800
const screen-height = 450
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - basic window")
(defer (rl/close-window))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-text "Congrats! You created your first window!" 190 200 20
rl/lightgray))))
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - basic window")
defer rl/close-window()
rl/set-target-fps(60)
until rl/window-should-close()
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-text("Congrats! You created your first window!", 190, 200, 20,
rl/lightgray)
-117
View File
@@ -1,117 +0,0 @@
;;;; raylib [core] example - delta time
;;;;
;;;; examples/core/core_delta_time.c. Needed get-mouse-wheel-move and get-fps,
;;;; both now bound — draw-fps was already there, but it puts the number on the
;;;; screen itself and this example wants to compare it against the target it
;;;; set, so it needs the number back.
;;;;
;;;; Three TextFormats in the C. Two of the three are a fixed number of
;;;; decimal places, which `f64->bytes` cannot do — it is "%g" — so they
;;;; still go through examples/digits.flan. The integer one does too, only
;;;; because its width is needed to put the next piece after it; the draw
;;;; itself is now (string (i64->bytes n)) and one draw-text.
;;;;
;;;; One faithfulness note that is a bug in the C and is kept anyway: it draws
;;;; `TextFormat("Frame time: %02.02f ms", GetFrameTime())` — GetFrameTime is
;;;; in SECONDS, so at 60 fps the C's own screen reads "0.02 ms" for what is
;;;; really 16.7 ms. Porting it as-is means the two programs show the same
;;;; thing, which is what a port is for; the milliseconds are drawn beside it
;;;; here rather than instead of it, so the file is not quietly asserting that
;;;; the original was right.
;;;;
;;;; The two circle positions are `defvar`s for the usual reason: they are
;;;; state between frames and a `let` inside the loop would reset them.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
(defconst speed f32 10.0)
(defconst circle-radius f32 32.0)
(defvar delta-circle rl/Vector2)
(defvar frame-circle rl/Vector2)
(defvar current-fps i32)
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - delta time")
(defer (rl/close-window))
(set current-fps 60)
(set delta-circle (rl/Vector2 {.x 0.0 .y (/ (f32 screen-height) 3.0)}))
(set frame-circle (rl/Vector2 {.x 0.0
.y (* (f32 screen-height) (/ 2.0 3.0))}))
(rl/set-target-fps current-fps)
(until (rl/window-should-close?)
;; Update
(let [wheel (rl/get-mouse-wheel-move)]
(unless (= wheel 0.0)
(set current-fps (+ current-fps (i32 wheel)))
(when (< current-fps 0) (set current-fps 0))
(rl/set-target-fps current-fps)))
;; The whole point of the example: one circle is scaled by the frame time
;; and one is not, so lowering the target with the wheel makes the second
;; one crawl while the first keeps its speed.
(set (.x delta-circle)
(+ (.x delta-circle) (* (rl/get-frame-time) (* 6.0 speed))))
(set (.x frame-circle) (+ (.x frame-circle) (* 0.1 speed)))
(when (> (.x delta-circle) (f32 screen-width)) (set (.x delta-circle) 0.0))
(when (> (.x frame-circle) (f32 screen-width)) (set (.x frame-circle) 0.0))
(when (rl/key-pressed? :r)
(set (.x delta-circle) 0.0)
(set (.x frame-circle) 0.0))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-circle-v delta-circle circle-radius rl/red)
(rl/draw-circle-v frame-circle circle-radius rl/blue)
;; "FPS: unlimited (%i)" when the target is 0, "FPS: %i (target: %i)"
;; otherwise — the C's own branch, reassembled out of literals and
;; draw-int. `x` walks along the line as each piece is drawn, which is what
;; the returned width from draw-int is for.
(if (<= current-fps 0)
(let [x 10]
(rl/draw-text "FPS: unlimited (" x 10 20 rl/darkgray)
(set x (+ x (rl/measure-text "FPS: unlimited (" 20)))
(set x (+ x (d/draw-int (rl/get-fps) x 10 20 rl/darkgray)))
(rl/draw-text ")" x 10 20 rl/darkgray))
(let [x 10]
(rl/draw-text "FPS: " x 10 20 rl/darkgray)
(set x (+ x (rl/measure-text "FPS: " 20)))
(set x (+ x (d/draw-int (rl/get-fps) x 10 20 rl/darkgray)))
(rl/draw-text " (target: " x 10 20 rl/darkgray)
(set x (+ x (rl/measure-text " (target: " 20)))
(set x (+ x (d/draw-int current-fps x 10 20 rl/darkgray)))
(rl/draw-text ")" x 10 20 rl/darkgray)))
;; The C's line, kept exactly — seconds, labelled ms.
(let [x 10]
(rl/draw-text "Frame time: " x 30 20 rl/darkgray)
(set x (+ x (rl/measure-text "Frame time: " 20)))
(set x (+ x (d/draw-f32 (rl/get-frame-time) 2 x 30 20 rl/darkgray)))
(rl/draw-text " ms" x 30 20 rl/darkgray))
;; And the same number in the unit the label claims, so the file does not
;; have to be read to notice.
(let [x 10]
(rl/draw-text "(really " x 50 20 rl/lightgray)
(set x (+ x (rl/measure-text "(really " 20)))
(set x (+ x (d/draw-f32 (* (rl/get-frame-time) 1000.0) 2 x 50 20
rl/lightgray)))
(rl/draw-text " ms)" x 50 20 rl/lightgray))
(rl/draw-text "Use the scroll wheel to change the fps limit, r to reset"
10 70 20 rl/darkgray)
(rl/draw-text "FUNC: x += GetFrameTime()*speed" 10 110 20 rl/red)
(rl/draw-text "FUNC: x += speed" 10 260 20 rl/blue))))
+107
View File
@@ -0,0 +1,107 @@
;;;; raylib [core] example - delta time
;;;;
;;;; examples/core/core_delta_time.c. Needed get-mouse-wheel-move and get-fps,
;;;; both now bound — draw-fps was already there, but it puts the number on the
;;;; screen itself and this example wants to compare it against the target it
;;;; set, so it needs the number back.
;;;;
;;;; Three TextFormats in the C. Two of the three are a fixed number of
;;;; decimal places, which `f64->bytes` cannot do — it is "%g" — so they
;;;; still go through examples/digits.fln. The integer one does too, only
;;;; because its width is needed to put the next piece after it; the draw
;;;; itself is now (string (i64->bytes n)) and one draw-text.
;;;;
;;;; One faithfulness note that is a bug in the C and is kept anyway: it draws
;;;; `TextFormat("Frame time: %02.02f ms", GetFrameTime())` — GetFrameTime is
;;;; in SECONDS, so at 60 fps the C's own screen reads "0.02 ms" for what is
;;;; really 16.7 ms. Porting it as-is means the two programs show the same
;;;; thing, which is what a port is for; the milliseconds are drawn beside it
;;;; here rather than instead of it, so the file is not quietly asserting that
;;;; the original was right.
;;;;
;;;; The two circle positions are `once`s for the usual reason: they are
;;;; state between frames and a `let` inside the loop would reset them.
import rl "vendor:raylib"
import d "digits.fln"
const screen-width = 800
const screen-height = 450
const speed: f32 = 10.0
const circle-radius: f32 = 32.0
once delta-circle: rl/Vector2
once frame-circle: rl/Vector2
once current-fps: i32
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - delta time")
defer rl/close-window()
current-fps = 60
delta-circle = rl/Vector2{.x 0.0, .y f32(screen-height) / 3.0}
frame-circle = rl/Vector2{.x 0.0, .y f32(screen-height) * (2.0 / 3.0)}
rl/set-target-fps(current-fps)
until rl/window-should-close()
;; Update
let wheel = rl/get-mouse-wheel-move()
unless(wheel == 0.0):
current-fps += i32(wheel)
if current-fps < 0
current-fps = 0
rl/set-target-fps(current-fps)
;; The whole point of the example: one circle is scaled by the frame time
;; and one is not, so lowering the target with the wheel makes the second
;; one crawl while the first keeps its speed.
delta-circle.x += rl/get-frame-time() * (6.0 * speed)
frame-circle.x += 0.1 * speed
if delta-circle.x > f32(screen-width)
delta-circle.x = 0.0
if frame-circle.x > f32(screen-width)
frame-circle.x = 0.0
if rl/is-key-pressed(:key-r)
delta-circle.x = 0.0
frame-circle.x = 0.0
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-circle-v(delta-circle, circle-radius, rl/red)
rl/draw-circle-v(frame-circle, circle-radius, rl/blue)
;; "FPS: unlimited (%i)" when the target is 0, "FPS: %i (target: %i)"
;; otherwise — the C's own branch, reassembled out of literals and
;; draw-int. `x` walks along the line as each piece is drawn, which is what
;; the returned width from draw-int is for.
if current-fps <= 0
let x = 10
rl/draw-text("FPS: unlimited (", x, 10, 20, rl/darkgray)
x += rl/measure-text("FPS: unlimited (", 20)
x += d/draw-int(rl/get-fps(), x, 10, 20, rl/darkgray)
rl/draw-text(")", x, 10, 20, rl/darkgray)
else
let x = 10
rl/draw-text("FPS: ", x, 10, 20, rl/darkgray)
x += rl/measure-text("FPS: ", 20)
x += d/draw-int(rl/get-fps(), x, 10, 20, rl/darkgray)
rl/draw-text(" (target: ", x, 10, 20, rl/darkgray)
x += rl/measure-text(" (target: ", 20)
x += d/draw-int(current-fps, x, 10, 20, rl/darkgray)
rl/draw-text(")", x, 10, 20, rl/darkgray)
;; The C's line, kept exactly — seconds, labelled ms.
let x = 10
rl/draw-text("Frame time: ", x, 30, 20, rl/darkgray)
x += rl/measure-text("Frame time: ", 20)
x += d/draw-f32(rl/get-frame-time(), 2, x, 30, 20, rl/darkgray)
rl/draw-text(" ms", x, 30, 20, rl/darkgray)
;; And the same number in the unit the label claims, so the file does not
;; have to be read to notice.
let x = 10
rl/draw-text("(really ", x, 50, 20, rl/lightgray)
x += rl/measure-text("(really ", 20)
x += d/draw-f32(rl/get-frame-time() * 1000.0, 2, x, 50, 20, rl/lightgray)
rl/draw-text(" ms)", x, 50, 20, rl/lightgray)
rl/draw-text("Use the scroll wheel to change the fps limit, r to reset",
10, 70, 20, rl/darkgray)
rl/draw-text("FUNC: x += GetFrameTime()*speed", 10, 110, 20, rl/red)
rl/draw-text("FUNC: x += speed", 10, 260, 20, rl/blue)
-278
View File
@@ -1,278 +0,0 @@
;;;; raylib [core] example - input gamepad
;;;;
;;;; examples/core/core_input_gamepad.c. Needed set-config-flags (and the
;;;; FLAG_ constants), both now bound.
;;;;
;;;; Three deliberate differences from the C, all of them things the port
;;;; could not or should not follow.
;;;;
;;;; **The pad artwork is gone, and with it the xbox/PS branches.** The C draws
;;;; `resources/ps3.png` or `resources/xbox.png` and picks between them by
;;;; matching `GetGamepadName` against four substrings. Neither image is in
;;;; this repository, and importing two binaries for one example is worse than
;;;; the alternative: the C has a third branch, for a pad it does not
;;;; recognise, which draws the whole thing out of rounded rectangles and
;;;; circles and needs no asset at all. That branch is what this ports, in
;;;; full. It is also the branch a Linux desktop usually takes anyway.
;;;;
;;;; **GetGamepadName is not bound, and cannot be.** It is what the C matches
;;;; on, and declare-c refuses it by name:
;;;;
;;;; the return type of get-gamepad-name is a string, and a string only
;;;; crosses as a parameter — a C function that *returns* one returns
;;;; something Flan has no owner for
;;;;
;;;; which is correct: raylib hands back a pointer into its own static storage
;;;; and Flan has nothing that owns a borrowed C string. So the pad's name is
;;;; not on screen and the branch that used it is not here.
;;;;
;;;; **The VIBRATE button is drawn and inert.** SetGamepadVibration is the one
;;;; call in the C this refuses to bind, and vendor/raylib/raylib.flan already
;;;; carries the paragraph saying why: its arity differs between raylib
;;;; versions with no 5.5 header here to settle it, so a guess is a corrupted
;;;; stack frame rather than a link error — and the symbol in libraylib.so.550
;;;; disassembles to a single TraceLog call and a jump. Binding it would be
;;;; binding a warning. The button is drawn because taking it out would hide
;;;; the finding; it is labelled so.
;;;;
;;;; With no pad attached this shows "GP0: NOT DETECTED" and nothing else,
;;;; which is the C's behaviour minus the greyed-out artwork.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
(defconst stick-deadzone f32 0.1)
(defconst trigger-deadzone f32 -0.9)
(defvar gamepad i32)
;; The C's deadzone test, which is a band around zero and not a clamp: inside
;; it the axis reads exactly 0, outside it the raw value passes through
;; unscaled. That is a step at the edge of the band and the C has it too.
(defn deadzone [v f32 limit f32] f32
(if (and (> v (- 0.0 limit)) (< v limit)) 0.0 v))
;; The triggers rest at -1 rather than 0, which raylib does not normalise —
;; see the note on GamepadAxis in vendor/raylib/raylib.flan. So their deadzone
;; is a floor near the resting end and not a band around the middle.
(defn trigger-deadzoned [v f32] f32
(if (< v trigger-deadzone) -1.0 v))
;; The C's read-out loop is `for (i = 0; i < GetGamepadAxisCount(gamepad); i++)
;; DrawText(TextFormat("AXIS %i: %.02f", i, GetGamepadAxisMovement(gamepad, i)))`
;; — an axis selected by a loop variable, which is what this is: an integer
;; converts to an enum when the conversion is written by name.
;;
;; It used to be a six-armed cond over the members, because an enum is its own
;; type in the checker and a bare i32 does not fit one:
;;
;; expected rl/GamepadAxis, found i32
;;
;; That rule is unchanged and should be. It is what makes a misspelled
;; `:lft-x` an error *here*, at the call site, rather than a wrong axis read
;; later — which is the whole reason an enum is a type at all rather than a
;; pile of i32 constants. What changed is that the conversion can be said out
;; loud: the rule was never "an integer is dangerous", it was "an integer must
;; not arrive silently", and (rl/GamepadAxis index) is not silent.
;;
;; (The other escape stays closed, and should: a second declare-c of
;; GetGamepadAxisMovement with an i32 face is still refused — one declare-c
;; per C function — and a defn wrapper still cannot retype a parameter. It is
;; no longer needed.)
;;
;; An index raylib does not name converts too, rather than being refused, and
;; raylib bounds-checks its own axis and answers 0.0. So the (min 6 ...) clamp
;; at the call site is what keeps the read-out to the six axes raylib names —
;; load-bearing now rather than cosmetic, since it replaced the :else arm.
(defn axis-at [pad i32 index i32] f32
(rl/get-gamepad-axis-movement pad (rl/GamepadAxis index)))
(defn draw-pad-background [] ()
(rl/draw-rectangle-rounded
(rl/Rectangle {.x 175.0 .y 110.0 .width 460.0 .height 220.0})
0.3 16 rl/darkgray)
;; The three middle buttons and the four face buttons, as outlines. The
;; filled overlays go on top only while the button is down.
(rl/draw-circle 365 170 12.0 rl/raywhite)
(rl/draw-circle 405 170 12.0 rl/raywhite)
(rl/draw-circle 445 170 12.0 rl/raywhite)
(rl/draw-circle 516 191 17.0 rl/raywhite)
(rl/draw-circle 551 227 17.0 rl/raywhite)
(rl/draw-circle 587 191 17.0 rl/raywhite)
(rl/draw-circle 551 155 17.0 rl/raywhite)
;; The d-pad cross: a light plate with a dark one inset, so a pressed
;; direction has something to show up against.
(rl/draw-rectangle 245 145 28 88 rl/raywhite)
(rl/draw-rectangle 215 174 88 29 rl/raywhite)
(rl/draw-rectangle 247 147 24 84 rl/black)
(rl/draw-rectangle 217 176 84 25 rl/black)
(rl/draw-rectangle-rounded
(rl/Rectangle {.x 215.0 .y 98.0 .width 100.0 .height 10.0})
0.5 16 rl/darkgray)
(rl/draw-rectangle-rounded
(rl/Rectangle {.x 495.0 .y 98.0 .width 100.0 .height 10.0})
0.5 16 rl/darkgray))
(defn draw-pad-buttons [] ()
(when (rl/gamepad-button-down? gamepad :middle-left)
(rl/draw-circle 365 170 10.0 rl/red))
(when (rl/gamepad-button-down? gamepad :middle)
(rl/draw-circle 405 170 10.0 rl/green))
(when (rl/gamepad-button-down? gamepad :middle-right)
(rl/draw-circle 445 170 10.0 rl/blue))
(when (rl/gamepad-button-down? gamepad :right-face-left)
(rl/draw-circle 516 191 15.0 rl/gold))
(when (rl/gamepad-button-down? gamepad :right-face-down)
(rl/draw-circle 551 227 15.0 rl/blue))
(when (rl/gamepad-button-down? gamepad :right-face-right)
(rl/draw-circle 587 191 15.0 rl/green))
(when (rl/gamepad-button-down? gamepad :right-face-up)
(rl/draw-circle 551 155 15.0 rl/red))
(when (rl/gamepad-button-down? gamepad :left-face-up)
(rl/draw-rectangle 247 147 24 29 rl/red))
(when (rl/gamepad-button-down? gamepad :left-face-down)
(rl/draw-rectangle 247 201 24 30 rl/red))
(when (rl/gamepad-button-down? gamepad :left-face-left)
(rl/draw-rectangle 217 176 30 25 rl/red))
(when (rl/gamepad-button-down? gamepad :left-face-right)
(rl/draw-rectangle 271 176 30 25 rl/red))
(when (rl/gamepad-button-down? gamepad :left-trigger-1)
(rl/draw-rectangle-rounded
(rl/Rectangle {.x 215.0 .y 98.0 .width 100.0 .height 10.0})
0.5 16 rl/red))
(when (rl/gamepad-button-down? gamepad :right-trigger-1)
(rl/draw-rectangle-rounded
(rl/Rectangle {.x 495.0 .y 98.0 .width 100.0 .height 10.0})
0.5 16 rl/red)))
(defn draw-stick [cx i32 cy i32 ax f32 ay f32 thumb-down bool] ()
(rl/draw-circle cx cy 40.0 rl/black)
(rl/draw-circle cx cy 35.0 rl/lightgray)
(rl/draw-circle (+ cx (i32 (* ax 20.0))) (+ cy (i32 (* ay 20.0))) 25.0
(if thumb-down rl/red rl/black)))
(defn main [] ()
;; Before init-window, and it has to be: raylib reads the flags while it is
;; creating the context, so the same call afterwards is accepted, logged, and
;; has no effect on the window that already exists.
(rl/set-config-flags rl/flag-msaa-4x-hint)
(rl/init-window screen-width screen-height
"raylib [core] example - input gamepad")
(defer (rl/close-window))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update
(when (and (rl/key-pressed? :left) (> gamepad 0))
(set gamepad (- gamepad 1)))
(when (rl/key-pressed? :right) (set gamepad (+ gamepad 1)))
(let [axis-count (min 6 (rl/get-gamepad-axis-count gamepad))
vibrate-rect (rl/Rectangle {.x 10.0
.y (+ 90.0 (* 20.0 (f32 axis-count)))
.width 75.0 .height 24.0})]
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(if (rl/gamepad-available? gamepad)
(do
;; The C draws `TextFormat("GP%d: %s", gamepad, GetGamepadName(...))`.
;; The name is unbindable — see the header — so this is the index and
;; the word the C would have followed it with.
(rl/draw-text "GP" 10 10 10 rl/black)
(let [x (+ 10 (rl/measure-text "GP" 10))]
(rl/draw-text ": CONNECTED (name unbindable)"
(+ x (d/draw-int gamepad x 10 10 rl/black))
10 10 rl/black))
(let [lx (deadzone (rl/get-gamepad-axis-movement gamepad :left-x)
stick-deadzone)
ly (deadzone (rl/get-gamepad-axis-movement gamepad :left-y)
stick-deadzone)
rx (deadzone (rl/get-gamepad-axis-movement gamepad :right-x)
stick-deadzone)
ry (deadzone (rl/get-gamepad-axis-movement gamepad :right-y)
stick-deadzone)
lt (trigger-deadzoned
(rl/get-gamepad-axis-movement gamepad :left-trigger))
rt (trigger-deadzoned
(rl/get-gamepad-axis-movement gamepad :right-trigger))]
(draw-pad-background)
(draw-pad-buttons)
(draw-stick 345 260 lx ly
(rl/gamepad-button-down? gamepad :left-thumb))
(draw-stick 465 260 rx ry
(rl/gamepad-button-down? gamepad :right-thumb))
;; The triggers as bars filling upward from a grey track. The +1
;; and the halving turn raylib's [-1, 1] into [0, 1].
(rl/draw-rectangle 151 110 15 70 rl/gray)
(rl/draw-rectangle 644 110 15 70 rl/gray)
(rl/draw-rectangle 151 110 15 (i32 (* (/ (+ 1.0 lt) 2.0) 70.0))
rl/red)
(rl/draw-rectangle 644 110 15 (i32 (* (/ (+ 1.0 rt) 2.0) 70.0))
rl/red))
(rl/draw-text "DETECTED AXIS [" 10 50 10 rl/maroon)
(let [x (+ 10 (rl/measure-text "DETECTED AXIS [" 10))]
(rl/draw-text "]:" (+ x (d/draw-int axis-count x 50 10 rl/maroon))
50 10 rl/maroon))
(dotimes [i axis-count]
(rl/draw-text "AXIS " 20 (+ 70 (* 20 i)) 10 rl/darkgray)
(let [x (+ 20 (rl/measure-text "AXIS " 10))
y (+ 70 (* 20 i))]
(set x (+ x (d/draw-int i x y 10 rl/darkgray)))
(rl/draw-text ": " x y 10 rl/darkgray)
(set x (+ x (rl/measure-text ": " 10)))
;; The raw reading, not the deadzoned one — the C shows the raw
;; value here too, which is what makes the deadzone visible as a
;; difference between this row and the stick.
(d/draw-f32 (axis-at gamepad i) 2 x y 10
rl/darkgray)))
;; Drawn and inert. See the header: SetGamepadVibration is a stub in
;; this raylib and is not bound.
(rl/draw-rectangle-rec vibrate-rect rl/skyblue)
(rl/draw-text "VIBRATE" (+ (i32 (.x vibrate-rect)) 14)
(+ (i32 (.y vibrate-rect)) 1) 10 rl/darkgray)
(rl/draw-text "(not bound: raylib stub)" 95
(+ (i32 (.y vibrate-rect)) 7) 10 rl/gray)
;; -1 when nothing is pressed, which is why the binding answers an
;; i32 and not a GamepadButton.
;; draw-int answers the width it drew, so a branch that ends in one
;; has type i32 while its sibling has type () and the `if` will not
;; typecheck — "expected i32, found ()". Both arms end in a
;; draw-text here, which is the tidy way out; where that is awkward a
;; trailing `(do)` is the other.
(let [b (rl/get-gamepad-button-pressed)]
(if (>= b 0)
(do (rl/draw-text "DETECTED BUTTON: " 10 430 10 rl/red)
(d/draw-int b (+ 10 (rl/measure-text "DETECTED BUTTON: " 10))
430 10 rl/red)
(do))
(rl/draw-text "DETECTED BUTTON: NONE" 10 430 10 rl/gray))))
(do
(rl/draw-text "GP" 10 10 10 rl/gray)
(let [x (+ 10 (rl/measure-text "GP" 10))]
(rl/draw-text ": NOT DETECTED"
(+ x (d/draw-int gamepad x 10 10 rl/gray))
10 10 rl/gray))
(rl/draw-text "left/right arrows select another pad" 10 30 10
rl/lightgray)))))))
+244
View File
@@ -0,0 +1,244 @@
;;;; raylib [core] example - input gamepad
;;;;
;;;; examples/core/core_input_gamepad.c. Needed set-config-flags (and the
;;;; FLAG_ constants), both now bound.
;;;;
;;;; Three deliberate differences from the C, all of them things the port
;;;; could not or should not follow.
;;;;
;;;; **The pad artwork is gone, and with it the xbox/PS branches.** The C draws
;;;; `resources/ps3.png` or `resources/xbox.png` and picks between them by
;;;; matching `GetGamepadName` against four substrings. Neither image is in
;;;; this repository, and importing two binaries for one example is worse than
;;;; the alternative: the C has a third branch, for a pad it does not
;;;; recognise, which draws the whole thing out of rounded rectangles and
;;;; circles and needs no asset at all. That branch is what this ports, in
;;;; full. It is also the branch a Linux desktop usually takes anyway.
;;;;
;;;; **The name is drawn and not matched on.** get-gamepad-name copies
;;;; raylib's static text into the context allocator, which this program never
;;;; frees — so the name is read once per frame into the frame arena, and the
;;;; branches that matched on it are the artwork's and are gone with it.
;;;;
;;;; **The VIBRATE button is drawn and inert.** SetGamepadVibration is the one
;;;; call in the C this refuses to bind, and vendor/raylib/raylib.fln already
;;;; carries the paragraph saying why: its arity differs between raylib
;;;; versions with no 5.5 header here to settle it, so a guess is a corrupted
;;;; stack frame rather than a link error — and the symbol in libraylib.so.550
;;;; disassembles to a single TraceLog call and a jump. Binding it would be
;;;; binding a warning. The button is drawn because taking it out would hide
;;;; the finding; it is labelled so.
;;;;
;;;; With no pad attached this shows "GP0: NOT DETECTED" and nothing else,
;;;; which is the C's behaviour minus the greyed-out artwork.
import rl "vendor:raylib"
import d "digits.fln"
const screen-width = 800
const screen-height = 450
const stick-deadzone: f32 = 0.1
const trigger-deadzone: f32 = -0.9
once gamepad: i32
;; The C's deadzone test, which is a band around zero and not a clamp: inside
;; it the axis reads exactly 0, outside it the raw value passes through
;; unscaled. That is a step at the edge of the band and the C has it too.
fn deadzone(v: f32, limit: f32) -> f32
if v > 0.0 - limit and v < limit then 0.0 else v
;; The triggers rest at -1 rather than 0, which raylib does not normalise —
;; see the note on GamepadAxis in vendor/raylib/raylib.fln. So their deadzone
;; is a floor near the resting end and not a band around the middle.
fn trigger-deadzoned(v: f32) -> f32
if v < trigger-deadzone then -1.0 else v
;; The C's read-out loop is `for (i = 0; i < GetGamepadAxisCount(gamepad); i++)
;; DrawText(TextFormat("AXIS %i: %.02f", i, GetGamepadAxisMovement(gamepad, i)))`
;; — an axis selected by a loop variable, which is what this is: an integer
;; converts to an enum when the conversion is written by name.
;;
;; It used to be a six-armed cond over the members, because an enum is its own
;; type in the checker and a bare i32 does not fit one:
;;
;; expected rl/GamepadAxis, found i32
;;
;; That rule is unchanged and should be. It is what makes a misspelled
;; `:lft-x` an error *here*, at the call site, rather than a wrong axis read
;; later — which is the whole reason an enum is a type at all rather than a
;; pile of i32 constants. What changed is that the conversion can be said out
;; loud: the rule was never "an integer is dangerous", it was "an integer must
;; not arrive silently", and (rl/GamepadAxis index) is not silent.
;;
;; (The other escape stays closed, and should: a second declare-c of
;; GetGamepadAxisMovement with an i32 face is still refused — one declare-c
;; per C function — and a defn wrapper still cannot retype a parameter. It is
;; no longer needed.)
;;
;; An index raylib does not name converts too, rather than being refused, and
;; raylib bounds-checks its own axis and answers 0.0. So the (min 6 ...) clamp
;; at the call site is what keeps the read-out to the six axes raylib names —
;; load-bearing now rather than cosmetic, since it replaced the :else arm.
fn axis-at(pad: i32, index: i32) -> f32
rl/get-gamepad-axis-movement(pad, rl/GamepadAxis(index))
fn draw-pad-background() -> ()
rl/draw-rectangle-rounded(rl/Rectangle{.x 175.0 .y 110.0 .width 460.0 .height 220.0},
0.3, 16, rl/darkgray)
;; The three middle buttons and the four face buttons, as outlines. The
;; filled overlays go on top only while the button is down.
rl/draw-circle(365, 170, 12.0, rl/raywhite)
rl/draw-circle(405, 170, 12.0, rl/raywhite)
rl/draw-circle(445, 170, 12.0, rl/raywhite)
rl/draw-circle(516, 191, 17.0, rl/raywhite)
rl/draw-circle(551, 227, 17.0, rl/raywhite)
rl/draw-circle(587, 191, 17.0, rl/raywhite)
rl/draw-circle(551, 155, 17.0, rl/raywhite)
;; The d-pad cross: a light plate with a dark one inset, so a pressed
;; direction has something to show up against.
rl/draw-rectangle(245, 145, 28, 88, rl/raywhite)
rl/draw-rectangle(215, 174, 88, 29, rl/raywhite)
rl/draw-rectangle(247, 147, 24, 84, rl/black)
rl/draw-rectangle(217, 176, 84, 25, rl/black)
rl/draw-rectangle-rounded(rl/Rectangle{.x 215.0 .y 98.0 .width 100.0 .height 10.0},
0.5, 16, rl/darkgray)
rl/draw-rectangle-rounded(rl/Rectangle{.x 495.0 .y 98.0 .width 100.0 .height 10.0},
0.5, 16, rl/darkgray)
fn draw-pad-buttons() -> ()
if rl/is-gamepad-button-down(gamepad, :button-middle-left)
rl/draw-circle(365, 170, 10.0, rl/red)
if rl/is-gamepad-button-down(gamepad, :button-middle)
rl/draw-circle(405, 170, 10.0, rl/green)
if rl/is-gamepad-button-down(gamepad, :button-middle-right)
rl/draw-circle(445, 170, 10.0, rl/blue)
if rl/is-gamepad-button-down(gamepad, :button-right-face-left)
rl/draw-circle(516, 191, 15.0, rl/gold)
if rl/is-gamepad-button-down(gamepad, :button-right-face-down)
rl/draw-circle(551, 227, 15.0, rl/blue)
if rl/is-gamepad-button-down(gamepad, :button-right-face-right)
rl/draw-circle(587, 191, 15.0, rl/green)
if rl/is-gamepad-button-down(gamepad, :button-right-face-up)
rl/draw-circle(551, 155, 15.0, rl/red)
if rl/is-gamepad-button-down(gamepad, :button-left-face-up)
rl/draw-rectangle(247, 147, 24, 29, rl/red)
if rl/is-gamepad-button-down(gamepad, :button-left-face-down)
rl/draw-rectangle(247, 201, 24, 30, rl/red)
if rl/is-gamepad-button-down(gamepad, :button-left-face-left)
rl/draw-rectangle(217, 176, 30, 25, rl/red)
if rl/is-gamepad-button-down(gamepad, :button-left-face-right)
rl/draw-rectangle(271, 176, 30, 25, rl/red)
if rl/is-gamepad-button-down(gamepad, :button-left-trigger-1)
rl/draw-rectangle-rounded(rl/Rectangle{.x 215.0 .y 98.0 .width 100.0 .height 10.0},
0.5, 16, rl/red)
if rl/is-gamepad-button-down(gamepad, :button-right-trigger-1)
rl/draw-rectangle-rounded(rl/Rectangle{.x 495.0 .y 98.0 .width 100.0 .height 10.0},
0.5, 16, rl/red)
fn draw-stick(cx: i32, cy: i32, ax: f32, ay: f32, thumb-down: bool) -> ()
rl/draw-circle(cx, cy, 40.0, rl/black)
rl/draw-circle(cx, cy, 35.0, rl/lightgray)
rl/draw-circle(cx + i32(ax * 20.0), cy + i32(ay * 20.0), 25.0,
if thumb-down then rl/red else rl/black)
fn main() -> ()
;; Before init-window, and it has to be: raylib reads the flags while it is
;; creating the context, so the same call afterwards is accepted, logged, and
;; has no effect on the window that already exists.
rl/set-config-flags(rl/flag-msaa-4x-hint)
rl/init-window(screen-width, screen-height,
"raylib [core] example - input gamepad")
defer rl/close-window()
rl/set-target-fps(60)
until rl/window-should-close()
;; The frame arena holds the pad's name for one frame.
free-all(context/temp)
;; Update
if rl/is-key-pressed(:key-left) and gamepad > 0
gamepad -= 1
if rl/is-key-pressed(:key-right)
gamepad += 1
let axis-count = min(6, rl/get-gamepad-axis-count(gamepad))
let vibrate-rect = rl/Rectangle{.x 10.0, .y 90.0 + 20.0 * f32(axis-count), .width 75.0, .height 24.0}
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
if rl/is-gamepad-available(gamepad)
;; The C draws `TextFormat("GP%d: %s", gamepad, GetGamepadName(...))`.
;; TextFormat is variadic and unbound, so the pieces are drawn in turn.
rl/draw-text("GP", 10, 10, 10, rl/black)
let x = 10 + rl/measure-text("GP", 10)
let x = x + d/draw-int(gamepad, x, 10, 10, rl/black)
rl/draw-text(": ", x, 10, 10, rl/black)
rl/draw-text(with-allocator(context/temp, rl/get-gamepad-name(gamepad)),
x + rl/measure-text(": ", 10), 10, 10, rl/black)
let lx = deadzone(rl/get-gamepad-axis-movement(gamepad, :axis-left-x),
stick-deadzone)
let ly = deadzone(rl/get-gamepad-axis-movement(gamepad, :axis-left-y),
stick-deadzone)
let rx = deadzone(rl/get-gamepad-axis-movement(gamepad, :axis-right-x),
stick-deadzone)
let ry = deadzone(rl/get-gamepad-axis-movement(gamepad, :axis-right-y),
stick-deadzone)
let lt = trigger-deadzoned(rl/get-gamepad-axis-movement(gamepad, :axis-left-trigger))
let rt = trigger-deadzoned(rl/get-gamepad-axis-movement(gamepad, :axis-right-trigger))
draw-pad-background()
draw-pad-buttons()
draw-stick(345, 260, lx, ly,
rl/is-gamepad-button-down(gamepad, :button-left-thumb))
draw-stick(465, 260, rx, ry,
rl/is-gamepad-button-down(gamepad, :button-right-thumb))
;; The triggers as bars filling upward from a grey track. The +1
;; and the halving turn raylib's [-1, 1] into [0, 1].
rl/draw-rectangle(151, 110, 15, 70, rl/gray)
rl/draw-rectangle(644, 110, 15, 70, rl/gray)
rl/draw-rectangle(151, 110, 15, i32((1.0 + lt) / 2.0 * 70.0), rl/red)
rl/draw-rectangle(644, 110, 15, i32((1.0 + rt) / 2.0 * 70.0), rl/red)
rl/draw-text("DETECTED AXIS [", 10, 50, 10, rl/maroon)
let x = 10 + rl/measure-text("DETECTED AXIS [", 10)
rl/draw-text("]:", x + d/draw-int(axis-count, x, 50, 10, rl/maroon), 50,
10, rl/maroon)
for i in 0..axis-count
rl/draw-text("AXIS ", 20, 70 + 20 * i, 10, rl/darkgray)
let x = 20 + rl/measure-text("AXIS ", 10)
y = 70 + 20 * i
x += d/draw-int(i, x, y, 10, rl/darkgray)
rl/draw-text(": ", x, y, 10, rl/darkgray)
x += rl/measure-text(": ", 10)
;; The raw reading, not the deadzoned one — the C shows the raw
;; value here too, which is what makes the deadzone visible as a
;; difference between this row and the stick.
d/draw-f32(axis-at(gamepad, i), 2, x, y, 10, rl/darkgray)
;; Drawn and inert. See the header: SetGamepadVibration is a stub in
;; this raylib and is not bound.
rl/draw-rectangle-rec(vibrate-rect, rl/skyblue)
rl/draw-text("VIBRATE", i32(vibrate-rect.x) + 14,
i32(vibrate-rect.y) + 1, 10, rl/darkgray)
rl/draw-text("(not bound: raylib stub)", 95, i32(vibrate-rect.y) + 7,
10, rl/gray)
;; -1 when nothing is pressed, which is why the binding answers an
;; i32 and not a GamepadButton.
;; draw-int answers the width it drew, so a branch that ends in one
;; has type i32 while its sibling has type () and the `if` will not
;; typecheck — "expected i32, found ()". Both arms end in a
;; draw-text here, which is the tidy way out; where that is awkward a
;; trailing `()` is the other.
let b = rl/get-gamepad-button-pressed()
if b >= 0
rl/draw-text("DETECTED BUTTON: ", 10, 430, 10, rl/red)
d/draw-int(b, 10 + rl/measure-text("DETECTED BUTTON: ", 10), 430, 10,
rl/red)
()
else
rl/draw-text("DETECTED BUTTON: NONE", 10, 430, 10, rl/gray)
else
rl/draw-text("GP", 10, 10, 10, rl/gray)
let x = 10 + rl/measure-text("GP", 10)
rl/draw-text(": NOT DETECTED",
x + d/draw-int(gamepad, x, 10, 10, rl/gray), 10, 10,
rl/gray)
rl/draw-text("left/right arrows select another pad", 10, 30, 10,
rl/lightgray)
-362
View File
@@ -1,362 +0,0 @@
;;;; raylib [core] example - input gestures testbed
;;;;
;;;; examples/core/core_input_gestures_testbed.c, the four-star one and the
;;;; hardest of the ten. It runs, and it is the example that pushed hardest on
;;;; the language. Four things it needed, in descending order of how much they
;;;; cost.
;;;;
;;;; **An enum converts to an integer, when you say so.** The C treats the
;;;; gesture as the raw bitfield it is and compares it with `<` and `>`:
;;;; `currentGesture > 255` picks out the two pinches, `> 15` the four swipes,
;;;; `!= 4` excludes hold, `< 3` admits tap and double-tap. `get-gesture-
;;;; detected` answers an `rl/Gesture`, and that used to end the discussion —
;;;;
;;;; i32 converts a number, found rl/Gesture
;;;;
;;;; — so the three range tests were spelled out as keyword equalities, one
;;;; arm per member. They are `(> (i32 g) 255)`, `(> (i32 g) 15)` and
;;;; `(< (i32 g) 3)` now, still behind the named predicates below, and the fix
;;;; is not brevity: the enumerated version was *wrong about the future*. A
;;;; gesture raylib adds later falls silently out of a list of four members,
;;;; where the C's `> 15` catches it. The range test is the honest reading of
;;;; a bitfield and now it is the one written.
;;;;
;;;; The rule the refusal came from is unchanged and worth keeping: an enum is
;;;; its own type in the checker, so `:tpa` is an error at the call site
;;;; instead of a number that is wrong later, and a bare integer still does not
;;;; fit an `rl/Gesture` parameter. `(i32 g)` does not weaken that — it is
;;;; named, and it is at the site. The rule was "an integer must not arrive
;;;; silently", not "an integer is dangerous".
;;;;
;;;; The `!= 4` stays a keyword comparison, `(not (= g :hold))`. That one was
;;;; never a range test; it is a single member, and the C's 4 is a magic
;;;; number the keyword reads better than.
;;;;
;;;; **No sin or cos.** The prelude has sqrt-f32 — one `declare` over libm,
;;;; with a comment explaining why it is not a builtin — and nothing else
;;;; transcendental. The protractor needs both, so they are two more `declare`
;;;; lines here, in the same shape. They link: libm is already on the line.
;;;;
;;;; **No string formatting.** A number can be made into a string now —
;;;; (string (i64->bytes n)) — but a *format* still cannot: f64->bytes is
;;;; "%g", with no way to ask for two decimal places. The C prints the angle
;;;; with `TextFormat("%f", ...)`, finds the decimal point with
;;;; `TextFindIndex` and cuts two digits past it with `TextSubtext`;
;;;; `draw-f32` in examples/digits.flan does the whole thing in one call, and
;;;; rounds rather than truncating — which is the one place this screen
;;;; differs from the C's by a digit.
;;;;
;;;; **No local fixed arrays.** The C declares `char gestureLog[20][12]` and
;;;; `Vector2 touchPosition[32]` inside main. A `let` binding takes no type
;;;; annotation, so a fixed array can only be a top-level `defvar` or a literal
;;;; with every element written out — thirty-two Vector2s, here. They are
;;;; `defvar`s, which is what the C's storage amounts to anyway.
;;;;
;;;; The log itself came out simpler than the C's: the names are compile-time
;;;; literals, so a slot holds a `string` and there is no TextCopy and no
;;;; twelve-byte truncation.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
(defconst pi f32 3.14159265)
(defconst gesture-log-size 20)
(defconst max-touch-count 32)
(defvar gesture-log [gesture-log-size string])
;; The C's inverted circular queue: the index counts DOWN and wraps at the top,
;; so the newest entry is always at gesture-log-index and the draw loop walks
;; forward from there. Starting at the size rather than at size-1 is the C's
;; too — the first write decrements before storing.
(defvar gesture-log-index i32)
(defvar previous-gesture rl/Gesture)
(defvar last-gesture rl/Gesture)
(defvar gesture-color rl/Color)
(defvar log-mode i32)
(defvar current-angle f32)
(defvar touch-positions [max-touch-count rl/Vector2])
;; ── The comparisons the C makes on the raw bitfield ─────────────────
;;
;; A gesture is a flag: 1, 2, 4, 8 … up to 512, so the ranges are the C's way
;; of asking which family a gesture belongs to. (i32 g) is what lets that be
;; written; they are named here rather than inline because a bare 255 at a
;; call site says nothing, and because a gesture raylib adds later lands in
;; the right family without this file being edited.
(defn pinch? [g rl/Gesture] bool ; the C's `> 255`
(> (i32 g) 255))
(defn swipe? [g rl/Gesture] bool ; the C's `> 15`
(> (i32 g) 15))
(defn tapish? [g rl/Gesture] bool ; the C's `< 3`
(< (i32 g) 3))
;; Two orderings these impose, both of them the C's as well. A pinch is above
;; 255 and therefore above 15, so swipe? has to be asked after the pinches
;; rather than before; and tapish? admits :none, which is 0, so it belongs
;; under a :none guard. The C's switch over single members hides both; a range
;; test cannot.
(defn gesture-name [g rl/Gesture] string
(cond
(= g :none) "None"
(= g :tap) "Tap"
(= g :double-tap) "Double Tap"
(= g :hold) "Hold"
(= g :drag) "Drag"
(= g :swipe-right) "Swipe Right"
(= g :swipe-left) "Swipe Left"
(= g :swipe-up) "Swipe Up"
(= g :swipe-down) "Swipe Down"
(= g :pinch-in) "Pinch In"
(= g :pinch-out) "Pinch Out"
:else "Unknown"))
(defn gesture-color-of [g rl/Gesture] rl/Color
(cond
(= g :tap) rl/blue
(= g :double-tap) rl/skyblue
(= g :drag) rl/lime
;; The two pinches are above 255 and so are above 15 as well: swipe? is a
;; range test and has to be asked after them, not before. The C gets this
;; for free by being a switch over single members.
(= g :pinch-in) rl/violet
(= g :pinch-out) rl/orange
(swipe? g) rl/red
:else rl/black))
;; ── The log ─────────────────────────────────────────────────────────
;; The C's four log modes, as a truth table rather than as nested ifs:
;; 0 shows repeated events
;; 1 hides repeated events
;; 2 shows repeated events but hides hold
;; 3 hides repeated events and hides hold
(defn should-log? [g rl/Gesture] bool
(cond
(= g :none) false
(= log-mode 3) (or (and (not (= g :hold)) (not (= g previous-gesture)))
(tapish? g))
(= log-mode 2) (not (= g :hold))
(= log-mode 1) (not (= g previous-gesture))
:else true))
(defn push-log [g rl/Gesture] ()
(set previous-gesture g)
(set gesture-color (gesture-color-of g))
(when (<= gesture-log-index 0) (set gesture-log-index gesture-log-size))
(set gesture-log-index (- gesture-log-index 1))
(set (at gesture-log gesture-log-index) (gesture-name g)))
;; ── Drawing ─────────────────────────────────────────────────────────
(defconst last-x 165)
(defconst last-y 130)
(defconst prot-x f32 266.0)
(defconst prot-y f32 315.0)
(defconst angle-length f32 90.0)
(defn swipe-box [gx i32 gy i32 which rl/Gesture] ()
(rl/draw-rectangle gx gy 20 20
(if (= last-gesture which) rl/red rl/lightgray)))
(defn draw-last-gesture [touch-count i32] ()
(rl/draw-text "Last gesture" (+ last-x 33) (- last-y 47) 20 rl/black)
(rl/draw-text "Swipe Tap Pinch Touch" (+ last-x 17)
(- last-y 18) 10 rl/black)
(swipe-box (+ last-x 20) last-y :swipe-up)
(swipe-box last-x (+ last-y 20) :swipe-left)
(swipe-box (+ last-x 40) (+ last-y 20) :swipe-right)
(swipe-box (+ last-x 20) (+ last-y 40) :swipe-down)
(rl/draw-circle (+ last-x 80) (+ last-y 16) 10.0
(if (= last-gesture :tap) rl/blue rl/lightgray))
;; segments 0 lets raylib pick the count from the radius.
(rl/draw-ring (rl/Vector2 {.x (f32 (+ last-x 103)) .y (f32 (+ last-y 16))})
6.0 11.0 0.0 360.0 0
(if (= last-gesture :drag) rl/lime rl/lightgray))
(rl/draw-circle (+ last-x 80) (+ last-y 43) 10.0
(if (= last-gesture :double-tap) rl/skyblue rl/lightgray))
(rl/draw-circle (+ last-x 103) (+ last-y 43) 10.0
(if (= last-gesture :double-tap) rl/skyblue rl/lightgray))
;; The two pairs of arrowheads, pointing outward for pinch-out and inward
;; for pinch-in. Counter-clockwise winding, or raylib culls them.
(let [out-c (if (= last-gesture :pinch-out) rl/orange rl/lightgray)
in-c (if (= last-gesture :pinch-in) rl/violet rl/lightgray)]
(rl/draw-triangle (rl/Vector2 {.x (f32 (+ last-x 122)) .y (f32 (+ last-y 16))})
(rl/Vector2 {.x (f32 (+ last-x 137)) .y (f32 (+ last-y 26))})
(rl/Vector2 {.x (f32 (+ last-x 137)) .y (f32 (+ last-y 6))})
out-c)
(rl/draw-triangle (rl/Vector2 {.x (f32 (+ last-x 147)) .y (f32 (+ last-y 6))})
(rl/Vector2 {.x (f32 (+ last-x 147)) .y (f32 (+ last-y 26))})
(rl/Vector2 {.x (f32 (+ last-x 162)) .y (f32 (+ last-y 16))})
out-c)
(rl/draw-triangle (rl/Vector2 {.x (f32 (+ last-x 125)) .y (f32 (+ last-y 33))})
(rl/Vector2 {.x (f32 (+ last-x 125)) .y (f32 (+ last-y 53))})
(rl/Vector2 {.x (f32 (+ last-x 140)) .y (f32 (+ last-y 43))})
in-c)
(rl/draw-triangle (rl/Vector2 {.x (f32 (+ last-x 144)) .y (f32 (+ last-y 43))})
(rl/Vector2 {.x (f32 (+ last-x 159)) .y (f32 (+ last-y 53))})
(rl/Vector2 {.x (f32 (+ last-x 159)) .y (f32 (+ last-y 33))})
in-c))
;; Four pips, one per simultaneous touch raylib reports.
(dotimes [i 4]
(rl/draw-circle (+ last-x 180) (+ (+ last-y 7) (* i 15)) 5.0
(if (<= touch-count i) rl/lightgray gesture-color))))
(defn draw-log [] ()
(rl/draw-text "Log" 10 10 20 rl/black)
;; Forward from the newest, wrapping — the inverted queue read the right way
;; round.
(dotimes [i gesture-log-size]
(let [ii (% (+ gesture-log-index i) gesture-log-size)]
(rl/draw-text (at gesture-log ii) 10 (- (+ 10 410) (* i 20)) 20
(if (= i 0) gesture-color rl/lightgray))))
;; The two mode buttons. Maroon means the mode that button controls is on.
(let [b1 (rl/Rectangle {.x 53.0 .y 7.0 .width 48.0 .height 26.0})
b2 (rl/Rectangle {.x 108.0 .y 7.0 .width 36.0 .height 26.0})]
(rl/draw-rectangle-rec b1 (if (or (= log-mode 1) (= log-mode 3))
rl/maroon rl/gray))
(rl/draw-text "Hide" 60 10 10 rl/white)
(rl/draw-text "Repeat" 60 20 10 rl/white)
(rl/draw-rectangle-rec b2 (if (or (= log-mode 2) (= log-mode 3))
rl/maroon rl/gray))
(rl/draw-text "Hide" 115 10 10 rl/white)
(rl/draw-text "Hold" 115 20 10 rl/white)))
(defn draw-protractor [] ()
(rl/draw-text "Angle" (+ (i32 prot-x) 55) (+ (i32 prot-y) 76) 10 rl/black)
;; The C's TextFormat/TextFindIndex/TextSubtext dance to get two decimals,
;; in one call. It rounds where the C truncated, so the last digit can
;; differ by one.
(d/draw-f32 current-angle 2 (+ (i32 prot-x) 55) (+ (i32 prot-y) 92) 20
gesture-color)
(rl/draw-circle-v (rl/Vector2 {.x prot-x .y prot-y}) 80.0 rl/white)
(rl/draw-line-ex (rl/Vector2 {.x (- prot-x 90.0) .y prot-y})
(rl/Vector2 {.x (+ prot-x 90.0) .y prot-y}) 3.0 rl/lightgray)
(rl/draw-line-ex (rl/Vector2 {.x prot-x .y (- prot-y 90.0)})
(rl/Vector2 {.x prot-x .y (+ prot-y 90.0)}) 3.0 rl/lightgray)
(rl/draw-line-ex (rl/Vector2 {.x (- prot-x 80.0) .y (- prot-y 45.0)})
(rl/Vector2 {.x (+ prot-x 80.0) .y (+ prot-y 45.0)}) 3.0
rl/green)
(rl/draw-line-ex (rl/Vector2 {.x (- prot-x 80.0) .y (+ prot-y 45.0)})
(rl/Vector2 {.x (+ prot-x 80.0) .y (- prot-y 45.0)}) 3.0
rl/green)
(rl/draw-text "0" (+ (i32 prot-x) 96) (- (i32 prot-y) 9) 20 rl/black)
(rl/draw-text "30" (+ (i32 prot-x) 74) (- (i32 prot-y) 68) 20 rl/black)
(rl/draw-text "90" (- (i32 prot-x) 11) (- (i32 prot-y) 110) 20 rl/black)
(rl/draw-text "150" (- (i32 prot-x) 100) (- (i32 prot-y) 68) 20 rl/black)
(rl/draw-text "180" (- (i32 prot-x) 124) (- (i32 prot-y) 9) 20 rl/black)
(rl/draw-text "210" (- (i32 prot-x) 100) (+ (i32 prot-y) 50) 20 rl/black)
(rl/draw-text "270" (- (i32 prot-x) 18) (+ (i32 prot-y) 92) 20 rl/black)
(rl/draw-text "330" (+ (i32 prot-x) 72) (+ (i32 prot-y) 50) 20 rl/black)
;; The needle. The C's +90 puts 0 degrees on the right, and sin feeding x
;; against cos feeding y is what rotates it the way the dial is labelled.
(unless (= current-angle 0.0)
(let [rad (/ (* (+ current-angle 90.0) pi) 180.0)]
(rl/draw-line-ex (rl/Vector2 {.x prot-x .y prot-y})
(rl/Vector2 {.x (+ (* angle-length (sin-f32 rad)) prot-x)
.y (+ (* angle-length (cos-f32 rad)) prot-y)})
3.0 gesture-color))))
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - input gestures testbed")
(defer (rl/close-window))
;; A zeroed Color has an alpha of 0 and is invisible; the C initialises this
;; to opaque black. Same for the log index, which starts at the SIZE.
(set gesture-color rl/black)
(set gesture-log-index gesture-log-size)
(set log-mode 1)
(rl/set-target-fps 60)
(let [b1 (rl/Rectangle {.x 53.0 .y 7.0 .width 48.0 .height 26.0})
b2 (rl/Rectangle {.x 108.0 .y 7.0 .width 36.0 .height 26.0})]
(until (rl/window-should-close?)
;; Update
(let [g (rl/get-gesture-detected)
touch-count (min max-touch-count (rl/get-touch-point-count))]
;; The C filters hold out of the "last gesture" display, because hold
;; repeats every frame and would drown everything else.
(when (and (and (not (= g :none)) (not (= g :hold)))
(not (= g previous-gesture)))
(set last-gesture g))
;; The two mode buttons toggle one bit each of log-mode.
(when (rl/mouse-button-released? :left)
(let [m (rl/get-mouse-position)]
(when (rl/collision-point-rec? m b1)
(set log-mode (cond (= log-mode 3) 2
(= log-mode 2) 3
(= log-mode 1) 0
:else 1)))
(when (rl/collision-point-rec? m b2)
(set log-mode (cond (= log-mode 3) 1
(= log-mode 2) 0
(= log-mode 1) 3
:else 2)))))
(when (should-log? g) (push-log g))
;; The protractor reads the pinch angle for a pinch, the drag angle for
;; a swipe, and sits at 0 for everything else. These are the C's
;; `> 255` / `> 15` / `> 0` — see the header for why they are spelled
;; this way.
(cond
(pinch? g) (set current-angle (rl/get-gesture-pinch-angle))
(swipe? g) (set current-angle (rl/get-gesture-drag-angle))
(not (= g :none)) (set current-angle 0.0)
:else (do))
(dotimes [i touch-count]
(set (at touch-positions i) (rl/get-touch-position i)))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-text "*" 165 12 10 rl/black)
(rl/draw-text "Example optimized for Web/HTML5\non Smartphones with Touch Screen."
175 12 10 rl/black)
(rl/draw-text "*" 165 42 10 rl/black)
(rl/draw-text "While running on Desktop Web Browsers,\ninspect and turn on Touch Emulation."
175 42 10 rl/black)
(draw-last-gesture touch-count)
(draw-log)
(draw-protractor)
;; The pointer itself: every live touch, or the mouse when there is no
;; touchscreen. The halo is the gesture colour faded, which is what
;; `fade` was bound for.
(unless (= g :none)
(if (> touch-count 0)
(do
(dotimes [i touch-count]
(let [p (at touch-positions i)]
(rl/draw-circle-v p 50.0 (rl/fade gesture-color 0.5))
(rl/draw-circle-v p 5.0 gesture-color)))
;; Two fingers: the line between them, thinner while pinching
;; out, which is the C's only use of the raw 512.
(when (= touch-count 2)
(rl/draw-line-ex (at touch-positions 0) (at touch-positions 1)
(if (= g :pinch-out) 8.0 12.0) gesture-color)))
(let [m (rl/get-mouse-position)]
(rl/draw-circle-v m 35.0 (rl/fade gesture-color 0.5))
(rl/draw-circle-v m 5.0 gesture-color)))))))))
+374
View File
@@ -0,0 +1,374 @@
;;;; raylib [core] example - input gestures testbed
;;;;
;;;; examples/core/core_input_gestures_testbed.c, the four-star one and the
;;;; hardest of the ten. It runs, and it is the example that pushed hardest on
;;;; the language. Four things it needed, in descending order of how much they
;;;; cost.
;;;;
;;;; **An enum converts to an integer, when you say so.** The C treats the
;;;; gesture as the raw bitfield it is and compares it with `<` and `>`:
;;;; `currentGesture > 255` picks out the two pinches, `> 15` the four swipes,
;;;; `!= 4` excludes hold, `< 3` admits tap and double-tap. `get-gesture-
;;;; detected` answers an `rl/Gesture`, and that used to end the discussion —
;;;;
;;;; i32 converts a number, found rl/Gesture
;;;;
;;;; — so the three range tests were spelled out as keyword equalities, one
;;;; arm per member. They are `(> (i32 g) 255)`, `(> (i32 g) 15)` and
;;;; `(< (i32 g) 3)` now, still behind the named predicates below, and the fix
;;;; is not brevity: the enumerated version was *wrong about the future*. A
;;;; gesture raylib adds later falls silently out of a list of four members,
;;;; where the C's `> 15` catches it. The range test is the honest reading of
;;;; a bitfield and now it is the one written.
;;;;
;;;; The rule the refusal came from is unchanged and worth keeping: an enum is
;;;; its own type in the checker, so `:tpa` is an error at the call site
;;;; instead of a number that is wrong later, and a bare integer still does not
;;;; fit an `rl/Gesture` parameter. `i32(g)` does not weaken that — it is
;;;; named, and it is at the site. The rule was "an integer must not arrive
;;;; silently", not "an integer is dangerous".
;;;;
;;;; The `!= 4` stays a keyword comparison, `not g == :gesture-hold`. That
;;;; one was never a range test; it is a single member, and the C's 4 is a
;;;; magic number the keyword reads better than.
;;;;
;;;; **No sin or cos.** The prelude has sqrt-f32 — one `declare` over libm,
;;;; with a comment explaining why it is not a builtin — and nothing else
;;;; transcendental. The protractor needs both, so they are two more `declare`
;;;; lines here, in the same shape. They link: libm is already on the line.
;;;;
;;;; **No string formatting.** A number can be made into a string now —
;;;; (string (i64->bytes n)) — but a *format* still cannot: f64->bytes is
;;;; "%g", with no way to ask for two decimal places. The C prints the angle
;;;; with `TextFormat("%f", ...)`, finds the decimal point with
;;;; `TextFindIndex` and cuts two digits past it with `TextSubtext`;
;;;; `draw-f32` in examples/digits.fln does the whole thing in one call, and
;;;; rounds rather than truncating — which is the one place this screen
;;;; differs from the C's by a digit.
;;;;
;;;; **No local fixed arrays.** The C declares `char gestureLog[20][12]` and
;;;; `Vector2 touchPosition[32]` inside main. A `let` binding takes no type
;;;; annotation, so a fixed array can only be a top-level `once` or a literal
;;;; with every element written out — thirty-two Vector2s, here. They are
;;;; `once`s, which is what the C's storage amounts to anyway.
;;;;
;;;; The log itself came out simpler than the C's: the names are compile-time
;;;; literals, so a slot holds a `string` and there is no TextCopy and no
;;;; twelve-byte truncation.
import rl "vendor:raylib"
import d "digits.fln"
const screen-width = 800
const screen-height = 450
const pi: f32 = 3.14159265
const gesture-log-size = 20
const max-touch-count = 32
once gesture-log: [gesture-log-size str]
;; The C's inverted circular queue: the index counts DOWN and wraps at the top,
;; so the newest entry is always at gesture-log-index and the draw loop walks
;; forward from there. Starting at the size rather than at size-1 is the C's
;; too — the first write decrements before storing.
once gesture-log-index: i32
once previous-gesture: rl/Gesture
once last-gesture: rl/Gesture
once gesture-color: rl/Color
once log-mode: i32
once current-angle: f32
once touch-positions: [max-touch-count rl/Vector2]
;; ── The comparisons the C makes on the raw bitfield ─────────────────
;;
;; A gesture is a flag: 1, 2, 4, 8 … up to 512, so the ranges are the C's way
;; of asking which family a gesture belongs to. (i32 g) is what lets that be
;; written; they are named here rather than inline because a bare 255 at a
;; call site says nothing, and because a gesture raylib adds later lands in
;; the right family without this file being edited.
fn is-pinch(g: rl/Gesture) -> bool ; the C's `> 255`
i32(g) > 255
fn is-swipe(g: rl/Gesture) -> bool ; the C's `> 15`
i32(g) > 15
fn is-tapish(g: rl/Gesture) -> bool ; the C's `< 3`
i32(g) < 3
;; Two orderings these impose, both of them the C's as well. A pinch is above
;; 255 and therefore above 15, so is-swipe has to be asked after the pinches
;; rather than before; and is-tapish admits :gesture-none, which is 0, so it
;; belongs under a :gesture-none guard. The C's switch over single members
;; hides both; a range test cannot.
fn gesture-name(g: rl/Gesture) -> str
if g == :gesture-none
"None"
elif g == :gesture-tap
"Tap"
elif g == :gesture-double-tap
"Double Tap"
elif g == :gesture-hold
"Hold"
elif g == :gesture-drag
"Drag"
elif g == :gesture-swipe-right
"Swipe Right"
elif g == :gesture-swipe-left
"Swipe Left"
elif g == :gesture-swipe-up
"Swipe Up"
elif g == :gesture-swipe-down
"Swipe Down"
elif g == :gesture-pinch-in
"Pinch In"
elif g == :gesture-pinch-out
"Pinch Out"
else
"Unknown"
fn gesture-color-of(g: rl/Gesture) -> rl/Color
if g == :gesture-tap
rl/blue
elif g == :gesture-double-tap
rl/skyblue
elif g == :gesture-drag
rl/lime
;; The two pinches are above 255 and so are above 15 as well: is-swipe is a
;; range test and has to be asked after them, not before. The C gets this
;; for free by being a switch over single members.
elif g == :gesture-pinch-in
rl/violet
elif g == :gesture-pinch-out
rl/orange
elif is-swipe(g)
rl/red
else
rl/black
;; ── The log ─────────────────────────────────────────────────────────
;; The C's four log modes, as a truth table rather than as nested ifs:
;; 0 shows repeated events
;; 1 hides repeated events
;; 2 shows repeated events but hides hold
;; 3 hides repeated events and hides hold
fn is-should-log(g: rl/Gesture) -> bool
if g == :gesture-none
false
elif log-mode == 3
(not g == :gesture-hold and not g == previous-gesture) or is-tapish(g)
elif log-mode == 2
not g == :gesture-hold
elif log-mode == 1
not g == previous-gesture
else
true
fn push-log(g: rl/Gesture) -> ()
previous-gesture = g
gesture-color = gesture-color-of(g)
if gesture-log-index <= 0
gesture-log-index = gesture-log-size
gesture-log-index -= 1
gesture-log[gesture-log-index] = gesture-name(g)
;; ── Drawing ─────────────────────────────────────────────────────────
const last-x = 165
const last-y = 130
const prot-x: f32 = 266.0
const prot-y: f32 = 315.0
const angle-length: f32 = 90.0
fn swipe-box(gx: i32, gy: i32, which: rl/Gesture) -> ()
rl/draw-rectangle(gx, gy, 20, 20,
if last-gesture == which then rl/red else rl/lightgray)
fn draw-last-gesture(touch-count: i32) -> ()
rl/draw-text("Last gesture", last-x + 33, last-y - 47, 20, rl/black)
rl/draw-text("Swipe Tap Pinch Touch", last-x + 17, last-y - 18,
10, rl/black)
swipe-box(last-x + 20, last-y, :gesture-swipe-up)
swipe-box(last-x, last-y + 20, :gesture-swipe-left)
swipe-box(last-x + 40, last-y + 20, :gesture-swipe-right)
swipe-box(last-x + 20, last-y + 40, :gesture-swipe-down)
rl/draw-circle(last-x + 80, last-y + 16, 10.0,
if last-gesture == :gesture-tap then rl/blue else rl/lightgray)
;; segments 0 lets raylib pick the count from the radius.
rl/draw-ring(rl/Vector2{.x f32(last-x + 103) .y f32(last-y + 16)}, 6.0, 11.0,
0.0, 360.0, 0,
if last-gesture == :gesture-drag then rl/lime else rl/lightgray)
rl/draw-circle(last-x + 80, last-y + 43, 10.0,
if last-gesture == :gesture-double-tap then rl/skyblue else rl/lightgray)
rl/draw-circle(last-x + 103, last-y + 43, 10.0,
if last-gesture == :gesture-double-tap then rl/skyblue else rl/lightgray)
;; The two pairs of arrowheads, pointing outward for pinch-out and inward
;; for pinch-in. Counter-clockwise winding, or raylib culls them.
let out-c =
if last-gesture == :gesture-pinch-out then rl/orange else rl/lightgray
let in-c =
if last-gesture == :gesture-pinch-in then rl/violet else rl/lightgray
rl/draw-triangle(rl/Vector2{.x f32(last-x + 122) .y f32(last-y + 16)},
rl/Vector2{.x f32(last-x + 137) .y f32(last-y + 26)},
rl/Vector2{.x f32(last-x + 137) .y f32(last-y + 6)}, out-c)
rl/draw-triangle(rl/Vector2{.x f32(last-x + 147) .y f32(last-y + 6)},
rl/Vector2{.x f32(last-x + 147) .y f32(last-y + 26)},
rl/Vector2{.x f32(last-x + 162) .y f32(last-y + 16)}, out-c)
rl/draw-triangle(rl/Vector2{.x f32(last-x + 125) .y f32(last-y + 33)},
rl/Vector2{.x f32(last-x + 125) .y f32(last-y + 53)},
rl/Vector2{.x f32(last-x + 140) .y f32(last-y + 43)}, in-c)
rl/draw-triangle(rl/Vector2{.x f32(last-x + 144) .y f32(last-y + 43)},
rl/Vector2{.x f32(last-x + 159) .y f32(last-y + 53)},
rl/Vector2{.x f32(last-x + 159) .y f32(last-y + 33)}, in-c)
;; Four pips, one per simultaneous touch raylib reports.
for i in 0..4
rl/draw-circle(last-x + 180, (last-y + 7) + i * 15, 5.0,
if touch-count <= i then rl/lightgray else gesture-color)
fn draw-log() -> ()
rl/draw-text("Log", 10, 10, 20, rl/black)
;; Forward from the newest, wrapping — the inverted queue read the right way
;; round.
for i in 0..gesture-log-size
let ii = (gesture-log-index + i) % gesture-log-size
rl/draw-text(gesture-log[ii], 10, 10 + 410 - i * 20, 20,
if i == 0 then gesture-color else rl/lightgray)
;; The two mode buttons. Maroon means the mode that button controls is on.
let b1 = rl/Rectangle{.x 53.0 .y 7.0 .width 48.0 .height 26.0}
let b2 = rl/Rectangle{.x 108.0 .y 7.0 .width 36.0 .height 26.0}
rl/draw-rectangle-rec(b1,
if log-mode == 1 or log-mode == 3 then rl/maroon else rl/gray)
rl/draw-text("Hide", 60, 10, 10, rl/white)
rl/draw-text("Repeat", 60, 20, 10, rl/white)
rl/draw-rectangle-rec(b2,
if log-mode == 2 or log-mode == 3 then rl/maroon else rl/gray)
rl/draw-text("Hide", 115, 10, 10, rl/white)
rl/draw-text("Hold", 115, 20, 10, rl/white)
fn draw-protractor() -> ()
rl/draw-text("Angle", i32(prot-x) + 55, i32(prot-y) + 76, 10, rl/black)
;; The C's TextFormat/TextFindIndex/TextSubtext dance to get two decimals,
;; in one call. It rounds where the C truncated, so the last digit can
;; differ by one.
d/draw-f32(current-angle, 2, i32(prot-x) + 55, i32(prot-y) + 92, 20,
gesture-color)
rl/draw-circle-v(rl/Vector2{.x prot-x .y prot-y}, 80.0, rl/white)
rl/draw-line-ex(rl/Vector2{.x prot-x - 90.0, .y prot-y},
rl/Vector2{.x prot-x + 90.0, .y prot-y}, 3.0, rl/lightgray)
rl/draw-line-ex(rl/Vector2{.x prot-x, .y prot-y - 90.0},
rl/Vector2{.x prot-x, .y prot-y + 90.0}, 3.0, rl/lightgray)
rl/draw-line-ex(rl/Vector2{.x prot-x - 80.0, .y prot-y - 45.0},
rl/Vector2{.x prot-x + 80.0, .y prot-y + 45.0}, 3.0, rl/green)
rl/draw-line-ex(rl/Vector2{.x prot-x - 80.0, .y prot-y + 45.0},
rl/Vector2{.x prot-x + 80.0, .y prot-y - 45.0}, 3.0, rl/green)
rl/draw-text("0", i32(prot-x) + 96, i32(prot-y) - 9, 20, rl/black)
rl/draw-text("30", i32(prot-x) + 74, i32(prot-y) - 68, 20, rl/black)
rl/draw-text("90", i32(prot-x) - 11, i32(prot-y) - 110, 20, rl/black)
rl/draw-text("150", i32(prot-x) - 100, i32(prot-y) - 68, 20, rl/black)
rl/draw-text("180", i32(prot-x) - 124, i32(prot-y) - 9, 20, rl/black)
rl/draw-text("210", i32(prot-x) - 100, i32(prot-y) + 50, 20, rl/black)
rl/draw-text("270", i32(prot-x) - 18, i32(prot-y) + 92, 20, rl/black)
rl/draw-text("330", i32(prot-x) + 72, i32(prot-y) + 50, 20, rl/black)
;; The needle. The C's +90 puts 0 degrees on the right, and sin feeding x
;; against cos feeding y is what rotates it the way the dial is labelled.
unless(current-angle == 0.0):
let rad = (current-angle + 90.0) * pi / 180.0
rl/draw-line-ex(rl/Vector2{.x prot-x .y prot-y},
rl/Vector2{.x angle-length * sin-f32(rad) + prot-x, .y angle-length * cos-f32(rad) + prot-y},
3.0, gesture-color)
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - input gestures testbed")
defer rl/close-window()
;; A zeroed Color has an alpha of 0 and is invisible; the C initialises this
;; to opaque black. Same for the log index, which starts at the SIZE.
gesture-color = rl/black
gesture-log-index = gesture-log-size
log-mode = 1
rl/set-target-fps(60)
let b1 = rl/Rectangle{.x 53.0 .y 7.0 .width 48.0 .height 26.0}
let b2 = rl/Rectangle{.x 108.0 .y 7.0 .width 36.0 .height 26.0}
until rl/window-should-close()
;; Update
let g = rl/get-gesture-detected()
let touch-count = min(max-touch-count, rl/get-touch-point-count())
;; The C filters hold out of the "last gesture" display, because hold
;; repeats every frame and would drown everything else.
if (not g == :gesture-none and not g == :gesture-hold) and not g == previous-gesture
last-gesture = g
;; The two mode buttons toggle one bit each of log-mode.
if rl/is-mouse-button-released(:mouse-left)
let m = rl/get-mouse-position()
if rl/check-collision-point-rec(m, b1)
log-mode =
if log-mode == 3
2
elif log-mode == 2
3
elif log-mode == 1
0
else
1
if rl/check-collision-point-rec(m, b2)
log-mode =
if log-mode == 3
1
elif log-mode == 2
0
elif log-mode == 1
3
else
2
if is-should-log(g)
push-log(g)
;; The protractor reads the pinch angle for a pinch, the drag angle for
;; a swipe, and sits at 0 for everything else. These are the C's
;; `> 255` / `> 15` / `> 0` — see the header for why they are spelled
;; this way.
if is-pinch(g)
current-angle = rl/get-gesture-pinch-angle()
elif is-swipe(g)
current-angle = rl/get-gesture-drag-angle()
elif not g == :gesture-none
current-angle = 0.0
else
()
for i in 0..touch-count
touch-positions[i] = rl/get-touch-position(i)
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-text("*", 165, 12, 10, rl/black)
rl/draw-text("Example optimized for Web/HTML5\non Smartphones with Touch Screen.",
175, 12, 10, rl/black)
rl/draw-text("*", 165, 42, 10, rl/black)
rl/draw-text("While running on Desktop Web Browsers,\ninspect and turn on Touch Emulation.",
175, 42, 10, rl/black)
draw-last-gesture(touch-count)
draw-log()
draw-protractor()
;; The pointer itself: every live touch, or the mouse when there is no
;; touchscreen. The halo is the gesture colour faded, which is what
;; `fade` was bound for.
unless(g == :gesture-none):
if touch-count > 0
for i in 0..touch-count
let p = touch-positions[i]
rl/draw-circle-v(p, 50.0, rl/fade(gesture-color, 0.5))
rl/draw-circle-v(p, 5.0, gesture-color)
;; Two fingers: the line between them, thinner while pinching
;; out, which is the C's only use of the raw 512.
if touch-count == 2
rl/draw-line-ex(touch-positions[0], touch-positions[1],
if g == :gesture-pinch-out then 8.0 else 12.0,
gesture-color)
else
let m = rl/get-mouse-position()
rl/draw-circle-v(m, 35.0, rl/fade(gesture-color, 0.5))
rl/draw-circle-v(m, 5.0, gesture-color)
-108
View File
@@ -1,108 +0,0 @@
;;;; raylib [core] example - input gestures
;;;;
;;;; examples/core/core_input_gestures.c. Needed `fade`, now bound.
;;;;
;;;; The C keeps `char gestureStrings[20][32]` and TextCopy's a name into the
;;;; next slot. Flan has no mutable character buffer and no way to copy into
;;;; one, but it does not need either: the names are compile-time literals, so
;;;; the log is a `[20 string]` and a slot holds the literal itself. That is
;;;; strictly better than the C — no truncation at 32 bytes, no copy — and it
;;;; is only possible because every string that ever goes into the log is
;;;; known at compile time. A log of strings the program had BUILT could not be
;;;; written at all.
;;;;
;;;; The C's `switch (currentGesture)` over the ten gesture values is a `cond`
;;;; of keyword equalities. `get-gesture-detected` answers an `rl/Gesture` and
;;;; `(= g :tap)` compares against a member by name, checked at compile time —
;;;; so a typo here is an error and the C's `default: break` has nothing to
;;;; catch.
;;;;
;;;; A slot that has not been written yet holds a zero-length string, because a
;;;; `defvar` with no initialiser is all-bytes-zero and a string is ptr+len —
;;;; a null pointer with a length of 0. draw-text draws nothing for it. That is
;;;; the C's `{ "" }` initialiser arriving by a different route, and it is why
;;;; resetting the log below only has to reset the counter.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defconst max-gesture-strings 20)
(defvar gesture-log [max-gesture-strings string])
(defvar gestures-count i32)
(defvar current-gesture rl/Gesture)
(defvar last-gesture rl/Gesture)
;; The C's switch, as a function. `:else` is its `default:` — an unnamed
;; gesture logs the empty string, which draws nothing, which is what falling
;; through the C's switch without a TextCopy leaves in the slot.
(defn gesture-name [g rl/Gesture] string
(cond
(= g :tap) "GESTURE TAP"
(= g :double-tap) "GESTURE DOUBLETAP"
(= g :hold) "GESTURE HOLD"
(= g :drag) "GESTURE DRAG"
(= g :swipe-right) "GESTURE SWIPE RIGHT"
(= g :swipe-left) "GESTURE SWIPE LEFT"
(= g :swipe-up) "GESTURE SWIPE UP"
(= g :swipe-down) "GESTURE SWIPE DOWN"
(= g :pinch-in) "GESTURE PINCH IN"
(= g :pinch-out) "GESTURE PINCH OUT"
:else ""))
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - input gestures")
(defer (rl/close-window))
(rl/set-target-fps 60)
(let [touch-area (rl/Rectangle {.x 220.0 .y 10.0
.width (- (f32 screen-width) 230.0)
.height (- (f32 screen-height) 20.0)})]
(until (rl/window-should-close?)
;; Update
(set last-gesture current-gesture)
(set current-gesture (rl/get-gesture-detected))
(let [touch (rl/get-touch-position 0)]
(when (and (rl/collision-point-rec? touch touch-area)
(not (= current-gesture :none))
(not (= current-gesture last-gesture)))
(set (at gesture-log gestures-count) (gesture-name current-gesture))
(set gestures-count (+ gestures-count 1))
;; Full: start over. The stale slots below the counter are never
;; drawn, so they do not have to be cleared the way the C clears
;; them — the loop below is bounded by gestures-count.
(when (>= gestures-count max-gesture-strings)
(set gestures-count 0)))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-rectangle-rec touch-area rl/gray)
(rl/draw-rectangle 225 15 (- screen-width 240) (- screen-height 30)
rl/raywhite)
(rl/draw-text "GESTURES TEST AREA" (- screen-width 270)
(- screen-height 40) 20 (rl/fade rl/gray 0.5))
(dotimes [i gestures-count]
(if (= 0 (% i 2))
(rl/draw-rectangle 10 (+ 30 (* 20 i)) 200 20
(rl/fade rl/lightgray 0.5))
(rl/draw-rectangle 10 (+ 30 (* 20 i)) 200 20
(rl/fade rl/lightgray 0.3)))
;; The newest entry in maroon and the rest in dark grey, as the C
;; has it.
(rl/draw-text (at gesture-log i) 35 (+ 36 (* 20 i)) 10
(if (< i (- gestures-count 1)) rl/darkgray rl/maroon)))
(rl/draw-rectangle-lines 10 29 200 (- screen-height 50) rl/gray)
(rl/draw-text "DETECTED GESTURES" 50 15 10 rl/gray)
(unless (= current-gesture :none)
(rl/draw-circle-v touch 30.0 rl/maroon)))))))
+106
View File
@@ -0,0 +1,106 @@
;;;; raylib [core] example - input gestures
;;;;
;;;; examples/core/core_input_gestures.c. Needed `fade`, now bound.
;;;;
;;;; The C keeps `char gestureStrings[20][32]` and TextCopy's a name into the
;;;; next slot. Flan has no mutable character buffer and no way to copy into
;;;; one, but it does not need either: the names are compile-time literals, so
;;;; the log is a `[20 string]` and a slot holds the literal itself. That is
;;;; strictly better than the C — no truncation at 32 bytes, no copy — and it
;;;; is only possible because every string that ever goes into the log is
;;;; known at compile time. A log of strings the program had BUILT could not be
;;;; written at all.
;;;;
;;;; The C's `switch (currentGesture)` over the ten gesture values is a `cond`
;;;; of keyword equalities. `get-gesture-detected` answers an `rl/Gesture` and
;;;; `(= g :gesture-tap)` compares against a member by name, checked at
;;;; compile time — so a typo here is an error and the C's `default: break`
;;;; has nothing to catch.
;;;;
;;;; A slot that has not been written yet holds a zero-length string, because a
;;;; `once` with no initialiser is all-bytes-zero and a string is ptr+len —
;;;; a null pointer with a length of 0. draw-text draws nothing for it. That is
;;;; the C's `{ "" }` initialiser arriving by a different route, and it is why
;;;; resetting the log below only has to reset the counter.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
const max-gesture-strings = 20
once gesture-log: [max-gesture-strings str]
once gestures-count: i32
once current-gesture: rl/Gesture
once last-gesture: rl/Gesture
;; The C's switch, as a function. `:else` is its `default:` — an unnamed
;; gesture logs the empty string, which draws nothing, which is what falling
;; through the C's switch without a TextCopy leaves in the slot.
fn gesture-name(g: rl/Gesture) -> str
if g == :gesture-tap
"GESTURE TAP"
elif g == :gesture-double-tap
"GESTURE DOUBLETAP"
elif g == :gesture-hold
"GESTURE HOLD"
elif g == :gesture-drag
"GESTURE DRAG"
elif g == :gesture-swipe-right
"GESTURE SWIPE RIGHT"
elif g == :gesture-swipe-left
"GESTURE SWIPE LEFT"
elif g == :gesture-swipe-up
"GESTURE SWIPE UP"
elif g == :gesture-swipe-down
"GESTURE SWIPE DOWN"
elif g == :gesture-pinch-in
"GESTURE PINCH IN"
elif g == :gesture-pinch-out
"GESTURE PINCH OUT"
else
""
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - input gestures")
defer rl/close-window()
rl/set-target-fps(60)
let touch-area = rl/Rectangle{.x 220.0, .y 10.0, .width f32(screen-width) - 230.0, .height f32(screen-height) - 20.0}
until rl/window-should-close()
;; Update
last-gesture = current-gesture
current-gesture = rl/get-gesture-detected()
let touch = rl/get-touch-position(0)
if rl/check-collision-point-rec(touch, touch-area) and not current-gesture == :gesture-none and not current-gesture == last-gesture
gesture-log[gestures-count] = gesture-name(current-gesture)
gestures-count += 1
;; Full: start over. The stale slots below the counter are never
;; drawn, so they do not have to be cleared the way the C clears
;; them — the loop below is bounded by gestures-count.
if gestures-count >= max-gesture-strings
gestures-count = 0
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-rectangle-rec(touch-area, rl/gray)
rl/draw-rectangle(225, 15, screen-width - 240, screen-height - 30,
rl/raywhite)
rl/draw-text("GESTURES TEST AREA", screen-width - 270, screen-height - 40,
20, rl/fade(rl/gray, 0.5))
for i in 0..gestures-count
if 0 == i % 2
rl/draw-rectangle(10, 30 + 20 * i, 200, 20,
rl/fade(rl/lightgray, 0.5))
else
rl/draw-rectangle(10, 30 + 20 * i, 200, 20,
rl/fade(rl/lightgray, 0.3))
;; The newest entry in maroon and the rest in dark grey, as the C
;; has it.
rl/draw-text(gesture-log[i], 35, 36 + 20 * i, 10,
if i < gestures-count - 1 then rl/darkgray else rl/maroon)
rl/draw-rectangle-lines(10, 29, 200, screen-height - 50, rl/gray)
rl/draw-text("DETECTED GESTURES", 50, 15, 10, rl/gray)
unless(current-gesture == :gesture-none):
rl/draw-circle-v(touch, 30.0, rl/maroon)
-46
View File
@@ -1,46 +0,0 @@
;;;; raylib [core] example - input keys
;;;;
;;;; examples/core/core_input_keys.c. Faithful, and the only thing worth noting
;;;; is a Flan rule rather than a raylib one.
;;;;
;;;; The C moves the ball by writing `ballPosition.x += 2.0f` on a local
;;;; struct. Flan has the same thing — a local IS an assignable place
;;;; (spec-memory.md) — but the local has to be a `defvar` here rather than a
;;;; `let` inside the loop, because a `let` binding is rebound every iteration
;;;; and the position has to survive between frames. The C's variable is
;;;; outside its while loop for the same reason; this is that, spelled with the
;;;; storage the language has.
;;;;
;;;; `(set (.x ball) ...)` works on a struct field of a global: a field of an
;;;; assignable place is itself one.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defvar ball rl/Vector2)
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - input keys")
(defer (rl/close-window))
(set ball (rl/Vector2 {.x (f32 (/ screen-width 2))
.y (f32 (/ screen-height 2))}))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update. key-down? and not key-pressed?: this is meant to repeat for as
;; long as the key is held, which is the whole difference between the two.
(when (rl/key-down? :right) (set (.x ball) (+ (.x ball) 2.0)))
(when (rl/key-down? :left) (set (.x ball) (- (.x ball) 2.0)))
(when (rl/key-down? :up) (set (.y ball) (- (.y ball) 2.0)))
(when (rl/key-down? :down) (set (.y ball) (+ (.y ball) 2.0)))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-text "move the ball with arrow keys" 10 10 20 rl/darkgray)
(rl/draw-circle-v ball 50.0 rl/maroon))))
+45
View File
@@ -0,0 +1,45 @@
;;;; raylib [core] example - input keys
;;;;
;;;; examples/core/core_input_keys.c. Faithful, and the only thing worth noting
;;;; is a Flan rule rather than a raylib one.
;;;;
;;;; The C moves the ball by writing `ballPosition.x += 2.0f` on a local
;;;; struct. Flan has the same thing — a local IS an assignable place
;;;; (spec-memory.md) — but the local has to be a `once` here rather than a
;;;; `let` inside the loop, because a `let` binding is rebound every iteration
;;;; and the position has to survive between frames. The C's variable is
;;;; outside its while loop for the same reason; this is that, spelled with the
;;;; storage the language has.
;;;;
;;;; `ball.x = ...` works on a struct field of a global: a field of an
;;;; assignable place is itself one.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
once ball: rl/Vector2
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - input keys")
defer rl/close-window()
ball = rl/Vector2{.x f32(screen-width / 2) .y f32(screen-height / 2)}
rl/set-target-fps(60)
until rl/window-should-close()
;; Update. is-key-down and not is-key-pressed: this is meant to repeat for as
;; long as the key is held, which is the whole difference between the two.
if rl/is-key-down(:key-right)
ball.x += 2.0
if rl/is-key-down(:key-left)
ball.x -= 2.0
if rl/is-key-down(:key-up)
ball.y -= 2.0
if rl/is-key-down(:key-down)
ball.y += 2.0
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-text("move the ball with arrow keys", 10, 10, 20, rl/darkgray)
rl/draw-circle-v(ball, 50.0, rl/maroon)
@@ -10,45 +10,42 @@
;;;; and the number half of that is now sayable: (string (i64->bytes n)).
;;;; What is not is the "%03" — i64->bytes has no field width — so the label
;;;; is drawn with draw-text and the number with draw-int-padded from
;;;; examples/digits.flan, which draws the leading zeroes itself and then the
;;;; examples/digits.fln, which draws the leading zeroes itself and then the
;;;; number in one call. A number wider than three digits is drawn in full,
;;;; exactly as printf's minimum-width means.
;;;;
;;;; The position is signed and goes negative as soon as the box scrolls past
;;;; the top, which is why draw-int-padded handles a sign at all.
(import rl "vendor:raylib")
(import d "digits.flan")
import rl "vendor:raylib"
(defconst screen-width 800)
(defconst screen-height 450)
import d "digits.fln"
(defconst scroll-speed 4)
const screen-width = 800
const screen-height = 450
(defvar box-y i32)
const scroll-speed = 4
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - input mouse wheel")
(defer (rl/close-window))
once box-y: i32
(set box-y (- (/ screen-height 2) 40))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - input mouse wheel")
defer rl/close-window()
box-y = screen-height / 2 - 40
rl/set-target-fps(60)
until rl/window-should-close()
;; Update. The wheel reading is a per-frame delta, so it is 0.0 on every
;; frame the wheel did not turn and this is a no-op then.
(set box-y (- box-y (i32 (* (rl/get-mouse-wheel-move) (f32 scroll-speed)))))
box-y -= i32(rl/get-mouse-wheel-move() * f32(scroll-speed))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-rectangle (- (/ screen-width 2) 40) box-y 80 80 rl/maroon)
(rl/draw-text "Use mouse wheel to move the cube up and down!" 10 10 20
rl/gray)
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-rectangle(screen-width / 2 - 40, box-y, 80, 80, rl/maroon)
rl/draw-text("Use mouse wheel to move the cube up and down!", 10, 10, 20,
rl/gray)
;; The two halves of the C's one TextFormat call: the literal, measured,
;; and then the number starting where it ended.
(rl/draw-text "Box position Y: " 10 40 20 rl/lightgray)
(d/draw-int-padded box-y 3 (+ 10 (rl/measure-text "Box position Y: " 20))
40 20 rl/lightgray))))
rl/draw-text("Box position Y: ", 10, 40, 20, rl/lightgray)
d/draw-int-padded(box-y, 3, 10 + rl/measure-text("Box position Y: ", 20),
40, 20, rl/lightgray)
-60
View File
@@ -1,60 +0,0 @@
;;;; raylib [core] example - input mouse
;;;;
;;;; examples/core/core_input_mouse.c. Needed three new bindings — show-cursor,
;;;; hide-cursor and cursor-hidden? — which are now in vendor/raylib.
;;;;
;;;; The C's `else if` chain over the seven mouse buttons is a `cond` here.
;;;; That is not a workaround: `cond` is what Flan has and it is the same
;;;; first-match-wins shape, with `:else` where C falls off the end. The order
;;;; matters in both — two buttons pressed on the same frame give the earlier
;;;; one, which is the C's behaviour and not an accident of the port.
;;;;
;;;; The colour has to be a `defvar` rather than a `let`, for the reason
;;;; core-input-keys' position does: it is state between frames.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defvar ball-color rl/Color)
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - input mouse")
(defer (rl/close-window))
;; The C also initialises the ball's POSITION, to {-100,-100} so it starts
;; off-screen. That is dead there and is not here: the first thing the loop
;; does is overwrite it with get-mouse-position, before anything is drawn.
(set ball-color rl/darkblue)
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update
(when (rl/key-pressed? :h)
(if (rl/cursor-hidden?) (rl/show-cursor) (rl/hide-cursor)))
(let [ball (rl/get-mouse-position)]
(set ball-color
(cond
(rl/mouse-button-pressed? :left) rl/maroon
(rl/mouse-button-pressed? :middle) rl/lime
(rl/mouse-button-pressed? :right) rl/darkblue
(rl/mouse-button-pressed? :side) rl/purple
(rl/mouse-button-pressed? :extra) rl/yellow
(rl/mouse-button-pressed? :forward) rl/orange
(rl/mouse-button-pressed? :back) rl/beige
:else ball-color))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-circle-v ball 40.0 ball-color)
(rl/draw-text "move ball with mouse and click mouse button to change color"
10 10 20 rl/darkgray)
(rl/draw-text "Press 'H' to toggle cursor visibility" 10 30 20 rl/darkgray)
(if (rl/cursor-hidden?)
(rl/draw-text "CURSOR HIDDEN" 20 60 20 rl/red)
(rl/draw-text "CURSOR VISIBLE" 20 60 20 rl/lime))))))
+64
View File
@@ -0,0 +1,64 @@
;;;; raylib [core] example - input mouse
;;;;
;;;; examples/core/core_input_mouse.c. Needed three new bindings — show-cursor,
;;;; hide-cursor and is-cursor-hidden — which are now in vendor/raylib.
;;;;
;;;; The C's `else if` chain over the seven mouse buttons is a `cond` here.
;;;; That is not a workaround: `cond` is what Flan has and it is the same
;;;; first-match-wins shape, with `:else` where C falls off the end. The order
;;;; matters in both — two buttons pressed on the same frame give the earlier
;;;; one, which is the C's behaviour and not an accident of the port.
;;;;
;;;; The colour has to be a `once` rather than a `let`, for the reason
;;;; core-input-keys' position does: it is state between frames.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
once ball-color: rl/Color
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - input mouse")
defer rl/close-window()
;; The C also initialises the ball's POSITION, to {-100,-100} so it starts
;; off-screen. That is dead there and is not here: the first thing the loop
;; does is overwrite it with get-mouse-position, before anything is drawn.
ball-color = rl/darkblue
rl/set-target-fps(60)
until rl/window-should-close()
;; Update
if rl/is-key-pressed(:key-h)
if rl/is-cursor-hidden() then rl/show-cursor() else rl/hide-cursor()
let ball = rl/get-mouse-position()
ball-color =
if rl/is-mouse-button-pressed(:mouse-left)
rl/maroon
elif rl/is-mouse-button-pressed(:mouse-middle)
rl/lime
elif rl/is-mouse-button-pressed(:mouse-right)
rl/darkblue
elif rl/is-mouse-button-pressed(:mouse-side)
rl/purple
elif rl/is-mouse-button-pressed(:mouse-extra)
rl/yellow
elif rl/is-mouse-button-pressed(:mouse-forward)
rl/orange
elif rl/is-mouse-button-pressed(:mouse-back)
rl/beige
else
ball-color
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-circle-v(ball, 40.0, ball-color)
rl/draw-text("move ball with mouse and click mouse button to change color",
10, 10, 20, rl/darkgray)
rl/draw-text("Press 'H' to toggle cursor visibility", 10, 30, 20,
rl/darkgray)
if rl/is-cursor-hidden()
rl/draw-text("CURSOR HIDDEN", 20, 60, 20, rl/red)
else
rl/draw-text("CURSOR VISIBLE", 20, 60, 20, rl/lime)
-68
View File
@@ -1,68 +0,0 @@
;;;; raylib [core] example - input multitouch
;;;;
;;;; examples/core/core_input_multitouch.c. No new bindings: the whole touch
;;;; surface was already there for sand.flan's read-out.
;;;;
;;;; The C keeps `Vector2 touchPositions[MAX_TOUCH_POINTS]`, and this is the
;;;; first example that needs a fixed array whose element type is a STRUCT
;;;; rather than a number. That works — `[10 rl/Vector2]` is ten Vector2s laid
;;;; out flat, no headers, and `(at touch-positions i)` is a place that can be
;;;; assigned a whole struct. Nothing in the repository used one before, so it
;;;; is worth saying that it does.
;;;;
;;;; It is a top-level `defvar` and not a local, which is NOT a stylistic
;;;; choice. A `let` binding takes no type annotation, so the only way to make
;;;; a fixed array inside a function is to initialise it from a literal with
;;;; every element written out — ten `(rl/Vector2 {.x 0.0 .y 0.0})`s here, and
;;;; thirty-two in the gestures testbed. A zeroed local array of a given type
;;;; cannot be spelled. Static storage is what the C's `= { 0 }` gets anyway.
;;;;
;;;; On a desktop with no touchscreen get-touch-point-count is 0 and this draws
;;;; nothing at all, which is correct and is also what the C does. The window
;;;; opening and the help text appearing is the whole of what can be checked
;;;; without hardware.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defconst max-touch-points 10)
(defvar touch-positions [max-touch-points rl/Vector2])
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - input multitouch")
(defer (rl/close-window))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update
(let [count (min max-touch-points (rl/get-touch-point-count))]
(dotimes [i count]
(set (at touch-positions i) (rl/get-touch-position i)))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(dotimes [i count]
(let [p (at touch-positions i)]
;; raylib reports (0,0) for a slot that is not being touched, so the
;; C filters on it and so does this. It means a real touch in the
;; very top-left corner is dropped; that is raylib's ambiguity, not
;; something the port introduced.
(when (and (> (.x p) 0.0) (> (.y p) 0.0))
(rl/draw-circle-v p 34.0 rl/orange)
;; The C's TextFormat("%d", i). This used to need examples/digits.flan
;; and a table of one-character strings; (string b) makes the
;; number a string with no instructions, so it is one draw-text and
;; this file imports nothing but raylib.
(rl/draw-text (string (i64->bytes (i64 i)))
(- (i32 (.x p)) 10) (- (i32 (.y p)) 70) 40
rl/black))))
(rl/draw-text "touch the screen at multiple locations to get multiple balls"
10 10 20 rl/darkgray)))))
+65
View File
@@ -0,0 +1,65 @@
;;;; raylib [core] example - input multitouch
;;;;
;;;; examples/core/core_input_multitouch.c. No new bindings: the whole touch
;;;; surface was already there for sand.fln's read-out.
;;;;
;;;; The C keeps `Vector2 touchPositions[MAX_TOUCH_POINTS]`, and this is the
;;;; first example that needs a fixed array whose element type is a STRUCT
;;;; rather than a number. That works — `[10 rl/Vector2]` is ten Vector2s laid
;;;; out flat, no headers, and `touch-positions[i]` is a place that can be
;;;; assigned a whole struct. Nothing in the repository used one before, so it
;;;; is worth saying that it does.
;;;;
;;;; It is a top-level `once` and not a local, which is NOT a stylistic
;;;; choice. A `let` binding takes no type annotation, so the only way to make
;;;; a fixed array inside a function is to initialise it from a literal with
;;;; every element written out — ten `rl/Vector2{.x 0.0 .y 0.0}`s here, and
;;;; thirty-two in the gestures testbed. A zeroed local array of a given type
;;;; cannot be spelled. Static storage is what the C's `= { 0 }` gets anyway.
;;;;
;;;; On a desktop with no touchscreen get-touch-point-count is 0 and this draws
;;;; nothing at all, which is correct and is also what the C does. The window
;;;; opening and the help text appearing is the whole of what can be checked
;;;; without hardware.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
const max-touch-points = 10
once touch-positions: [max-touch-points rl/Vector2]
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - input multitouch")
defer rl/close-window()
rl/set-target-fps(60)
until rl/window-should-close()
;; Update
let count = min(max-touch-points, rl/get-touch-point-count())
for i in 0..count
touch-positions[i] = rl/get-touch-position(i)
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
for i in 0..count
let p = touch-positions[i]
;; raylib reports (0,0) for a slot that is not being touched, so the
;; C filters on it and so does this. It means a real touch in the
;; very top-left corner is dropped; that is raylib's ambiguity, not
;; something the port introduced.
if p.x > 0.0 and p.y > 0.0
rl/draw-circle-v(p, 34.0, rl/orange)
;; The C's TextFormat("%d", i). This used to need examples/digits.fln
;; and a table of one-character strings; (string b) makes the
;; number a string with no instructions, so it is one draw-text and
;; this file imports nothing but raylib.
rl/draw-text(str(i64->bytes(i64(i))), i32(p.x) - 10, i32(p.y) - 70,
40, rl/black)
rl/draw-text("touch the screen at multiple locations to get multiple balls",
10, 10, 20, rl/darkgray)
;; The numbers drawn this frame were formatted into the temp allocator;
;; this hands that memory back once the frame is drawn.
free-temp()
-193
View File
@@ -1,193 +0,0 @@
;;;; raylib [core] example - input virtual controls
;;;;
;;;; examples/core/core_input_virtual_controls.c. No new bindings.
;;;;
;;;; Split the way sand.flan is split: everything above the second banner is
;;;; arithmetic with no raylib calls in it, so test/programs/virtual-controls-
;;;; headless.flan can import this file as a package, drive the pointer over a
;;;; scripted path and hash where the player ended up. That is the only one of
;;;; the ten examples with a headless half, and it has one because it is the
;;;; only one whose interesting part — which D-pad button is under the pointer,
;;;; and what that does to the player — is a function of numbers rather than of
;;;; a frame buffer. The link follows what the program reaches, so the headless
;;;; driver pulls in neither a window nor libraylib.
;;;;
;;;; Two language notes, both visible below.
;;;;
;;;; **No `break`.** The C's nearest-button search is
;;;;
;;;; for (i = 0; i < BUTTON_MAX; i++) { if (...) { pressedButton = i; break; } }
;;;;
;;;; and `(break)` is refused: "break is not implemented yet (see the build
;;;; sequence in plan.org)". `return` from the function works and is what this
;;;; uses — which is why the search is its own `defn` and not written inline
;;;; the way the C has it. That is not a loss: the function is the thing the
;;;; headless case wants to call anyway. Where a loop genuinely cannot be a
;;;; function, the shape left is a flag and a compound loop condition.
;;;;
;;;; **No `fabsf`.** The prelude has sqrt-f32 and sign-f32 but no absolute
;;;; value for floats, and the comment there says why for integers. `(max v
;;;; (- 0.0 v))` is it, and unlike the integer case it has no edge: there is no
;;;; float whose negation is itself except -0.0, whose absolute value is 0.0
;;;; either way.
;;;;
;;;; The button geometry is `defconst` arrays of struct literals. That works —
;;;; and it is the only way a fixed array can be made inside anything but a
;;;; top-level `defvar`, since a `let` binding takes no type annotation.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
;; The C's PadButton enum. -1 is BUTTON_NONE and the four directions are 0..3,
;; in the order the arrays below are written. A `defenum` would not help: the
;; value is used as an index and an enum does not convert to an integer
;; ("i32 converts a number, found ..."), so it would have to be turned back
;; into one at every use.
(defconst button-none -1)
(defconst button-up 0)
(defconst button-left 1)
(defconst button-right 2)
(defconst button-down 3)
(defconst button-max 4)
(defconst button-radius f32 30.0)
;; padPosition is {100, 350} and the buttons sit a radius and a half away from
;; it on each axis. The C computes these at run time from padPosition; they are
;; written out here because a defconst is compile-time and the numbers are.
(defconst button-positions [button-max rl/Vector2]
[(rl/Vector2 {.x 100.0 .y 305.0}) ; up
(rl/Vector2 {.x 55.0 .y 350.0}) ; left
(rl/Vector2 {.x 145.0 .y 350.0}) ; right
(rl/Vector2 {.x 100.0 .y 395.0})]) ; down
(defconst player-speed f32 75.0)
;;;; ── The part with no raylib in it ──────────────────────────────────
(defvar player rl/Vector2)
(defn reset-player [] ()
(set player (rl/Vector2 {.x (/ (f32 screen-width) 2.0)
.y (/ (f32 screen-height) 2.0)})))
(defn abs-f32 [v f32] f32
(max v (- 0.0 v)))
;; The C's search, with `return` where it had `break`. Manhattan distance and
;; not Euclidean — the C adds the two axis distances and compares against the
;; radius — so the hit area is a diamond, which is deliberate: it makes the
;; four buttons tile without overlapping.
(defn nearest-button [pos rl/Vector2] i32
(dotimes [i button-max]
(let [b (at button-positions i)
dist-x (abs-f32 (- (.x b) (.x pos)))
dist-y (abs-f32 (- (.y b) (.y pos)))]
(when (< (+ dist-x dist-y) button-radius)
(return i))))
button-none)
;; The C's switch on the pressed button. Nothing moves for button-none, which
;; is the `default: break`.
(defn move-player [button i32 dt f32] ()
(let [step (* player-speed dt)]
(cond
(= button button-up) (set (.y player) (- (.y player) step))
(= button button-left) (set (.x player) (- (.x player) step))
(= button button-right) (set (.x player) (+ (.x player) step))
(= button button-down) (set (.y player) (+ (.y player) step))
:else (do))))
;; FNV-1a over the player's two floats, so the headless driver has one number
;; to compare. The floats are read through their bit patterns rather than
;; rounded to integers: rounding would hide exactly the kind of drift — a
;; crossed x and y, a step applied twice — the case exists to catch.
(defconst fnv-offset u64 0xcbf29ce484222325)
(defconst fnv-prime u64 1099511628211)
(defn hash-f32 [h u64 v f32] u64
;; No float-to-bits cast in the language, so the value is scaled and
;; truncated instead. 1024 keeps three decimal places of a screen
;; coordinate, which is finer than any real difference between two correct
;; runs and coarser than the last bit of an f32 — so this is reproducible
;; across targets where a raw bit pattern would depend on the FPU.
(let [n (i64 (* v 1024.0))
g h]
(dotimes [b 8]
(set g (bit-xor g (u64 (bit-and (>> n (i64 (* b 8))) 255))))
(set g (* g fnv-prime)))
g))
(defn hash-player [] u64
(hash-f32 (hash-f32 fnv-offset (.x player)) (.y player)))
;;;; ── The raylib front-end ───────────────────────────────────────────
;; The arrowheads, one triangle of three points per button, in the winding
;; raylib wants — counter-clockwise, or it culls them and draws nothing. A
;; [4 [3 rl/Vector2]]: a fixed array of fixed arrays of a struct, which is the
;; deepest shape any of these ten examples asks for and which works.
(defconst arrow-tris [button-max [3 rl/Vector2]]
[[(rl/Vector2 {.x 100.0 .y 293.0})
(rl/Vector2 {.x 91.0 .y 314.0})
(rl/Vector2 {.x 109.0 .y 314.0})]
[(rl/Vector2 {.x 64.0 .y 341.0})
(rl/Vector2 {.x 43.0 .y 350.0})
(rl/Vector2 {.x 64.0 .y 359.0})]
[(rl/Vector2 {.x 157.0 .y 350.0})
(rl/Vector2 {.x 136.0 .y 341.0})
(rl/Vector2 {.x 136.0 .y 359.0})]
[(rl/Vector2 {.x 91.0 .y 386.0})
(rl/Vector2 {.x 100.0 .y 407.0})
(rl/Vector2 {.x 109.0 .y 386.0})]])
(defconst label-colors [button-max rl/Color]
[(rl/Color {.r 253 .g 249 .b 0 .a 255}) ; yellow, up
(rl/Color {.r 0 .g 121 .b 241 .a 255}) ; blue, left
(rl/Color {.r 230 .g 41 .b 55 .a 255}) ; red, right
(rl/Color {.r 0 .g 228 .b 48 .a 255})]) ; green, down
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - input virtual controls")
(defer (rl/close-window))
(reset-player)
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update. Touch first, mouse as the desktop stand-in — and on the desktop
;; the left button has to be held, or the player would follow the cursor
;; wherever it went.
(let [touching (> (rl/get-touch-point-count) 0)
input (if touching
(rl/get-touch-position 0)
(rl/get-mouse-position))
pressed (if (or touching (rl/mouse-button-down? :left))
(nearest-button input)
button-none)]
(move-player pressed (rl/get-frame-time))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-circle-v player 50.0 rl/maroon)
(dotimes [i button-max]
(rl/draw-circle-v (at button-positions i) button-radius
(if (= i pressed) rl/darkgray rl/black))
(let [t (at arrow-tris i)]
(rl/draw-triangle (at t 0) (at t 1) (at t 2)
(at label-colors i))))
(rl/draw-text "move the player with D-Pad buttons" 10 10 20 rl/darkgray)
;; Not in the C: which button the search picked, as a number, so the
;; headless case and the window agree about the same thing.
(rl/draw-text "button: " 10 34 20 rl/lightgray)
(rl/draw-text (string (i64->bytes (i64 pressed)))
(+ 10 (rl/measure-text "button: " 20)) 34 20
rl/lightgray)))))
+185
View File
@@ -0,0 +1,185 @@
;;;; raylib [core] example - input virtual controls
;;;;
;;;; examples/core/core_input_virtual_controls.c. No new bindings.
;;;;
;;;; Split the way sand.fln is split: everything above the second banner is
;;;; arithmetic with no raylib calls in it, so test/programs/virtual-controls-
;;;; headless.flan can import this file as a package, drive the pointer over a
;;;; scripted path and hash where the player ended up. That is the only one of
;;;; the ten examples with a headless half, and it has one because it is the
;;;; only one whose interesting part — which D-pad button is under the pointer,
;;;; and what that does to the player — is a function of numbers rather than of
;;;; a frame buffer. The link follows what the program reaches, so the headless
;;;; driver pulls in neither a window nor libraylib.
;;;;
;;;; Two language notes, both visible below.
;;;;
;;;; **No `break`.** The C's nearest-button search is
;;;;
;;;; for (i = 0; i < BUTTON_MAX; i++) { if (...) { pressedButton = i; break; } }
;;;;
;;;; and `break()` is refused: "break is not implemented yet (see the build
;;;; sequence in plan.org)". `return` from the function works and is what this
;;;; uses — which is why the search is its own `defn` and not written inline
;;;; the way the C has it. That is not a loss: the function is the thing the
;;;; headless case wants to call anyway. Where a loop genuinely cannot be a
;;;; function, the shape left is a flag and a compound loop condition.
;;;;
;;;; **No `fabsf`.** The prelude has sqrt-f32 and sign-f32 but no absolute
;;;; value for floats, and the comment there says why for integers.
;;;; `max(v, 0.0 - v)` is it, and unlike the integer case it has no edge: there
;;;; is no float whose negation is itself except -0.0, whose absolute value is
;;;; 0.0 either way.
;;;;
;;;; The button geometry is `const` arrays of struct literals. That works —
;;;; and it is the only way a fixed array can be made inside anything but a
;;;; top-level `once`, since a `let` binding takes no type annotation.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
;; The C's PadButton enum. -1 is BUTTON_NONE and the four directions are 0..3,
;; in the order the arrays below are written. An `enum` would not help: the
;; value is used as an index and an enum does not convert to an integer
;; ("i32 converts a number, found ..."), so it would have to be turned back
;; into one at every use.
const button-none = -1
const button-up = 0
const button-left = 1
const button-right = 2
const button-down = 3
const button-max = 4
const button-radius: f32 = 30.0
;; padPosition is {100, 350} and the buttons sit a radius and a half away from
;; it on each axis. The C computes these at run time from padPosition; they are
;; written out here because a const is compile-time and the numbers are.
const button-positions: [button-max rl/Vector2] = [rl/Vector2{.x 100.0 .y 305.0} ; up
; left
; right
; down
rl/Vector2{.x 55.0 .y 350.0}
rl/Vector2{.x 145.0 .y 350.0}
rl/Vector2{.x 100.0 .y 395.0}]
const player-speed: f32 = 75.0
;;;; ── The part with no raylib in it ──────────────────────────────────
once player: rl/Vector2
fn reset-player() -> ()
player = rl/Vector2{.x f32(screen-width) / 2.0, .y f32(screen-height) / 2.0}
fn abs-f32(v: f32) -> f32 = max(v, 0.0 - v)
;; The C's search, with `return` where it had `break`. Manhattan distance and
;; not Euclidean — the C adds the two axis distances and compares against the
;; radius — so the hit area is a diamond, which is deliberate: it makes the
;; four buttons tile without overlapping.
fn nearest-button(pos: rl/Vector2) -> i32
for i in 0..button-max
let b = button-positions[i]
dist-x = abs-f32(b.x - pos.x)
dist-y = abs-f32(b.y - pos.y)
if dist-x + dist-y < button-radius
return i
button-none
;; The C's switch on the pressed button. Nothing moves for button-none, which
;; is the `default: break`.
fn move-player(button: i32, dt: f32) -> ()
let step = player-speed * dt
if button == button-up
player.y -= step
elif button == button-left
player.x -= step
elif button == button-right
player.x += step
elif button == button-down
player.y += step
else
()
;; FNV-1a over the player's two floats, so the headless driver has one number
;; to compare. The floats are read through their bit patterns rather than
;; rounded to integers: rounding would hide exactly the kind of drift — a
;; crossed x and y, a step applied twice — the case exists to catch.
const fnv-offset: u64 = 0xcbf29ce484222325
const fnv-prime: u64 = 1099511628211
fn hash-f32(h: u64, v: f32) -> u64
;; No float-to-bits cast in the language, so the value is scaled and
;; truncated instead. 1024 keeps three decimal places of a screen
;; coordinate, which is finer than any real difference between two correct
;; runs and coarser than the last bit of an f32 — so this is reproducible
;; across targets where a raw bit pattern would depend on the FPU.
let n = i64(v * 1024.0)
g = h
for b in 0..8
g = g ^ u64(n >> i64(b * 8) & 255)
g *%= fnv-prime
g
fn hash-player() -> u64 = hash-f32(hash-f32(fnv-offset, player.x), player.y)
;;;; ── The raylib front-end ───────────────────────────────────────────
;; The arrowheads, one triangle of three points per button, in the winding
;; raylib wants — counter-clockwise, or it culls them and draws nothing. A
;; [4 [3 rl/Vector2]]: a fixed array of fixed arrays of a struct, which is the
;; deepest shape any of these ten examples asks for and which works.
const arrow-tris: [button-max [3 rl/Vector2]] = [[rl/Vector2{.x 100.0 .y 293.0} rl/Vector2{.x 91.0 .y 314.0} rl/Vector2{.x 109.0 .y 314.0}]
[rl/Vector2{.x 64.0 .y 341.0} rl/Vector2{.x 43.0 .y 350.0} rl/Vector2{.x 64.0 .y 359.0}]
[rl/Vector2{.x 157.0 .y 350.0} rl/Vector2{.x 136.0 .y 341.0} rl/Vector2{.x 136.0 .y 359.0}]
[rl/Vector2{.x 91.0 .y 386.0} rl/Vector2{.x 100.0 .y 407.0} rl/Vector2{.x 109.0 .y 386.0}]]
const label-colors: [button-max rl/Color] = [rl/Color{.r 253 .g 249 .b 0 .a 255} ; yellow, up
; blue, left
; red, right
; green, down
rl/Color{.r 0 .g 121 .b 241 .a 255}
rl/Color{.r 230 .g 41 .b 55 .a 255}
rl/Color{.r 0 .g 228 .b 48 .a 255}]
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - input virtual controls")
defer rl/close-window()
reset-player()
rl/set-target-fps(60)
until rl/window-should-close()
;; Update. Touch first, mouse as the desktop stand-in — and on the desktop
;; the left button has to be held, or the player would follow the cursor
;; wherever it went.
let touching = rl/get-touch-point-count() > 0
let input =
if touching then rl/get-touch-position(0) else rl/get-mouse-position()
let pressed =
if touching or rl/is-mouse-button-down(:mouse-left)
nearest-button(input)
else
button-none
move-player(pressed, rl/get-frame-time())
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-circle-v(player, 50.0, rl/maroon)
for i in 0..button-max
rl/draw-circle-v(button-positions[i], button-radius,
if i == pressed then rl/darkgray else rl/black)
let t = arrow-tris[i]
rl/draw-triangle(t[0], t[1], t[2], label-colors[i])
rl/draw-text("move the player with D-Pad buttons", 10, 10, 20,
rl/darkgray)
;; Not in the C: which button the search picked, as a number, so the
;; headless case and the window agree about the same thing.
rl/draw-text("button: ", 10, 34, 20, rl/lightgray)
rl/draw-text(str(i64->bytes(i64(pressed))),
10 + rl/measure-text("button: ", 20), 34, 20, rl/lightgray)
;; The number drawn this frame was formatted into the temp allocator;
;; this hands that memory back once the frame is drawn.
free-temp()
@@ -1,8 +1,8 @@
;;;; raylib [core] example - scissor test
;;;;
;;;; examples/core/core_scissor_test.c. Needed begin-scissor-mode and
;;;; end-scissor-mode, now hand-written in vendor/raylib/raylib.flan: they are
;;;; a begin/end pair inside a frame, which is the class PORTING.md §1 says
;;;; end-scissor-mode, now hand-written in vendor/raylib/raylib.fln: they are
;;;; a begin/end pair inside a frame, which is the class docs/PORTING.md §1 says
;;;; belongs in the hand-written half and not in the importer's. get-mouse-x
;;;; and get-mouse-y come from the generated half — their Flan face is the C
;;;; one, two plain i32s, and there is nothing for a hand-written line to say
@@ -24,47 +24,41 @@
;;;; place — so the only check is the outline drawn around it afterwards
;;;; landing on the revealed patch.
(import rl "vendor:raylib")
import rl "vendor:raylib"
(defconst screen-width 800)
(defconst screen-height 450)
const screen-width = 800
const screen-height = 450
(defvar scissor rl/Rectangle)
(defvar scissor-mode bool)
once scissor: rl/Rectangle
once scissor-mode: bool
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - scissor test")
(defer (rl/close-window))
(set scissor (rl/Rectangle {.x 0.0 .y 0.0 .width 300.0 .height 300.0}))
(set scissor-mode true)
(rl/set-target-fps 60)
(until (rl/window-should-close?)
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - scissor test")
defer rl/close-window()
scissor = rl/Rectangle{.x 0.0 .y 0.0 .width 300.0 .height 300.0}
scissor-mode = true
rl/set-target-fps(60)
until rl/window-should-close()
;; Update
(when (rl/key-pressed? :s) (set scissor-mode (not scissor-mode)))
if rl/is-key-pressed(:key-s)
scissor-mode = not scissor-mode
;; Centre the scissor area on the mouse.
(set (.x scissor) (- (f32 (rl/get-mouse-x)) (/ (.width scissor) 2.0)))
(set (.y scissor) (- (f32 (rl/get-mouse-y)) (/ (.height scissor) 2.0)))
scissor.x = f32(rl/get-mouse-x()) - scissor.width / 2.0
scissor.y = f32(rl/get-mouse-y()) - scissor.height / 2.0
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(when scissor-mode
(rl/begin-scissor-mode (i32 (.x scissor)) (i32 (.y scissor))
(i32 (.width scissor)) (i32 (.height scissor))))
rl/with-drawing:
rl/clear-background(rl/raywhite)
if scissor-mode
rl/begin-scissor-mode(i32(scissor.x), i32(scissor.y),
i32(scissor.width), i32(scissor.height))
;; A full-screen rectangle and a line of text. Only the part inside the
;; scissor rectangle reaches the framebuffer, which is the whole example.
(rl/draw-rectangle 0 0 (rl/get-screen-width) (rl/get-screen-height) rl/red)
(rl/draw-text "Move the mouse around to reveal this text!" 190 200 20
rl/lightgray)
(when scissor-mode (rl/end-scissor-mode))
(rl/draw-rectangle-lines-ex scissor 1.0 rl/black)
(rl/draw-text "Press S to toggle scissor test" 10 10 20 rl/black))))
rl/draw-rectangle(0, 0, rl/get-screen-width(), rl/get-screen-height(),
rl/red)
rl/draw-text("Move the mouse around to reveal this text!", 190, 200, 20,
rl/lightgray)
if scissor-mode
rl/end-scissor-mode()
rl/draw-rectangle-lines-ex(scissor, 1.0, rl/black)
rl/draw-text("Press S to toggle scissor test", 10, 10, 20, rl/black)
-177
View File
@@ -1,177 +0,0 @@
;;;; raylib [core] example - window flags
;;;;
;;;; examples/core/core_window_flags.c. The reason to port this one is the
;;;; flags themselves: raylib.flan carried four of ConfigFlags' sixteen
;;;; members — the four sand.flan sets before the window exists — and this
;;;; example reads and writes eleven of them after it exists. All sixteen are
;;;; in raylib.flan now, read off raylib.h 5.5, in the header's order and not
;;;; in bit order (FLAG_VSYNC_HINT is 0x40 and FLAG_FULLSCREEN_MODE is 0x02,
;;;; which is the kind of thing nobody remembers correctly).
;;;;
;;;; They are `defconst u32`s and not a `defenum` for the reason the file
;;;; already gives about gestures: raylib wants the OR of several and a
;;;; keyword can only ever name one member.
;;;;
;;;; window-state?, set-window-state, clear-window-state, toggle-fullscreen,
;;;; minimize-window, maximize-window and restore-window all come from the
;;;; generated half. That is the line drawn in vendor/raylib/bindings and it
;;;; is worth saying why it falls here: the rule in PORTING.md §1 exists so
;;;; that a build with no header set can still draw, and generated.flan is
;;;; committed, so it can. What a hand-written line adds on top of that is a
;;;; Flan face the C signature does not have — a Key instead of an int, a
;;;; (Ptr Camera3D) instead of a raw pointer — and these seven have no such
;;;; face to add. They are u32 in and bool out in both languages.
;;;;
;;;; Not faithful in one place, deliberately: the C draws twelve lines of
;;;; on/off state through TextFormat and a two-branch if each time. Flan has
;;;; no TextFormat, so the pair is one helper here — which also removes the
;;;; C's own copy-paste bug, where the UNFOCUSED line says "[G]" when it is on
;;;; and "[U]" when it is off.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
;; Frames the window has been hidden or minimized for. H and N both put the
;; window somewhere it cannot be typed at, so the only way back is a count.
(defconst restore-after 240)
(defvar ball-pos rl/Vector2)
(defvar ball-speed rl/Vector2)
(defvar frames i32)
(defconst ball-radius f32 20.0)
;; One line of the state list: the label, then "on" or "off" in lime or
;; maroon, at the x the label ended. The C writes two whole DrawText calls per
;; flag with the text repeated; this says it once.
(defn draw-flag [label string flag u32 y i32] ()
(rl/draw-text label 10 y 10 rl/gray)
(let [x (+ 10 (rl/measure-text label 10))]
(if (rl/window-state? flag)
(rl/draw-text " on" x y 10 rl/lime)
(rl/draw-text " off" x y 10 rl/maroon))))
;; A flag the program owns: pressing the key turns it off if it is on and on
;; if it is off. Nine of the C's eleven key handlers are exactly this.
(defn toggle-flag [flag u32] ()
(if (rl/window-state? flag)
(rl/clear-window-state flag)
(rl/set-window-state flag)))
(defn main [] ()
;; The C leaves its SetConfigFlags call commented out, so that every flag
;; the list shows starts off. Kept that way: the point of the example is
;; which of them can be changed afterwards.
(rl/init-window screen-width screen-height
"raylib [core] example - window flags")
(defer (rl/close-window))
(set ball-pos (rl/Vector2 {.x (/ (f32 (rl/get-screen-width)) 2.0)
.y (/ (f32 (rl/get-screen-height)) 2.0)}))
(set ball-speed (rl/Vector2 {.x 5.0 .y 4.0}))
;; No set-target-fps, as in the C: with FLAG_VSYNC_HINT among the flags the
;; example toggles, a frame limiter on top of it would hide what V-Sync
;; does to the number draw-fps reports.
(until (rl/window-should-close?)
;; Update
(when (rl/key-pressed? :f) (rl/toggle-fullscreen))
(when (rl/key-pressed? :r) (toggle-flag rl/flag-window-resizable))
(when (rl/key-pressed? :d) (toggle-flag rl/flag-window-undecorated))
(when (rl/key-pressed? :u) (toggle-flag rl/flag-window-unfocused))
(when (rl/key-pressed? :t) (toggle-flag rl/flag-window-topmost))
(when (rl/key-pressed? :a) (toggle-flag rl/flag-window-always-run))
(when (rl/key-pressed? :v) (toggle-flag rl/flag-vsync-hint))
;; Hidden and minimized are not toggles: the window they hide is also the
;; window the key would have to be pressed in, so each comes back on a
;; timer instead.
(when (rl/key-pressed? :h)
(unless (rl/window-state? rl/flag-window-hidden)
(rl/set-window-state rl/flag-window-hidden))
(set frames 0))
(when (rl/window-state? rl/flag-window-hidden)
(set frames (+ frames 1))
(when (>= frames restore-after)
(rl/clear-window-state rl/flag-window-hidden)))
(when (rl/key-pressed? :n)
(unless (rl/window-state? rl/flag-window-minimized)
(rl/minimize-window))
(set frames 0))
(when (rl/window-state? rl/flag-window-minimized)
(set frames (+ frames 1))
(when (>= frames restore-after) (rl/restore-window)))
;; Maximize needs FLAG_WINDOW_RESIZABLE first, which is what R is for.
(when (rl/key-pressed? :m)
(if (rl/window-state? rl/flag-window-maximized)
(rl/restore-window)
(rl/maximize-window)))
;; The ball bounces off whatever the window is right now, so resizing,
;; maximizing and going fullscreen all show up in its path.
(set (.x ball-pos) (+ (.x ball-pos) (.x ball-speed)))
(set (.y ball-pos) (+ (.y ball-pos) (.y ball-speed)))
(when (or (>= (.x ball-pos) (- (f32 (rl/get-screen-width)) ball-radius))
(<= (.x ball-pos) ball-radius))
(set (.x ball-speed) (* (.x ball-speed) -1.0)))
(when (or (>= (.y ball-pos) (- (f32 (rl/get-screen-height)) ball-radius))
(<= (.y ball-pos) ball-radius))
(set (.y ball-speed) (* (.y ball-speed) -1.0)))
;; Draw
(rl/with-drawing
;; A transparent framebuffer wants a transparent clear; anything else
;; paints over the desktop the flag exists to show.
(if (rl/window-state? rl/flag-window-transparent)
(rl/clear-background rl/blank)
(rl/clear-background rl/raywhite))
(rl/draw-circle-v ball-pos ball-radius rl/maroon)
(rl/draw-rectangle-lines-ex
(rl/Rectangle {.x 0.0 .y 0.0
.width (f32 (rl/get-screen-width))
.height (f32 (rl/get-screen-height))})
4.0 rl/raywhite)
(rl/draw-circle-v (rl/get-mouse-position) 10.0 rl/darkblue)
(rl/draw-fps 10 10)
;; The C's TextFormat("Screen Size: [%i, %i]", ...), reassembled out of
;; literals and examples/digits.flan the way the other ports do it: `x`
;; walks along the line, and draw-int hands back the width it drew.
(let [x 10]
(rl/draw-text "Screen Size: [" x 40 10 rl/green)
(set x (+ x (rl/measure-text "Screen Size: [" 10)))
(set x (+ x (d/draw-int (rl/get-screen-width) x 40 10 rl/green)))
(rl/draw-text ", " x 40 10 rl/green)
(set x (+ x (rl/measure-text ", " 10)))
(set x (+ x (d/draw-int (rl/get-screen-height) x 40 10 rl/green)))
(rl/draw-text "]" x 40 10 rl/green))
(rl/draw-text "Following flags can be set after window creation:"
10 60 10 rl/gray)
(draw-flag "[F] FLAG_FULLSCREEN_MODE:" rl/flag-fullscreen-mode 80)
(draw-flag "[R] FLAG_WINDOW_RESIZABLE:" rl/flag-window-resizable 100)
(draw-flag "[D] FLAG_WINDOW_UNDECORATED:" rl/flag-window-undecorated 120)
(draw-flag "[H] FLAG_WINDOW_HIDDEN:" rl/flag-window-hidden 140)
(draw-flag "[N] FLAG_WINDOW_MINIMIZED:" rl/flag-window-minimized 160)
(draw-flag "[M] FLAG_WINDOW_MAXIMIZED:" rl/flag-window-maximized 180)
(draw-flag "[U] FLAG_WINDOW_UNFOCUSED:" rl/flag-window-unfocused 200)
(draw-flag "[T] FLAG_WINDOW_TOPMOST:" rl/flag-window-topmost 220)
(draw-flag "[A] FLAG_WINDOW_ALWAYS_RUN:" rl/flag-window-always-run 240)
(draw-flag "[V] FLAG_VSYNC_HINT:" rl/flag-vsync-hint 260)
(rl/draw-text "Following flags can only be set before window creation:"
10 300 10 rl/gray)
(draw-flag "FLAG_WINDOW_HIGHDPI:" rl/flag-window-highdpi 320)
(draw-flag "FLAG_WINDOW_TRANSPARENT:" rl/flag-window-transparent 340)
(draw-flag "FLAG_MSAA_4X_HINT:" rl/flag-msaa-4x-hint 360))))
+168
View File
@@ -0,0 +1,168 @@
;;;; raylib [core] example - window flags
;;;;
;;;; examples/core/core_window_flags.c. The reason to port this one is the
;;;; flags themselves: raylib.fln carried four of ConfigFlags' sixteen
;;;; members — the four sand.fln sets before the window exists — and this
;;;; example reads and writes eleven of them after it exists. All sixteen are
;;;; in raylib.fln now, read off raylib.h 5.5, in the header's order and not
;;;; in bit order (FLAG_VSYNC_HINT is 0x40 and FLAG_FULLSCREEN_MODE is 0x02,
;;;; which is the kind of thing nobody remembers correctly).
;;;;
;;;; They are `const u32`s and not an `enum` for the reason the file
;;;; already gives about gestures: raylib wants the OR of several and a
;;;; keyword can only ever name one member.
;;;;
;;;; is-window-state, set-window-state, clear-window-state, toggle-fullscreen,
;;;; minimize-window, maximize-window and restore-window all come from the
;;;; generated half. That is the line drawn in vendor/raylib/bindings and it
;;;; is worth saying why it falls here: the rule in docs/PORTING.md §1 exists so
;;;; that a build with no header set can still draw, and generated.fln is
;;;; committed, so it can. What a hand-written line adds on top of that is a
;;;; Flan face the C signature does not have — a Key instead of an int, a
;;;; (Ptr Camera3D) instead of a raw pointer — and these seven have no such
;;;; face to add. They are u32 in and bool out in both languages.
;;;;
;;;; Not faithful in one place, deliberately: the C draws twelve lines of
;;;; on/off state through TextFormat and a two-branch if each time. Flan has
;;;; no TextFormat, so the pair is one helper here — which also removes the
;;;; C's own copy-paste bug, where the UNFOCUSED line says "[G]" when it is on
;;;; and "[U]" when it is off.
import rl "vendor:raylib"
import d "digits.fln"
const screen-width = 800
const screen-height = 450
;; Frames the window has been hidden or minimized for. H and N both put the
;; window somewhere it cannot be typed at, so the only way back is a count.
const restore-after = 240
once ball-pos: rl/Vector2
once ball-speed: rl/Vector2
once frames: i32
const ball-radius: f32 = 20.0
;; One line of the state list: the label, then "on" or "off" in lime or
;; maroon, at the x the label ended. The C writes two whole DrawText calls per
;; flag with the text repeated; this says it once.
fn draw-flag(label: str, flag: u32, y: i32) -> ()
rl/draw-text(label, 10, y, 10, rl/gray)
let x = 10 + rl/measure-text(label, 10)
if rl/is-window-state(flag)
rl/draw-text(" on", x, y, 10, rl/lime)
else
rl/draw-text(" off", x, y, 10, rl/maroon)
;; A flag the program owns: pressing the key turns it off if it is on and on
;; if it is off. Nine of the C's eleven key handlers are exactly this.
fn toggle-flag(flag: u32) -> ()
if rl/is-window-state(flag)
rl/clear-window-state(flag)
else
rl/set-window-state(flag)
fn main() -> ()
;; The C leaves its SetConfigFlags call commented out, so that every flag
;; the list shows starts off. Kept that way: the point of the example is
;; which of them can be changed afterwards.
rl/init-window(screen-width, screen-height,
"raylib [core] example - window flags")
defer rl/close-window()
ball-pos = rl/Vector2{.x f32(rl/get-screen-width()) / 2.0, .y f32(rl/get-screen-height()) / 2.0}
ball-speed = rl/Vector2{.x 5.0 .y 4.0}
;; No set-target-fps, as in the C: with FLAG_VSYNC_HINT among the flags the
;; example toggles, a frame limiter on top of it would hide what V-Sync
;; does to the number draw-fps reports.
until rl/window-should-close()
;; Update
if rl/is-key-pressed(:key-f)
rl/toggle-fullscreen()
if rl/is-key-pressed(:key-r)
toggle-flag(rl/flag-window-resizable)
if rl/is-key-pressed(:key-d)
toggle-flag(rl/flag-window-undecorated)
if rl/is-key-pressed(:key-u)
toggle-flag(rl/flag-window-unfocused)
if rl/is-key-pressed(:key-t)
toggle-flag(rl/flag-window-topmost)
if rl/is-key-pressed(:key-a)
toggle-flag(rl/flag-window-always-run)
if rl/is-key-pressed(:key-v)
toggle-flag(rl/flag-vsync-hint)
;; Hidden and minimized are not toggles: the window they hide is also the
;; window the key would have to be pressed in, so each comes back on a
;; timer instead.
if rl/is-key-pressed(:key-h)
unless(rl/is-window-state(rl/flag-window-hidden)):
rl/set-window-state(rl/flag-window-hidden)
frames = 0
if rl/is-window-state(rl/flag-window-hidden)
frames += 1
if frames >= restore-after
rl/clear-window-state(rl/flag-window-hidden)
if rl/is-key-pressed(:key-n)
unless(rl/is-window-state(rl/flag-window-minimized)):
rl/minimize-window()
frames = 0
if rl/is-window-state(rl/flag-window-minimized)
frames += 1
if frames >= restore-after
rl/restore-window()
;; Maximize needs FLAG_WINDOW_RESIZABLE first, which is what R is for.
if rl/is-key-pressed(:key-m)
if rl/is-window-state(rl/flag-window-maximized)
rl/restore-window()
else
rl/maximize-window()
;; The ball bounces off whatever the window is right now, so resizing,
;; maximizing and going fullscreen all show up in its path.
ball-pos.x += ball-speed.x
ball-pos.y += ball-speed.y
if ball-pos.x >= f32(rl/get-screen-width()) - ball-radius or ball-pos.x <= ball-radius
ball-speed.x *= -1.0
if ball-pos.y >= f32(rl/get-screen-height()) - ball-radius or ball-pos.y <= ball-radius
ball-speed.y *= -1.0
;; Draw
rl/with-drawing:
;; A transparent framebuffer wants a transparent clear; anything else
;; paints over the desktop the flag exists to show.
if rl/is-window-state(rl/flag-window-transparent)
rl/clear-background(rl/blank)
else
rl/clear-background(rl/raywhite)
rl/draw-circle-v(ball-pos, ball-radius, rl/maroon)
rl/draw-rectangle-lines-ex(rl/Rectangle{.x 0.0 .y 0.0 .width f32(rl/get-screen-width()) .height f32(rl/get-screen-height())},
4.0, rl/raywhite)
rl/draw-circle-v(rl/get-mouse-position(), 10.0, rl/darkblue)
rl/draw-fps(10, 10)
;; The C's TextFormat("Screen Size: [%i, %i]", ...), reassembled out of
;; literals and examples/digits.fln the way the other ports do it: `x`
;; walks along the line, and draw-int hands back the width it drew.
let x = 10
rl/draw-text("Screen Size: [", x, 40, 10, rl/green)
x += rl/measure-text("Screen Size: [", 10)
x += d/draw-int(rl/get-screen-width(), x, 40, 10, rl/green)
rl/draw-text(", ", x, 40, 10, rl/green)
x += rl/measure-text(", ", 10)
x += d/draw-int(rl/get-screen-height(), x, 40, 10, rl/green)
rl/draw-text("]", x, 40, 10, rl/green)
rl/draw-text("Following flags can be set after window creation:", 10, 60,
10, rl/gray)
draw-flag("[F] FLAG_FULLSCREEN_MODE:", rl/flag-fullscreen-mode, 80)
draw-flag("[R] FLAG_WINDOW_RESIZABLE:", rl/flag-window-resizable, 100)
draw-flag("[D] FLAG_WINDOW_UNDECORATED:", rl/flag-window-undecorated, 120)
draw-flag("[H] FLAG_WINDOW_HIDDEN:", rl/flag-window-hidden, 140)
draw-flag("[N] FLAG_WINDOW_MINIMIZED:", rl/flag-window-minimized, 160)
draw-flag("[M] FLAG_WINDOW_MAXIMIZED:", rl/flag-window-maximized, 180)
draw-flag("[U] FLAG_WINDOW_UNFOCUSED:", rl/flag-window-unfocused, 200)
draw-flag("[T] FLAG_WINDOW_TOPMOST:", rl/flag-window-topmost, 220)
draw-flag("[A] FLAG_WINDOW_ALWAYS_RUN:", rl/flag-window-always-run, 240)
draw-flag("[V] FLAG_VSYNC_HINT:", rl/flag-vsync-hint, 260)
rl/draw-text("Following flags can only be set before window creation:",
10, 300, 10, rl/gray)
draw-flag("FLAG_WINDOW_HIGHDPI:", rl/flag-window-highdpi, 320)
draw-flag("FLAG_WINDOW_TRANSPARENT:", rl/flag-window-transparent, 340)
draw-flag("FLAG_MSAA_4X_HINT:", rl/flag-msaa-4x-hint, 360)
-66
View File
@@ -1,66 +0,0 @@
;;;; raylib [core] example - window should close
;;;;
;;;; examples/core/core_window_should_close.c. Needed set-exit-key, and one
;;;; enum member to go with it: raylib.flan's `Key` now has `null 0`, read off
;;;; KEY_NULL in raylib.h. The binding is hand-written rather than taken from
;;;; the generated half because the Flan face differs — raylib declares
;;;; `void SetExitKey(int key)` and the generated line therefore takes an i32,
;;;; where this one takes a Key, so `(rl/set-exit-key :null)` is checked
;;;; against the enum and `:nul` is a compile error instead of a 0.
;;;;
;;;; The whole example is about what window-should-close? means. It is not a
;;;; flag raylib latches: it is "the close button was clicked, or the exit key
;;;; is down", recomputed each frame, and it goes back to false on its own.
;;;; That is what lets this program ask for confirmation and then carry on —
;;;; and it is also why the loop is driven by a `defvar` of its own rather
;;;; than by the predicate, which is the one structural difference from every
;;;; other example in this directory.
;;;;
;;;; `(set-exit-key :null)` takes ESC away from raylib so the program can read
;;;; it itself. The window's X button still works and is still what
;;;; window-should-close? reports.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defvar exit-requested bool)
(defvar exiting bool)
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - window should close")
(defer (rl/close-window))
;; No key closes the window any more. ESC is an ordinary key from here on.
(rl/set-exit-key :null)
(rl/set-target-fps 60)
(until exiting
;; Update. Either way of asking to leave raises the question; only Y and N
;; answer it. window-should-close? is false again on the next frame, so
;; the request has to be remembered in a variable.
(when (or (rl/window-should-close?) (rl/key-pressed? :escape))
(set exit-requested true))
;; The C's `if (Y) ... else if (N) ...`, which is an `if` with a `when`
;; in its else and not a `cond`: a `cond` needs an `:else` arm and there
;; is nothing to do when neither key was pressed.
(when exit-requested
(if (rl/key-pressed? :y)
(set exiting true)
(when (rl/key-pressed? :n) (set exit-requested false))))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(if exit-requested
(do
(rl/draw-rectangle 0 100 screen-width 200 rl/black)
(rl/draw-text "Are you sure you want to exit program? [Y/N]" 40 180 30
rl/white))
(rl/draw-text "Try to close the window to get confirmation message!"
120 200 20 rl/lightgray)))))
+61
View File
@@ -0,0 +1,61 @@
;;;; raylib [core] example - window should close
;;;;
;;;; examples/core/core_window_should_close.c. Needed set-exit-key, and one
;;;; enum member to go with it: raylib.fln's `Key` now has `null 0`, read off
;;;; KEY_NULL in raylib.h. The binding is hand-written rather than taken from
;;;; the generated half because the Flan face differs — raylib declares
;;;; `void SetExitKey(int key)` and the generated line therefore takes an i32,
;;;; where this one takes a Key, so `rl/set-exit-key(:key-null)` is checked
;;;; against the enum and `:nul` is a compile error instead of a 0.
;;;;
;;;; The whole example is about what window-should-close means. It is not a
;;;; flag raylib latches: it is "the close button was clicked, or the exit key
;;;; is down", recomputed each frame, and it goes back to false on its own.
;;;; That is what lets this program ask for confirmation and then carry on —
;;;; and it is also why the loop is driven by a `once` of its own rather
;;;; than by the predicate, which is the one structural difference from every
;;;; other example in this directory.
;;;;
;;;; `set-exit-key(:null)` takes ESC away from raylib so the program can read
;;;; it itself. The window's X button still works and is still what
;;;; window-should-close reports.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
once exit-requested: bool
once exiting: bool
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - window should close")
defer rl/close-window()
;; No key closes the window any more. ESC is an ordinary key from here on.
rl/set-exit-key(:key-null)
rl/set-target-fps(60)
until exiting
;; Update. Either way of asking to leave raises the question; only Y and N
;; answer it. window-should-close is false again on the next frame, so
;; the request has to be remembered in a variable.
if rl/window-should-close() or rl/is-key-pressed(:key-escape)
exit-requested = true
;; The C's `if (Y) ... else if (N) ...`, which is an `if` with a `when`
;; in its else and not a `cond`: a `cond` needs an `:else` arm and there
;; is nothing to do when neither key was pressed.
if exit-requested
if rl/is-key-pressed(:key-y)
exiting = true
elif rl/is-key-pressed(:key-n)
exit-requested = false
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
if exit-requested
rl/draw-rectangle(0, 100, screen-width, 200, rl/black)
rl/draw-text("Are you sure you want to exit program? [Y/N]", 40, 180,
30, rl/white)
else
rl/draw-text("Try to close the window to get confirmation message!",
120, 200, 20, rl/lightgray)
-111
View File
@@ -1,111 +0,0 @@
;;;; raylib [core] example - world to screen
;;;;
;;;; examples/core/core_world_screen.c, and the first thing in this directory
;;;; to draw in 3D at all. That is why it is here: before it, vendor/raylib
;;;; described no Vector3, no Camera3D and not one call that takes either, so
;;;; the importer refused every 3D function in raylib.h by name — "Vector3 is
;;;; a struct the package does not describe". Two defstructs later it refuses
;;;; none of them, and the generated half grew the cubes, spheres, cylinders
;;;; and billboards along with the eight lines this example needs.
;;;;
;;;; Hand-written in raylib.flan rather than generated, and why each:
;;;;
;;;; begin-mode-3d / end-mode-3d a begin/end pair inside a frame, the
;;;; class PORTING.md §1 names, and taken
;;;; together the way begin-mode-2d is
;;;; draw-cube / draw-cube-wires / draw-grid drawn every frame
;;;; update-camera the Flan face differs: (Ptr Camera3D),
;;;; because it mutates, and a CameraMode
;;;; rather than the header's int
;;;; get-world-to-screen it reads the same camera as the mode
;;;;
;;;; What is NOT pinned by anything, and cannot be: Camera3D's first three
;;;; fields are all Vector3, so every permutation of position, target and up
;;;; has the identical layout and no computed test can tell them apart. A
;;;; camera looking from the wrong place is a picture, not a number. The same
;;;; is true of Vector3's own x, y and z. Only the screen catches those.
;;;;
;;;; update-camera in :third-person mode also takes the mouse, which is why
;;;; the C calls DisableCursor: the cursor is locked to the window and its
;;;; movement becomes camera rotation rather than a pointer. disable-cursor
;;;; comes from the generated half — it is called once, before the loop.
;;;;
;;;; The C's two TextFormats go through examples/digits.flan, as in the other
;;;; ports, because Flan has no TextFormat and i64->bytes has no field width.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
(defvar camera rl/Camera3D)
(defvar cube rl/Vector3)
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [core] example - core world screen")
(defer (rl/close-window))
;; `projection` is a CameraProjection, so the keyword resolves against the
;; enum's members right here and a misspelt projection is a compile error
;; rather than a 0. raylib's field is `int` and the layout is unchanged —
;; an enum is four bytes either way.
(set camera
(rl/Camera3D {.position (rl/Vector3 {.x 10.0 .y 10.0 .z 10.0})
.target (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0})
.up (rl/Vector3 {.x 0.0 .y 1.0 .z 0.0})
.fovy 45.0
.projection :perspective}))
(set cube (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0}))
;; The mouse becomes the camera's, not a pointer's.
(rl/disable-cursor)
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update. The camera is passed by address because update-camera writes
;; to it — a global is an assignable place, so it has an address to take.
(rl/update-camera (addr camera) :third-person)
;; Where the point 2.5 units above the cube lands on the screen. This is
;; the whole example: the same transform raylib is about to draw with,
;; asked in advance, so a label can be put on a thing in the world.
(let [label-pos (rl/get-world-to-screen
(rl/Vector3 {.x (.x cube)
.y (+ (.y cube) 2.5)
.z (.z cube)})
camera)]
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/with-mode-3d camera
(rl/draw-cube cube 2.0 2.0 2.0 rl/red)
(rl/draw-cube-wires cube 2.0 2.0 2.0 rl/maroon)
(rl/draw-grid 10 1.0))
;; And the 2D half, drawn after end-mode-3d and therefore on top of the
;; cube whatever the depth buffer says — which is the second half of
;; what the example demonstrates.
(rl/draw-text "Enemy: 100 / 100"
(- (i32 (.x label-pos))
(/ (rl/measure-text "Enemy: 100/100" 20) 2))
(i32 (.y label-pos)) 20 rl/black)
(let [x 10]
(rl/draw-text "Cube position in screen space coordinates: [" x 10 20
rl/lime)
(set x (+ x (rl/measure-text
"Cube position in screen space coordinates: [" 20)))
(set x (+ x (d/draw-int (i32 (.x label-pos)) x 10 20 rl/lime)))
(rl/draw-text ", " x 10 20 rl/lime)
(set x (+ x (rl/measure-text ", " 20)))
(set x (+ x (d/draw-int (i32 (.y label-pos)) x 10 20 rl/lime)))
(rl/draw-text "]" x 10 20 rl/lime))
(rl/draw-text "Text 2d should be always on top of the cube" 10 40 20
rl/gray)))))
+91
View File
@@ -0,0 +1,91 @@
;;;; raylib [core] example - world to screen
;;;;
;;;; examples/core/core_world_screen.c, and the first thing in this directory
;;;; to draw in 3D at all. That is why it is here: before it, vendor/raylib
;;;; described no Vector3, no Camera3D and not one call that takes either, so
;;;; the importer refused every 3D function in raylib.h by name — "Vector3 is
;;;; a struct the package does not describe". Two defstructs later it refuses
;;;; none of them, and the generated half grew the cubes, spheres, cylinders
;;;; and billboards along with the eight lines this example needs.
;;;;
;;;; Hand-written in raylib.fln rather than generated, and why each:
;;;;
;;;; begin-mode-3d / end-mode-3d a begin/end pair inside a frame, the
;;;; class docs/PORTING.md §1 names, and taken
;;;; together the way begin-mode-2d is
;;;; draw-cube / draw-cube-wires / draw-grid drawn every frame
;;;; update-camera the Flan face differs: (Ptr Camera3D),
;;;; because it mutates, and a CameraMode
;;;; rather than the header's int
;;;; get-world-to-screen it reads the same camera as the mode
;;;;
;;;; What is NOT pinned by anything, and cannot be: Camera3D's first three
;;;; fields are all Vector3, so every permutation of position, target and up
;;;; has the identical layout and no computed test can tell them apart. A
;;;; camera looking from the wrong place is a picture, not a number. The same
;;;; is true of Vector3's own x, y and z. Only the screen catches those.
;;;;
;;;; update-camera in :camera-third-person mode also takes the mouse, which
;;;; is why the C calls DisableCursor: the cursor is locked to the window and
;;;; its movement becomes camera rotation rather than a pointer. disable-cursor
;;;; comes from the generated half — it is called once, before the loop.
;;;;
;;;; The C's two TextFormats go through examples/digits.fln, as in the other
;;;; ports, because Flan has no TextFormat and i64->bytes has no field width.
import rl "vendor:raylib"
import d "digits.fln"
const screen-width = 800
const screen-height = 450
once camera: rl/Camera3D
once cube: rl/Vector3
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [core] example - core world screen")
defer rl/close-window()
;; `projection` is a CameraProjection, so the keyword resolves against the
;; enum's members right here and a misspelt projection is a compile error
;; rather than a 0. raylib's field is `int` and the layout is unchanged —
;; an enum is four bytes either way.
camera = rl/Camera3D{.position rl/Vector3{.x 10.0 .y 10.0 .z 10.0} .target rl/Vector3{.x 0.0 .y 0.0 .z 0.0} .up rl/Vector3{.x 0.0 .y 1.0 .z 0.0} .fovy 45.0 .projection :projection-perspective}
cube = rl/Vector3{.x 0.0 .y 0.0 .z 0.0}
;; The mouse becomes the camera's, not a pointer's.
rl/disable-cursor()
rl/set-target-fps(60)
until rl/window-should-close()
;; Update. The camera is passed by address because update-camera writes
;; to it — a global is an assignable place, so it has an address to take.
rl/update-camera(addr(camera), :camera-third-person)
;; Where the point 2.5 units above the cube lands on the screen. This is
;; the whole example: the same transform raylib is about to draw with,
;; asked in advance, so a label can be put on a thing in the world.
let label-pos = rl/get-world-to-screen(rl/Vector3{.x cube.x, .y cube.y + 2.5, .z cube.z},
camera)
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/with-mode-3d(camera):
rl/draw-cube(cube, 2.0, 2.0, 2.0, rl/red)
rl/draw-cube-wires(cube, 2.0, 2.0, 2.0, rl/maroon)
rl/draw-grid(10, 1.0)
;; And the 2D half, drawn after end-mode-3d and therefore on top of the
;; cube whatever the depth buffer says — which is the second half of
;; what the example demonstrates.
rl/draw-text("Enemy: 100 / 100",
i32(label-pos.x) - rl/measure-text("Enemy: 100/100", 20) / 2,
i32(label-pos.y), 20, rl/black)
let x = 10
rl/draw-text("Cube position in screen space coordinates: [", x, 10, 20,
rl/lime)
x += rl/measure-text("Cube position in screen space coordinates: [", 20)
x += d/draw-int(i32(label-pos.x), x, 10, 20, rl/lime)
rl/draw-text(", ", x, 10, 20, rl/lime)
x += rl/measure-text(", ", 20)
x += d/draw-int(i32(label-pos.y), x, 10, 20, rl/lime)
rl/draw-text("]", x, 10, 20, rl/lime)
rl/draw-text("Text 2d should be always on top of the cube", 10, 40, 20,
rl/gray)
+56 -60
View File
@@ -7,12 +7,12 @@
;;;; allocator to build one in. So a number was drawn one glyph at a time out
;;;; of a `[10 string]` table.
;;;;
;;;; `(string b)` closed that. It is the mirror of `(bytes s)` and costs no
;;;; `string(b)` closed that. It is the mirror of `bytes-view(s)` and costs no
;;;; instructions — a `string` and a `[u8]` are the same 16-byte %slice — so
;;;; `(string (i64->bytes n))` draws in one call and the table, the per-glyph
;;;; `string(i64->bytes(n))` draws in one call and the table, the per-glyph
;;;; pen and the digit arithmetic behind them are gone.
;;;;
;;;; What is left is the part `(string ...)` does not answer, which is
;;;; What is left is the part `string(...)` does not answer, which is
;;;; *formatting*: `i64->bytes` has no field width, so "%03i" still has to be
;;;; assembled, and `f64->bytes` is `%g` and not "%.02f", so a fixed number of
;;;; decimal places still has to be split and drawn in two pieces. Those two
@@ -24,23 +24,20 @@
;;;; a `char *` into a rotating static buffer, which `declare-c` refuses by
;;;; name anyway.
;;;;
;;;; ONE TRAP, and it is the reason every function below is written as a strict
;;;; sequence of format-draw-measure rather than as a let of several pieces:
;;;; `i64->bytes` and `f64->bytes` both write into a single shared static
;;;; buffer in the runtime, overwritten by the next such call. `(string ...)`
;;;; does not copy it. So a number must be *drawn before the next one is
;;;; formatted* — holding two at once is wrong pixels with no crash and no
;;;; diagnostic.
;;;; `i64->bytes` and `f64->bytes` put their text in the temp allocator, where
;;;; it lasts until the next `free-temp()`. A program drawing these every frame
;;;; calls `free-temp()` once per frame, after drawing, and clones any text it
;;;; keeps longer.
;;;;
;;;; Everything here needs a window: `measure-text` answers 0 for every string
;;;; until init-window has loaded the default font, and a zero advance would
;;;; stack every piece on top of the first.
(import rl "vendor:raylib")
import rl "vendor:raylib"
;; Padding is drawn from a literal, one zero at a time. This is the last of the
;; old glyph table and it is here only because there is no field width.
(defconst zero-glyph "0")
const zero-glyph = "0"
;; One piece of text, and how far the pen moved. raylib's default font is not
;; monospaced — "1" is narrower than "8" — so the advance is measured rather
@@ -48,25 +45,25 @@
;;
;; Nothing may be formatted between the draw and the measure: `s` may be a view
;; of the shared buffer, and both calls have to see the same bytes.
(defn draw-piece [s string x i32 y i32 size i32 color rl/Color] i32
(rl/draw-text s x y size color)
(rl/measure-text s size))
fn draw-piece(s: str, x: i32, y: i32, size: i32, color: rl/Color) -> i32
rl/draw-text(s, x, y, size, color)
rl/measure-text(s, size)
;; How many decimal digits `n` has, for n >= 0. 0 has one. Only the padded
;; forms need it now — it is how many zeroes go in front.
(defn digit-count [n i32] i32
(let [d 1
r (/ n 10)]
(while (> r 0)
(set d (+ d 1))
(set r (/ r 10)))
d))
fn digit-count(n: i32) -> i32
let d = 1
r = n / 10
while r > 0
d += 1
r /= 10
d
(defn pow10 [e i32] i32
(let [p 1]
(dotimes [i e]
(set p (* p 10)))
p))
fn pow10(e: i32) -> i32
let p = 1
for i in 0..e
p *= 10
p
;; The whole number, in one draw-text. Answers the width drawn, so a caller can
;; put something after it — which is how the `TextFormat("%s: %i", ...)` shapes
@@ -76,8 +73,8 @@
;; The sign comes free now: i64->bytes renders "-7" itself, which also retires
;; the old note about i32's most negative value — it is widened to i64 before
;; formatting, so there is no negation to wrap.
(defn draw-int [n i32 x i32 y i32 size i32 color rl/Color] i32
(draw-piece (string (i64->bytes (i64 n))) x y size color))
fn draw-int(n: i32, x: i32, y: i32, size: i32, color: rl/Color) -> i32
draw-piece(str(i64->bytes(i64(n))), x, y, size, color)
;; The same with a fixed minimum number of digits — the C's "%03i". A number
;; wider than `width` is drawn in full rather than truncated, which is what
@@ -87,18 +84,17 @@
;; is still live when it is drawn. A sign goes in front of the padding, as
;; printf's "%03i" does for -7 → "-07"; hence the magnitude is what gets
;; counted and the minus is drawn separately.
(defn draw-int-padded [n i32 width i32 x i32 y i32 size i32 color rl/Color]
i32
(let [cx x
v n]
(when (< v 0)
(set cx (+ cx (draw-piece "-" cx y size color)))
(set v (- 0 v)))
(let [pad (- width (digit-count v))]
(dotimes [i pad]
(set cx (+ cx (draw-piece zero-glyph cx y size color)))))
(set cx (+ cx (draw-int v cx y size color)))
(- cx x)))
fn draw-int-padded(n: i32, width: i32, x: i32, y: i32, size: i32, color: rl/Color) -> i32
let cx = x
v = n
if v < 0
cx += draw-piece("-", cx, y, size, color)
v = 0 - v
let pad = width - digit-count(v)
for i in 0..pad
cx += draw-piece(zero-glyph, cx, y, size, color)
cx += draw-int(v, cx, y, size, color)
cx - x
;; "%.02f" and friends. `places` digits after the point, rounded by adding a
;; half at that scale before the split — so 0.999 at two places is "1.00" and
@@ -113,22 +109,22 @@
;; value asked for at three places and 1e+06 for a large one. The split into
;; two integers is what buys the fixed width, and it also keeps every formatted
;; value drawn before the next one is made.
(defn draw-f32 [v f32 places i32 x i32 y i32 size i32 color rl/Color] i32
(let [cx x
av v]
(when (< av 0.0)
(set cx (+ cx (draw-piece "-" cx y size color)))
(set av (- 0.0 av)))
(let [scale (pow10 places)
;; The rounding and the split happen in one integer so the two halves
;; cannot disagree: rounding them separately is how "0.999" becomes
;; "0.100" — the fraction carries and the whole part does not hear
;; about it.
total (i32 (+ (* av (f32 scale)) 0.5))
whole (/ total scale)
frac (% total scale)]
(set cx (+ cx (draw-int whole cx y size color)))
(when (> places 0)
(set cx (+ cx (draw-piece "." cx y size color)))
(set cx (+ cx (draw-int-padded frac places cx y size color)))))
(- cx x)))
fn draw-f32(v: f32, places: i32, x: i32, y: i32, size: i32, color: rl/Color) -> i32
let cx = x
av = v
if av < 0.0
cx += draw-piece("-", cx, y, size, color)
av = 0.0 - av
let scale = pow10(places)
;; The rounding and the split happen in one integer so the two halves
;; cannot disagree: rounding them separately is how "0.999" becomes
;; "0.100" — the fraction carries and the whole part does not hear
;; about it.
total = i32(av * f32(scale) + 0.5)
whole = total / scale
frac = total % scale
cx += draw-int(whole, cx, y, size, color)
if places > 0
cx += draw-piece(".", cx, y, size, color)
cx += draw-int-padded(frac, places, cx, y, size, color)
cx - x
-129
View File
@@ -1,129 +0,0 @@
;;;; raylib [models] example - box collisions
;;;;
;;;; examples/models/models_box_collisions.c. The previous lane's finding was
;;;; that the whole models category is refused by the importer for want of a
;;;; defstruct, and that a models example is therefore mostly a binding
;;;; exercise. That is true of the twenty that load a Model — but not of this
;;;; one, which is why it is here: nothing in it is a Model, a Mesh or a
;;;; Material. It is two cubes, a sphere and one aggregate the package did not
;;;; describe.
;;;;
;;;; That aggregate is BoundingBox, and it is two Vector3s and nothing else —
;;;; no owned pointer, no array, no lifetime. One `defstruct` in
;;;; vendor/raylib/raylib.flan un-refuses four raylib functions at once
;;;; (CheckCollisionBoxes, CheckCollisionBoxSphere, DrawBoundingBox and
;;;; GetRayCollisionBox), and that is the cheapest widening of the 3D surface
;;;; available anywhere in the tree. Ray and RayCollision went in beside it
;;;; for examples/core-3d-picking.flan and for the same reason.
;;;;
;;;; So the answer to "is a models example just a binding exercise" is: the
;;;; ones that need Model are, and this one is the counterexample. What
;;;; separates them is not the category, it is whether the struct owns memory.
;;;;
;;;; What it also exercises, and what needed hand-writing rather than
;;;; generating: draw-cube-v, draw-sphere and draw-sphere-wires. All three
;;;; were in the generated half already, and all three moved to the
;;;; hand-written one here on `bindings`'s own rule — they are inside a frame,
;;;; in an example that ships in examples/. draw-cube-v in particular was
;;;; named there as generated *because nothing called it*; something calls it
;;;; now.
;;;;
;;;; The collision maths is written twice in the C, once per test, building
;;;; the player's box inline both times. It is a function here for the reason
;;;; the eyes example gives: two copies of the same eight expressions cannot
;;;; be checked against each other by reading them.
(import rl "vendor:raylib")
(defconst screen-width 800)
(defconst screen-height 450)
(defvar camera rl/Camera3D)
(defvar player-position rl/Vector3)
(defvar player-size rl/Vector3)
(defvar player-color rl/Color)
(defvar enemy-box-pos rl/Vector3)
(defvar enemy-box-size rl/Vector3)
(defvar enemy-sphere-pos rl/Vector3)
(defconst enemy-sphere-size f32 1.5)
;; A box centred on `centre` with the given extents. raylib's BoundingBox is
;; min-corner/max-corner, not centre/size, and every collision call in the
;; family takes the corner form — so this is the conversion the C writes out
;; longhand at each of its two call sites.
;;
;; It used to be eight expressions of field arithmetic here too; it is the
;; half-extent subtracted and added now that the package carries vector
;; arithmetic — see vendor/raylib/vector.flan, which is Flan and not C
;; because raymath is `static inline` and has no symbol to bind.
(defn box-around [centre rl/Vector3 size rl/Vector3] rl/BoundingBox
(let [half (rl/v3-scale size 0.5)]
(rl/BoundingBox {.min (rl/v3-sub centre half)
.max (rl/v3-add centre half)})))
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [models] example - box collisions")
(defer (rl/close-window))
;; The C writes this camera as a brace initialiser with a trailing 0 for the
;; projection, which is CAMERA_PERSPECTIVE. Named here, because the defenum
;; exists precisely so that the 0 does not have to be remembered.
(set camera (rl/Camera3D {.position (rl/Vector3 {.x 0.0 .y 10.0 .z 10.0})
.target (rl/Vector3 {.x 0.0 .y 0.0 .z 0.0})
.up (rl/Vector3 {.x 0.0 .y 1.0 .z 0.0})
.fovy 45.0
.projection :perspective}))
(set player-position (rl/Vector3 {.x 0.0 .y 1.0 .z 2.0}))
(set player-size (rl/Vector3 {.x 1.0 .y 2.0 .z 1.0}))
(set player-color rl/green)
(set enemy-box-pos (rl/Vector3 {.x -4.0 .y 1.0 .z 0.0}))
(set enemy-box-size (rl/Vector3 {.x 2.0 .y 2.0 .z 2.0}))
(set enemy-sphere-pos (rl/Vector3 {.x 4.0 .y 0.0 .z 0.0}))
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update. The arrow keys move on the XZ plane, and the C's else-if chain
;; means only one direction applies per frame — diagonal movement is not
;; possible, deliberately.
(cond
(rl/key-down? :right) (set (.x player-position) (+ (.x player-position) 0.2))
(rl/key-down? :left) (set (.x player-position) (- (.x player-position) 0.2))
(rl/key-down? :down) (set (.z player-position) (+ (.z player-position) 0.2))
(rl/key-down? :up) (set (.z player-position) (- (.z player-position) 0.2)))
(let [player-box (box-around player-position player-size)
hit (or (rl/check-collision-boxes player-box
(box-around enemy-box-pos enemy-box-size))
(rl/check-collision-box-sphere player-box enemy-sphere-pos
enemy-sphere-size))]
(set player-color (if hit rl/red rl/green)))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/with-mode-3d camera
(rl/draw-cube enemy-box-pos (.x enemy-box-size) (.y enemy-box-size)
(.z enemy-box-size) rl/gray)
(rl/draw-cube-wires enemy-box-pos (.x enemy-box-size) (.y enemy-box-size)
(.z enemy-box-size) rl/darkgray)
;; draw-sphere is draw-sphere-ex at 16 rings and 16 slices; the wires form
;; takes them because the tessellation is only visible in the wireframe.
(rl/draw-sphere enemy-sphere-pos enemy-sphere-size rl/gray)
(rl/draw-sphere-wires enemy-sphere-pos enemy-sphere-size 16 16 rl/darkgray)
(rl/draw-cube-v player-position player-size player-color)
(rl/draw-grid 10 1.0))
(rl/draw-text "Move player with arrow keys to collide" 220 40 20 rl/gray)
(rl/draw-fps 10 10))))
+113
View File
@@ -0,0 +1,113 @@
;;;; raylib [models] example - box collisions
;;;;
;;;; examples/models/models_box_collisions.c. The previous lane's finding was
;;;; that the whole models category is refused by the importer for want of a
;;;; defstruct, and that a models example is therefore mostly a binding
;;;; exercise. That is true of the twenty that load a Model — but not of this
;;;; one, which is why it is here: nothing in it is a Model, a Mesh or a
;;;; Material. It is two cubes, a sphere and one aggregate the package did not
;;;; describe.
;;;;
;;;; That aggregate is BoundingBox, and it is two Vector3s and nothing else —
;;;; no owned pointer, no array, no lifetime. One `defstruct` in
;;;; vendor/raylib/raylib.fln un-refuses four raylib functions at once
;;;; (CheckCollisionBoxes, CheckCollisionBoxSphere, DrawBoundingBox and
;;;; GetRayCollisionBox), and that is the cheapest widening of the 3D surface
;;;; available anywhere in the tree. Ray and RayCollision went in beside it
;;;; for examples/core-3d-picking.fln and for the same reason.
;;;;
;;;; So the answer to "is a models example just a binding exercise" is: the
;;;; ones that need Model are, and this one is the counterexample. What
;;;; separates them is not the category, it is whether the struct owns memory.
;;;;
;;;; What it also exercises, and what needed hand-writing rather than
;;;; generating: draw-cube-v, draw-sphere and draw-sphere-wires. All three
;;;; were in the generated half already, and all three moved to the
;;;; hand-written one here on `bindings`'s own rule — they are inside a frame,
;;;; in an example that ships in examples/. draw-cube-v in particular was
;;;; named there as generated *because nothing called it*; something calls it
;;;; now.
;;;;
;;;; The collision maths is written twice in the C, once per test, building
;;;; the player's box inline both times. It is a function here for the reason
;;;; the eyes example gives: two copies of the same eight expressions cannot
;;;; be checked against each other by reading them.
import rl "vendor:raylib"
const screen-width = 800
const screen-height = 450
once camera: rl/Camera3D
once player-position: rl/Vector3
once player-size: rl/Vector3
once player-color: rl/Color
once enemy-box-pos: rl/Vector3
once enemy-box-size: rl/Vector3
once enemy-sphere-pos: rl/Vector3
const enemy-sphere-size: f32 = 1.5
;; A box centred on `centre` with the given extents. raylib's BoundingBox is
;; min-corner/max-corner, not centre/size, and every collision call in the
;; family takes the corner form — so this is the conversion the C writes out
;; longhand at each of its two call sites.
;;
;; It used to be eight expressions of field arithmetic here too; it is the
;; half-extent subtracted and added now that the package carries vector
;; arithmetic — see vendor/raylib/vector.fln, which is Flan and not C
;; because raymath is `static inline` and has no symbol to bind.
fn box-around(centre: rl/Vector3, size: rl/Vector3) -> rl/BoundingBox
let half = rl/v3-scale(size, 0.5)
rl/BoundingBox{.min rl/v3-sub(centre, half) .max rl/v3-add(centre, half)}
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [models] example - box collisions")
defer rl/close-window()
;; The C writes this camera as a brace initialiser with a trailing 0 for the
;; projection, which is CAMERA_PERSPECTIVE. Named here, because the enum
;; exists precisely so that the 0 does not have to be remembered.
camera = rl/Camera3D{.position rl/Vector3{.x 0.0 .y 10.0 .z 10.0} .target rl/Vector3{.x 0.0 .y 0.0 .z 0.0} .up rl/Vector3{.x 0.0 .y 1.0 .z 0.0} .fovy 45.0 .projection :projection-perspective}
player-position = rl/Vector3{.x 0.0 .y 1.0 .z 2.0}
player-size = rl/Vector3{.x 1.0 .y 2.0 .z 1.0}
player-color = rl/green
enemy-box-pos = rl/Vector3{.x -4.0 .y 1.0 .z 0.0}
enemy-box-size = rl/Vector3{.x 2.0 .y 2.0 .z 2.0}
enemy-sphere-pos = rl/Vector3{.x 4.0 .y 0.0 .z 0.0}
rl/set-target-fps(60)
until rl/window-should-close()
;; Update. The arrow keys move on the XZ plane, and the C's else-if chain
;; means only one direction applies per frame — diagonal movement is not
;; possible, deliberately.
if rl/is-key-down(:key-right)
player-position.x += 0.2
elif rl/is-key-down(:key-left)
player-position.x -= 0.2
elif rl/is-key-down(:key-down)
player-position.z += 0.2
elif rl/is-key-down(:key-up)
player-position.z -= 0.2
let player-box = box-around(player-position, player-size)
let hit = rl/check-collision-boxes(player-box, box-around(enemy-box-pos, enemy-box-size)) or rl/check-collision-box-sphere(player-box, enemy-sphere-pos, enemy-sphere-size)
player-color = (if hit then rl/red else rl/green)
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/with-mode-3d(camera):
rl/draw-cube(enemy-box-pos, enemy-box-size.x, enemy-box-size.y,
enemy-box-size.z, rl/gray)
rl/draw-cube-wires(enemy-box-pos, enemy-box-size.x, enemy-box-size.y,
enemy-box-size.z, rl/darkgray)
;; draw-sphere is draw-sphere-ex at 16 rings and 16 slices; the wires form
;; takes them because the tessellation is only visible in the wireframe.
rl/draw-sphere(enemy-sphere-pos, enemy-sphere-size, rl/gray)
rl/draw-sphere-wires(enemy-sphere-pos, enemy-sphere-size, 16, 16,
rl/darkgray)
rl/draw-cube-v(player-position, player-size, player-color)
rl/draw-grid(10, 1.0)
rl/draw-text("Move player with arrow keys to collide", 220, 40, 20,
rl/gray)
rl/draw-fps(10, 10)
@@ -7,17 +7,17 @@
;;;; poly draws — in one frame, beside the four the core examples already
;;;; exercise. Every one of them came out of the generated half of the
;;;; bindings and none needed a new line anywhere; that is the result worth
;;;; recording, because the whole point of a committed generated.flan is that
;;;; recording, because the whole point of a committed generated.fln is that
;;;; a build with no FLAN_RAYLIB_H set can draw with all of it.
;;;;
;;;; What it is actually a test of. The gradient calls are the first thing in
;;;; the corpus to pass *two* Colors in one call, and draw-triangle is the
;;;; first to pass three Vector2s. A struct argument crosses the FFI by
;;;; pointer into a generated shim (vendor/raylib/raylib.flan's header note),
;;;; pointer into a generated shim (vendor/raylib/raylib.fln's header note),
;;;; so the arity of that copying is what a call with several small structs in
;;;; a row puts under load. Nothing here can be asserted headless — every line
;;;; needs a GL context — so the check is the link and the screen, which is
;;;; what the Shapes comment in raylib.flan already says about this family.
;;;; what the Shapes comment in raylib.fln already says about this family.
;;;;
;;;; The one deliberate difference from the C: the C writes `screenWidth/4*2`
;;;; and `screenWidth/4.0f*3.0f` for the same column and gets 400 and 600 out
@@ -27,74 +27,63 @@
;;;; parameters of draw-circle, and the poly centre is built from f32
;;;; literals because a Vector2 holds f32.
(import rl "vendor:raylib")
import rl "vendor:raylib"
(defconst screen-width 800)
(defconst screen-height 450)
const screen-width = 800
const screen-height = 450
;; The three columns the C lays the shapes out in. The middle one is
;; screenWidth/4*2 and the right one screenWidth/4*3, which is 400 and 600.
(defconst col-left 160) ; screen-width / 5
(defconst col-mid 400)
(defconst col-right 600)
const col-left = 160 ; screen-width / 5
const col-mid = 400
const col-right = 600
(defvar rotation f32)
once rotation: f32
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [shapes] example - basic shapes drawing")
(defer (rl/close-window))
(set rotation 0.0)
(rl/set-target-fps 60)
(until (rl/window-should-close?)
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [shapes] example - basic shapes drawing")
defer rl/close-window()
rotation = 0.0
rl/set-target-fps(60)
until rl/window-should-close()
;; Update. The polygons spin; nothing else moves.
(set rotation (+ rotation 0.2))
rotation += 0.2
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-text "some basic shapes available on raylib" 20 20 20 rl/darkgray)
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-text("some basic shapes available on raylib", 20, 20, 20,
rl/darkgray)
;; Circles. The gradient one takes an inner and an outer colour and is the
;; first call in the corpus to pass two Colors at once.
(rl/draw-circle col-left 120 35.0 rl/darkblue)
(rl/draw-circle-gradient col-left 220 60.0 rl/green rl/skyblue)
(rl/draw-circle-lines col-left 340 80.0 rl/darkblue)
rl/draw-circle(col-left, 120, 35.0, rl/darkblue)
rl/draw-circle-gradient(col-left, 220, 60.0, rl/green, rl/skyblue)
rl/draw-circle-lines(col-left, 340, 80.0, rl/darkblue)
;; Rectangles. draw-rectangle-gradient-h runs the colour left to right;
;; the -v form runs it top to bottom and the -ex form takes all four
;; corners. The C uses the horizontal one here.
(rl/draw-rectangle (- col-mid 60) 100 120 60 rl/red)
(rl/draw-rectangle-gradient-h (- col-mid 90) 170 180 130 rl/maroon rl/gold)
rl/draw-rectangle(col-mid - 60, 100, 120, 60, rl/red)
rl/draw-rectangle-gradient-h(col-mid - 90, 170, 180, 130, rl/maroon,
rl/gold)
;; raylib draws this with quads internally rather than with lines, which
;; is why its thickness does not follow the line width anywhere.
(rl/draw-rectangle-lines (- col-mid 40) 320 80 60 rl/orange)
rl/draw-rectangle-lines(col-mid - 40, 320, 80, 60, rl/orange)
;; Triangles. raylib wants the three vertices in counter-clockwise order
;; and draws nothing at all for a clockwise one, so the order here is not
;; cosmetic.
(rl/draw-triangle (rl/Vector2 {.x 600.0 .y 80.0})
(rl/Vector2 {.x 540.0 .y 150.0})
(rl/Vector2 {.x 660.0 .y 150.0})
rl/violet)
(rl/draw-triangle-lines (rl/Vector2 {.x 600.0 .y 160.0})
(rl/Vector2 {.x 580.0 .y 230.0})
(rl/Vector2 {.x 620.0 .y 230.0})
rl/darkblue)
rl/draw-triangle(rl/Vector2{.x 600.0 .y 80.0},
rl/Vector2{.x 540.0 .y 150.0},
rl/Vector2{.x 660.0 .y 150.0}, rl/violet)
rl/draw-triangle-lines(rl/Vector2{.x 600.0 .y 160.0},
rl/Vector2{.x 580.0 .y 230.0},
rl/Vector2{.x 620.0 .y 230.0}, rl/darkblue)
;; Polygons, all three at the same centre and the same rotation, at three
;; radii, so the filled hexagon sits inside the two outlines.
(let [centre (rl/Vector2 {.x (f32 col-right) .y 330.0})]
(rl/draw-poly centre 6 80.0 rotation rl/brown)
(rl/draw-poly-lines centre 6 90.0 rotation rl/brown)
(rl/draw-poly-lines-ex centre 6 85.0 rotation 6.0 rl/beige))
let centre = rl/Vector2{.x f32(col-right) .y 330.0}
rl/draw-poly(centre, 6, 80.0, rotation, rl/brown)
rl/draw-poly-lines(centre, 6, 90.0, rotation, rl/brown)
rl/draw-poly-lines-ex(centre, 6, 85.0, rotation, 6.0, rl/beige)
;; The C draws every LINES-based shape together so raylib can batch them
;; into one pass. This last line is part of that comment and not an
;; afterthought, so it stays where the C has it.
(rl/draw-line 18 42 (- screen-width 18) 42 rl/black))))
rl/draw-line(18, 42, screen-width - 18, 42, rl/black)
-127
View File
@@ -1,127 +0,0 @@
;;;; raylib [shapes] example - collision area
;;;;
;;;; examples/shapes/shapes_collision_area.c. Picked for one call:
;;;; get-collision-rec, which is the only binding in the package that takes
;;;; two Rectangles and answers a third. Everything else in the corpus that
;;;; crosses a struct either passes one in (Camera2D, Color) or gets one back
;;;; (get-mouse-position, fade) — this is the first per-frame call doing both
;;;; at once, and the returned struct comes back through the shim's `out`
;;;; pointer rather than in registers, which is a different lowering again.
;;;;
;;;; It is also the only headless-adjacent thing in the file: both
;;;; collision-recs? and get-collision-rec are pure arithmetic over their
;;;; arguments and need no window at all. The acceptance table already asserts
;;;; the pair; what this adds is the same two calls on a frame path with one
;;;; rectangle driven by the mouse, where a permuted Rectangle would put the
;;;; green overlap patch somewhere the two boxes are not.
;;;;
;;;; Both TextFormats go through examples/digits.flan the way the core ports
;;;; do it: draw the literal part, then the number at x plus its width.
(import rl "vendor:raylib")
(import d "digits.flan")
(defconst screen-width 800)
(defconst screen-height 450)
;; The top strip the collision message is drawn into, and the floor box B is
;; not allowed above.
(defconst screen-upper-limit 40)
(defvar box-a rl/Rectangle) ; moves by itself, bounces off the sides
(defvar box-b rl/Rectangle) ; follows the mouse
(defvar box-collision rl/Rectangle) ; their overlap, valid only while touching
(defvar box-a-speed-x i32)
(defvar paused bool)
(defvar collision bool)
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [shapes] example - collision area")
(defer (rl/close-window))
(set box-a (rl/Rectangle {.x 10.0
.y (- (/ (f32 screen-height) 2.0) 50.0)
.width 200.0 .height 100.0}))
(set box-a-speed-x 4)
(set box-b (rl/Rectangle {.x (- (/ (f32 screen-width) 2.0) 30.0)
.y (- (/ (f32 screen-height) 2.0) 30.0)
.width 60.0 .height 60.0}))
;; A fresh Rectangle is four zeroes, which is the C's `= { 0 }`. It is never
;; drawn before the first collision sets it.
(set box-collision (rl/Rectangle {.x 0.0 .y 0.0 .width 0.0 .height 0.0}))
(set paused false)
(set collision false)
(rl/set-target-fps 60)
(until (rl/window-should-close?)
;; Update
(when (not paused)
(set (.x box-a) (+ (.x box-a) (f32 box-a-speed-x))))
;; Bounce at either edge. The test is on the far edge going right and on
;; the near one going left, so the box reverses on whichever it reaches.
(when (or (>= (+ (.x box-a) (.width box-a)) (f32 (rl/get-screen-width)))
(<= (.x box-a) 0.0))
(set box-a-speed-x (* box-a-speed-x -1)))
;; Box B is centred on the mouse and then clamped back inside the play
;; area — which is the window minus the top strip.
(set (.x box-b) (- (f32 (rl/get-mouse-x)) (/ (.width box-b) 2.0)))
(set (.y box-b) (- (f32 (rl/get-mouse-y)) (/ (.height box-b) 2.0)))
(if (>= (+ (.x box-b) (.width box-b)) (f32 (rl/get-screen-width)))
(set (.x box-b) (- (f32 (rl/get-screen-width)) (.width box-b)))
(when (<= (.x box-b) 0.0) (set (.x box-b) 0.0)))
(if (>= (+ (.y box-b) (.height box-b)) (f32 (rl/get-screen-height)))
(set (.y box-b) (- (f32 (rl/get-screen-height)) (.height box-b)))
(when (<= (.y box-b) (f32 screen-upper-limit))
(set (.y box-b) (f32 screen-upper-limit))))
(set collision (rl/collision-recs? box-a box-b))
;; Only meaningful while they touch: raylib answers a zero rectangle for
;; two boxes that do not, and the C leaves the previous one in place
;; rather than reading that back. This follows it.
(when collision (set box-collision (rl/get-collision-rec box-a box-b)))
(when (rl/key-pressed? :space) (set paused (not paused)))
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
(rl/draw-rectangle 0 0 screen-width screen-upper-limit
(if collision rl/red rl/black))
(rl/draw-rectangle-rec box-a rl/gold)
(rl/draw-rectangle-rec box-b rl/blue)
(when collision
;; The overlap itself, drawn over both boxes. This patch landing
;; anywhere other than where the gold and the blue cross is what a
;; permuted Rectangle would look like.
(rl/draw-rectangle-rec box-collision rl/lime)
(rl/draw-text "COLLISION!"
(- (/ (rl/get-screen-width) 2)
(/ (rl/measure-text "COLLISION!" 20) 2))
(- (/ screen-upper-limit 2) 10) 20 rl/black)
;; The C's TextFormat("Collision Area: %i", ...). Both dimensions are
;; truncated to int before multiplying, as the C's two casts do.
(let [x (- (/ (rl/get-screen-width) 2) 100)
y (+ screen-upper-limit 10)]
(set x (+ x (d/draw-piece "Collision Area: " x y 20 rl/black)))
(d/draw-int (* (i32 (.width box-collision)) (i32 (.height box-collision)))
x y 20 rl/black)))
(rl/draw-text "Press SPACE to PAUSE/RESUME" 20 (- screen-height 35) 20
rl/lightgray)
(rl/draw-fps 10 10))))
+104
View File
@@ -0,0 +1,104 @@
;;;; raylib [shapes] example - collision area
;;;;
;;;; examples/shapes/shapes_collision_area.c. Picked for one call:
;;;; get-collision-rec, which is the only binding in the package that takes
;;;; two Rectangles and answers a third. Everything else in the corpus that
;;;; crosses a struct either passes one in (Camera2D, Color) or gets one back
;;;; (get-mouse-position, fade) — this is the first per-frame call doing both
;;;; at once, and the returned struct comes back through the shim's `out`
;;;; pointer rather than in registers, which is a different lowering again.
;;;;
;;;; It is also the only headless-adjacent thing in the file: both
;;;; check-collision-recs and get-collision-rec are pure arithmetic over their
;;;; arguments and need no window at all. The acceptance table already asserts
;;;; the pair; what this adds is the same two calls on a frame path with one
;;;; rectangle driven by the mouse, where a permuted Rectangle would put the
;;;; green overlap patch somewhere the two boxes are not.
;;;;
;;;; Both TextFormats go through examples/digits.fln the way the core ports
;;;; do it: draw the literal part, then the number at x plus its width.
import rl "vendor:raylib"
import d "digits.fln"
const screen-width = 800
const screen-height = 450
;; The top strip the collision message is drawn into, and the floor box B is
;; not allowed above.
const screen-upper-limit = 40
once box-a: rl/Rectangle ; moves by itself, bounces off the sides
once box-b: rl/Rectangle ; follows the mouse
once box-collision: rl/Rectangle ; their overlap, valid only while touching
once box-a-speed-x: i32
once paused: bool
once collision: bool
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [shapes] example - collision area")
defer rl/close-window()
box-a = rl/Rectangle{.x 10.0, .y f32(screen-height) / 2.0 - 50.0, .width 200.0, .height 100.0}
box-a-speed-x = 4
box-b = rl/Rectangle{.x f32(screen-width) / 2.0 - 30.0, .y f32(screen-height) / 2.0 - 30.0, .width 60.0, .height 60.0}
;; A fresh Rectangle is four zeroes, which is the C's `= { 0 }`. It is never
;; drawn before the first collision sets it.
box-collision = rl/Rectangle{.x 0.0 .y 0.0 .width 0.0 .height 0.0}
paused = false
collision = false
rl/set-target-fps(60)
until rl/window-should-close()
;; Update
if not paused
box-a.x += f32(box-a-speed-x)
;; Bounce at either edge. The test is on the far edge going right and on
;; the near one going left, so the box reverses on whichever it reaches.
if box-a.x + box-a.width >= f32(rl/get-screen-width()) or box-a.x <= 0.0
box-a-speed-x *= -1
;; Box B is centred on the mouse and then clamped back inside the play
;; area — which is the window minus the top strip.
box-b.x = f32(rl/get-mouse-x()) - box-b.width / 2.0
box-b.y = f32(rl/get-mouse-y()) - box-b.height / 2.0
if box-b.x + box-b.width >= f32(rl/get-screen-width())
box-b.x = f32(rl/get-screen-width()) - box-b.width
elif box-b.x <= 0.0
box-b.x = 0.0
if box-b.y + box-b.height >= f32(rl/get-screen-height())
box-b.y = f32(rl/get-screen-height()) - box-b.height
elif box-b.y <= f32(screen-upper-limit)
box-b.y = f32(screen-upper-limit)
collision = rl/check-collision-recs(box-a, box-b)
;; Only meaningful while they touch: raylib answers a zero rectangle for
;; two boxes that do not, and the C leaves the previous one in place
;; rather than reading that back. This follows it.
if collision
box-collision = rl/get-collision-rec(box-a, box-b)
if rl/is-key-pressed(:key-space)
paused = not paused
;; Draw
rl/with-drawing:
rl/clear-background(rl/raywhite)
rl/draw-rectangle(0, 0, screen-width, screen-upper-limit,
if collision then rl/red else rl/black)
rl/draw-rectangle-rec(box-a, rl/gold)
rl/draw-rectangle-rec(box-b, rl/blue)
if collision
;; The overlap itself, drawn over both boxes. This patch landing
;; anywhere other than where the gold and the blue cross is what a
;; permuted Rectangle would look like.
rl/draw-rectangle-rec(box-collision, rl/lime)
rl/draw-text("COLLISION!",
rl/get-screen-width() / 2 - rl/measure-text("COLLISION!", 20) / 2,
screen-upper-limit / 2 - 10, 20, rl/black)
;; The C's TextFormat("Collision Area: %i", ...). Both dimensions are
;; truncated to int before multiplying, as the C's two casts do.
let x = rl/get-screen-width() / 2 - 100
y = screen-upper-limit + 10
x += d/draw-piece("Collision Area: ", x, y, 20, rl/black)
d/draw-int(i32(box-collision.width) * i32(box-collision.height), x, y,
20, rl/black)
rl/draw-text("Press SPACE to PAUSE/RESUME", 20, screen-height - 35, 20,
rl/lightgray)
rl/draw-fps(10, 10)
@@ -5,8 +5,8 @@
;;;; subtract two points, take the angle, walk back out along it — and every
;;;; one of those steps is raymath. raymath is `static inline` in
;;;; raymath.h, so Vector2Subtract and Vector2Angle have no symbol in
;;;; libraylib at all and there is nothing for declare-c to bind. NEXT.md
;;;; names this as one of the three open gaps.
;;;; libraylib at all and there is nothing for declare-c to bind. TODO.org,
;;;; "raymath is written in Flan, because static inline has no symbol".
;;;;
;;;; What that costs here is nothing, because the C does not use raymath
;;;; either — it writes the four subtractions out and calls atan2f, cosf and
@@ -18,7 +18,7 @@
;;;; file.
;;;;
;;;; That measurement is what decided the answer, and the answer landed:
;;;; vendor/raylib/vector.flan is raymath written in Flan, since a C shim
;;;; vendor/raylib/vector.fln is raymath written in Flan, since a C shim
;;;; re-exporting the inlines would have bought identical arithmetic at the
;;;; price of a compilation unit and a second place raylib's semantics live.
;;;; The measurement is left standing rather than rewritten away — one
@@ -27,7 +27,7 @@
;;;; for a zero-length vector where a normalise would. One line saved, in a
;;;; file that is nothing but vector maths, is the honest size of the gap.
;;;;
;;;; The raylib call it does exercise is collision-point-circle?, which no
;;;; The raylib call it does exercise is check-collision-point-circle, which no
;;;; ported example had called, on a frame path, with the point coming
;;;; straight out of get-mouse-position — one struct out of raylib and back
;;;; into it untouched.
@@ -37,18 +37,18 @@
;;;; centre, so the iris disc is tangent to the sclera's edge at the extreme
;;;; rather than hanging over it.
(import rl "vendor:raylib")
import rl "vendor:raylib"
(defconst screen-width 800)
(defconst screen-height 450)
const screen-width = 800
const screen-height = 450
(defconst sclera-radius f32 80.0)
(defconst iris-radius f32 24.0)
const sclera-radius: f32 = 80.0
const iris-radius: f32 = 24.0
(defvar sclera-left rl/Vector2)
(defvar sclera-right rl/Vector2)
(defvar iris-left rl/Vector2)
(defvar iris-right rl/Vector2)
once sclera-left: rl/Vector2
once sclera-right: rl/Vector2
once iris-left: rl/Vector2
once iris-right: rl/Vector2
;; One eye's pupil, given where the mouse is and where the eye is. Answers the
;; mouse position unchanged while it is inside the eye, and the point on the
@@ -57,54 +57,45 @@
;; The C repeats this block twice with different variables; it is one function
;; here because the two copies are identical and a difference between them
;; would be invisible.
(defn track [mouse rl/Vector2 centre rl/Vector2] rl/Vector2
(let [limit (- sclera-radius iris-radius)]
(if (rl/collision-point-circle? mouse centre limit)
mouse
;; Outside: the direction from the eye to the mouse, and then `limit`
;; units back out along it. atan2 then cos/sin rather than a normalise,
;; because that is what the C does and because it needs no guard for a
;; zero-length vector — atan2(0,0) is 0 and the pupil sits at the right
;; of the eye, which is unreachable anyway since (0,0) is inside.
(let [d (rl/v2-sub mouse centre)
angle (atan2-f32 (.y d) (.x d))]
(rl/Vector2 {.x (+ (.x centre) (* limit (cos-f32 angle)))
.y (+ (.y centre) (* limit (sin-f32 angle)))})))))
(defn main [] ()
(rl/init-window screen-width screen-height
"raylib [shapes] example - following eyes")
(defer (rl/close-window))
fn track(mouse: rl/Vector2, centre: rl/Vector2) -> rl/Vector2
let limit = sclera-radius - iris-radius
if rl/check-collision-point-circle(mouse, centre, limit)
mouse
;; Outside: the direction from the eye to the mouse, and then `limit`
;; units back out along it. atan2 then cos/sin rather than a normalise,
;; because that is what the C does and because it needs no guard for a
;; zero-length vector — atan2(0,0) is 0 and the pupil sits at the right
;; of the eye, which is unreachable anyway since (0,0) is inside.
else
let d = rl/v2-sub(mouse, centre)
angle = atan2-f32(d.y, d.x)
rl/Vector2{.x centre.x + limit * cos-f32(angle), .y centre.y + limit * sin-f32(angle)}
fn main() -> ()
rl/init-window(screen-width, screen-height,
"raylib [shapes] example - following eyes")
defer rl/close-window()
;; The C reads these off get-screen-width/get-screen-height after the window
;; is open rather than off the constants, and so does this: on a high-DPI
;; display the two can differ.
(set sclera-left (rl/Vector2 {.x (- (/ (f32 (rl/get-screen-width)) 2.0) 100.0)
.y (/ (f32 (rl/get-screen-height)) 2.0)}))
(set sclera-right (rl/Vector2 {.x (+ (/ (f32 (rl/get-screen-width)) 2.0) 100.0)
.y (/ (f32 (rl/get-screen-height)) 2.0)}))
(set iris-left sclera-left)
(set iris-right sclera-right)
(rl/set-target-fps 60)
(until (rl/window-should-close?)
sclera-left = rl/Vector2{.x f32(rl/get-screen-width()) / 2.0 - 100.0, .y f32(rl/get-screen-height()) / 2.0}
sclera-right = rl/Vector2{.x f32(rl/get-screen-width()) / 2.0 + 100.0, .y f32(rl/get-screen-height()) / 2.0}
iris-left = sclera-left
iris-right = sclera-right
rl/set-target-fps(60)
until rl/window-should-close()
;; Update
(let [mouse (rl/get-mouse-position)]
(set iris-left (track mouse sclera-left))
(set iris-right (track mouse sclera-right)))
let mouse = rl/get-mouse-position()
iris-left = track(mouse, sclera-left)
iris-right = track(mouse, sclera-right)
;; Draw
(rl/with-drawing
(rl/clear-background rl/raywhite)
rl/with-drawing:
rl/clear-background(rl/raywhite)
;; White of the eye, iris, pupil — in that order, each over the last.
(rl/draw-circle-v sclera-left sclera-radius rl/lightgray)
(rl/draw-circle-v iris-left iris-radius rl/brown)
(rl/draw-circle-v iris-left 10.0 rl/black)
(rl/draw-circle-v sclera-right sclera-radius rl/lightgray)
(rl/draw-circle-v iris-right iris-radius rl/darkgreen)
(rl/draw-circle-v iris-right 10.0 rl/black)
(rl/draw-fps 10 10))))
rl/draw-circle-v(sclera-left, sclera-radius, rl/lightgray)
rl/draw-circle-v(iris-left, iris-radius, rl/brown)
rl/draw-circle-v(iris-left, 10.0, rl/black)
rl/draw-circle-v(sclera-right, sclera-radius, rl/lightgray)
rl/draw-circle-v(iris-right, iris-radius, rl/darkgreen)
rl/draw-circle-v(iris-right, 10.0, rl/black)
rl/draw-fps(10, 10)
Loaded 100 of 863 files, more files were not shown because too many files have changed in this diff. Show more