Merge branch 'enum-convert' into dev-loop

An enum and an integer convert both ways when you write the conversion
down, and not otherwise. Zero instructions in both directions -- emit's
cast already reduces an enum to its i32 before choosing an opcode -- so
what this costs is only that you have to say it.

The property worth keeping is kept: a bare integer is still refused for
an enum parameter, so :spcae is still an error at the call site. What is
gone is the wall, where an index could not reach an enum parameter at all
and the second declare-c escape was closed too.
This commit is contained in:
Joseph Ferano 2026-09-12 09:12:49 +07:00
commit aa0b98f2e5
9 changed files with 291 additions and 48 deletions

31
NEXT.md
View File

@ -216,12 +216,31 @@ vague intention — if it is listed, someone has already established it is real.
Ranked by how often they were hit, top two first because they are walls rather than conveniences:
1. ~~No number reaches `draw-text`.~~ **Fixed** by `(string b)`.
2. **An enum parameter cannot be driven by a loop variable.** `(rl/get-gamepad-axis-movement pad i)` with an `i32`
index: *expected rl/GamepadAxis, found i32*, and the other direction refuses too — *i32 converts a number, found
rl/Gesture*. The obvious escape is closed as well: a second `declare-c` of the same symbol with an `i32` face gives
*one declare-c per C function, and another Flan name for it is a defn* — and a `defn` renames without retyping.
Two individually-correct rules composing into a wall; the caller writes a `cond` over the members instead. Cost:
every gamepad-axis loop, and four comparisons on the raw gesture bitfield.
2. ~~An enum parameter cannot be driven by a loop variable.~~ **Fixed** by explicit conversions in both directions:
`(i32 k)` takes an enum to its integer, `(GamepadAxis n)` takes an integer to an enum. Neither is an instruction —
an enum is an i32 at run time and `emit.ml`'s `cast` already reduced one to that before choosing an opcode — so the
change is a guard in `check.ml`'s cast arm and nothing in the backend. The rule the refusals came from is
deliberately *not* relaxed: a bare integer still does not fit an enum parameter, so `:spcae` is still an error at
the call site. The rule was "an integer must not arrive silently", and a written `(GamepadAxis i)` is not silent.
The other escape stays closed too — one `declare-c` per C function — and no longer needs to be open.
- **A value that is no declared member is allowed**, deliberately. raylib's gesture is a bitfield and an OR of
flags is a legal `Gesture` that is no single member; and `session.ml`'s printer already falls through to the
number for an out-of-range enum, on purpose, so refusing to construct one while agreeing to print it would be
incoherent. An `Option` would make every site unwrap for no safety bought, and a literal-only refusal would
catch nothing, because the bitfield case is a run-time value.
- **Only an integer converts *to* an enum.** Not a float, and not another enum — a cross-enum hop goes through
`(i32 x)` so both ends are written down. Enum → any numeric is always allowed: lossless to i32 by construction,
and a narrower target truncates by the rule every int→int cast already follows.
- **The comparisons needed nothing else.** `(> (i32 g) 255)` checks because `binary` takes the non-literal side
first; `binary` was deliberately left ignorant of enums, since teaching it would be the implicit conversion this
avoids.
- **A bit-set type later builds on this rather than replacing it.** It would be its own type with its own
operations and would still want a named escape to the underlying integer for the FFI, spelled the same way. If
`Gesture` becomes one, the `(i32 g)` calls stay valid and only the range tests migrate to a membership test.
- One parse fix came with it: `defenum` names were not in `parse.ml`'s type set, so a local enum could not be a
function's return type. They are in it now under a key of their own, admitted as a bare symbol and never as a
list head — because `(Key n)` is a *value* now, and putting `Key` in `types` would make a body starting with one
be eaten as a return type.
3. **`break` is not implemented.** Declined deliberately rather than built — see below.
4. **A `let` binding takes no type annotation**, so a fixed array is either a top-level `defvar` or a literal with
every element spelled out. `(let [pts [4 rl/Vector2]] …)` parses as a two-element array literal and fails with

