A struct writes itself where its type is already known, and positionally

This commit is contained in:
Joseph Ferano 2026-09-20 18:23:42 +07:00
commit 92bf091361
8 changed files with 527 additions and 30 deletions

135
FIX.org
View File

@ -1611,3 +1611,138 @@ 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.
* Two struct spellings, 2026-09-20
Both were DISCUSS.org items, both diagnosed there as the same parse/check
boundary problem, and both are decided by the author on 2026-09-20.
** A. A bare ~{.field v}~ takes its type from the position it stands in
The note's own diagnosis was right: the refusal ("a bare map is not an
expression; write (Type {.field v})") sat in ~Parse.expr~, before any
checking, so ~(defn get-mouse-cell [] Cell ... {.row r .col c})~ could not
work no matter what the checker knew. It has moved.
Parse now builds ~Ast.Bare~ — a field list with no name — out of the same
~struct_fields~ the named form uses, so the two field lists are parsed by one
function and cannot drift. ~Check.check_bare~ reads the type name off the
expectation and hands that same list to ~check_struct~. That is the whole
feature: ZII for an omitted field, the unknown-field refusal, the
duplicate-field refusal, their notes and their error kinds are not "the same
as" the named form's, they *are* the named form's, reached by the same call.
*Accepted* — every position that carries a want:
- a defn's return position (the case from the notes),
- an argument of a call, the only argument or a later one,
- a field of an enclosing literal, at any depth,
- a typed place being ~set~, a local or a field of one,
- a union want, which reaches ~check_union~ by the same route.
*Refused, at checking, by name*:
- no want at all (a ~let~ binding, a body form that is not the last): "does
not say which struct it builds — the fields alone do not name a type",
naming both ways out, and saying why a let binding is not one of them (a
local takes its type from its value).
- a ~dyn~ want: refused, and told that a dyn map's keys are keywords. This is
the boundary that mattered most. Braces at a dyn want are the dyn map
literal and stay exactly that; a ~.field~-keyed brace was never part of
that spelling and is not being quietly given a second meaning now.
- a want that is not a struct type at all: "i32 is expected here, which is
not a struct type".
- a data type's name as the want: inherits the existing message, which names
the cases — ~D~ is not specific enough, a value of ~D~ is one of its cases.
One thing had to move in ~Check~ as well as in ~Parse~. ~(g {.row 2})~ is
parsed as a struct literal named ~g~, because the parser's struct-literal arm
fires on the *shape* of the single argument and has no table to consult; it
used to be refused with "g is a function, not a struct". Now, when the head is
a name that would actually resolve to a callee (a defn, a generic, or a local
of function type — ~callable~), it is handed back to ~named_call~ as the call
it was written as, with the fields rebuilt into the ~Ast.Bare~ node the parser
would have made anywhere else. An unknown name keeps the old "unknown struct"
report, because that shape is usually a misspelled struct name. ~(name {})~
keeps its old refusal untouched: the empty braces are genuinely ambiguous
between the zero-field struct literal and the empty dyn map, and that one does
have the let-binding fix its message already names.
** B. ~(Cell 1 2)~ positional, and arity is exact
The note's diagnosis again: the parser cannot tell ~(Cell 1 2)~ from any other
call, so ~Check~ does it. The decision sits on the last arm of ~named_call~
after a local of function type, after a generic, after the global function
table — where the old "Cell is a type" refusal used to be.
*No collision is possible.* ~collect~'s ~claimed~ table spans every
declaration kind, so one name is one declaration and a ~defstruct Cell~ beside
a ~defn Cell~ is "Cell is defined twice" before any of this is reached. A
~(defclass point [x y])~ constructor is a real ~defn~ that ~Classes.expand~
wrote before checking began, so ~(point 1 2)~ resolves in ~env.fns~ two arms
above the struct one and never reaches it. Pinned both ways.
*** The arity decision: exact, no partial ZII
Positional construction gives every field or it is refused. This is not a
retreat from ZII — ZII is what the designated form does, and ~(Cell {.row 1})~
still zeroes ~.col~, which is where the message points. The reason is that a
positional list cannot *say* which field it left out. ~(Cell 1)~ reads as a
Cell with one field given, and which field that is depends on a declaration
order the author is free to change later; a trailing field silently zeroed
there is the field-reorder hazard at its very worst, arriving as a wrong value
rather than as an error. So a short list is a refusal that names the first
field it did not reach:
: Cell has 2 fields and 1 was given positionally — .col has no value.
: Positional construction gives every field, in declaration order; to give
: some of them and zero the rest, a struct value is written
: (Cell {.field value ...})
with ~declared_note~ pointing at the declaration. A long list points at the
first extra argument and shows both spellings. Odin's positional literal takes
the same line, and the repo's ZII philosophy is not against this: ZII is about
what an *omitted* field means, and this refusal is about a spelling that
cannot express omission at all.
*** The field-reorder hazard, accepted
The author's words, on B's remaining cost: "B can be fixed with refactorings
later on when we decide to add it." Reordering a ~defstruct~'s fields silently
changes what every positional call site builds, and nothing in the compiler
catches it when the types happen to line up. Accepted as the price, with
refactoring tooling named as the eventual answer rather than a compiler rule.
*** Argument type errors
Each argument is checked against its own field's type by ~map2_lr~ and
~~want~~, exactly as a call's arguments are checked against its parameters —
so the mismatch is reported at the argument, in the words a call's argument
already gets ("expected f32, found string"). What is added is a *note*,
"this is Cell's field .col", plus ~declared_note~, because a positional call
site is the one place the source does not show the field name. The note is
attached only to a diagnostic raised at that argument's own location, and it
only ever names the position — which is true whatever went wrong there — so a
nested failure inside the argument cannot be miscaptioned by it. Naming the
field *in the message* would need an error-context helper ~loc.ml~ does not
have; not built here, since the note carries the same information and adding
the helper touches a file the Elm-messages lane is in.
** Backends
Zero edits, and nothing to edit. Both features are gone by the time ~Check~
finishes: a bare literal becomes the ~Tast.Make~ the named form already built,
and a positional call becomes that same node with its arguments put into
declaration order. ~Emit~ and ~X86~ have no case for either. The three
acceptance rows for ~test/programs/struct-ergonomics.flan~ — default, ~-O0~,
~--x86~ — print the same fifteen lines, which is what says so out loud.
** One existing test's needle had to change, and one did not
~test_flan.ml~'s two "bare struct-shaped braces still refuse" rows refused at
*parse* and now refuse at *checking*, for a different and better reason; their
needles and the comment above them were rewritten together. The row for
~(Cursor {:src s})~ did not have to move: that form is now a Cursor built from
too few arguments, and the new refusal still contains "a struct value is
written (Cursor {.field value ...})", which is what its needle asks for. Its
comment was corrected anyway, since the *reason* it refuses changed.
** What was run
~dune test --root .~ in the lane's worktree: exit 0, no FAIL lines. The first
run exited 1 with ~Fatal error: exception Flan.Wire.Closed~ and no FAIL line
anywhere — the known ~test_dev.ml~ ~trap_park~ race recorded under the classes
lane above, not this lane's; the rerun was clean. The survey program was run
by hand at all three settings and its output diffed across them before the
acceptance rows were written. Not added to ~test_sanitize.ml~: the program
allocates one small dyn map and nothing else, so there is nothing for ASan to
find that ~dyn-map.flan~ does not already exercise.

