Empty fn bodies, the pins for all six items, and the FIX.org entry

This commit is contained in:
Joseph Ferano 2026-09-20 18:11:20 +07:00
parent 6c017c2cf8
commit fa2b56ba5a
5 changed files with 354 additions and 4 deletions

220
FIX.org
View File

@ -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.

View File

@ -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)

View File

@ -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 ...)")

View File

@ -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:"<arity>" 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

View File

@ -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";