From fa2b56ba5a1d588b01052c7d95b067ab67288987 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 18:11:20 +0700 Subject: [PATCH] Empty fn bodies, the pins for all six items, and the FIX.org entry --- FIX.org | 220 ++++++++++++++++++++++++++++++++++++++++ lib/check.ml | 11 ++ lib/parse.ml | 9 +- test/test_acceptance.ml | 36 +++++++ test/test_flan.ml | 82 ++++++++++++++- 5 files changed, 354 insertions(+), 4 deletions(-) diff --git a/FIX.org b/FIX.org index 58df760..ed74150 100644 --- a/FIX.org +++ b/FIX.org @@ -1598,3 +1598,223 @@ because the code is the same. Not fixed here, deliberately: the fix is to catch ~Closed~ in that poll and read it as the program having ended, which is a claim about what those rows mean and belongs to whoever owns them. Flagged rather than patched. + +* Six dogfooding items off DISCUSS.org, 2026-09-20 +Each of these is an author note from a session of writing Flan rather than a +report from a test. They are small and they are unrelated to each other, which +is why they went in one lane: every one of them is a place the language said no +for no reason, or did not have a name it should have had. + +** (when test) with no body, and the family it turned out to belong to +[lib/parse.ml]'s ~when~ required ~body <> []~ and failed "when is (when test +body ...)". The guard is gone and an empty body is the ~Do []~ that ~(do)~ +already means. A ~(when)~ with no test at all is still refused, because there +is nothing to branch on. + +~unless~ is a prelude macro now, not a special form, and carried the same +restriction as ~(< (len args) 2)~. It is ~(< (len args) 1)~, and its +unknown-name report narrowed with it: ~unless-takes-a-test~ rather than +~unless-takes-a-test-and-a-body~, since the body is no longer part of the +claim. + +The author then added the third member of the family, and it turned out to be +two different questions: + +- =(defn foo [bar i32] ())= — a declared return type of () and no body — was + *already legal*, and the refusal for the other case was already the right + one: [Check] says "foo returns i32 but has no body" at the declaration. No + change; both are pinned now, which they were not. +- =(fn [])= was refused by the parser, by the same ~body <> []~ guard ~when~ + had. Dropped. An fn declares no return type, so "legal exactly when the + return type is ()" has to be decided somewhere else, and the position it is + written in is the only thing that knows: check_fn now refuses an empty body + at a non-unit want — "an fn with no body answers (), and this one is in a + position that wants i32". *That refusal is new and it was needed:* without + it the empty body fell straight through check_fn's ~List.rev fbody~ match, + the fn compiled, and the call read a return value nothing had written. So + relaxing the parser here opened a hole that had to be closed in the checker, + which is not true of ~when~ or of ~defn~. + +** () as a unit value in expression position — considered and dropped +The author, 2026-09-20, closing the question DISCUSS.org left open beside +=(rl/with-drawing ())=: making bare ~()~ a unit value in expression position +was considered and is not wanted. Empty forms doing the right thing — the +three above — covers the need that made it look attractive, and ~()~ stays +unspoken-for in value position on purpose, in case the language ever grows +lists: "we might want lists at some point." + +So ~()~ remains the type-position spelling of Unit and nothing else, and the +guard below is written against that rather than around it. + +** (rl/with-drawing ()) — a body that was not written, spelled the second way +[vendor/raylib/modes.flan]'s guards caught zero arguments and not one argument +that was itself ~()~, so the latter was spliced into the expansion verbatim and +the report came out of the middle of the expanded ~do~ saying ~()~ is not an +expression — several forms from anything anyone wrote. All five ~with-*~ macros +now treat a lone ~()~ where the body goes as no body, answering the same +unknown-name they already answered for the missing one. + +Only a lone ~()~, and only in the body position. ~()~ as a camera or a render +target is left to fail on its own: nothing the macro could say about it would +be truer than what the compiler says. + +The macros needed a predicate they did not have. ~form-items~ cannot tell ~()~ +from a symbol — it answers the empty slice for both — so [lib/prelude.ml] grew +~form-empty-list?~, which matches ~Form.List~ and asks its length. + +** (comment ...), built in +A prelude ~defmacro~ answering ~(do)~ and reading none of its arguments, which +is the whole feature: a macro's arguments are raw Form and are never checked as +expressions, so what is inside never has to be a program. The pinned test puts +an unknown function, a wrong arity, ~(+ 1 "two")~ and a field that does not +exist inside one and compiles it. + +The one rule it does obey is the reader's — balanced delimiters, legal tokens — +because reading happens before any macro runs. ~#_~ is the other spelling and +they are not rivals: ~#_~ is the reader's and discards the one form after it, +so it works in argument position; this is a form of its own and takes any +number, which is what a parked block wants. + +** inc/dec, ++/-- +The four the note spells out, in the prelude rather than per project. A word +for the pure pair, C's punctuation for the mutating pair, so ~(inc i)~ in an +argument and ~(++ i)~ as a statement cannot be confused the way C's ~i++~ and +~i+1~ can. + +Generic for free, and verified rather than assumed: the pinned program runs +~inc~ over i8, i16, i32, i64, u8, u16, u32, u64, f32, f64 and a dyn, and prints +the answers. Nothing in the four macros mentions a type, because ~+~ and ~-~ +already work at all of them and a macro has no type to get in the way. + +*The accepted tradeoff, documented at the definition:* ~(++ PLACE)~ expands to +~(set PLACE (+ PLACE 1))~, so the place is read once and written once and is +therefore *evaluated twice*. Free for a variable, a field or a deref. Not free +for ~(at arr (next-index))~: ~next-index~ runs twice and the read and the write +land on different elements. Not fixable here — macros are non-hygienic by +decision, and a macro cannot bind a temporary for a *place* without a reference +type the language does not have. rl/with-drawing and rl/with-mode-2d already +take the same trade on their arguments. + +The note's four macros have no arity guard, and they needed one: ~(inc)~ would +have indexed past the end of its own argument slice and failed inside the +compiler rather than saying anything about the program. Each guards on +~(!= (len args) 1)~ — both too few and too many — and each is pinned. + +** Type-limit constants +[lib/prelude.ml] gained i8/i16/i32/i64 and u8/u16/u32/u64 max and min, and +f32/f64 max, min-positive and epsilon. Kebab and the type's own name, following +~ns-per-second~: ~i32-max~, not ~INT_MAX~. Each carries its type, so ~i32-max~ +where a u8 is wanted is a type error rather than a silent 255. + +The u*-min constants are all zero and are all there. A family with a hole in it +is worse than four lines that say nothing surprising. + +There is no ~f32-min~, and the absence is the design. A float's least value is +the negation of its greatest and needs no constant; what a caller reaching for +"min" actually wants is the smallest positive one, which is a different number +entirely. Naming either of them ~f32-min~ would put the collision at the worst +possible place, so the name says which it is: ~f32-min-positive~, the smallest +*normal* value, as Rust's MIN_POSITIVE does. + +*u64-max is written in hex and has to be.* The reader parses a decimal integer +through ~Int64.of_string~, and 18446744073709551615 does not fit one; +~0xFFFFFFFFFFFFFFFF~ is read as the 64-bit pattern it names, which is what a +u64 literal is here — [Check.in_range] accepts any pattern at 64 bits unsigned +for exactly this reason. i64-min's decimal *does* fit, being i64's own least +value, so it is written the ordinary way. + +*Every value is pinned against an independent derivation, not against itself.* +A wrong constant compiles — that is the whole hazard — so +[test/programs/limits.flan] does not compare any constant to the way the +prelude spells it. The integers are printed, and the expected text in +test_acceptance is the decimal spelling written out from the definition of each +type; an integer's decimal rendering is exact, so that comparison is the whole +value. The floats cannot be pinned that way, because printing one is snprintf +"%g" and 3.40282e+38 is equally true of f32-max and of a neighbourhood around +it — so each is *derived* by exact power-of-two arithmetic and compared for +equality. Every step of those derivations is exact in IEEE-754, and the two +that are not powers of two have representable operands and a representable +product: + +| constant | derivation | bit pattern | +|------------------+-------------------------------+--------------------| +| f32-epsilon | 2^-23 | 0x34000000 | +| f64-epsilon | 2^-52 | 0x3CB0000000000000 | +| f32-min-positive | 2^-126 | 0x00800000 | +| f64-min-positive | 2^-1022 | 0x0010000000000000 | +| f32-max | (2 - 2^-23) * 2^127 | 0x7F7FFFFF | +| f64-max | (2 - 2^-52) * 2^1023 | 0x7FEFFFFFFFFFFFFF | + +and each epsilon additionally against the property its name promises — adding +it to 1.0 moves, adding half of it does not — and each max against there being +nothing finite above it, since doubling one overflows to an infinity. + +The program runs on *both backends*, and that is not ceremony: materialising a +full-width u64 immediate and an f64 bit pattern is a different job in LLVM and +in the hand-written x86 backend, and a lowering that truncated one would print +a number this row catches and nothing else in the suite does. Both print +identical text. + +*No infinity or NaN constant, and none is possible to write down.* The reader +has no literal for either. ~(/ 1.0 0.0)~ is the only route to an infinity +today, and under the defconst-is-const rule decided the same day it is not one +a defconst can take: the folding pass is integers only, so a float division is +a computed initialiser and refused by name. So an ~f64-infinity~ defconst is +not available without either a reader literal or a second folder, and neither +is this lane's. Recorded, not added. The *runtime* test for one is in the +prelude already and limits.flan reuses it: an infinity is the value that equals +its own double and is not zero. + +** {.row .col} — and the collision the note said was not there +DISCUSS.org: "No obvious grammar collision — nothing currently matches a bare +.field symbol on its own." *That is false*, and it was worth checking before +relying on it. [dmap]'s pair arm takes any pattern in head position, and +[destructure]'s first arm accepts any ~Sym~ as a name — a dotted one included. +So before this change: + +- =(let [{.x .y} p] ...)= parsed, as "bind a local called ~.x~ to field ~y~", + and the program failed later with "unknown name x" pointing at the *use*. + Verified against the compiler, not reasoned about. +- an odd number of bare fields hit the ~[odd]~ arm and was refused, which is + where test_flan's =rejects_check "a field name with no pattern before it"= + came from. + +So the dot in head position did have a meaning; it was just never a useful one. +The new arm is checked *before* the pair arm and takes both readings away. The +~[odd]~ arm survives for the case it was actually written for — a plain name +with nothing after it, ~{a}~ — and that test is now two: the old one inverted +to an ~accepts~, and a new one on ~{a}~. + +Where it works: ~let~, and nowhere else, which is where ~{name .field}~ works +today. Destructuring binds in ~let~ only — a defn parameter, an fn parameter, a +dotimes counter and *a match arm's binds* all take a plain name and are refused +by [no_pattern] — so "let and match positions" from the brief has only one half +to satisfy. The shorthand inherits that rule rather than changing it. + +An unknown field gets the named form's refusal unchanged, because it is the +same field access underneath: "Point has no field z" with the declared_note +listing the fields there are. + +** Pinned +- test_flan.ml: ~(when c)~ parses to if + empty do; ~(when)~ still refused; + ~(fn [])~ parses with an empty body; ~(fn)~ still refused; a defn returning + () with no body accepted and one returning i32 refused; an fn with no body + accepted at a ~(Fn [] ())~ want and refused at a ~(Fn [] i32)~ one; the + ~{.x .y}~ shorthand accepted plain, mixed with a pair, and nested; ~{.z}~ + refused by field name; ~{.x}~ inverted from a refusal to an ~accepts~; ~{a}~ + refused. +- test/programs/prelude-macros.flan, plain and -O0: ~comment~ with four + different kinds of garbage in it; inc/dec over eleven types; ++/-- over a + local, a field, an element and a deref; empty ~when~ and ~unless~ bodies. +- test/programs/limits.flan, plain, -O0 and --x86: every constant, as above. +- test/programs/destructure.flan gained ~shorthand~ and ~shorthand-mixed~ + rows, so the shorthand is in the program that is the destructuring test. +- test_acceptance.ml: the arity guard of every new macro, by the name it + answers, plus ~unless~'s narrowed one. + +** Note for the concurrent lanes +The diagnostics lane owns check.ml's message strings. This lane added *one* new +message at a *new* site — check_fn's empty-body refusal — and rewrote none. The +parse.ml edits are structural: a dropped guard in ~when~, a dropped guard in +~fn~, a new arm at the top of ~dmap~. Expect a rebase, not a conflict of +intent. diff --git a/lib/check.ml b/lib/check.ml index 179bb35..d77b7cd 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -2938,6 +2938,17 @@ and check_fn ctx ~want loc (params : string list) body = answer, and it has to be the declared return type. *) let fbody = match List.rev fbody with + (* An fn with no body answers unit, the same as a defn whose declared + return type is () and whose body is empty. Unlike a defn it declares no + return type of its own, so there is nothing here to contradict — but + the *position* names one, and a position wanting a value is the case + [Check] has to refuse. Without this the empty body would simply fall + through and the call would read a return value nothing ever wrote. *) + | [] when not (Types.equal ret Types.Unit) -> + fail loc + "an fn with no body answers (), and this one is in a position that \ + wants %s — write the value it should answer" + (Types.to_string ret) | [] -> fbody | last :: rest -> List.rev (expect fctx last.Tast.loc ~want:(Some ret) last :: rest) diff --git a/lib/parse.ml b/lib/parse.ml index 563f2aa..f2d0a8a 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -438,9 +438,16 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = (* ── binding and control: never a call ─────────────────────────── *) (* A form that binds a name or alters control flow cannot fall through to Call — it would parse cleanly and mean the wrong thing, silently. *) + (* An empty body is allowed here too, and means the same as it does in + [when] and in a [defn]: the body is [Do []] and the function answers + unit. A [defn] can only have one when its declared return type is (), + because there is a type written down to contradict — [Check] refuses + "returns i32 but has no body". An [fn] declares nothing, so an empty body + is not in conflict with anything: it makes the answer type unit rather + than failing to produce a value of some other one. *) | Sym "fn" -> (match args with - | { v = Vec ps; _ } :: body when body <> [] -> + | { v = Vec ps; _ } :: body -> List.iter no_pattern ps; mk (Ast.Fn (List.map sym ps, body_of body)) | _ -> fail f "fn is (fn [param ...] body ...)") diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 1f4c0bb..078ff6a 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -3484,6 +3484,42 @@ level "1" print_endline "FAIL the report does not say which macro" end); + (* Every prelude macro's arity guard, said the only way a macro can say + anything: a call to a name nothing defines, reported at the call site. + The point of pinning these is that without a guard the macro would + index past the end of its own argument slice, and the failure would be + a bounds trap inside the compiler rather than a message about the + program. Each row therefore asserts the *name*, which is the sentence + the author actually reads. *) + let macro_arity name src needle = + match + Check.program (Parse.program (Reader.read_all ~file:"" src)) + with + | _ -> + incr failures; + Printf.printf "FAIL %s\n it was accepted\n" name + | exception Loc.Error { Loc.dmsg = m; _ } -> + if not (contains m needle) then begin + incr failures; + Printf.printf "FAIL %s\n said: %S\n wanted: %S in it\n" + name m needle + end + in + macro_arity "inc with no argument" + "(defn main [] i32 (inc))" "inc-takes-one-number"; + macro_arity "inc with two arguments" + "(defn main [] i32 (inc 1 2))" "inc-takes-one-number"; + macro_arity "dec with no argument" + "(defn main [] i32 (dec))" "dec-takes-one-number"; + macro_arity "++ with no argument" + "(defn main [] i32 (++) 0)" "++-takes-one-place"; + macro_arity "-- with two arguments" + "(defn main [] i32 (let [a 1 b 2] (-- a b)) 0)" "---takes-one-place"; + (* unless keeps a guard, and it is now the narrower one: a body may be + missing, a test may not. *) + macro_arity "unless with no test at all" + "(defn main [] i32 (unless) 0)" "unless-takes-a-test"; + (* The two ways expansion does not terminate, and they are different failures. A ring is a compile-order problem -- each body calls the other while the other is being compiled -- and there is no order, so it is diff --git a/test/test_flan.ml b/test/test_flan.ml index 753bc77..93719b6 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -314,6 +314,34 @@ let () = | If (_, { e = Do [ _; _ ]; _ }, None) -> () | _ -> check "when -> if+do" false); + (* An empty body is the same [Do []] that [(do)] already is, and not a + refusal. (when test) is a guard whose consequent has not been written yet + -- a state a program passes through while it is being written -- and + refusing it bought nothing that the empty [do] does not already allow. + [unless] in the prelude took the same change; it is a macro now, so it is + asserted in programs/prelude-macros.flan instead of here. *) + (match (parse1 "(when c)").e with + | If (_, { e = Do []; _ }, None) -> () + | _ -> check "(when test) with no body -> if+(do)" false); + + (* The test is still required, because there is nothing to branch on + without one. *) + parse_rejects "when with no test at all" "(defn f [] () (when))" + ~needle:"when is (when test body ...)"; + + (* The same rule for a function with nothing in it. A [defn] whose declared + return type is () and whose body is empty has always been legal -- there + is a unit to answer and no forms needed to reach it -- and [Check] refuses + the case where the declaration disagrees, "returns i32 but has no body". + An [fn] now parses the same way; it declares no return type, so the + position it sits in is what decides, and the two rows below check.ml's + arms are in the checker section further down. *) + (match (parse1 "(fn [])").e with + | Fn ([], []) -> () + | _ -> check "(fn []) parses with an empty body" false); + parse_rejects "fn with no parameter vector" "(defn f [] () (fn))" + ~needle:"fn is (fn [param ...] body ...)"; + (* unless was here, and is not any more: it is a defmacro in the prelude, and the parser has nothing to say about it. What it expands to is the same if-over-(not) this used to assert, and it is asserted where it can @@ -2549,6 +2577,28 @@ let () = (boom ^ "(defn f [] i32 (handler-case 1 [(Boom [] 2)]))") ~needle:"a handler-case clause is (Type [name] body ...)"; + (* ── A function with nothing in it ─────────────────────────────── *) + + (* The empty body, the other half of (when test) with no body: a function + that does nothing is a function, and the only question is whether it has + a value to answer. A declared () says it does not and the body may be + empty; a declared anything else says it does, and an empty body cannot + provide one. *) + accepts "a defn returning () with no body" + "(defn nothing [n i32] ())\n(defn f [] i32 (nothing 1) 0)"; + rejects_check "a defn returning a value with no body" + "(defn nothing [n i32] i32)" + ~needle:"returns i32 but has no body"; + + (* An fn declares no return type, so the position decides instead. Both arms + are asserted, because the refusal is the one that would otherwise let a + call read a return value nothing wrote. *) + accepts "an fn with no body where a (Fn [] ()) is wanted" + "(defn call [f (Fn [] ())] () (f))\n(defn f [] i32 (call (fn [])) 0)"; + rejects_check "an fn with no body where a value is wanted" + "(defn call [f (Fn [] i32)] i32 (f))\n(defn f [] i32 (call (fn [])))" + ~needle:"an fn with no body answers ()"; + (* ── Destructuring ─────────────────────────────────────────────── *) (* A pattern is desugared in [Parse] into the bindings and field accesses that @@ -2574,6 +2624,35 @@ let () = (pt ^ "(defn mk [] Point (Point {.x 1 .y 2}))\n\ (defn f [] i32 (let [{:keys [x y]} (mk)] (+ x y)))"); + (* The shorthand: a lone .field binds a local of the field's own name. It is + :keys said in the spelling the rest of the language uses for a field, and + it mixes with the pair form in one brace because the two are told apart + one item at a time -- a dot in head position is the shorthand, anything + else is a pattern expecting its .field next. *) + accepts "struct pattern with the .field shorthand" + (pt ^ "(defn f [p Point] i32 (let [{.x .y} p] (+ x y)))"); + accepts "the shorthand mixed with a pair in one brace" + (pt ^ "(defn f [p Point] i32 (let [{.x b .y} p] (+ x b)))"); + accepts "the shorthand inside a nested pattern" + (line ^ "(defn f [l Line] i32 (let [{{.x .y} .a} l] (+ x y)))"); + (* The shorthand names a field, so an unknown one is the same refusal the + named form gets -- it is the same field access underneath. *) + rejects_check "the shorthand naming a field the struct does not have" + (pt ^ "(defn f [p Point] i32 (let [{.z} p] 0))") + ~needle:"Point has no field z"; + (* This used to be "{.x} has no .field": a lone dotted symbol was read as a + name to bind and the brace then wanted a field after it. An even number + of them was worse than a refusal -- {.x .y} parsed as "bind a local + called .x to field y" and the program failed later with "unknown name x", + several lines from the mistake. Both readings are gone. *) + accepts "a lone .field is the shorthand and not a missing pair" + (pt ^ "(defn f [p Point] i32 (let [{.x} p] x))"); + (* And the arm that refusal came from is still there for the case it was + written for: a plain name with nothing after it. *) + rejects_check "a name with no field after it" + (pt ^ "(defn f [p Point] i32 (let [{a} p] 0))") + ~needle:"has no .field"; + rejects_check "a field the struct does not have" (pt ^ "(defn f [p Point] i32 (let [{:keys [x z]} p] (+ x z)))") ~needle:"Point has no field z"; @@ -2586,9 +2665,6 @@ let () = rejects_check "an empty struct pattern" (pt ^ "(defn f [p Point] i32 (let [{} p] 0))") ~needle:"an empty struct pattern {} binds nothing"; - rejects_check "a field name with no pattern before it" - (pt ^ "(defn f [p Point] i32 (let [{.x} p] 0))") - ~needle:"has no .field"; rejects_check "a pattern with no field name after it" (pt ^ "(defn f [p Point] i32 (let [{a b} p] 0))") ~needle:"expected .field after a";