View File

@ -66,6 +66,14 @@ and expr_kind =
| Call of expr * expr list
| Match of expr * arm list
| Struct of string * (string * expr) list (* (Cursor {.src s}) *)
(* {.src s .pos 0} with no type written in front of it. The fields alone do
not name a type, so this node carries no name and is only checkable where
the checker already has an expectation to read one off a defn's return
position, a typed argument, a field of an enclosing literal, a typed
place. [Check] refuses it everywhere else. The parser cannot make this
decision: it has no symbol table and no expectation, which is why the
refusal that used to live in [Parse.expr] moved. *)
| Bare of (string * expr) list (* {.src s} *)
(* {:a 1 :b s} — a dyn map literal. Braces whose first form is not a
[.field] symbol are this; the struct spelling keeps the dot. Keys are
ordinary expressions, keywords being the common case. *)
@ -357,6 +365,7 @@ let map_children f (e : expr) : expr =
| Call (fn, args) -> Call (ex fn, List.map ex args)
| Match (s, arms) -> Match (ex s, List.map arm arms)
| Struct (n, fs) -> Struct (n, List.map (fun (n, v) -> (n, ex v)) fs)
| Bare fs -> Bare (List.map (fun (n, v) -> (n, ex v)) fs)
| MapLit (tag, kvs) -> MapLit (tag, List.map (fun (k, v) -> (ex k, ex v)) kvs)
| Arr es -> Arr (List.map ex es)
| Fn (ps, es) -> Fn (ps, List.map ex es)