View File

@ -63,32 +63,32 @@
;; The C's read-out loop is `for (i = 0; i < GetGamepadAxisCount(gamepad); i++)
;; DrawText(TextFormat("AXIS %i: %.02f", i, GetGamepadAxisMovement(gamepad, i)))`
;; — an axis selected by a loop variable. That cannot go through the binding,
;; twice over:
;; — an axis selected by a loop variable, which is what this is: an integer
;; converts to an enum when the conversion is written by name.
;;
;; It used to be a six-armed cond over the members, because an enum is its own
;; type in the checker and a bare i32 does not fit one:
;;
;; expected rl/GamepadAxis, found i32
;;
;; because an integer does not convert to an enum and a keyword can only name
;; one member; and a second declare-c of the same C function with an i32
;; parameter is refused as well —
;; That rule is unchanged and should be. It is what makes a misspelled
;; `:lft-x` an error *here*, at the call site, rather than a wrong axis read
;; later — which is the whole reason an enum is a type at all rather than a
;; pile of i32 constants. What changed is that the conversion can be said out
;; loud: the rule was never "an integer is dangerous", it was "an integer must
;; not arrive silently", and (rl/GamepadAxis index) is not silent.
;;
;; rl/get-gamepad-axis-movement and rl/get-gamepad-axis-movement-by-index
;; both bind the C function GetGamepadAxisMovement — one declare-c per C
;; function, and another Flan name for it is a defn
;; (The other escape stays closed, and should: a second declare-c of
;; GetGamepadAxisMovement with an i32 face is still refused — one declare-c
;; per C function — and a defn wrapper still cannot retype a parameter. It is
;; no longer needed.)
;;
;; — and a defn wrapper cannot change a parameter's type. So the index is
;; turned back into a member here, by hand, which is the only shape left. It
;; covers the six axes raylib names; a pad reporting more reads 0.0 for the
;; extras, and the count is clamped below so they are not drawn at all.
;; An index raylib does not name converts too, rather than being refused, and
;; raylib bounds-checks its own axis and answers 0.0. So the (min 6 ...) clamp
;; at the call site is what keeps the read-out to the six axes raylib names —
;; load-bearing now rather than cosmetic, since it replaced the :else arm.
(defn axis-at [pad i32 index i32] f32
(cond
(= index 0) (rl/get-gamepad-axis-movement pad :left-x)
(= index 1) (rl/get-gamepad-axis-movement pad :left-y)
(= index 2) (rl/get-gamepad-axis-movement pad :right-x)
(= index 3) (rl/get-gamepad-axis-movement pad :right-y)
(= index 4) (rl/get-gamepad-axis-movement pad :left-trigger)
(= index 5) (rl/get-gamepad-axis-movement pad :right-trigger)
:else 0.0))
(rl/get-gamepad-axis-movement pad (rl/GamepadAxis index)))
(defn draw-pad-background []
(rl/draw-rectangle-rounded

View File

@ -5,20 +5,32 @@
;;;; the language. Four things it needed, in descending order of how much they
;;;; cost.
;;;;
;;;; **An enum does not convert to an integer.** The C treats the gesture as
;;;; the raw bitfield it is and compares it with `<` and `>`:
;;;; **An enum converts to an integer, when you say so.** The C treats the
;;;; gesture as the raw bitfield it is and compares it with `<` and `>`:
;;;; `currentGesture > 255` picks out the two pinches, `> 15` the four swipes,
;;;; `!= 4` excludes hold, `< 3` admits tap and double-tap. `get-gesture-
;;;; detected` answers an `rl/Gesture`, and
;;;; detected` answers an `rl/Gesture`, and that used to end the discussion —
;;;;
;;;; i32 converts a number, found rl/Gesture
;;;;
;;;; so none of those comparisons can be written. They are spelled out below as
;;;; named predicates over keyword equalities — `pinch?`, `swipe?`, `tapish?`.
;;;; That is arguably better source than the magic numbers were, and it is
;;;; strictly more checkable, but it is not a choice: it is the only shape
;;;; available, and a gesture raylib adds later would silently fall out of
;;;; `swipe?` where the C's `> 15` would have caught it.
;;;; — so the three range tests were spelled out as keyword equalities, one
;;;; arm per member. They are `(> (i32 g) 255)`, `(> (i32 g) 15)` and
;;;; `(< (i32 g) 3)` now, still behind the named predicates below, and the fix
;;;; is not brevity: the enumerated version was *wrong about the future*. A
;;;; gesture raylib adds later falls silently out of a list of four members,
;;;; where the C's `> 15` catches it. The range test is the honest reading of
;;;; a bitfield and now it is the one written.
;;;;
;;;; The rule the refusal came from is unchanged and worth keeping: an enum is
;;;; its own type in the checker, so `:tpa` is an error at the call site
;;;; instead of a number that is wrong later, and a bare integer still does not
;;;; fit an `rl/Gesture` parameter. `(i32 g)` does not weaken that — it is
;;;; named, and it is at the site. The rule was "an integer must not arrive
;;;; silently", not "an integer is dangerous".
;;;;
;;;; The `!= 4` stays a keyword comparison, `(not (= g :hold))`. That one was
;;;; never a range test; it is a single member, and the C's 4 is a magic
;;;; number the keyword reads better than.
;;;;
;;;; **No sin or cos.** The prelude has sqrt-f32 — one `declare` over libm,
;;;; with a comment explaining why it is not a builtin — and nothing else
@ -76,18 +88,26 @@
;; ── The comparisons the C makes on the raw bitfield ─────────────────
;;
;; See the header: an rl/Gesture will not convert to an i32, so `> 255` and
;; friends are these instead.
;; A gesture is a flag: 1, 2, 4, 8 … up to 512, so the ranges are the C's way
;; of asking which family a gesture belongs to. (i32 g) is what lets that be
;; written; they are named here rather than inline because a bare 255 at a
;; call site says nothing, and because a gesture raylib adds later lands in
;; the right family without this file being edited.
(defn pinch? [g rl/Gesture] bool ; the C's `> 255`
(or (= g :pinch-in) (= g :pinch-out)))
(> (i32 g) 255))
(defn swipe? [g rl/Gesture] bool ; the C's `> 15`
(or (or (= g :swipe-right) (= g :swipe-left))
(or (= g :swipe-up) (= g :swipe-down))))
(> (i32 g) 15))
(defn tapish? [g rl/Gesture] bool ; the C's `< 3`
(or (= g :tap) (= g :double-tap)))
(< (i32 g) 3))
;; Two orderings these impose, both of them the C's as well. A pinch is above
;; 255 and therefore above 15, so swipe? has to be asked after the pinches
;; rather than before; and tapish? admits :none, which is 0, so it belongs
;; under a :none guard. The C's switch over single members hides both; a range
;; test cannot.
(defn gesture-name [g rl/Gesture] string
(cond
@ -109,9 +129,12 @@
(= g :tap) rl/blue
(= g :double-tap) rl/skyblue
(= g :drag) rl/lime
(swipe? g) rl/red
;; The two pinches are above 255 and so are above 15 as well: swipe? is a
;; range test and has to be asked after them, not before. The C gets this
;; for free by being a switch over single members.
(= g :pinch-in) rl/violet
(= g :pinch-out) rl/orange
(swipe? g) rl/red
:else rl/black))
;; ── The log ─────────────────────────────────────────────────────────

