diff --git a/lib/ast.ml b/lib/ast.ml index c7d9b47..7abd55f 100644 --- a/lib/ast.ml +++ b/lib/ast.ml @@ -80,7 +80,6 @@ and place = | Pvar of string | Pfield of expr * string (* (set (.hp e) v) *) | Pindex of expr * expr list (* (set (at grid r c) v) *) - | Pkey of expr * expr (* (set (get m k) v) *) | Pderef of expr (* (set (deref p) v) *) and arm = { pat : pattern; body : expr list; aloc : Loc.t } diff --git a/lib/check.ml b/lib/check.ml index 6ac6aa3..c6ee9df 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -262,7 +262,6 @@ let place_of_expr (e : Ast.expr) : Ast.place option = | Ast.Field (t, f) -> Some (Ast.Pfield (t, f)) | Ast.Call ({ Ast.e = Ast.Var "at"; _ }, t :: idx) when idx <> [] -> Some (Ast.Pindex (t, idx)) - | Ast.Call ({ Ast.e = Ast.Var "get"; _ }, [ m; k ]) -> Some (Ast.Pkey (m, k)) | Ast.Call ({ Ast.e = Ast.Var "deref"; _ }, [ p ]) -> Some (Ast.Pderef p) | _ -> None @@ -882,7 +881,6 @@ and check_place ctx loc (p : Ast.place) : Tast.place * Types.t = let target = check ctx target in let idx, ty = indexed ctx target idx in Tast.Pindex (target, idx), ty - | Ast.Pkey _ -> unimplemented loc "(get m k) as a place — Map" 6 | Ast.Pderef target -> let target = check ctx target in (match target.Tast.ty with diff --git a/lib/emit.ml b/lib/emit.ml index bd31149..0cfea65 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -458,7 +458,6 @@ and place f (p : Tast.place) : string * Types.t = | Types.Ptr t -> t | t -> failwith ("deref of " ^ Types.to_string t) in value f target, t - | Tast.Pkey _ -> failwith "Map places are milestone 6" (* A struct or fixed-array value, built field by field from zeroinitializer. The checker already filled the omitted fields in with Zero, so this is diff --git a/lib/load.ml b/lib/load.ml index b576e9b..3fa5869 100644 --- a/lib/load.ml +++ b/lib/load.ml @@ -208,7 +208,6 @@ and rename_place owned alias bound (p : Ast.place) : Ast.place = Ast.Pvar (if List.mem n owned && not (List.mem n bound) then qualify alias n else n) | Ast.Pfield (t, f) -> Ast.Pfield (go t, f) | Ast.Pindex (t, idx) -> Ast.Pindex (go t, List.map go idx) - | Ast.Pkey (m, k) -> Ast.Pkey (go m, go k) | Ast.Pderef t -> Ast.Pderef (go t) let rename_field owned alias (f : Ast.field) : Ast.field = diff --git a/lib/parse.ml b/lib/parse.ml index baa0065..c5db730 100644 --- a/lib/parse.ml +++ b/lib/parse.ml @@ -357,12 +357,17 @@ and place (f : Form.t) : Ast.place = | _ -> fail f "field place is (.%s value)" field) | List ({ v = Sym "at"; _ } :: target :: idx) when idx <> [] -> Ast.Pindex (expr target, List.map expr idx) - | List [ { v = Sym "get"; _ }; m; k ] -> Ast.Pkey (expr m, expr k) + (* Not a place. spec-memory.md gives a map an upsert of its own — [put] + either inserts or replaces — so there is no store into a lookup, and an + entry that is absent has no location to store into. Refused here rather + than parsed into a place form the language does not have. *) + | List ({ v = Sym "get"; _ } :: _) -> + fail f "(get m k) is not a place — a map is written with (put m k v)" | List [ { v = Sym "deref"; _ }; p ] -> Ast.Pderef (expr p) | _ -> fail f "%s is not assignable. set takes a name, (.field x), (at a i ...), \ - (get m k) or (deref p)" + or (deref p)" (Form.to_string f) and arms f (items : Form.t list) : Ast.arm list = diff --git a/lib/tast.ml b/lib/tast.ml index 43b8d2e..d79c2a8 100644 --- a/lib/tast.ml +++ b/lib/tast.ml @@ -87,7 +87,6 @@ and place = | Pglobal of string | Pfield of expr * int | Pindex of expr * expr list - | Pkey of expr * expr | Pderef of expr (* A pushed handler: which condition type it matches, and the lifted function diff --git a/plan.org b/plan.org index 49ef49d..0e02b50 100644 --- a/plan.org +++ b/plan.org @@ -95,6 +95,12 @@ world. ~Vec~ and ~Map~ are monomorphic on element type and record their allocator. Not a Lua-style array/hash hybrid — that is what makes Lua's layout and performance unpredictable. +- Map keys initially use compiler-provided structural equality and hashing for + integers, enums, strings, fixed arrays and value structs; pointers, slices and + owning containers are excluded. A map is homogeneous, and empty construction + is type-directed: ~(defvar enemies (Map string Enemy) (map-new))~. ~get~ + returns ~(Option V)~; ~put~ is the `Unit`-returning upsert. See + spec-memory.md for the deferred move-aware operations. - Operations: ~get~, ~put~, ~remove~, ~push~, ~pop~, ~nth~, ~len~, ~update~. Copying is explicit: ~(clone m)~, and owning containers move rather than copy on assignment. No ~!~ convention — nothing is immutable, so it @@ -151,10 +157,11 @@ static typing the tag is paid only where it is asked for, in ~any~. HKTs). Lowercase type names are variables, Capitalized are concrete — no sigil. This is what makes ~map~/~filter~/~reduce~ and the monomorphic containers work. The price, made explicit in spec-memory.md: with no constraints, a type variable - supports only what every type supports. ~=~, ~<~, ~+~, ~hash~ and ~print~ over an + supports only what every type supports. ~=~, ~<~, ~+~ and ~hash~ over an unconstrained ~a~ are rejected, not silently instantiated — they are passed in as - function values. Compile-time interfaces, if they are ever wanted, come after the - base checker is stable. + function values. ~println~ is the one compiler-provided exception: it selects a + structural printer at each concrete instantiation. Compile-time interfaces, if + they are ever wanted, come after the base checker is stable. - Function values split three ways (spec-memory.md): ~(Fn [T1 T2] R)~ is a plain pointer with no environment — the only kind that crosses FFI or sits in a reload cell; a *non-escaping* ~fn~ captures enclosing locals by value into a stack @@ -164,7 +171,8 @@ static typing the tag is paid only where it is asked for, in ~any~. conditions plus ~Option~ and ~or-else~. Monadic sequencing, if ever wanted, is a macro. - ~any~ is an explicit opt-in tagged union, for heterogeneous containers and debug - printing. It is the only place a tag word is paid. + printing. ~Error~ is a second, specialised erased carrier for fallible results; + neither adds a header to ordinary values. - Tagged unions, ~distinct~ types, bit sets. ** Consequences @@ -227,20 +235,27 @@ see Compilation. 1. ~Option~ for expected absence: lookup miss, empty collection, end of stream. 2. Conditions for exceptional failure where a caller may have a recovery policy. 3. ~Result~ where failure should be visible in the signature (parsers, fallible - pure functions). Error sets *inferred* from the body (Zig's model) so ~try~ - widens callers automatically; explicit sets required on exported functions. + pure functions). The ordinary form is ~(Result T Error)~: an erased built-in + error carrier, not an external ~anyhow~-style library. A precise ~(Result T E)~ + remains available where it materially helps. + +~Error~ is a small value carrying a type id, pointer to an allocator-owned typed +payload, source location, context and a rendered summary. It preserves structured +fields for debugger/editor inspection while the summary is stable to print and +send over the wire. This makes ~(Vec Error)~ the standard heterogeneous diagnostic +collection. Wrapping an error uses the current implicit allocator; metadata is +out-of-band, so ordinary values keep their header-free C layout. Rule of thumb: if you can name the one correct recovery at the point of failure, return a ~Result~. If the answer is "depends who is calling", signal a condition. Mechanisms — two unwrap operators, because they are two different things (Zig's split): -- ~try~ — unwrap ~Ok~, else early-return ~Err~, widening the enclosing error set +- ~try~ — unwrap ~Ok~, else early-return ~Err~ - ~some~ — unwrap ~Some~, else early-return ~None~ -- ~(ok-or opt err)~ / ~(ok res)~ — conversions, always *explicit*. This is the one - thing Rust got right and ~From~/~anyhow~ got wrong: the noise is not ~?~, it is - the implicit conversion machinery ~?~ demands. ~try~ does *not* accept an - ~Option~ in a ~Result~-returning function. +- ~(ok-or opt err)~ / ~(ok res)~ — conversions, always *explicit*. ~try~ does not + accept an ~Option~ in a ~Result~-returning function. Converting a precise error + to ~Error~ is explicit too; there is no trait-based conversion search. - ~some->~ (short-circuiting thread), ~or-else~, ~if-let~ - ~errdefer~ — cleanup on the failure path only, pairs with arenas @@ -269,7 +284,10 @@ combination, no MOP, no runtime dispatch table — see Types. - ~&env~ equivalent: macros seeing the lexical environment (names of locals in scope). No longer urgent — its "decide now" status came from the instrumentation-based step debugger, which is cut (see Tooling). Milestone 5. -- Hygiene model: open decision. +- Macros are initially deliberately non-hygienic, in the Common Lisp/Clojure + style. A macro uses explicit ~gensym~ for introduced bindings; there is no + syntax-object hygiene system. Lexically local macros (~macrolet~-style) wait + for a concrete use case. * Host language *OCaml.* The compiler only; the runtime and stdlib are Flan with a few C @@ -308,9 +326,11 @@ wasm32 target cheap, because a primitive is the only thing implemented twice. | arithmetic, comparison, casts | per machine type | Printing is *not* a primitive. ~print-str~, ~print-f64~ and friends are Flan -functions over ~write-stdout~. A single overloaded ~println~ waits for milestone -5 — until then the acceptance programs name the type, because compile-time -overloading before the checker is stable is how a small language stops being one. +functions over ~write-stdout~. At milestone 5, compiler-provided ~println~ emits +or selects a structural printer for every concrete type, including generic +instantiations. This is intentionally not user-defined overload resolution: +ordinary values remain untagged, while ~any~ and ~Error~ carry the metadata their +dynamic printers need. *Entry point.* ~(defn main [args [string]] i32)~. Both the parameter and the return type are optional: omitting ~args~ means the program ignores argv, @@ -480,7 +500,9 @@ behaviour. That agreement is what the acceptance programs test. - Non-local exit lowered *explicitly* (result propagation + branch targets), not via platform unwinding. Same on both targets, no dependency on the WASM - exception-handling proposal. Escape analysis narrows which functions pay for it. + exception-handling proposal. Every Flan function carries the transfer channel; + uniformity keeps indirect calls and hot reload ABI-safe. A later optimisation may + narrow that cost, but it cannot change the ABI. - Stdlib written *in Flan*, not the host language. A few hundred primitives per backend, everything else on top. This is what keeps a second backend cheap. @@ -514,9 +536,17 @@ Deliberately different. Build and run the release config regularly, not just at ship time. * Hot reload -Every cross-function call goes through an *indirection cell*; redefinition is one -atomic pointer store. *Old code is never unloaded*, so a thread mid-execution -finishes safely in the old version. +Every cross-function call goes through an *indirection cell*; body redefinition +is one atomic pointer store. A top-level function value is a stable trampoline +over the same cell, never a pointer to a specific body, so callers holding it +observe a body redefinition too. *Old code is never unloaded*, so a thread +mid-execution finishes safely in the old version. + +A signature-changing redefinition creates a new internal function version and +trampoline. Newly compiled callers use it; existing callers and stored function +values safely retain the old version. The session immediately warns at each +tracked old caller site, and recompiling that caller either updates it or gives a +normal type error. This preserves live running code without hiding stale calls. This is the fix for jank issue #947 (segfault redefining a running loop's function plus its callee — their JIT relinks and unloads under a running thread). @@ -638,8 +668,9 @@ building the whole live environment at once. 6. *Allocators, ~Vec~/~Map~, ~Result~/~try~/~errdefer~, then conditions and restarts* against spec-conditions.md, with dedicated tests per numbered case. 7. *Hot reload* — free in the interpreter, indirection cells for compiled dev - builds, with the compatibility limits written down and enforced: signature - changes, struct layout changes, live callbacks held by C, captured environments. + builds, with signature generations and stale-caller warnings, plus the + remaining compatibility limits written down and enforced: struct layout + changes, live callbacks held by C, captured environments. 8. *Debugger, nREPL, async* — last, and 8 splits into transport (8a) and editor client (8b). @@ -685,25 +716,34 @@ marked. 1. *Host language: OCaml or Rust* — the only thing blocking the scaffold. See Host language. /Milestone 2./ -2. Macro hygiene model. /Milestone 5./ -3. Escape analysis: automatic promotion of escaping frame-arena values, or - explicit. /Milestone 6./ +2. Macro hygiene is settled for milestone 5: explicit ~gensym~, deliberately + non-hygienic expansion, no local macros until a concrete use case appears. +3. Borrow checking and escaping frame-arena values. /Deferred; revisit after + Vec/Map are useful in real programs./ The first implementation deliberately + follows Zig/Odin: slices and pointers have explicit lifetime and + reallocation contracts, with dev generation checks. A later lightweight + provenance pass may catch obvious escaping/stale-borrow mistakes, but must + not impose Rust-style lifetime annotations, automatic promotion, or an + ECS-shaped object model. 4. Threads *in the language*: yes or no, decided before the host ABI. (The dev /runtime/ is multithreaded regardless — it needs a reload thread. Settled; see Compilation.) /Milestone 3, before the host ABI./ -5. /Escaping/ closures. /Milestone 6./ Settled in spec-memory.md: +5. /Escaping/ closures. /Deferred until a concrete use case./ Settled in + spec-memory.md: ~(Fn ...)~ is a bare pointer with no environment (callbacks, reload cells, FFI), and a *non-escaping* ~fn~ captures by value into a stack environment — which is what makes ~handler-bind~ handlers able to see enclosing locals, without which conditions are not worth building. Still open: a closure that is stored, returned, or pushed into a container. Which allocator owns its environment, and - what happens when the frame arena resets? Decided together with #3, because - the same escape analysis classifies both. -6. Hot-reload compatibility rules. /Milestone 7./ Cells cover a function - body changing. Not covered: a changed signature, a changed struct layout with - live instances, a function pointer already handed to C, a captured environment, - and redefining a ~defvar~. Each needs an answer of the form "rejected", - "accepted with a migration", or "accepted and the old code keeps running". + what happens when the frame arena resets? Do not design this speculatively; + revisit it alongside the deferred provenance work only when it is needed. +6. Hot-reload compatibility rules. /Milestone 7./ A changed function signature + creates a new version: new callers resolve it, old callers keep the old one, + and the session warns at tracked stale caller sites. Still open: a changed + struct layout with live instances, a function pointer already handed to C, a + captured environment, and redefining a ~defvar~. Each needs an answer of the + form "rejected", "accepted with a migration", or "accepted and the old code + keeps running". 7. Does the interpreter survive milestone 3, or is the compiled path the only backend? /Milestone 3, on measured numbers./ See Compilation. 8. ~(Option a)~ /settled:/ an ordinary stdlib union with ~Some~/~None~; the diff --git a/spec-memory.md b/spec-memory.md index 17722bb..b672727 100644 --- a/spec-memory.md +++ b/spec-memory.md @@ -24,6 +24,31 @@ Everything else in the design references this vocabulary. transfers ownership; the source binding is dead afterwards and using it is a compile error. There is no shallow copy, so there is no double free. +## Maps — first implementation + +Every map is homogeneous: `(Map K V)` has one key type and one value type. The +first implementation accepts only built-in structural key types: integers, +enums, strings, fixed arrays, and value structs composed recursively from those +types. Tuples and triples join that set when they are introduced. `Ptr`, slices, +`Vec`, and `Map` are not map keys yet. + +Equality and hashing for those keys are compiler-provided structural operations, +not type classes and not operations available to an unconstrained type variable. +An empty map takes its type from its context: + +``` +(defvar enemies (Map string Enemy) (map-new)) +``` + +`(get m k)` returns `(Option V)`: absence is `None`, not an untyped `nil`. +`(put m k v)` is the upsert operation and returns `Unit`; it either inserts or +replaces. `(set (get m k) v)` is not map syntax. + +The first Map implementation admits copyable keys and values only, so `get` +returns a copy. Move-aware lookup, removal, and owned entries are deferred until +`Vec`/`Map` values are supported in maps; the map itself remains an owning, +move-only container. + ## Copying is always explicit `(clone x)` produces an independent deep copy of a `Vec`/`Map` using the current @@ -36,9 +61,17 @@ not declared: a type is a value type iff all of its fields are. ## Borrowing - `(as-slice v)` / `(as-slice v lo hi)` view a `Vec` or fixed array as `[T]`. -- A slice is invalidated by any operation that may reallocate the owner (`push`, - `put`, `reserve`). This is **not checked** in the first implementation; dev - builds carry a generation word on `Vec` and trap on use of a stale slice. +- **The first implementation follows Zig/Odin's explicit model, not Rust's + borrow checker.** A slice is invalidated by any operation that may reallocate + its owner (`push`, `put`, `reserve`); its user is responsible for respecting + that contract. Dev builds carry a generation word on `Vec` and trap on use of + a stale slice. `Ptr` is the explicit lower-level escape hatch and has the + same lifetime contract. +- A future lightweight provenance pass may reject the obvious mistakes (a + borrow of a local escaping, use after an owner moves, and reallocation with a + live borrow). It must not require Rust-style lifetime annotations or dictate + an ECS-shaped object model. Long-lived graph links use `(Handle a)`; temporary + graphs may use explicitly managed, stable region storage. - Cross-referencing long-lived objects uses `(Handle a)` into a pool, never a raw pointer or slice. A stale handle is detectable. @@ -47,12 +80,14 @@ not declared: a type is a value type iff all of its fields are. `(addr x)` yields `(Ptr T)` for any assignable place `x` — a local, a global, a field, an element. The pointer is non-owning and does not extend anything's lifetime, so `addr` of a local is only valid while that frame lives. This is the -same escape question as case 3 below and is checked by the same analysis; until -that analysis exists, `addr` of a local may not be stored or returned. +same escape question as case 3 below. The first implementation leaves it as an +explicit lifetime contract; a future provenance pass can check it. `addr` is how a value struct is shared mutably without an allocator — recursive descent over a cursor, an entity passed down a call chain — and it is why -milestone 2 needs no heap at all. +milestone 2 needs no heap at all. In the first implementation its non-escape +rule is an explicit programmer contract, aided by dev checks; the future +provenance pass above may enforce it. ## Places — what `set` accepts @@ -62,7 +97,6 @@ A fixed set of assignable forms, not a `setf`-style extensible place mechanism: (set x v) ; a local or a defvar (set (.field x) v) ; struct field; x may be a struct, (Ptr S) or (Handle S) (set (at a i ...) v) ; fixed array, slice, or Vec element -(set (get m k) v) ; map entry (set (deref p) v) ; whole-object store through a pointer ``` @@ -89,7 +123,7 @@ Parametric polymorphism is monomorphisation, with **no type classes and no constraints**. The consequence is a hard rule: > A type variable `a` supports only what every type supports: move, `clone`, -> field-free storage. It does **not** support `=`, `<`, `+`, `hash`, or `print`. +> field-free storage. It does **not** support `=`, `<`, `+`, or `hash`. Anything else is passed in explicitly as a function value: @@ -102,6 +136,16 @@ instantiated. The alternatives — compile-time interfaces, or intrinsics restricted to primitives — are deliberately deferred until the base checker is stable (build sequence milestone 4). +`println` is the deliberate exception. It is a compiler-provided, +type-directed intrinsic: monomorphisation selects or emits a structural printer +for each concrete instantiation, so `(println x)` is legal for `x : a` without +introducing a `Printable` type class. Structs, fixed arrays, options and, +eventually, Vecs and Maps print structurally. `Ptr` and `Handle` print their +address or identity rather than recursively dereferencing, and collection +printers impose depth and length limits. `any` and `Error` use their runtime type +metadata. User generic code still passes an explicit function for every other +operation that depends on a type's structure. + Type arguments are **inferred at call sites** from the argument types; there is no explicit instantiation syntax in the first implementation. A type variable that appears only in the return type is therefore an error. @@ -110,12 +154,24 @@ that appears only in the return type is therefore an error. Three cases, split by whether the value escapes the frame that made it. -**1. `(Fn [T1 T2] R)` — a plain function pointer.** No captured environment, no -allocation, C calling convention plus the implicit allocator argument. This is -what raylib callbacks, hot-reload indirection cells, and function *parameters* -use. A top-level `defn` is one, so `(largest hps >)` passes `>` at `i32` -directly. This is the only function type that may cross an FFI boundary or sit -in a reload cell. +**1. `(Fn [T1 T2] R)` — a plain, stable function pointer.** No captured +environment or allocation. In a dev build, a reference to a top-level `defn` +is the address of a stable trampoline that loads that function version's +indirection cell and calls its current body; it is never the address of a +particular body. Thus stored callbacks and ordinary calls observe a later +*body* redefinition, as in Common Lisp. Release builds may call the body +directly because it cannot be redefined. + +A signature-changing redefinition makes a new internal function version and a +new trampoline ABI. Newly compiled code resolves the source name to that new +version. Existing callers and stored `Fn` values keep their old trampoline and +therefore safely call the old version. The session immediately warns at every +tracked caller source location that still targets the old signature; recompiling +one either retargets it successfully or reports an ordinary type error. This is +what raylib callbacks, hot-reload cells, and function parameters use. A +top-level `defn` is one, so `(largest hps >)` passes `>` at `i32` directly. This +is the only function type that may cross an FFI boundary or sit in a reload +cell. **2. Non-escaping `fn` — captures by value into a stack environment.** A `fn` whose value provably does not outlive the frame that created it gets an @@ -136,11 +192,11 @@ outlives every call to them. Captured `Vec`/`Map` are captured **by pointer**, not moved, since the capture does not outlive the owner. A non-escaping `fn` is therefore not itself an owner. -**3. Escaping closures — still open.** A `fn` stored in a struct, pushed into a +**3. Escaping closures — deferred.** A `fn` stored in a struct, pushed into a container, or returned needs a heap environment and an answer to "which allocator -owns it, and what happens when the frame arena resets". Not settled; see -plan.org open decisions. Escape analysis (open decision #4) is the same analysis -that classifies cases 2 and 3, so they are decided together. +owns it, and what happens when the frame arena resets". Do not settle this until +a concrete use case requires it; revisit it with the optional lightweight +provenance work. **Early exit inside a `fn`.** `try`, `some`, and `return` in a `fn` body exit the `fn`, not the enclosing function — a `fn` is a function. Code that wants to @@ -150,5 +206,6 @@ propagate out of a loop uses an imperative loop form, not a callback. The allocator is part of the calling convention (`context/allocator`, `context/temp`). `Vec` and `Map` record the allocator they were created with, so -`free` and `clone` never need it named again. No core operation allocates -implicitly. +`free` and `clone` never need it named again. Allocation uses the current +implicit allocator by default, as in Odin; an operation never falls back to a +hidden global allocator, and an explicit allocator can override the context. diff --git a/test/test_flan.ml b/test/test_flan.ml index 3f9f334..9bf26b7 100644 --- a/test/test_flan.ml +++ b/test/test_flan.ml @@ -661,6 +661,13 @@ let () = "(defstruct C [id i32])\n\ (defn f [] i32 (handler-bind [(C [c] (signal c))] (return 1)) 0)" ~needle:"return is not allowed inside handler-bind"; + (* spec-memory.md gives a map an upsert of its own, so there is no store + into a lookup and no place form for one. Refused with that reason rather + than as a milestone that will never arrive. *) + rejects_check "a map entry as a place" + "(defn f [] (set (get m 1) 2))" + ~needle:"a map is written with (put m k v)"; + (* ── restart-case and invoke-restart, §3 to §6 ─────────────────── *) accepts "restart-case with a clause that transfers into it"