View File

@ -2548,6 +2548,7 @@ let rec check ctx ?want (e : Ast.expr) : Tast.expr =
let fty = (List.nth s.Tast.fields i).Tast.fty in
expect ctx loc ~want (mk loc fty (Tast.Field (target, i))))
| Ast.Struct (name, kvs) -> check_struct ctx ~want loc name kvs
| Ast.Bare kvs -> check_bare ctx ~want loc kvs
(* A bracket literal where a dyn is wanted is the runtime's own vec, built
where it stands the same lowering the map literal gets, and what makes
{:xs [1 2]} mean what it reads as. Everywhere else brackets stay the
@ -3746,6 +3747,138 @@ and check_if ctx ?(tail = false) ?want loc c t e =
in
mk loc ty (Tast.If (c, t, e))
(* Whether a name would reach a callee if it were called — a global function, a
generic, or a local holding a function value. The three sources [named_call]
itself consults, in its own order; builtins are deliberately not among them,
so that [(println {.f v})] still reports what it reports today. *)
and callable ctx name =
Hashtbl.mem ctx.env.fns name
|| Hashtbl.mem ctx.env.gsigs name
|| (match lookup ctx name with
| Some b -> (match b.bty with Types.Fn _ -> true | _ -> false)
| None -> false)
(* [(Cell 1 2)] — a struct built from its fields in declaration order.
The parser cannot make this one either, and for a sharper reason than the
bare literal above: [(Cell 1 2)] is character-for-character an ordinary call
and only the symbol table tells the two apart. So it is decided here, on the
last arm of [named_call], which is to say *after* a local of function type,
after a generic and after the global function table. Nothing can be shadowed
into a struct constructor by accident, because a name is one declaration:
[collect]'s [claimed] table spans every declaration kind, so a [defstruct
Cell] and a [defn Cell] cannot both exist. A [(defclass point [x y])]
constructor is a real [defn] that [Classes.expand] wrote before checking
began, so [(point 1 2)] resolves in [env.fns] two arms above this one and
never reaches here.
ARITY IS EXACT, and that is the decision worth writing down. ZII is not
withdrawn it is what the designated form does, and [(Cell {.row 1})]
still zeroes [.col]. What positional construction cannot do is *say* which
field was left out: [(Cell 1)] reads as a Cell with one field given, and
which one depends on a declaration order that the author is free to change
later. A trailing field silently zeroed there is the field-reorder hazard
at its worst, so a short argument list is a refusal that names the first
field it did not reach, and points at the spelling that does mean "zero the
rest". Odin's positional literal takes the same line. *)
and positional_struct ctx ~want loc name args =
let s = Hashtbl.find ctx.env.structs name in
let fields = s.Tast.fields in
let n = List.length fields in
let given = List.length args in
let note = declared_note ctx.env name in
if given < n then begin
let missing = List.nth fields given in
Loc.failk "check/positional-too-few" loc ~notes:note
"%s has %d field%s and %d %s given positionally — .%s has no value. \
Positional construction gives every field, in declaration order; to \
give some of them and zero the rest, a struct value is written (%s \
{.field value ...})"
name n (if n = 1 then "" else "s") given
(if given = 1 then "was" else "were") missing.Tast.fname name
end;
if given > n then begin
let extra = List.nth args n in
Loc.failk "check/positional-too-many" extra.Ast.loc ~notes:note
"%s has %d field%s, and this is argument %d — a struct value is written \
(%s {.field value ...}) or (%s %s)"
name n (if n = 1 then "" else "s") (n + 1) name name
(String.concat " " (List.map (fun (f : Tast.field) -> f.Tast.fname) fields))
end;
(* Left to right, each against its own field's type, exactly as the argument
list of a call is checked against its parameters same [map2_lr], same
[~want], so an untyped literal takes the field's type and a nested bare
literal (feature A above) gets an expectation here as well. The mismatch
is reported at the argument, by [expect], in the words a call's argument
already gets; what is added is a note saying which field that argument
was, because at a positional call site the field name is the one thing
the source does not show. The note is attached only to a failure raised
at this argument's own location, and it says nothing about the failure
it names the position, which is true whatever went wrong there. *)
let fields =
map2_lr
(fun (f : Tast.field) (a : Ast.expr) ->
try check ctx ~want:f.Tast.fty a with
| Loc.Error d when d.Loc.dloc = a.Ast.loc ->
Loc.raise_diag
{ d with
Loc.notes =
d.Loc.notes
@ [ Loc.note a.Ast.loc
(Printf.sprintf "this is %s's field .%s" name
f.Tast.fname) ]
@ note })
fields args
in
expect ctx loc ~want (mk loc (Types.Named name) (Tast.Make (name, fields)))
(* [{.f v}] with no type written in front of it, checked against whatever type
the position it stands in expects.
The whole of the feature is the one line that hands [kvs] to [check_struct]
with the expected type's name: from there a bare literal and a named one are
the same literal, checked by the same code. Duplicate fields, unknown
fields, their notes and their error kinds, and ZII zero-fill for the fields
left out are therefore not "the same rules" as the named form's they are
the named form's, reached through the same call.
A named type is the only expectation that says anything. [Dyn] deliberately
does not: braces at a dyn want are the dyn map literal ({:key value}, and
[Ast.MapLit]) and always have been, and a [.field]-keyed brace was never
part of that spelling. So a dyn want is refused here rather than quietly
given a second meaning, and the message points at the map spelling because
that is what someone at a dyn want almost certainly wanted.
With no expectation at all there is nothing to infer from and the refusal
names both ways out: write the type, or move the literal somewhere a type
is known. *)
and check_bare ctx ~want loc kvs =
let written =
"{" ^ String.concat " " (List.map (fun (k, _) -> "." ^ k) kvs) ^ " ...}"
in
match want with
| Some (Types.Named n) -> check_struct ctx ~want loc n kvs
| Some Types.Dyn ->
Loc.failk "check/bare-struct-dyn" loc
"a dyn is expected here, and %s is a struct field list, not a dyn map — \
a dyn map's keys are keywords, as {:%s value ...}"
written
(match kvs with (k, _) :: _ -> k | [] -> "key")
| Some other ->
Loc.failk "check/bare-struct-want" loc
"%s is a struct field list and %s is expected here, which is not a \
struct type"
written (Types.to_string other)
| None ->
Loc.failk "check/bare-struct-untyped" loc
"%s does not say which struct it builds — the fields alone do not name \
a type. Write it, as (Type %s), or put the literal where a type is \
already known: a function's return position, an argument of a call, a \
field of another literal, or a typed place being set. A let binding is \
none of those a local takes its type from its value, so there is \
nothing there to read one off"
written written
(* A record-shaped literal: one form for both, because [(Name {.f v})] is the
same syntax whether [Name] is a struct or a data type case, and the two differ
only in what is built at the end. Deciding here rather than in the parser is
@ -3782,16 +3915,26 @@ and check_struct ctx ~want loc name kvs =
size a [.field]-first map is never a dyn map argument, only ever
this struct-literal shape, whatever [name] is. [(name {:a 1})] is
the one that keeps [name] an ordinary call, because a
keyword-keyed map is never mistaken for a struct literal. So both
shapes below get the helpful message, not just the empty one: if
[name] is a known function, "unknown struct" is the wrong report
either way, and the two shapes need different advice, because only
one of them has a fix. An empty map can be bound to a variable and
passed as an ordinary dyn map argument, [(let [m {}] (name m))]. A
[.field]-keyed one cannot [expr] itself refuses a bare
[{.field v}] outside a struct-literal position ("a bare map is not
an expression"), so there is no let-binding that rescues it. The
syntax was never a map's; a dyn map's keys are keywords. *)
keyword-keyed map is never mistaken for a struct literal.
The [.field]-keyed blind spot is no longer a blind spot, and that
is the argument half of the bare-literal feature. [(g {.row 2})] is
a call to [g] whose one argument is a bare struct literal the
parameter is the expectation that names its type and the only
reason it arrives here wearing a struct literal's clothes is that
the parser had to guess and guessed by shape. So it is handed back
to [named_call] as the call it was written as, with the fields
rebuilt into the [Ast.Bare] node the parser would have made had the
braces stood anywhere else. Only for a name that is actually
callable: an unknown name keeps the "unknown struct" report below,
because a misspelled struct name is what that shape usually is.
The empty braces keep their old refusal, because they are still
genuinely ambiguous [{}] is the zero-field struct literal AND the
empty dyn map, with nothing in the shape to separate them and
that one does have the let-binding fix the message names. *)
else if kvs <> [] && callable ctx name then
named_call ctx ~want loc name [ { Ast.e = Ast.Bare kvs; loc } ]
else if Hashtbl.mem ctx.env.fns name then
(match kvs with
| [] ->
@ -6381,9 +6524,7 @@ and named_call ctx ~want loc name args =
or %s.%s on its own when it has no fields"
name dname dname c.Tast.vname dname c.Tast.vname
else if Hashtbl.mem ctx.env.structs name then
fail loc
"%s is a type — a struct value is written (%s {.field value ...})"
name name
positional_struct ctx ~want loc name args
else if String.contains name '/' then
unimplemented loc
(Printf.sprintf "the call %s into an imported package" name) 4

View File

@ -265,6 +265,11 @@ let rec rename_expr owned alias bound (e : Ast.expr) : Ast.expr =
[ ("s", { v with Ast.e = Ast.Str (qualify_name owned alias bound s) }) ])
| Ast.Struct (n, kvs) ->
Ast.Struct (name n, List.map (fun (k, v) -> (k, go v)) kvs)
(* A bare field list has no type name of its own to qualify — that is what
makes it bare so only its values are walked. The type it turns out to
be comes from the expectation at the point of use, and whatever wrote
that expectation was qualified where it stands. *)
| Ast.Bare kvs -> Ast.Bare (List.map (fun (k, v) -> (k, go v)) kvs)
(* A dyn map literal has no name of its own to qualify; its keys and
values are ordinary expressions and are walked like anything else. *)
| Ast.MapLit (tag, kvs) ->
@ -731,6 +736,11 @@ let rec expr_uses acc (e : Ast.expr) =
| Ast.Struct (n, kvs) ->
acc := (n, e.Ast.loc) :: !acc;
List.iter (fun (_, v) -> go v) kvs
(* No name to record: a bare field list mentions no type, so it makes no
type reachable on its own. The expectation that gives it one is written
somewhere that this walk already reaches a signature, an annotation, or
an enclosing literal's own name. *)
| Ast.Bare kvs -> List.iter (fun (_, v) -> go v) kvs
| Ast.MapLit (_, kvs) -> List.iter (fun (k, v) -> go k; go v) kvs
| Ast.Arr items -> gos items
| Ast.ArrayOf t -> texpr_uses acc t

View File

@ -313,13 +313,20 @@ let rec expr (f : Form.t) : Ast.expr =
they are a slice or array type. Position disambiguates, as with {}. *)
| Vec items -> mk (Ast.Arr (List.map expr items))
(* Braces in value position are two literals told apart by their first form.
A [.field] symbol says struct, and a bare struct field list still needs
its type written (Type {.field v}) because the fields alone do not
name one. Anything else, the empty braces included, is a dyn map literal:
{:a 1 :b s}, keys and values alternating, each an ordinary expression. *)
| Map ({ v = Sym s; _ } :: _)
A [.field] symbol says struct; anything else, the empty braces included,
is a dyn map literal: {:a 1 :b s}, keys and values alternating, each an
ordinary expression.
A struct field list with no type in front of it is [Ast.Bare]. The fields
alone do not name a type, but the *position* often does a defn's return
type, a typed parameter, a field of an enclosing literal and only the
checker can see that. So this parses and [Check] decides: it reads the
type off the expectation where there is one, and refuses by name where
there is not. The refusal used to stand right here, which is why
[(defn f [] Cell {.row r .col c})] could not work at all. *)
| Map (({ v = Sym s; _ } :: _) as kvs)
when String.length s > 1 && s.[0] = '.' ->
fail f "a bare map is not an expression; write (Type {.field v})"
mk (Ast.Bare (struct_fields f kvs))
| Map items -> mk (Ast.MapLit (None, map_pairs f items))
| List [] -> fail f "() is not an expression"
| List (head :: args) -> form f mk head args