View File

@ -1359,13 +1359,60 @@ and named_call ctx ~want loc name args =
arity loc name 0 args;
prim Tast.Argv (Types.Slice Types.String) []
(* ── casts: (i32 x), (f64 x) ───────────────────────────────────── *)
(* ── casts: (i32 x), (f64 x), and an enum both ways ────────────────
(i32 e) and (GamepadAxis n) are written here rather than in an arm of
their own because they are the same operation: an enum is an i32 at run
time Types.Enum says so and emit.ml's [cast] already reduces one to
its i32 before choosing an instruction. So both directions cost nothing:
src and target are equal after that reduction and [cast] answers the
value unchanged.
Why this does not give the typo back. The property worth keeping is that
:spcae at a call site is an error at that site, and it still is: a
keyword resolves against the parameter's enum and a bare integer does not
fit one. What changes is only that a program can *say* it means the
conversion, by name, at the site. The rule was never "an integer is
dangerous", it was "an integer must not arrive silently", and a written
(GamepadAxis i) is not silent.
Three sub-decisions:
1. enum any numeric is always allowed and never checked. It is lossless
to i32 by construction, and a narrower target truncates by the same
rule every intint cast already follows no special case, and (f32 e)
means (f32 (i32 e)) rather than an arbitrary refusal.
2. integer enum accepts a value that is not a declared member. raylib's
gesture is a bitfield and an OR of flags is a legal Gesture that is no
single member, so refusing it would refuse correct programs; and
session.ml's printer already falls through to the number for an
out-of-range enum, on purpose, so refusing to *construct* one while
blessing its display would be incoherent. An Option would make every
site unwrap for no safety bought, and a literal-only refusal would
catch nothing the bitfield case is a run-time value.
3. Only an integer converts *to* an enum. Not a float, which has no
meaning here, and not another enum: an enum-to-enum hop goes through
(i32 x) so that both ends are written down. *)
| _ when Hashtbl.mem ctx.env.enums name ->
arity loc name 1 args;
let target = resolve_name ctx.env ~seen:[] loc name in
let a = check ctx (List.hd args) in
(match a.Tast.ty with
| Types.Int _ -> ()
| other ->
fail loc "%s converts an integer to an enum, found %s — an enum or a \
float goes through (i32 x) first" name
(Types.to_string other));
prim (Tast.Cast target) target [ a ]
| _ when is_cast name && List.length args = 1 ->
let target = resolve_name ctx.env ~seen:[] loc name in
let a = check ctx (List.hd args) in
if not (Types.is_numeric a.Tast.ty) then
fail loc "%s converts a number, found %s" name
(Types.to_string a.Tast.ty);
(match a.Tast.ty with
| Types.Enum _ -> ()
| t when Types.is_numeric t -> ()
| t -> fail loc "%s converts a number, found %s" name (Types.to_string t));
prim (Tast.Cast target) target [ a ]
(* ── ordinary calls ────────────────────────────────────────────── *)

