defenum members may leave their value to the previous one

This commit is contained in:
Joseph Ferano 2026-09-17 18:36:34 +07:00
commit de3ad8c052
3 changed files with 129 additions and 8 deletions

View File

@ -34,9 +34,12 @@ type and logical on an unsigned one), and `rand-f32`.
only a regression test if the sequence is byte-identical on native and wasm32 (plan.org, RNG is ours). `rand-seed` sets
the state. This is what the bitwise operators were added for.
**Enums and keywords.** `(defenum Name [member value ...])` gives a type that is an `i32` at run time and its own type
**Enums and keywords.** `(defenum Name [member value? ...])` gives a type that is an `i32` at run time and its own type
in the checker, so `:space` at a call site resolves against the parameter's enum and a typo is an error there rather
than a wrong number later. A keyword means nothing where no enum is expected — there is no keyword type to fall back on.
A member's value may be left out, and then it is the one above it plus one, starting at 0 — C's rule, because these
enums are as often a transcription of a header as they are original. A value written twice is an alias and is allowed;
a value autoincrement *walks into* is refused, naming both members, because nothing in the source chose it.
## Why the FFI goes through a C shim

View File

@ -924,19 +924,82 @@ let rec decl (f : Form.t) : Ast.decl =
| _ -> fail f "%s" usage)
| _ -> fail f "%s" usage)
(* A member's value is optional and autoincrements, which is C's rule and is
here for C's reason: the enums this language writes are as often a
transcription of a header as they are original, and every value written by
hand is a value that can be wrong. [0 1 2 3 4] typed out is fine until a
member is inserted in the middle, and then the renumbering is a manual
edit of every line below it.
Which values were *written* does not survive this form. [Ast.Defenum]
holds resolved numbers, so by the time [Check] sees an enum the difference
between a number someone chose and one autoincrement produced is gone --
and there are no per-member locations in the AST to point at either. That
is why the collision rule below is enforced here and not in the checker
beside the duplicate-*name* rule: this one is a question about the source
text, and the parser is the last pass that can still answer it. *)
| List ({ v = Sym "defenum"; _ } :: args) ->
(match args with
| [ n; { v = Form.Vec ms; _ } ] ->
let rec pairs = function
(* Each member becomes its name, its value, whether that value was
written, and where the name is. The last two exist only so the
refusal below can be made; neither reaches the AST. *)
let rec members next = function
| [] -> []
| { v = Form.Sym m; _ } :: { v = Form.Int k; _ } :: rest ->
(m, k) :: pairs rest
| { v = Form.Sym m; loc } :: { v = Form.Int k; _ } :: rest ->
(m, k, true, loc) :: members (Int64.add k 1L) rest
| { v = Form.Sym m; loc } :: rest ->
(m, next, false, loc) :: members (Int64.add next 1L) rest
| bad :: _ ->
fail bad "an enum member is a name followed by an integer, found %s"
(Form.to_string bad)
fail bad
"an enum member is a name, optionally followed by an integer, \
found %s" (Form.to_string bad)
in
mk (Ast.Defenum (sym n, pairs ms))
| _ -> fail f "defenum is (defenum Name [member value ...])")
let ms = members 0L ms in
(* A duplicate value that was written is an alias and is meant: a [Count]
or a [Last] pointing at a value another member already holds is how C
spells the end of a range, and refusing it would refuse a real idiom.
A duplicate that autoincrement walked into is nobody's decision. It
happens when a member above is renumbered or one is inserted, and the
result is two names for one number with nothing in the source saying
so -- silently, and the program still compiles, and one of the two is
now unreachable through a [match] on the other. That silence is the
same failure class as a silent misparse, so the implicit member is
refused; writing its value out is both the fix and the way to say the
alias was intended.
Every member is resolved before any of this runs, because the member
an autoincrement collides with is as often below it as above: in
[(defenum E [A B 0])] it is [A], the implicit one, that has to be
refused, and a left-to-right check would never see [B] coming. *)
let indexed =
List.mapi (fun i (m, v, explicit, loc) -> (i, m, v, explicit, loc)) ms
in
List.iter
(fun (i, m, v, explicit, loc) ->
if not explicit then
match
List.find_opt
(fun (j, _, ov, _, _) -> j <> i && Int64.equal ov v)
indexed
with
| None -> ()
| Some (_, other, _, _, oloc) ->
Loc.failk "parse/enum-autoincrement-collision" loc
~notes:
[ Loc.note oloc
(Printf.sprintf "%s has the value %Ld" other v) ]
"%s has no value of its own, so it autoincrements to %Ld, \
which is the value %s already has. Give %s its value \
explicitly if the two are meant to be one number under two \
names, or a value no other member holds"
m v other m)
indexed;
mk (Ast.Defenum (sym n, List.map (fun (m, v, _, _) -> (m, v)) ms))
| _ ->
fail f
"defenum is (defenum Name [member value? ...]). A member with no \
value takes the previous member's plus one, and the first takes 0")
| List ({ v = Sym "defvar"; _ } :: args) ->
(match args with

View File

@ -523,6 +523,61 @@ let () =
parse_rejects "splice not inside a bracket" "(defn f [] Form `~@xs)"
~needle:"nothing here for it to splice into";
(* ── defenum: a value is optional, and autoincrements ──────────── *)
(* The numbers are the whole of what the form means, so they are what is
asserted on -- the parser has resolved them by the time a decl exists, and
nothing downstream can tell an implicit value from a written one. *)
let enum_values name src want =
match (parse_decl src).d with
| Defenum (_, ms) ->
let show vs = String.concat " " (List.map Int64.to_string vs) in
let got = List.map snd ms in
if got <> want then begin
incr failures;
Printf.printf "FAIL %s\n wanted: [%s]\n got: [%s]\n"
name (show want) (show got)
end
| _ -> check (name ^ ": parses as a defenum") false
in
enum_values "every member implicit" "(defenum E [A B C])" [ 0L; 1L; 2L ];
enum_values "every member explicit" "(defenum E [A 3 B 9 C -1])"
[ 3L; 9L; -1L ];
(* The mixed case is the point of the feature: an explicit value resets the
count, and the members below it carry on from there. *)
enum_values "an implicit member follows the explicit one above it"
"(defenum E [A B C 10 D])" [ 0L; 1L; 10L; 11L ];
(* The C idiom the explicit-duplicate rule exists for. *)
enum_values "a written duplicate is an alias and is kept"
"(defenum E [First 0 Second 1 Last 1])" [ 0L; 1L; 1L ];
enum_values "an enum with no members at all" "(defenum E [])" [];
(* A value autoincrement walked into is refused, because nothing in the
source chose it -- and the refusal has to name the *other* member, which
is the half a reader cannot see from the line that failed. Three needles
for one source: the message is only doing its job if both names, the
number, and the way out are all in it. *)
parse_rejects "an autoincrement onto a value already taken"
"(defenum E [A 0 B 1 C 0 D])"
~needle:"D has no value of its own, so it autoincrements to 1";
parse_rejects "the refusal names the member already holding the value"
"(defenum E [A 0 B 1 C 0 D])"
~needle:"which is the value B already has";
parse_rejects "the refusal says how to say the alias was meant"
"(defenum E [A 0 B 1 C 0 D])"
~needle:"Give D its value explicitly";
(* The member collided with is as often below as above: here it is [A], the
implicit one, that is refused, and a left-to-right check would pass it. *)
parse_rejects "an autoincrement onto a value written further down"
"(defenum E [A B 0])"
~needle:"A has no value of its own, so it autoincrements to 0";
(* Genuinely malformed input still says what a member is, in the grammar the
form now has. *)
parse_rejects "an enum member that is not a name" "(defenum E [1 A])"
~needle:"an enum member is a name, optionally followed by an integer";
parse_rejects "a defenum with no member vector" "(defenum E)"
~needle:"defenum is (defenum Name [member value? ...])";
(* ── Malformed syntax is caught with a location ────────────────── *)
parse_rejects "odd let bindings" "(let [a])";
parse_rejects "odd field pairs" "(defstruct S [a])";