View File

@ -0,0 +1,70 @@
;;;; Two spellings a struct value gained, both decided by the checker.
;;;;
;;;; A bare {.field v} has no type written in front of it. The fields alone do
;;;; not name one, but the position usually does: a defn's return type, a
;;;; parameter, a field of an enclosing literal, a place being set. The parser
;;;; cannot see any of that -- it has no symbol table and no expectation -- so
;;;; it builds the field list and the checker reads the type off the want and
;;;; hands the very same list to the named form's own code. Everything below
;;;; that follows -- ZII for an omitted field, the unknown-field and
;;;; duplicate-field refusals -- is therefore not a copy of the named form's
;;;; rules, it is the named form's rules.
;;;;
;;;; (Cell 1 2) is the other half, and it is a call until the checker looks the
;;;; head up: a struct name where a function name would be. Arity is exact.
;;;; ZII is still available and is what the braces do; what a positional list
;;;; cannot do is SAY which field it left out, so it is not allowed to leave
;;;; one out.
(defstruct Cell [row i32 col i32])
(defstruct Grid [a Cell b Cell])
;; The case from the notes: a defn whose return type is the only place the
;; type is written. This is what used to fail at parse, before any checking.
(defn origin [] Cell {.row 0 .col 0})
;; An omitted field is zeroed, exactly as (Cell {.row 3}) zeroes .col.
(defn just-row [r i32] Cell {.row r})
;; A parameter is an expectation too -- as the only argument and as a later
;; one, which are two different paths into the checker and both arrive here.
(defn sum [c Cell] i32 (+ (.row c) (.col c)))
(defn offset-sum [n i32 c Cell] i32 (+ n (sum c)))
;; A field of an enclosing literal. The inner braces are bare and the outer
;; ones are not, so both readings stand side by side in one form.
(defn grid [] Grid (Grid {.a {.row 1 .col 2} .b (Cell 3 4)}))
;; Positional, in the position that names the type anyway.
(defn diag [n i32] Cell (Cell n n))
;; Nested positional, and positional feeding a bare literal's field.
(defn pair [] Grid (Grid {.a (Cell 5 6) .b (Cell 7 8)}))
(defn main [] ()
(println (sum (origin)))
(println (sum (just-row 9)))
(println (sum {.row 10 .col 20}))
(println (offset-sum 1 {.row 2}))
(let [g (grid)]
(println (.row (.a g)))
(println (.col (.a g)))
(println (.row (.b g)))
(println (.col (.b g))))
(println (sum (diag 11)))
(let [p (pair)]
(println (sum (.a p)))
(println (sum (.b p))))
;; A bare literal set into a typed place: the place's type is the want.
(let [c (Cell 0 0)]
(set c {.row 7 .col 8})
(println (sum c))
;; And into a field, whose type is the want one level down.
(let [g (Grid {.a c .b c})]
(set (.b g) {.row 100})
(println (sum (.b g)))))
;; The dyn map literal is untouched by any of this: keyword keys, and a
;; .field-keyed brace was never part of that spelling.
(let [m {:a 1 :b 2}]
(println (len m))
(println (get m :b))))