View File

@ -802,7 +802,8 @@ and qualified_type types s =
and is_type_form types (f : Form.t) =
match f.v with
| Sym s -> Names.mem s types || qualified_type types s
| Sym s ->
Names.mem s types || Names.mem ("enum " ^ s) types || qualified_type types s
| Vec _ -> true (* [T] and [n T] are only types *)
| Map _ -> true (* {K V} in this position *)
| List ({ v = Sym n; _ } :: _) ->
@ -831,6 +832,14 @@ let declared_types (forms : Form.t list) : Names.t =
it that nothing imported. *)
| List [ { v = Sym "import"; _ }; { v = Sym a; _ }; { v = Str _; _ } ] ->
Names.add ("import " ^ a) acc
(* An enum is a type too, but under its own key rather than beside the
structs, because [(Key n)] is now a *value* the integer-to-enum
conversion and putting Key in [types] would make [is_type_form]
read that as a type application and eat it as a return type. So an
enum name counts only as a bare symbol, which is the one position it
can appear in as a type, and never as a list head. *)
| List [ { v = Sym "defenum"; _ }; { v = Sym n; _ }; _ ] ->
Names.add ("enum " ^ n) acc
| _ -> acc)
builtin_types forms

View File

@ -0,0 +1,71 @@
;;;; Converting between an enum and an integer, both ways, explicitly.
;;;;
;;;; An enum is an i32 at run time and its own type in the checker. That is
;;;; what makes :spcae an error at the call site rather than a wrong number
;;;; later, and it is deliberately not weakened here: a bare integer still
;;;; does not fit an enum parameter, and a keyword still resolves against the
;;;; enum the site expects. What is added is a way to *say* the conversion,
;;;; by name, where it is meant — (i32 k) and (K n).
;;;;
;;;; Both directions are free. emit.ml's cast reduces an enum to its i32
;;;; before choosing an instruction, so src and target are the same type and
;;;; the value is answered unchanged; there is no instruction to see at -O0
;;;; either, which is why this runs at both optimisation levels.
(defenum K [lo -1 mid 0 hi 1])
(defn num [k K] i32 (i32 k))
;; An enum parameter driven by a loop variable, which is the shape this exists
;; for: the caller has an index, not a member.
(defn name-at [i i32] string
(let [k (K i)]
(cond (= k :lo) "lo"
(= k :mid) "mid"
(= k :hi) "hi"
:else "other")))
(defn main [] i32
;; enum → i32. Lossless by construction: i32 is the representation.
(println (num :lo))
(println (num :mid))
(println (num :hi))
;; i32 → enum, then back. A round trip is the identity both ways because
;; neither direction is a conversion at run time.
(println (num (K 1)))
(println (i32 (K (num :lo))))
;; A value that is no declared member is allowed. raylib's gesture bitfield
;; is an OR of flags and is exactly this, and the printer already falls
;; through to the number for an out-of-range enum, so refusing to construct
;; one while agreeing to print it would be incoherent.
(let [odd (K 7)]
(println (num odd))
;; And printed as itself, which is the half that makes this coherent: the
;; structural printer is a comparison chain over the declared members and
;; falls through to the number when none match. Agreeing to show a value
;; outside the members while refusing to build one would be the
;; incoherence.
(println odd)
;; The contrast: a value that IS a member prints as the member.
(println (K 1))
(println (if (= odd :hi) "member" "not a member"))
;; The comparisons the raw bitfield wants, which need nothing beyond the
;; conversion: the enum goes to i32 and the literal follows it.
(println (if (> (i32 odd) 3) "above 3" "not above 3")))
;; A narrower or wider target truncates and extends by the same rule every
;; int→int cast follows — lo is -1, so i64 sign-extends and u8 wraps.
(println (i64 (K -1)))
(println (u8 (K -1)))
;; A float target means (f32 (i32 k)); nothing special. Written as (K 1)
;; rather than :hi because a bare keyword outside an enum-typed position has
;; no enum to resolve against, and that refusal is unchanged.
(println (f32 (K 1)))
;; And the index-driven loop the whole thing is for.
(dotimes [i 4]
(println (name-at (- i 1))))
0)

