diff --git a/FIX.org b/FIX.org index 58df760..08fd425 100644 --- a/FIX.org +++ b/FIX.org @@ -1598,3 +1598,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. diff --git a/lib/ast.ml b/lib/ast.ml index 222fb24..44430a5 100644 --- a/lib/ast.ml +++ b/lib/ast.ml @@ -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) diff --git a/lib/check.ml b/lib/check.ml index 179bb35..9e105c3 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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 diff --git a/lib/load.ml b/lib/load.ml index 9308fba..1d495ec 100644 --- a/lib/load.ml +++ b/lib/load.ml @@ -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 diff --git a/lib/parse.ml b/lib/parse.ml index 6a5ac37..d2d47da 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -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 diff --git a/test/programs/struct-ergonomics.flan b/test/programs/struct-ergonomics.flan new file mode 100644 index 0000000..54e5e38 --- /dev/null +++ b/test/programs/struct-ergonomics.flan @@ -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)))) diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index bcc7366..8b04566 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -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" diff --git a/test/test_flan.ml b/test/test_flan.ml index 753bc77..b315cf4 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -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"