View File

@ -3650,6 +3650,25 @@ level "1"
"programs/dyn-struct.flan" dyn_struct_out;
outputs ~x86:true "dyn: a struct with dyn fields, under collection, --x86"
"programs/dyn-struct.flan" dyn_struct_out;
(* Two struct spellings the checker decides: a bare {.field v} typed by
the position it stands in, and (Cell 1 2) positional. Three rows
because that is the house rule, not because the backends could differ
neither feature reaches a backend at all. Both are gone by the time
[Check] is finished: a bare literal becomes the [Tast.Make] the named
form already built, and a positional call becomes the same node with
its arguments put in declaration order. [Emit] and [X86] have no case
for either and needed no edit, which is what identical output across
the three rows says out loud. *)
let struct_erg_out =
"0\n9\n30\n3\n1\n2\n3\n4\n22\n11\n15\n15\n100\n2\n2\n"
in
outputs "structs: a bare literal and a positional constructor"
"programs/struct-ergonomics.flan" struct_erg_out;
outputs ~opt:"-O0" "structs: a bare literal and a positional constructor, -O0"
"programs/struct-ergonomics.flan" struct_erg_out;
outputs ~x86:true "structs: a bare literal and a positional constructor, --x86"
"programs/struct-ergonomics.flan" struct_erg_out;
let dyn_global_out = "0 start\n2 done\n" in
outputs "dyn: a global" "programs/dyn-global.flan" dyn_global_out;
outputs ~opt:"-O0" "dyn: a global, -O0"

