From 6c017c2cf807643535a2a7dff5c64d9b20a0dd89 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 18:02:59 +0700 Subject: [PATCH 1/3] Six dogfooding items: empty bodies, comment, inc/dec, () bodies, type limits, {.field} --- lib/parse.ml | 42 ++++++-- lib/prelude.ml | 168 +++++++++++++++++++++++++++++- test/programs/destructure.flan | 10 ++ test/programs/limits.flan | 121 +++++++++++++++++++++ test/programs/prelude-macros.flan | 103 ++++++++++++++++++ test/test_acceptance.ml | 64 +++++++++++- vendor/raylib/modes.flan | 32 +++++- 7 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 test/programs/limits.flan create mode 100644 test/programs/prelude-macros.flan diff --git a/lib/parse.ml b/lib/parse.ml index 6a5ac37..563f2aa 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -348,9 +348,15 @@ and form f mk (head : Form.t) (args : Form.t list) : Ast.expr = | _ -> fail f "if is (if test then) or (if test then else)") (* Sugar, desugared here: special forms until macros land at milestone 5. *) + (* An empty body is allowed, and becomes the same [Ast.Do []] that [(do)] + already means. There was never a reason for the restriction: [(when test)] + is a guard whose consequent has not been written yet, which is a state a + program passes through while it is being written, and refusing it buys + nothing. A [(when)] with no test at all is still refused, because there is + no expression to test. *) | Sym "when" -> (match args with - | c :: body when body <> [] -> + | c :: body -> mk (Ast.If (expr c, { Ast.e = Ast.Do (body_of body); loc = f.loc }, None)) | _ -> fail f "when is (when test body ...)") @@ -755,11 +761,27 @@ and destructure (p : Form.t) (v : Ast.expr) : Ast.binding list = {:keys [x y]} over a struct or [a b] over a fixed array" (Form.to_string p) -(* {:keys [x y]} and {inner .field}, over a struct. Clojure's map destructuring - with Flan's structs standing in for its maps: [:keys] is the common case and - the pair form is what nests, since a [:keys] entry is a name and never a - pattern. Everything else Clojure puts in this position — [:as], [:or], - [:strs], [:syms] — is refused by name where it is written. +(* {:keys [x y]}, {.x .y} and {inner .field}, over a struct. Clojure's map + destructuring with Flan's structs standing in for its maps: [:keys] is the + common case and the pair form is what nests, since a [:keys] entry is a name + and never a pattern. Everything else Clojure puts in this position — [:as], + [:or], [:strs], [:syms] — is refused by name where it is written. + + [{.x .y}] is [:keys]'s other spelling and the shortest one: a lone [.field] + with no pattern before it binds a local of the field's own name. It is what + [:keys] would have been if the language had only ever had structs — a + struct's fields are typed and known, so naming one is naming the binding — + and it puts the field syntax in the place the rest of the language spells a + field. [:keys] stays, because a dyn map's keys are not field names and that + is the form they will keep. + + This arm comes before the pair arm and has to: a lone [.x] is a [Sym], and + [destructure] takes any [Sym] as a name, so before this existed [{.x .y}] + parsed as the pair "bind a local called [.x] to field [y]" and the program + failed later with "unknown name x" — a mis-parse rather than a refusal. An + odd number of them hit the [has no .field] arm instead. So the dot in head + position did have a meaning here, and this replaces it with the one that was + wanted. [:keys] keeps its colon while [.field] takes the dot, and the split is the point rather than an inconsistency: [.field] names a field of the struct, @@ -770,8 +792,16 @@ and destructure (p : Form.t) (v : Ast.expr) : Ast.binding list = and dmap (p : Form.t) (t : Ast.expr) (items : Form.t list) : Ast.binding list = let ex loc e : Ast.expr = { Ast.e; loc } in let field loc name = ex loc (Ast.Field (t, name)) in + let dotted s = String.length s > 1 && s.[0] = '.' in let rec go = function | [] -> [] + (* The shorthand. Checked first, so a [.field] in head position is never + read as a name to bind. *) + | ({ v = Sym s; _ } as fform) :: rest when dotted s -> + let name = String.sub s 1 (String.length s - 1) in + { Ast.bname = name; bty = None; bval = field fform.loc name; + bloc = fform.loc } + :: go rest | { v = Kw "keys"; _ } :: names :: rest -> let ns = match names.v with diff --git a/lib/prelude.ml b/lib/prelude.ml index b015fd9..c9610c9 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -553,6 +553,80 @@ let source = {flan| (set i (+ i 1))) (if neg (Some (- 0 n)) (Some n)))) +;; ── The limits of each numeric type ─────────────────────────────────── +;; +;; What C spells INT_MAX and FLT_MAX, and what nothing here could reach for: +;; cimport pulls in declared functions, structs and typedefs, never a #define, +;; so limits.h and float.h have no way in. These are written out instead, once, +;; where every program already sees them. +;; +;; Kebab-case and the type's own name, like ns-per-second above: i32-max, not +;; I32_MAX and not INT_MAX. The type prefix is the type as the language spells +;; it, so the constant for a u8 is u8-max and there is nothing to translate. +;; +;; Each carries its type, which is the point of them — i32-max is an i32 and +;; putting it where a u8 is wanted is a type error rather than a silent 255. +;; That also means the pair for a type is the pair the *language* has, so +;; u8-min is here beside u16-min and u32-min and u64-min, all of them zero: a +;; family with a hole in it is worse than four lines that say nothing +;; surprising, and code generated over a list of type names needs the hole +;; filled. +;; +;; u64-max is written in hex and it has to be. The reader parses a decimal +;; integer into an i64, and 18446744073709551615 does not fit one; the hex +;; spelling is read as the 64-bit pattern it names, which is what a u64 +;; literal is here (see [Check.in_range], which accepts any pattern at 64 bits +;; unsigned for exactly this reason). i64-min's decimal spelling *does* fit, +;; since it is i64's own least value, so it is written the ordinary way. +(defconst i8-max i8 127) +(defconst i8-min i8 -128) +(defconst i16-max i16 32767) +(defconst i16-min i16 -32768) +(defconst i32-max i32 2147483647) +(defconst i32-min i32 -2147483648) +(defconst i64-max i64 9223372036854775807) +(defconst i64-min i64 -9223372036854775808) + +(defconst u8-max u8 255) +(defconst u8-min u8 0) +(defconst u16-max u16 65535) +(defconst u16-min u16 0) +(defconst u32-max u32 4294967295) +(defconst u32-min u32 0) +(defconst u64-max u64 0xFFFFFFFFFFFFFFFF) +(defconst u64-min u64 0) + +;; The floats are three questions and not two, which is why there is no +;; f32-min here to sit beside f32-max. +;; +;; A float's least value is just the negation of its greatest — (- 0.0 f32-max) +;; — so a constant for it would say nothing the language cannot. What a caller +;; actually reaches for under the name "min" is the smallest positive one, and +;; that is a different number entirely. Naming it f32-min would make the two +;; readings collide at the worst possible place, so the name says which it is: +;; f32-min-positive, the smallest *normal* positive value, as Rust's +;; MIN_POSITIVE does. Below it the subnormals run further down still, trading +;; mantissa bits for exponent range; nothing here names one, because a program +;; that wants the last subnormal wants to say so. +;; +;; The epsilons are the gap from 1.0 to the next representable value above it — +;; 2^-23 and 2^-52, the mantissa widths — and not "the smallest number you can +;; add to anything". That distinction is the whole reason a comparison written +;; (< (abs (- a b)) f64-epsilon) is wrong for any a and b of interesting size, +;; and the reason this is named epsilon and not tolerance. +;; +;; Every decimal below is the shortest one that round-trips to the exact value +;; intended, and each is pinned against an independent derivation in +;; test/programs/limits.flan rather than trusted. There is no infinity or NaN +;; constant, and there cannot be one written down: the reader has no literal +;; for either. (/ 1.0 0.0) is the only way to reach an infinity today. +(defconst f32-max f32 3.4028234663852886e38) +(defconst f64-max f64 1.7976931348623157e308) +(defconst f32-min-positive f32 1.1754943508222875e-38) +(defconst f64-min-positive f64 2.2250738585072014e-308) +(defconst f32-epsilon f32 1.1920928955078125e-07) +(defconst f64-epsilon f64 2.220446049250313e-16) + ;; ── Numbers ─────────────────────────────────────────────────────────── ;; ;; Only the ones that encode a decision. abs is (max x (- 0 x)); a wrapper over @@ -1960,11 +2034,91 @@ let source = {flan| ;; nothing defines, and the report is "unknown name unless-takes-a-test-and-a- ;; body" at the call site, which is the right place and the wrong sentence. ;; That is the next thing a macro needs and it is written down in NEXT.md. +;; +;; An empty body is allowed, and expands to the (do) it always would have: +;; (unless test) is a guard whose body has not been written yet, which is a +;; state a program passes through on the way to being finished, and refusing it +;; bought nothing. `when` in lib/parse.ml is the same change; the two are +;; halves of one form and only a restriction they both carried would be worth +;; keeping. A test is still required, because there is nothing to negate +;; without one. (defmacro unless [args] - (if (< (len args) 2) - `(unless-takes-a-test-and-a-body) + (if (< (len args) 1) + `(unless-takes-a-test) `(if (not ~(at args 0)) (do ~@(form-rest args 1))))) +;; ── comment ─────────────────────────────────────────────────────────── +;; +;; (comment (whatever you like)) is nothing at all, and the "whatever you like" +;; is the whole feature. A macro's arguments arrive as raw Form and are never +;; checked as expressions, so what is inside can name functions that do not +;; exist, call them at the wrong arity, or add a string to a number: none of it +;; is ever looked at, because this answers (do) without reading a single +;; argument. That is Clojure's (comment ...) exactly, and it is what ;; cannot +;; do — a commented-out block stops being a form, so an editor can no longer +;; move over it, indent it or send it to the REPL, and a discarded one still +;; can. +;; +;; The one thing it does require is that the contents READ: balanced +;; delimiters and legal tokens, since the reader runs before any macro does. +;; An unterminated string inside a (comment ...) is still an unterminated +;; string. +;; +;; #_ is the other spelling and they are not rivals: #_ discards the one form +;; after it and is the reader's, so it works in any position including inside +;; another form's arguments; this is a form of its own and takes any number, +;; which is what a block of parked code wants. Built in rather than left to +;; every project, because a name this standard should mean the same thing in +;; all of them. +(defmacro comment [args] + `(do)) + +;; ── inc/dec and ++/-- ───────────────────────────────────────────────── +;; +;; Two pairs, and the split between them is the whole design. inc and dec +;; answer a number and change nothing; ++ and -- change a place and answer +;; whatever `set` answers. The spelling says which: a word for the pure one, a +;; punctuation pair borrowed from C for the one with the effect, so +;; (inc i) in an argument and (++ i) as a statement never get confused for one +;; another the way C's i++ and i+1 do. +;; +;; Generic for free, all four of them, because + and - already are: (inc x) is +;; (+ x 1) with the literal taking whichever numeric type x has — i8 through +;; i64, u8 through u64, f32, f64, and a dyn — and none of that is this macro's +;; business. There is no per-type family here and there is no `where` clause, +;; because a macro does not have a type at all; the expansion is checked at the +;; call site as if it had been written there. +;; +;; **++ and -- read the place twice, and that is an accepted cost.** The +;; expansion is (set PLACE (+ PLACE 1)), so PLACE is evaluated once to read +;; and once to write. For a variable, a field or a deref that is free and +;; means nothing. For (at arr (next-index)) — an index with a side effect — +;; it means next-index runs twice and the read and the write land on different +;; elements. That is not a bug to be fixed here: macros are non-hygienic by +;; decision (plan.org, open decision 2), a macro cannot bind a temporary for +;; the *place* without a reference type it does not have, and +;; rl/with-drawing and rl/with-mode-2d already take the same trade on their +;; arguments. Write the index out first if it does anything. +(defmacro inc [args] + (if (!= (len args) 1) + `(inc-takes-one-number) + `(+ ~(at args 0) 1))) + +(defmacro dec [args] + (if (!= (len args) 1) + `(dec-takes-one-number) + `(- ~(at args 0) 1))) + +(defmacro ++ [args] + (if (!= (len args) 1) + `(++-takes-one-place) + `(set ~(at args 0) (+ ~(at args 0) 1)))) + +(defmacro -- [args] + (if (!= (len args) 1) + `(---takes-one-place) + `(set ~(at args 0) (- ~(at args 0) 1)))) + ;; ── into: a fused transformation, and not a transducer ──────────────── ;; ;; (into xs (vec-new i32) (map double) (filter even?)) @@ -2049,6 +2203,16 @@ let source = {flan| (Form.Sym s) (bytes=? (bytes s) (bytes name)) _ false)) +;; Whether a form is the empty list, (). [form-items] cannot answer this: it +;; returns the empty slice for a non-list too, so "no items" and "not a list" +;; arrive the same. A macro that has to tell `()` from a name needs the +;; difference — see vendor/raylib/modes.flan, where a lone () argument is a +;; body that was not written rather than a body of one form. +(defn form-empty-list? [f Form] bool + (match f + (Form.List xs) (= (len xs) 0) + _ false)) + (defn form-is-sym? [f Form] bool (match f (Form.Sym s) true diff --git a/test/programs/destructure.flan b/test/programs/destructure.flan index 5ebf6be..ed6bc90 100644 --- a/test/programs/destructure.flan +++ b/test/programs/destructure.flan @@ -40,6 +40,16 @@ (let [{a .x b .y} (Point {.x 10 .y 20})] (show2 "pairs" a b)) + ;; The shorthand: a lone .field with no name before it binds a local of the + ;; field's own name, which is what :keys does and in the spelling the rest of + ;; the language uses for a field. It mixes with the pair form in one brace, + ;; because the two are read one item at a time and a dot in head position is + ;; the only thing that tells them apart. + (let [{.x .y} (Point {.x 30 .y 40})] + (show2 "shorthand" x y)) + (let [{.x b .y} (Point {.x 50 .y 60})] + (show2 "shorthand-mixed" x b)) + (let [l (Line {.a (Point {.x 5 .y 6}) .b (Point {.x 7 .y 8})})] (let [{{:keys [x y]} .b} l] (show2 "nested" x y)) diff --git a/test/programs/limits.flan b/test/programs/limits.flan new file mode 100644 index 0000000..6c43082 --- /dev/null +++ b/test/programs/limits.flan @@ -0,0 +1,121 @@ +;;;; The prelude's type limits, checked against something other than themselves. +;;;; +;;;; A wrong constant here would compile. That is the whole reason this program +;;;; exists: i32-max off by one, or f64-max one ulp low, is a number the +;;;; compiler has no opinion about, and it would sit in the prelude being +;;;; subtly wrong in every program that read it. So nothing below asserts a +;;;; constant against the way it is spelled in the prelude. +;;;; +;;;; The integers are checked by printing them. The expected output beside this +;;;; program in test_acceptance is the decimal spelling of each limit, written +;;;; out independently, and an integer's decimal rendering is exact — so the +;;;; comparison is the whole value and not an approximation of it. u64-max is +;;;; the one that matters most: it is written in hex in the prelude, because +;;;; the reader cannot take its decimal, and this is where that hex is read +;;;; back as the number it is supposed to name. +;;;; +;;;; The floats cannot be checked that way, because printing one is snprintf +;;;; "%g" and that is six significant digits — 3.40282e+38 is equally true of +;;;; f32-max and of a dozen values around it. So each is *derived* here by +;;;; exact power-of-two arithmetic and compared for equality. Every step of +;;;; that derivation is exact in IEEE-754: doubling and halving a float only +;;;; moves the exponent, and the one multiplication that is not a power of two +;;;; has both operands representable and a representable product. The +;;;; derivations are therefore a second, independent construction of the same +;;;; bit pattern, which is what a pin needs to be. + +;; 2^n, built by repeated doubling from 1.0 and reciprocated for a negative n. +;; Exact for every n this program asks for: the largest is 2^1023, which is +;; half of f64-max and so is nowhere near overflowing, and the smallest is +;; 2^-1022, whose reciprocal partner 2^1022 is a normal value — so no step +;; passes through a subnormal, where the halving would start losing bits. +(defn p2-f64 [n i32] f64 + (let [m (if (< n 0) (- 0 n) n) + x 1.0] + (dotimes [i m] + (set x (* x 2.0))) + (if (< n 0) (/ 1.0 x) x))) + +;; The same, at f32's width and with f32's exponent range. 2^127 is the +;; largest normal power of two an f32 holds and 2^-126 the smallest, and both +;; are exactly the ends this file asks for. +(defn p2-f32 [n i32] f32 + (let [m (if (< n 0) (- 0 n) n) + x (f32 1.0)] + (dotimes [i m] + (set x (* x (f32 2.0)))) + (if (< n 0) (/ (f32 1.0) x) x))) + +;; An infinity is a value that equals its own double and is not zero — the +;; same test format-f64 in the prelude uses, and the only one available with +;; no infinity literal to compare against. +(defn inf-f64? [x f64] bool + (and (= x (* x 2.0)) (!= x 0.0))) + +(defn inf-f32? [x f32] bool + (and (= x (* x (f32 2.0))) (!= x (f32 0.0)))) + +(defn say [name string ok bool] () + (print name) + (print " ") + (println (if ok "ok" "WRONG"))) + +(defn main [] i32 + ;; The integers, each printed as the exact decimal the expected output pins. + (println i8-max) + (println i8-min) + (println i16-max) + (println i16-min) + (println i32-max) + (println i32-min) + (println i64-max) + (println i64-min) + (println u8-max) + (println u8-min) + (println u16-max) + (println u16-min) + (println u32-max) + (println u32-min) + (println u64-max) + (println u64-min) + + ;; The floats, each against its derivation. + ;; + ;; An epsilon is the gap from 1.0 to the next value above it, which is + ;; 2^-(mantissa bits): 23 for an f32, 52 for an f64. Derived that way here, + ;; and then confirmed by the property the name actually promises — adding it + ;; to 1.0 moves, adding half of it does not. + (say "f32-epsilon" (= f32-epsilon (p2-f32 -23))) + (say "f64-epsilon" (= f64-epsilon (p2-f64 -52))) + (say "f32-epsilon is the step above 1.0" + (and (!= (+ (f32 1.0) f32-epsilon) (f32 1.0)) + (= (+ (f32 1.0) (/ f32-epsilon (f32 2.0))) (f32 1.0)))) + (say "f64-epsilon is the step above 1.0" + (and (!= (+ 1.0 f64-epsilon) 1.0) + (= (+ 1.0 (/ f64-epsilon 2.0)) 1.0))) + + ;; The smallest positive *normal* value is 2^(1-bias): 2^-126 and 2^-1022. + ;; Halving one leaves the normals, so the value below it is not simply half + ;; — that is the property that says this is the boundary and not some value + ;; near it. + (say "f32-min-positive" (= f32-min-positive (p2-f32 -126))) + (say "f64-min-positive" (= f64-min-positive (p2-f64 -1022))) + + ;; The greatest finite value is (2 - 2^-mantissa) * 2^maxexp. Both factors + ;; are exactly representable and so is the product, which is why this + ;; derivation is an equality and not a near-miss. Doubling it overflows to + ;; an infinity, which is the other end of the same claim: there is nothing + ;; finite above it. + (say "f32-max" (= f32-max (* (p2-f32 127) (- (f32 2.0) (p2-f32 -23))))) + (say "f64-max" (= f64-max (* (p2-f64 1023) (- 2.0 (p2-f64 -52))))) + (say "f32-max is the last finite f32" (inf-f32? (* f32-max (f32 2.0)))) + (say "f64-max is the last finite f64" (inf-f64? (* f64-max 2.0))) + + ;; And the two the language does not need a constant for, said once so that + ;; the absence is recorded rather than merely unmentioned: a float's least + ;; value is the negation of its greatest, and there is nothing to derive. + (say "f32's least value negates its greatest" + (< (- (f32 0.0) f32-max) (- (f32 0.0) f32-min-positive))) + (say "f64's least value negates its greatest" + (< (- 0.0 f64-max) (- 0.0 f64-min-positive))) + 0) diff --git a/test/programs/prelude-macros.flan b/test/programs/prelude-macros.flan new file mode 100644 index 0000000..d4ec1dc --- /dev/null +++ b/test/programs/prelude-macros.flan @@ -0,0 +1,103 @@ +;;;; comment, inc/dec, ++/--, and an empty body — the prelude's small macros, +;;;; asserted through a compiler that has to run them. +;;;; +;;;; These cannot be asserted in test_flan the way a special form can: a macro +;;;; is compiled into a shared object and dlopened into the compiler before +;;;; the first line below is parsed, so the only honest test of one is a +;;;; program that was built. macro-unless.flan beside this file is the same +;;;; argument for the same reason. + +(defstruct Counter [hits i32 misses i32]) + +(defvar dyn-count dyn 5) + +(defn show [label string n i32] () + (print label) + (print " ") + (println n)) + +;; (comment ...) never reads its arguments, so nothing inside one has to be a +;; program. Everything in this function's comment would be a refusal written +;; anywhere else: a name nothing defines, a call at an arity it does not have, +;; a string added to a number, a field of a struct that has no such field. +;; The one rule it does obey is the reader's — delimiters balance and every +;; token is legal — because reading happens before any macro runs. +(defn commented [] i32 + (comment + (no-such-function 1 2 3) + (show "too" "few") + (+ 1 "two") + (.nonexistent (Counter {})) + (defn this is not even a definition)) + 7) + +(defn main [] i32 + (println (commented)) + + ;; inc and dec answer a number and change nothing. + (let [n 10] + (show "inc" (inc n)) + (show "dec" (dec n)) + (show "n unchanged" n)) + + ;; Generic for free at every numeric type, because + and - already are. + ;; Nothing below names a type twice and there is no per-type family. + (let [a (i8 1) + b (i16 1) + c 1 + d (i64 1) + e (u8 1) + f (u16 1) + g (u32 1) + h (u64 1)] + (print (inc a)) (print " ") + (print (inc b)) (print " ") + (print (inc c)) (print " ") + (print (inc d)) (print " ") + (print (inc e)) (print " ") + (print (inc f)) (print " ") + (print (inc g)) (print " ") + (println (inc h))) + (let [x (f32 1.5) + y 1.5] + (print (inc x)) (print " ") + (println (dec y))) + (println (inc dyn-count)) + + ;; ++ and -- change a place. Every place `set` takes is one: a local, a + ;; field, an element, a deref. + (let [n 0] + (++ n) + (++ n) + (-- n) + (show "local" n)) + + (let [c (Counter {.hits 0 .misses 9})] + (++ (.hits c)) + (++ (.hits c)) + (-- (.misses c)) + (show "field hits" (.hits c)) + (show "field misses" (.misses c))) + + (let [xs [10 20 30]] + (++ (at xs 1)) + (-- (at xs 2)) + (show "element 1" (at xs 1)) + (show "element 2" (at xs 2))) + + (let [n 100 + p (addr n)] + (++ (deref p)) + (show "through a pointer" n)) + + ;; A body that was not written. (when test) and (unless test) are the guard + ;; a program passes through while it is being written, and both expand to + ;; the (do) they always would have — no branch taken, nothing printed, and + ;; the form's value is () either way. + (let [n 0] + (when (= n 0)) + (unless (= n 0)) + (when (= n 0) (++ n)) + (unless (= n 1) (++ n)) + (show "after empty and written bodies" n)) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index bcc7366..1f4c0bb 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -3426,6 +3426,29 @@ level "1" outputs ~opt:"-O0" "unless, now a prelude macro, -O0" "programs/macro-unless.flan" unless_out; + (* The rest of the prelude's macros, and for the same reason: a macro is + compiled into a shared object and dlopened into the compiler before the + program that calls it is parsed, so the only honest assertion about one + is a program that was built. + + The generic row -- eight integer widths, two float widths and a dyn, + all printing 2 or the obvious successor -- is the claim that inc and + dec needed no type machinery of their own: + already works at every one + of those, and a macro has no type to get in the way. A per-type family + would have had to be written and this row would look the same, which is + why it prints the answers rather than merely compiling. *) + let prelude_macros_out = + "7\ninc 11\ndec 9\nn unchanged 10\n\ + 2 2 2 2 2 2 2 2\n2.5 0.5\n6\n\ + local 1\nfield hits 2\nfield misses 8\n\ + element 1 21\nelement 2 29\nthrough a pointer 101\n\ + after empty and written bodies 1\n" + in + outputs "comment, inc/dec, ++/-- and an empty body" + "programs/prelude-macros.flan" prelude_macros_out; + outputs ~opt:"-O0" "comment, inc/dec, ++/-- and an empty body, -O0" + "programs/prelude-macros.flan" prelude_macros_out; + (* An error on code a macro produced says which macro, and it has to be asserted through a real expansion: the tag is put on by [Macro] and defaulted into the diagnostic by [Loc], and a unit test on either half @@ -4240,6 +4263,44 @@ level "1" incr failures; Printf.printf "FAIL %s\n refused: %S\n" name m); + (* ── The prelude's type limits ──────────────────────────────── *) + + (* A wrong constant compiles, which is the only reason this row is worth + its seconds: nothing in the compiler has an opinion about whether + i32-max is 2147483647 or one less, and a prelude constant that is + subtly wrong is wrong in every program that reads it. + + The expected text below is written out from the definitions of the + types and not copied from the prelude, so the two spellings of each + integer limit have to agree. The floats cannot be pinned this way — + printing one is "%g", six digits, true of a whole neighbourhood of + values — so limits.flan derives each by exact power-of-two arithmetic + and prints whether the derivation matched; a WRONG in that half fails + this row on the text. + + --x86 as well, and that is not ceremony: a limit is a constant the + backend has to materialise, and the two backends build an f64 bit + pattern and a full-width u64 immediate by entirely different routes. + An x86 lowering that truncated one would print a number this row + would catch and nothing else in the suite would. *) + let limits_out = + "127\n-128\n32767\n-32768\n2147483647\n-2147483648\n\ + 9223372036854775807\n-9223372036854775808\n\ + 255\n0\n65535\n0\n4294967295\n0\n18446744073709551615\n0\n\ + f32-epsilon ok\nf64-epsilon ok\n\ + f32-epsilon is the step above 1.0 ok\n\ + f64-epsilon is the step above 1.0 ok\n\ + f32-min-positive ok\nf64-min-positive ok\n\ + f32-max ok\nf64-max ok\n\ + f32-max is the last finite f32 ok\n\ + f64-max is the last finite f64 ok\n\ + f32's least value negates its greatest ok\n\ + f64's least value negates its greatest ok\n" + in + outputs "type limits" "programs/limits.flan" limits_out; + outputs ~opt:"-O0" "type limits, -O0" "programs/limits.flan" limits_out; + outputs ~x86:true "type limits, --x86" "programs/limits.flan" limits_out; + let signed_out = "-4\n-1\nbig is not small\nbig is large\n1\n" in outputs "signedness" "programs/signedness.flan" signed_out; outputs ~opt:"-O0" "signedness, -O0" "programs/signedness.flan" signed_out; @@ -4256,7 +4317,8 @@ level "1" tail slice is an address into a local array, and mem2reg launders a sloppy one. *) let destructure_out = - "keys 1 2\npairs 10 20\nnested 7 8\nshadow 5 6\nsequential 100 200\n\ + "keys 1 2\npairs 10 20\nshorthand 30 40\nshorthand-mixed 50 60\n\ + nested 7 8\nshadow 5 6\nsequential 100 200\n\ array 11 22 33\nrest 1 4 2 5\nempty-tail 17 0\nnested-in-array 1 4\n\ struct-tail 1 2 4 5\ncalls 2 14\n" in diff --git a/vendor/raylib/modes.flan b/vendor/raylib/modes.flan index 7910e7f..86ebc60 100644 --- a/vendor/raylib/modes.flan +++ b/vendor/raylib/modes.flan @@ -74,10 +74,28 @@ ;;;; Each macro answers the value of its `End*` call, which is (). A pair was ;;;; never an expression worth reading anyway. +;; Each guard below asks the same question twice over, and the second half is +;; the one worth explaining. A body that was not written can arrive two ways: +;; as no argument at all — (with-drawing) — and as a single bare () — +;; (with-drawing ()). The second used to slip past, because one argument is one +;; argument however empty it is: the () was spliced into the expansion +;; verbatim, and the report came out of the middle of the expanded (do) saying +;; that () is not an expression, several forms away from the line anyone wrote. +;; () has no value-position meaning in the language at all, so a lone one here +;; is never a body and can be answered with the same message the missing-body +;; case gets. (do) is what to write for a body that really is meant to be +;; empty, and it is an ordinary expression that needs none of this. +;; +;; Only a *lone* () is caught, and only where the body goes. () anywhere else — +;; as a camera, as a render target — is left to fail on its own, because +;; nothing here could say anything truer about it than the compiler already +;; does. + ;; The frame. Everything drawn lands on the back buffer; end-drawing swaps it ;; and waits out the frame time set by set-target-fps. (defmacro with-drawing [args] - (if (< (len args) 1) + (if (or (< (len args) 1) + (and (= (len args) 1) (form-empty-list? (at args 0)))) `(with-drawing-takes-a-body) `(do (begin-drawing) ~@args @@ -87,7 +105,8 @@ ;; always was. Remember that a fresh (Camera2D {}) has zoom 0.0 and is not ;; usable as an identity — raylib.flan says so beside the struct. (defmacro with-mode-2d [args] - (if (< (len args) 2) + (if (or (< (len args) 2) + (and (= (len args) 2) (form-empty-list? (at args 1)))) `(with-mode-2d-takes-a-camera-and-a-body) `(do (begin-mode-2d ~(at args 0)) ~@(form-rest args 1) @@ -97,7 +116,8 @@ ;; more here than anywhere: ending a 3D mode with end-mode-2d type-checks ;; fine and leaves the projection matrix wrong for everything after it. (defmacro with-mode-3d [args] - (if (< (len args) 2) + (if (or (< (len args) 2) + (and (= (len args) 2) (form-empty-list? (at args 1)))) `(with-mode-3d-takes-a-camera-and-a-body) `(do (begin-mode-3d ~(at args 0)) ~@(form-rest args 1) @@ -108,7 +128,8 @@ ;; that correction is the caller's and is deliberately not hidden here, since ;; it belongs with the draw and not with the mode. (defmacro with-texture-mode [args] - (if (< (len args) 2) + (if (or (< (len args) 2) + (and (= (len args) 2) (form-empty-list? (at args 1)))) `(with-texture-mode-takes-a-target-and-a-body) `(do (begin-texture-mode ~(at args 0)) ~@(form-rest args 1) @@ -118,7 +139,8 @@ ;; scalars rather than a Rectangle, because that is what BeginScissorMode ;; takes and this file is not the place to invent a second spelling. (defmacro with-scissor-mode [args] - (if (< (len args) 5) + (if (or (< (len args) 5) + (and (= (len args) 5) (form-empty-list? (at args 4)))) `(with-scissor-mode-takes-x-y-width-height-and-a-body) `(do (begin-scissor-mode ~(at args 0) ~(at args 1) ~(at args 2) ~(at args 3)) ~@(form-rest args 4) From fa2b56ba5a1d588b01052c7d95b067ab67288987 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 18:11:20 +0700 Subject: [PATCH 2/3] 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"; From 1702a62308c07c51885d35ec57ee4c0422c84204 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 20 Sep 2026 18:18:20 +0700 Subject: [PATCH 3/3] Pin the () body guards, the match-arm rule, and correct two comments --- FIX.org | 26 +++++++++++++++++++------- lib/prelude.ml | 6 +++--- test/programs/rl-with-empty-arg.flan | 21 +++++++++++++++++++++ test/programs/rl-with-empty.flan | 21 +++++++++++++++++++++ test/test_acceptance.ml | 13 +++++++++++++ test/test_flan.ml | 12 ++++++++++++ 6 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 test/programs/rl-with-empty-arg.flan create mode 100644 test/programs/rl-with-empty.flan diff --git a/FIX.org b/FIX.org index ed74150..012f3fb 100644 --- a/FIX.org +++ b/FIX.org @@ -1640,8 +1640,9 @@ 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." +unspoken-for in value position on purpose, against the possibility that the +language grows lists later and wants the spelling. (Paraphrased from the +author's note, not quoted.) So ~()~ remains the type-position spelling of Unit and nothing else, and the guard below is written against that rather than around it. @@ -1786,10 +1787,14 @@ 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. +today. Destructuring binds in ~let~ only. A match arm is *not* a second +position the shorthand had to reach: a struct pattern has never worked in one, +and the refusal there is the match grammar's own — "expected a pattern, found +{a .x}" — not [no_pattern], which is what a defn parameter, an fn parameter +and a dotimes counter get. Checked against the compiler rather than read off +parse.ml's comment: ~{.x .y}~ and ~{a .x}~ in a match arm produce the same +refusal as each other, which is the claim that matters — the shorthand +inherited the existing 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 @@ -1802,7 +1807,14 @@ listing the fields there are. 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. + refused; and both ~{.x .y}~ and ~{a .x}~ refused identically in a match arm, + which is the "wherever the named form works" half of the claim. +- test/programs/rl-with-empty.flan and rl-with-empty-arg.flan, through + test_acceptance's ~refuses~: the two guard shapes, a body starting at + argument zero and a body starting after a camera, each given a bare ~()~ + and each answering the name the zero-argument case already answered. Never + built, which is how rl-with-reject.flan beside them works and is why these + need no raylib on the machine. - 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. diff --git a/lib/prelude.ml b/lib/prelude.ml index c9610c9..454684c 100644 --- a/lib/prelude.ml +++ b/lib/prelude.ml @@ -2031,9 +2031,9 @@ let source = {flan| ;; The one thing the compiler could say and this cannot is a reason. A macro ;; has no error facility: it runs inside the compiler and anything it signals ;; aborts the compile with no location. So a malformed (unless) answers a name -;; nothing defines, and the report is "unknown name unless-takes-a-test-and-a- -;; body" at the call site, which is the right place and the wrong sentence. -;; That is the next thing a macro needs and it is written down in NEXT.md. +;; nothing defines, and the report is "unknown name unless-takes-a-test" at the +;; call site, which is the right place and the wrong sentence. That is the next +;; thing a macro needs and it is written down in NEXT.md. ;; ;; An empty body is allowed, and expands to the (do) it always would have: ;; (unless test) is a guard whose body has not been written yet, which is a diff --git a/test/programs/rl-with-empty-arg.flan b/test/programs/rl-with-empty-arg.flan new file mode 100644 index 0000000..800920f --- /dev/null +++ b/test/programs/rl-with-empty-arg.flan @@ -0,0 +1,21 @@ +;;;; The same bare-() body, on a macro that takes an argument before it. +;;;; +;;;; Two guard shapes, so two programs. with-drawing's body starts at argument +;;;; zero; with-mode-2d's starts at argument one, after the camera, so the +;;;; check is "exactly the minimum arguments, and the last of them is ()" +;;;; rather than "one argument and it is ()". A guard written only for the +;;;; first shape would leave the four siblings that take an argument exactly +;;;; where with-drawing was. +;;;; +;;;; The camera is real here on purpose: the refusal has to be about the body +;;;; and not about the camera, and a () in the camera position is deliberately +;;;; not this guard's business. +;;;; +;;;; Being refused is the whole test; this is never built. + +(import rl "vendor:raylib") + +(defn main [] i32 + (let [c (rl/Camera2D {})] + (rl/with-mode-2d c ())) + 0) diff --git a/test/programs/rl-with-empty.flan b/test/programs/rl-with-empty.flan new file mode 100644 index 0000000..24cc110 --- /dev/null +++ b/test/programs/rl-with-empty.flan @@ -0,0 +1,21 @@ +;;;; with-drawing given a body that is a bare (). +;;;; +;;;; The other way a body can be missing, and the one that used to get through. +;;;; rl-with-reject.flan beside this file is the zero-argument case, which the +;;;; guard always caught; one argument that happens to be () is one argument, +;;;; so it was spliced into the expansion verbatim and the refusal came out of +;;;; the middle of the expanded (do) — "() is not an expression", several forms +;;;; from anything anyone wrote. +;;;; +;;;; () has no value-position meaning in the language at all, so a lone one +;;;; where a body goes is never a body, and the guard answers the same name the +;;;; missing-body case already answered. (do) is what to write for a body that +;;;; is meant to be empty. +;;;; +;;;; Being refused is the whole test; this is never built. + +(import rl "vendor:raylib") + +(defn main [] i32 + (rl/with-drawing ()) + 0) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index 078ff6a..6524357 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -2515,6 +2515,19 @@ let () = "programs/rl-with-reject.flan" "with-mode-2d-takes-a-camera-and-a-body"; + (* The other spelling of a body that was not written, and the reason these + are two programs rather than one: the guard has two shapes. A body that + starts at argument zero is "one argument and it is ()"; a body that + starts after a camera is "exactly the minimum arguments, and the last + of them is ()". Both answer the same name the zero-argument case above + answers, because it is the same mistake. *) + refuses "with-drawing given a bare () for a body" + "programs/rl-with-empty.flan" + "with-drawing-takes-a-body"; + refuses "with-mode-2d given a bare () for a body" + "programs/rl-with-empty-arg.flan" + "with-mode-2d-takes-a-camera-and-a-body"; + (* Visibility: main is not a name a package offers, and saying so is the point — "unknown name sand/main" would be true and useless. *) (* Generics, at the definition rather than at a call site. Both of these diff --git a/test/test_flan.ml b/test/test_flan.ml index 93719b6..541f6c9 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -2652,6 +2652,18 @@ let () = rejects_check "a name with no field after it" (pt ^ "(defn f [p Point] i32 (let [{a} p] 0))") ~needle:"has no .field"; + (* And where the shorthand does *not* reach, which is not a limitation it + introduced: a struct pattern has never worked in a match arm, and the two + spellings are refused identically there. Pinned as a pair, because "the + shorthand works wherever {name .field} works" is the claim, and a row on + only one of them would not be saying it. *) + List.iter + (fun pat -> + rejects_check ("a struct pattern in a match arm: " ^ pat) + (pt ^ Printf.sprintf + "(defn f [p Point] i32 (match p %s 0))" pat) + ~needle:"expected a pattern, found") + [ "{.x .y}"; "{a .x}" ]; rejects_check "a field the struct does not have" (pt ^ "(defn f [p Point] i32 (let [{:keys [x z]} p] (+ x z)))")