diff --git a/TODO.org b/TODO.org index cc934a8c..3552de2f 100644 --- a/TODO.org +++ b/TODO.org @@ -726,10 +726,10 @@ of !=. * Checker -** NEXT A dyn value takes .field and [:key] -Decided 2026-09-26: on a dyn value, ~x.name~ / ~(.name x)~ reads ~(get x :name)~ and -assigning it is ~(put x :name v)~ — a class slot or a map key; ~m[:k]~ indexes a dyn -map as ~(get m :k)~, and assigning it puts. +** DONE A dyn value takes .field and [:key] +CLOSED: [2026-09-26] +Assigning ~x.name~ or ~m[:k]~ is ~put~, not the stricter ~(set (get x :k) v)~: it adds +a key a plain map or a class lacks. ~m[k]~ on a dyn map takes any key, as ~get~ does. ** DONE A slice from a C pointer, and a pointer cast CLOSED: [2026-09-25] diff --git a/lib/check.ml b/lib/check.ml index a23f2e83..9508adf3 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -5945,12 +5945,36 @@ and check_value ctx ?want (e : Ast.expr) : Tast.expr = let v = check ctx ~want:Types.Dyn v in expect ctx loc ~want (rt loc Types.Unit "flan_dyn_slot_set" [ target; k; v; here loc ]) + (* (set (.name x) v) on a dyn is (put x :name v): a class slot's declared + type is checked by put, and a map takes a key it did not hold. Not + [flan_dyn_slot_set], which refuses a plain map — .name reads either, so + assigning it writes either. The target is checked once, here. *) + | Ast.Set (Ast.Pfield (target, name), v) -> + let t = check_target ctx target in + if t.Tast.ty = Types.Dyn then begin + refuse_const_change ctx loc t; + let k = dyn_kw ctx loc name in + let v = check ctx ~want:Types.Dyn v in + expect ctx loc ~want + (rt loc Types.Unit "flan_dyn_map_put" [ t; k; v; here loc ]) + end else begin + let p, pty = field_place ~store:true ctx loc target t name in + let v = check ctx ~want:pty v in + expect ctx loc ~want (mk loc Types.Unit (Tast.Set (p, v))) + end | Ast.Set (p, v) -> let p, pty = check_place ctx loc p in let v = check ctx ~want:pty v in expect ctx loc ~want (mk loc Types.Unit (Tast.Set (p, v))) + (* On a dyn, (.name x) is (get x :name) — the same call, so a missing key is + nil and a value that is not a map traps with get's own sentence. *) | Ast.Field (target, name) -> - let target, sname = struct_target ctx target in + let t = check_target ctx target in + if t.Tast.ty = Types.Dyn then + expect ctx loc ~want + (rt loc Types.Dyn "flan_dyn_get" [ t; dyn_kw ctx loc name; here loc ]) + else + let target, sname = struct_of ctx target t in let s = Option.get (fields_named ctx.env sname) in (match Tast.field_index s name with | None -> @@ -9567,9 +9591,9 @@ and fields_named env n : Tast.structure option = (* The target of [.field] is a struct or an untagged union, or one level of pointer to one. The auto-deref is inserted here as a real node, so no - backend re-derives it. *) -and struct_target ctx (target : Ast.expr) : Tast.expr * string = - let t = check_target ctx target in + backend re-derives it. The target comes checked, because every caller + looks first for a dyn, whose [.name] is a map entry and not a field. *) +and struct_of ctx (target : Ast.expr) (t : Tast.expr) : Tast.expr * string = let has n = fields_named ctx.env n <> None in match t.Tast.ty with | Types.Named n when has n -> t, n @@ -9704,15 +9728,19 @@ and check_place ?(store = true) ctx loc (p : Ast.place) : Tast.place * Types.t = | Some (ty, false) -> Tast.Pglobal name, ty | None -> unknown_name ~setting:true ctx loc name) | Ast.Pfield (target, name) -> - let target, sname = struct_target ctx target in - let s = Option.get (fields_named ctx.env sname) in - (match Tast.field_index s name with - | None -> - Loc.failk "check/unknown-field" loc ~notes:(declared_note ctx.env sname) - "%s has no field %s" (tyname loc (Types.Named sname)) name - | Some i -> - if store then Option.iter (refuse_const_place ctx.env loc) (const_reached target); - Tast.Pfield (target, i), (List.nth s.Tast.fields i).Tast.fty) + let t = check_target ctx target in + if t.Tast.ty = Types.Dyn then begin + let x = match target.Ast.e with Ast.Var x -> x | _ -> "x" in + if Source.indented_at loc then + fail loc + "%s.%s is an entry of a dyn map, and has no address. Read it into \ + a local: let v = %s.%s" x name x name + else + fail loc + "(.%s %s) is an entry of a dyn map, and has no address. Read it \ + into a local: (let [v (.%s %s)] ...)" name x name x + end; + field_place ~store ctx loc target t name | Ast.Pindex (target, idx) -> let target = check_target ctx target in (match target.Tast.ty with @@ -9742,6 +9770,21 @@ and check_place ?(store = true) ctx loc (p : Ast.place) : Tast.place * Types.t = "a class slot (get inst :slot) is written with set and has no address. \ Read it into a local with let" +(* The dyn keyword [:name], for a dyn's [.name]. *) +and dyn_kw ctx loc name = check ctx ~want:Types.Dyn { Ast.e = Ast.Kw name; loc } + +(* A struct field as a place, over a target already checked. *) +and field_place ~store ctx loc target t name = + let target, sname = struct_of ctx target t in + let s = Option.get (fields_named ctx.env sname) in + match Tast.field_index s name with + | None -> + Loc.failk "check/unknown-field" loc ~notes:(declared_note ctx.env sname) + "%s has no field %s" (tyname loc (Types.Named sname)) name + | Some i -> + if store then Option.iter (refuse_const_place ctx.env loc) (const_reached target); + Tast.Pfield (target, i), (List.nth s.Tast.fields i).Tast.fty + (* An index or a slice bound that is a literal is known now, so it is an error now rather than a trap later. Only literals: a [defconst] is a global in the typed IR, not a folded constant, so [(at arr size)] still traps at runtime — @@ -11840,8 +11883,8 @@ and named_call ?(qualified = false) ctx ~want loc name args = stored under the key. *) if target.Tast.ty = Types.Dyn then expect ctx loc ~want - (rt loc Types.Dyn "flan_dyn_map_get" - [ target; check ctx ~want:Types.Dyn k ]) + (rt loc Types.Dyn "flan_dyn_get" + [ target; check ctx ~want:Types.Dyn k; here loc ]) else begin let kt, vt = map_kv loc "get" target.Tast.ty in let k = check ctx ~want:kt k in diff --git a/lib/emit.ml b/lib/emit.ml index ed5eb35c..2df56188 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -5007,6 +5007,7 @@ declare void @flan_dyn_class_def(i64, ptr, i64) declare void @flan_dyn_class_hook(ptr) declare i64 @flan_dyn_kw(ptr, i64) declare i64 @flan_dyn_map_get(i64, i64) +declare i64 @flan_dyn_get(i64, i64, ptr, i64) declare void @flan_dyn_map_set(i64, i64, i64) declare i64 @flan_dyn_map_contains(i64, i64) ; The ones that trap carry the site as ptr+len, the way the bounds and diff --git a/runtime/flan_dyn.c b/runtime/flan_dyn.c index 2c9e048f..75cba6d5 100644 --- a/runtime/flan_dyn.c +++ b/runtime/flan_dyn.c @@ -1963,6 +1963,10 @@ flan_dyn flan_dyn_vec_new(void); flan_dyn flan_dyn_map_new(void); void flan_dyn_push(flan_dyn v, flan_dyn x, const uint8_t *loc, int64_t loclen); void flan_dyn_map_set(flan_dyn m, flan_dyn k, flan_dyn v); +flan_dyn flan_dyn_get(flan_dyn m, flan_dyn k, const uint8_t *loc, + int64_t loclen); +void flan_dyn_map_put(flan_dyn m, flan_dyn k, flan_dyn v, const uint8_t *loc, + int64_t loclen); /* The user hook, run on an instance the name-matching has just brought up to * date. [inst], [added] and [gone] are rooted by the caller. @@ -3037,8 +3041,12 @@ flan_dyn flan_dyn_at(flan_dyn v, flan_dyn i, const uint8_t *loc, int64_t loclen) { int64_t k; flan_obj *o; + /* m[:k] on a map is (get m :k), whatever the key: get's rule, nil when + * absent. */ + if (is_map(v)) return flan_dyn_get(v, i, loc, loclen); if (!is_text(v) && !is_vec(v)) - trap2(loc, loclen, TYPE_TRAP, "at", "only a text or a vec is indexed", v, i); + trap2(loc, loclen, TYPE_TRAP, "at", "only a text, a vec or a map is indexed", + v, i); k = need_index(loc, loclen, "at", v, i); o = dyn_obj(v); if (o->kind == OBJ_VIEW) { @@ -3086,8 +3094,14 @@ void flan_dyn_set_at(flan_dyn v, flan_dyn i, flan_dyn x, const uint8_t *loc, if (is_text(v)) trap2(loc, loclen, TYPE_TRAP, "set-at", "a text is immutable — build another one", v, i); + /* Assigning m[:k] is (put m :k x), a class slot's type check with it. */ + if (is_map(v)) { + flan_dyn_map_put(v, i, x, loc, loclen); + return; + } if (!is_vec(v)) - trap2(loc, loclen, TYPE_TRAP, "set-at", "only a vec is assigned into", v, i); + trap2(loc, loclen, TYPE_TRAP, "set-at", + "only a vec or a map is assigned into", v, i); k = need_index(loc, loclen, "set-at", v, i); o = dyn_obj(v); if (o->kind == OBJ_VIEW) { @@ -3191,6 +3205,14 @@ flan_dyn flan_dyn_map_get(flan_dyn m, flan_dyn k) { return i < 0 ? flan_dyn_nil() : o->u.v.items[i * 2 + 1]; } +/* A program's (get m k) and (.k m): the same, with the site a value that is + * not a map is refused at. */ +flan_dyn flan_dyn_get(flan_dyn m, flan_dyn k, const uint8_t *loc, + int64_t loclen) { + if (!is_map(m)) trap2(loc, loclen, TYPE_TRAP, "get", "only a map answers it", m, k); + return flan_dyn_map_get(m, k); +} + flan_dyn flan_dyn_map_contains(flan_dyn m, flan_dyn k) { flan_obj *o = want_map("has-key?", m, k); return flan_dyn_from_bool(map_find(o, k) >= 0); @@ -3333,7 +3355,7 @@ void flan_dyn_map_put(flan_dyn m, flan_dyn k, flan_dyn v, const uint8_t *loc, int64_t loclen) { flan_obj *o; class_entry *e; - if (!is_map(m)) trap2(NULL, 0, TYPE_TRAP, "put", "only a map answers it", m, k); + if (!is_map(m)) trap2(loc, loclen, TYPE_TRAP, "put", "only a map answers it", m, k); o = dyn_obj(m); e = class_sync(o); /* A map with no class, and a class with no typed slot, stop at the test. */ diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index 39d6173e..7e8538a6 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -244,6 +244,9 @@ void flan_dyn_push(flan_dyn v, flan_dyn x, const uint8_t *loc, int64_t loclen); * to ask when nil might also be stored. [set] replaces the value of an equal * key in place, so a key occurs once and insertion order is print order. */ flan_dyn flan_dyn_map_get(flan_dyn m, flan_dyn k); +/* [get]'s, and a dyn's [.field]: [flan_dyn_map_get] with a site. */ +flan_dyn flan_dyn_get(flan_dyn m, flan_dyn k, const uint8_t *loc, + int64_t loclen); void flan_dyn_map_set(flan_dyn m, flan_dyn k, flan_dyn v); /* [put]'s: [flan_dyn_map_set], with the site a typed class slot's refusal * prints. */ diff --git a/spec-syntax.md b/spec-syntax.md index 0416c89a..ca66d31e 100644 --- a/spec-syntax.md +++ b/spec-syntax.md @@ -172,6 +172,10 @@ Each item: the proposal, then the reason in one line. one symbol. `test/programs/dev-rerun.flan:65` names a global `.init-once.counter`; rename it. **Built**, without the rename: it prints and reads back through the fallback, `defonce(.init-once.counter, i64, 7)`. +- **On a dyn value, `x.name` is `(get x :name)` and `x.name = v` is + `(put x :name v)`**, for a class slot and a plain map's key alike; `m[:k]` + is `(get m :k)` and `m[:k] = v` puts. The paren spellings `(.name x)` and + `(at m :k)` mean the same. **Built.** - **`and`, `or`, `not` are words**, since they are Flan's own names. **Built.** - **Casts and type-taking builtins are calls:** `i32(x)`, `vec-new(u8)`, `max-value(u8)`, `the([3 f32], [1 2 3.5])`. A pointer cast is the type diff --git a/test/programs/dyn-field-trap.flan b/test/programs/dyn-field-trap.flan new file mode 100644 index 00000000..499f3bf3 --- /dev/null +++ b/test/programs/dyn-field-trap.flan @@ -0,0 +1,18 @@ +;;;; A dyn's .field and [:key] trap with get's and put's own sentences, one +;;;; per run. The argument chooses which; the test asserts the line numbers. +(defclass State [paused bool step bool]) + +(defn as-dyn [d dyn] dyn d) + +(defn main [args [string]] i32 + (let [which (if (> (length args) 1) (i32 (bytes->i64 (bytes-view (at args 1)))) 0) + s (State false false) + n (as-dyn 3)] + (println "before") + (cond + (= which 0) (println (.paused n)) + (= which 1) (set (.paused s) 1) + (= which 2) (set (.paused n) true) + (= which 3) (set (at s :step) 2) + :else (println (at n :paused)))) + 0) diff --git a/test/programs/dyn-field-trap.fln b/test/programs/dyn-field-trap.fln new file mode 100644 index 00000000..dd9c64ea --- /dev/null +++ b/test/programs/dyn-field-trap.fln @@ -0,0 +1,23 @@ +;;;; A dyn's .field and [:key] trap with get's and put's own sentences, one +;;;; per run. The argument chooses which; the test asserts the line numbers. +defclass(State, [paused bool step bool]) + +fn as-dyn(d) -> dyn = d + +fn main(args: [string]) -> i32 + let which = + if length(args) > 1 then i32(bytes->i64(bytes-view(args[1]))) else 0 + let s = State(false, false) + let n = as-dyn(3) + println("before") + if which == 0 + println(n.paused) + elif which == 1 + s.paused = 1 + elif which == 2 + n.paused = true + elif which == 3 + s[:step] = 2 + else + println(n[:paused]) + 0 diff --git a/test/programs/dyn-fields.flan b/test/programs/dyn-fields.flan new file mode 100644 index 00000000..e1c83ad5 --- /dev/null +++ b/test/programs/dyn-fields.flan @@ -0,0 +1,36 @@ +;;;; A dyn value's (.field x) and (at m :key): a read is get, a set is put. +(defclass State [paused bool step bool]) +(defclass Pos [x y]) +(defclass Body [pos count items]) + +(defonce state (State false false)) + +(defn game-input [] () + (when true + (set (.paused state) (not (.paused state))))) + +(defn pick [b dyn] dyn b) + +(defn main [] i32 + (game-input) + (println (.paused state)) + (game-input) + (println (.paused state)) + (let [b (Body (Pos 1 2) 0 [10 20 30])] + (set (.count b) (+ (.count b) 1)) + (++ (.count b)) + (update (.count (pick b)) + 10) + (set (.x (.pos b)) 7) + (update (.y (.pos b)) * 10) + (set (at (.items b) 0) 11) + (update (at (.items b) 1) + 5) + (println (.count b) (.x (.pos b)) (.y (.pos b)) (at (.items b) 0) (at (.items b) 1)) + (println (.missing b) (= (.missing b) (get b :missing)))) + (let [m {:hp 3}] + (set (.hp m) (- (.hp m) 1)) + (set (at m :mp) 9) + (update (at m :mp) + 1) + (set (.name m) "slime") + (set (at m 1) :one) + (println (at m :hp) (.mp m) (at m :gone) (at m 1) m)) + 0) diff --git a/test/syntax/handwritten/dynfields.fln b/test/syntax/handwritten/dynfields.fln new file mode 100644 index 00000000..49d2956d --- /dev/null +++ b/test/syntax/handwritten/dynfields.fln @@ -0,0 +1,33 @@ +;; A dyn value's .field and [:key]: a read is get, an assignment is put. + +defclass(State, [paused bool step bool]) +defclass(Pos, [x y]) +defclass(Body, [pos count items]) + +once state = State(false, false) + +fn game-input() -> () + if true + state.paused = not(state.paused) + +fn main() -> i32 + game-input() + println(state.paused) + game-input() + println(state.paused) + let b = Body(Pos(1, 2), 0, [10 20 30]) + b.count += 1 + b.count += 1 + b.pos.x = 7 + b.pos.y *= 10 + b.items[0] = 11 + b.items[1] += 5 + println(b.count, b.pos.x, b.pos.y, b.items[0], b.items[1]) + println(b.missing) + let m = {:hp 3} + m.hp -= 1 + m[:mp] = 9 + m[:mp] += 1 + m.name = "slime" + println(m[:hp], m.mp, m[:gone], m) + 0 diff --git a/test/syntax/handwritten/dynfields.out b/test/syntax/handwritten/dynfields.out new file mode 100644 index 00000000..780925d7 --- /dev/null +++ b/test/syntax/handwritten/dynfields.out @@ -0,0 +1,5 @@ +true +false +2 7 20 11 25 +nil +2 10 nil {:hp 2 :mp 10 :name "slime"} diff --git a/test/test_acceptance.ml b/test/test_acceptance.ml index f12aca6d..eec23df7 100644 --- a/test/test_acceptance.ml +++ b/test/test_acceptance.ml @@ -5701,6 +5701,63 @@ level "1" slot_trap (); slot_trap ~x86:true (); + (* A dyn's (.field x) and (at m :key) are get and put: a class slot, a + plain map's key, nil for one it lacks, and chained and compound forms + over both. The .fln spelling is test/syntax/handwritten/dynfields.fln. *) + let fields_out = + "true\nfalse\n12 7 20 11 25\nnil true\n\ + 2 10 nil :one {:hp 2 :mp 10 :name \"slime\" 1 :one}\n" + in + outputs "dyn: .field and [:key]" "programs/dyn-fields.flan" fields_out; + outputs ~opt:"-O0" "dyn: .field and [:key], -O0" "programs/dyn-fields.flan" + fields_out; + outputs ~x86:true "dyn: .field and [:key], --x86" "programs/dyn-fields.flan" + fields_out; + outputs ~dev:true "dyn: .field and [:key], --dev" "programs/dyn-fields.flan" + fields_out; + (* Their refusals are get's, put's and at's own sentences, placed at the + access, in both syntaxes. *) + let field_trap ?x86 path rows = + let exe = compile ?x86 path in + List.iter + (fun (arg, want) -> + let code, text = run exe (Some arg) in + if code <> 134 || not (contains text want) then begin + incr failures; + Printf.printf + "FAIL dyn: a .field's refusal%s\n got: %S (exit %d)\n \ + wanted: %S (exit 134)\n" + (match x86 with Some true -> ", --x86" | _ -> "") + text code want + end) + rows; + (try Sys.remove exe with Sys_error _ -> ()) + in + let field_rows (l0, l1, l2, l3, l4) = + [ ("0", l0 ^ ": dyn get: int and keyword, and only a map answers it — \ + (get 3 :paused)"); + ("1", l1 ^ ": dyn put: the slot :paused of State is declared bool, \ + and this is int — (put #State{:paused false :step false} \ + :paused 1)"); + ("2", l2 ^ ": dyn put: int and keyword, and only a map answers it — \ + (put 3 :paused)"); + ("3", l3 ^ ": dyn put: the slot :step of State is declared bool, and \ + this is int"); + ("4", l4 ^ ": dyn at: int and keyword, and only a text, a vec or a \ + map is indexed — (at 3 :paused)") ] + in + let flan_rows = + field_rows ("dyn-field-trap.flan:13:28", "dyn-field-trap.flan:14:19", + "dyn-field-trap.flan:15:19", "dyn-field-trap.flan:16:19", + "dyn-field-trap.flan:17:22") + in + field_trap "programs/dyn-field-trap.flan" flan_rows; + field_trap ~x86:true "programs/dyn-field-trap.flan" flan_rows; + field_trap "programs/dyn-field-trap.fln" + (field_rows ("dyn-field-trap.fln:14:13", "dyn-field-trap.fln:16:5", + "dyn-field-trap.fln:18:5", "dyn-field-trap.fln:20:5", + "dyn-field-trap.fln:22:13")); + (* A numeric cast opening a dyn box — TODO.org, "A numeric cast opens a dyn box". programs/dyn-cast.flan is one program because the three behaviours are one story told in order: the same-kind casts print, the diff --git a/test/test_dev.ml b/test/test_dev.ml index e7dd4f1b..8ad6cb22 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -7418,6 +7418,22 @@ let () = if read () <> "\"kept\"" then fail "--%s: the global was not readable before any thunk ran: %S" backend (read ()); + (* A dyn's .field and [:key] are get and put in a thunk too. *) + List.iter + (fun (code, want) -> + let r = + request c + (Printf.sprintf + "(:op \"eval-expr\" :code %s \ + :file \"programs/dev-dyn-global.flan\")" + (Wire.quote code)) + in + if answer r <> want then + fail "--%s: %s answered %S (%s: %s), not %S" backend code + (answer r) (status r) (said r) want) + [ ("(.s config)", "\"kept\""); + ("(do (set (.n config) 5) (++ (.n config)) (.n config))", "6"); + ("(do (set (at config :n) 1) (at config :n))", "1") ]; for cycle = 1 to 3 do let r = churn () in if status r <> "ok" then diff --git a/test/test_dyn.ml b/test/test_dyn.ml index 96875fce..7ab4675d 100644 --- a/test/test_dyn.ml +++ b/test/test_dyn.ml @@ -230,7 +230,7 @@ let () = ("atrange", "index 9 is out of bounds for text of length 2"); ("atnegative", "index -1 is out of bounds"); ("setattext", "a text is immutable"); - ("setatnotvec", "only a vec is assigned into"); + ("setatnotvec", "only a vec or a map is assigned into"); ("setatrange", "index 0 is out of bounds for vec of length 0"); ("push", "only a vec is pushed to"); ("needi64", "dyn i64: text"); diff --git a/web/index.html b/web/index.html index 817f687b..4314a6c0 100644 --- a/web/index.html +++ b/web/index.html @@ -700,7 +700,9 @@ program was edited elsewhere would not be worth having.

{:a 1 :b "two"} is a dyn map and [1 2 3] is a dyn vector. get, put, has-key?, at and length read and write them, the same names the typed -Map and Vec answer to. A keyword is a value here rather +Map and Vec answer to. (.hp m) is +(get m :hp) and (at m :hp) is too; set on +either is put. A keyword is a value here rather than only a way to name an enum member: keywords are interned, so comparing two is comparing two pointers.

@@ -724,10 +726,10 @@ for anything that is not an instance. type-of answers any value's kind as a keyword — :nil, :bool, :int, :float, :text, :vec, :map or :keyword — and an instance's class name, so a class cannot be named -after one of those kinds. The slots are map keys: get -reads one, and set writes one, as in -(set (get s :pause) true). put writes one too, and is -also how a key the class does not declare is added.

+after one of those kinds. The slots are map keys: (.pause s) +reads one, and (set (.pause s) true) writes one, checking its +type. get and put do the same, and put +is also how a key the class does not declare is added.

Dispatch comes in the two styles and they are one mechanism. defgeneric dispatches on the class of the first argument, which is