View File

@ -1318,18 +1318,22 @@ let () =
"(defn main [] i32 (let [m {:a 1 :b}] (len m)))"
~needle:"odd number of forms";
(* The struct spelling is untouched on both of its sides: bare braces
opening on a .field are still a struct literal that wants its type
written, and (Type {.field v}) still builds one. It is untouched at
the head of a defn body too, single-form or not [constraints]
(parse.ml) leaves a [.field]-first map alone precisely so this
dedicated message, not "a constraint map is keyword/value pairs",
is what a program gets there. *)
rejects_check "bare struct-shaped braces still refuse"
opening on a .field are still a struct field list and not a map, and
(Type {.field v}) still builds one. What changed is *where* the two
rows below are refused at checking now, not at parsing, because a
.field-keyed brace with a type expected of it is a struct literal and
only the checker can see the expectation. Neither position has one: a
let binding takes its type from its value, and a form that is not a
body's last is not the return value. It is untouched at the head of a
defn body too, single-form or not [constraints] (parse.ml) leaves a
[.field]-first map alone precisely so this dedicated message, not "a
constraint map is keyword/value pairs", is what a program gets there. *)
rejects_check "bare struct-shaped braces with nothing to infer from"
"(defn main [] i32 (let [m {.x 1}] 0))"
~needle:"write (Type {.field v})";
~needle:"does not say which struct it builds";
rejects_check "bare struct-shaped braces at a defn body's head, too"
"(defn main [] i32 {.x 1} 0)"
~needle:"write (Type {.field v})";
~needle:"does not say which struct it builds";
accepts "a struct literal still builds"
"(defstruct P [x i32])\n\
(defn main [] i32 (let [p (P {.x 1})] (.x p)))";
@ -1637,11 +1641,113 @@ let () =
(* {:src s} is a dyn map literal now, not a struct field list with the
wrong punctuation the colon is wanted for keys, and this is one. What
used to be caught as a mispunctuated struct is caught one level up
instead: (Cursor {:src s}) is a call whose head names a struct type, and
that refusal still points at the struct spelling. *)
instead: (Cursor {:src s}) is a struct type applied to one argument, and
since positional construction landed that is a Cursor built from too few
arguments. The refusal still points at the struct spelling, which is why
the needle below did not have to move. *)
rejects_check "a struct type called with a colon-keyed map"
(cursor ^ "(defn f [s [u8]] Cursor (Cursor {:src s}))")
~needle:"a struct value is written (Cursor {.field value ...})";
(* ── A bare {.field v}, typed by its position ──────────────────── *)
(* The five positions that carry an expectation, and the three that do
not. Everything the named form checks unknown field, duplicate
field, ZII for the ones left out is checked here by *being* the
named form: [check_bare] reads the type name off the want and hands
the very same field list to [check_struct]. The two rows below the
accepts are what says so, since they are the named form's own kinds
and messages arriving at a literal with no name on it. *)
let cell = "(defstruct Cell [row i32 col i32]) " in
accepts "bare literal in a defn's return position"
(cell ^ "(defn f [] Cell {.row 1 .col 2})");
accepts "bare literal with a field omitted is ZII, as the named form is"
(cell ^ "(defn f [] Cell {.row 1})");
accepts "bare literal as the only argument of a call"
(cell ^ "(defn g [c Cell] i32 (.row c)) (defn f [] i32 (g {.row 1}))");
accepts "bare literal as a later argument of a call"
(cell ^ "(defn g [n i32 c Cell] i32 (+ n (.row c))) \
(defn f [] i32 (g 1 {.row 2}))");
accepts "bare literal as a field of another literal"
(cell ^ "(defstruct Grid [a Cell b Cell]) \
(defn f [] Grid (Grid {.a {.row 1} .b {.col 2}}))");
accepts "bare literal set into a typed place"
(cell ^ "(defn f [] i32 (let [c (Cell 0 0)] (set c {.row 7}) (.row c)))");
accepts "bare literal at a union want"
"(defunion U [i i32 f f32]) (defn f [] U {.i 5})";
rejects_check "bare literal with an unknown field"
(cell ^ "(defn f [] Cell {.nope 1})") ~needle:"Cell has no field nope";
rejects_check "bare literal with a field given twice"
(cell ^ "(defn f [] Cell {.row 1 .row 2})") ~needle:"given twice";
(* The three no-reading positions. The dyn one is the boundary that
matters most: braces at a dyn want are the dyn map literal and stay
it, so a .field-keyed brace is refused there rather than quietly
given a second meaning and told which punctuation a map uses. *)
rejects_check "bare literal with no expectation to read"
(cell ^ "(defn f [] i32 (let [c {.row 1}] (.row c)))")
~needle:"does not say which struct it builds";
rejects_check "bare literal at a dyn want is not a dyn map"
(cell ^ "(defn f [] dyn {.row 1})")
~needle:"a dyn map's keys are keywords, as {:row value ...}";
rejects_check "bare literal at a want that is not a struct type"
(cell ^ "(defn f [] i32 {.row 1})")
~needle:"i32 is expected here, which is not a struct type";
(* A data type's name is an expectation, but not a specific enough one:
the want says D and a value of D is one of its cases. The existing
message for that already names them, so the bare path inherits it. *)
rejects_check "bare literal at a data type want names the cases"
"(defdata D [(A [x i32]) (B [y i32])]) (defn f [] D {.x 1})"
~needle:"write (D.A {.field value ...})";
(* Patterns are a separate parser ([dmap], not [expr]), so nothing here
reaches them: the struct pattern is still {name .field} and a bare
{.field v} is still not a pattern of any kind. Both rows answer the
same as they did before this feature checked against the tip by
hand, not only asserted here. *)
accepts "a struct pattern still destructures in a let"
(cell ^ "(defn f [c Cell] i32 (let [{r .row} c] r))");
parse_rejects "braces are still not a pattern in a match arm"
(cell ^ "(defn f [c Cell] i32 (match c {r .row} r))")
~needle:"expected a pattern, found {r .row}";
(* The dyn map literal is untouched on every side of this. *)
accepts "a keyword-keyed literal is still a dyn map at a dyn want"
"(defn f [] dyn {:a 1 :b [2 3]})";
accepts "the empty braces are still an empty dyn map"
"(defn main [] i32 (let [m {}] (len m)))";
(* ── (Cell 1 2), positional ────────────────────────────────────── *)
accepts "positional struct construction"
(cell ^ "(defn f [] Cell (Cell 1 2))");
accepts "a zero-field struct called with no arguments"
"(defstruct E []) (defn f [] E (E))";
(* Exact arity, and the refusal names the first field it did not reach.
ZII is not withdrawn it is what the designated form does, and the
message says so but a positional list cannot say *which* field it
left out, so it is not allowed to leave one out. *)
rejects_check "positional with too few arguments names the missing field"
(cell ^ "(defn f [] Cell (Cell 1))")
~needle:".col has no value";
rejects_check "positional with too few also offers the designated form"
(cell ^ "(defn f [] Cell (Cell 1))")
~needle:"a struct value is written (Cell {.field value ...})";
rejects_check "positional with too many points at the extra argument"
(cell ^ "(defn f [] Cell (Cell 1 2 3))")
~needle:"Cell has 2 fields, and this is argument 3";
(* The mismatch is reported at the argument, in the words a call's
argument already gets; the note is what names the field, because a
positional call site is the one place the source does not show it. *)
rejects_check "positional argument of the wrong type"
"(defstruct Cell [row i32 col f32]) \
(defn f [] Cell (Cell 1 \"x\"))"
~needle:"expected f32, found string";
(* A struct type and a function cannot share a name — [collect]'s
[claimed] table spans every declaration kind so the head of a call
resolves to exactly one of them and this refusal is what proves it. *)
rejects_check "a struct name and a function name cannot collide"
(cell ^ "(defn Cell [] i32 1)") ~needle:"Cell is defined twice";
(* Mixed spellings are not a thing: the struct-literal arm in [Parse]
takes the braces only as the *whole* argument list. *)
parse_rejects "a struct literal followed by more arguments"
(cell ^ "(defn f [] Cell (Cell {.row 1} 2))")
~needle:"a struct literal is (Cell {.field value ...})";
accepts "field through a pointer auto-derefs"
(cursor ^ "(defn f [c (Ptr Cursor)] i32 (.pos c))");
accepts "set through a pointer"