View File

@ -270,4 +270,22 @@
(Some p) (show-v p)
None (println "no crossing"))
;; An enum reaching a declare-c parameter through (Enum n): the axis is a
;; loop variable, which is what nothing could express before. No pad is
;; attached, so raylib answers its own resting value per axis — 0.0 for the
;; four stick axes and -1.0 for the two triggers, which rest at the negative
;; end. That split is what makes this pin worth having: it shows six
;; distinct i32s arriving as six distinct GamepadAxis members rather than
;; one constant answered six times.
(print "axes")
(dotimes [i 6]
(print " ")
(print (rl/get-gamepad-axis-movement 0 (rl/GamepadAxis i))))
;; And an axis that is no declared member. raylib bounds-checks it itself and
;; answers 0.0; the conversion does not refuse it, for the same reason the
;; printer shows an out-of-range enum as its number.
(print " past-end ")
(print (rl/get-gamepad-axis-movement 0 (rl/GamepadAxis 9)))
(println "")
0)

View File

@ -324,7 +324,8 @@ let () =
point on line yes\npoint off line no\n\
point in poly yes\npoint outside poly no\n\
in square, four corners yes\nout of triangle, three no\n\
no crossing\n"
no crossing\n\
axes 0 0 0 0 -1 -1 past-end 0\n"
in
if Sys.command "ldconfig -p 2>/dev/null | grep -q libraylib" = 0 then begin
outputs "raylib ffi, headless" "programs/raylib-ffi.flan" raylib_out;
@ -916,6 +917,28 @@ ERR@7 unexpected token: not the kind the caller was reading
outputs ~opt:"-O0" "enum comparison, -O0" "programs/enum-compare.flan"
enum_out;
(* Converting an enum, explicitly, in both directions: (i32 k) and (K n).
An enum is an i32 at run time, so neither direction is an instruction
and the interesting thing is what the checker will let through which
is why this is here and at -O0 rather than only in test_flan. The rows
are the three members out, a round trip back, a value that is no
declared member and the comparisons it exists for, a narrowing and a
widening of a negative member, and an enum parameter driven by a loop
variable, which is the whole point.
The two bare prints in the middle are the ones that hold the design up
rather than merely exercising it: 7 prints as 7 and 1 prints as :hi.
Allowing a non-member to be *built* is only coherent because the
printer already shows one as its number, and this is where that is
asserted rather than asserted about. *)
let enum_conv_out =
"-1\n0\n1\n1\n-1\n7\n7\n:hi\nnot a member\nabove 3\n-1\n255\n1\n\
lo\nmid\nhi\nother\n"
in
outputs "enum conversion" "programs/enum-convert.flan" enum_conv_out;
outputs ~opt:"-O0" "enum conversion, -O0" "programs/enum-convert.flan"
enum_conv_out;
(* ── declare-c: the generated FFI shim (lib/shim.ml) ────────────────
The raylib package is the proof that the generator is real 84

View File

@ -633,6 +633,39 @@ let () =
~needle:"has no member :spcae";
accepts "a keyword that is a member"
"(defenum Key [space 32 r 82]) (defn g [k Key]) (defn f [] (g :r))";
(* Converting an enum, explicitly, in both directions. The point of the
conversion is that it is written at the site: a bare integer still does
not fit an enum parameter, so the checked property a typo is an error
here rather than a wrong number later is untouched. *)
accepts "an enum converts to an integer"
"(defenum Key [space 32]) (defn f [k Key] i32 (i32 k))";
accepts "an enum converts to a float, through its i32"
"(defenum Key [space 32]) (defn f [k Key] f32 (f32 k))";
accepts "an integer converts to an enum"
"(defenum Key [space 32]) (defn g [k Key]) (defn f [i i32] (g (Key i)))";
accepts "a value that is no declared member converts"
"(defenum Key [space 32]) (defn g [k Key]) (defn f [] (g (Key 999)))";
rejects_check "an integer still does not fit an enum on its own"
"(defenum Key [space 32]) (defn g [k Key]) (defn f [i i32] (g i))"
~needle:"expected Key";
rejects_check "an enum does not convert to another enum"
"(defenum A [x 1]) (defenum B [y 1]) (defn f [a A] B (B a))"
~needle:"converts an integer to an enum";
rejects_check "a float does not convert to an enum"
"(defenum Key [space 32]) (defn f [x f32] Key (Key x))"
~needle:"converts an integer to an enum";
(* An enum is a return type, which needed parse.ml to know enum names. It
knows them under a key of their own: (Key n) is a value now, so putting
Key in [types] would make a body starting with one be eaten as a return
type the exact trap [is_type_form]'s comment is about. *)
accepts "an enum is a return type"
"(defenum Key [space 32]) (defn f [i i32] Key (Key i))";
accepts "an enum conversion at the head of a body is not a return type"
"(defenum Key [space 32]) (defn g [k Key]) \
(defn f [] (Key 1) (g :space))";
rejects_check "an enum conversion takes one argument"
"(defenum Key [space 32]) (defn f [] Key (Key 1 2))"
~needle:"1 argument";
(* A folded constant skips [check], so its range check has to be its own. *)
rejects_check "a folded constant is still range-checked"
"(defconst c u8 300) (defn f [] u8 c)" ~needle:"does not fit in u8";