From 9152a8a690166351097eddba0a0f55b6cfca19f4 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:39:08 +0100 Subject: [PATCH 01/21] fix(typecheck,bun-esm): generic enum kinds; qualified ctors; sync receiver-first fns Found while probing the Bun-ESM target for a TEA UI runtime: - typecheck: `infer_kind` gave every non-builtin type constructor kind `Type`, so naming a user generic enum applied to arguments in any signature (`fn mk(x: M) -> Box`, `Html`) failed with "Too many arguments for kind". Record each parametric enum's arity, and recover the arity of imported enums from the imported value schemes (the only cross-module type information `check_program` receives). Over-application is still rejected (planted negative test). - bun-esm: a qualified payload constructor `Msg::SetName(s)` lowered to a call on the nullary `({ tag })` object. It now lowers to the emitted constructor binding. - bun-esm: every receiver-first fn was folded into an async class method and its calls rewritten to `(await recv.m(..))`. Struct literals are plain objects, so chained calls failed and `await` leaked into sync callers (e.g. a TEA `update`). Fns are now always emitted as plain sync exports and called directly; the synthesised class remains as an extra JS-facing surface (ref_fields/class_basic unchanged). Tests: test/test_generic_enum_kinds.ml (6), tests/codegen-deno/tea_shape (Bun-ESM harness; 33/33 harnesses pass; dune test main suite 550 OK). Co-Authored-By: Claude Opus 5.5 --- lib/codegen_deno.ml | 27 ++++-- lib/typecheck.ml | 47 ++++++++++- test/test_generic_enum_kinds.ml | 102 +++++++++++++++++++++++ test/test_main.ml | 1 + tests/codegen-deno/tea_shape.affine | 40 +++++++++ tests/codegen-deno/tea_shape.harness.mjs | 14 ++++ 6 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 test/test_generic_enum_kinds.ml create mode 100644 tests/codegen-deno/tea_shape.affine create mode 100644 tests/codegen-deno/tea_shape.harness.mjs diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 0b677d04..74530443 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -77,6 +77,10 @@ type codegen_ctx = { operand — needed to truncate e.g. [abs(a*b) / gcd(a,b)]. Populated in {!generate}. *) int_fns : (string, unit) Hashtbl.t; + (* Variant constructors of enums declared in this program; each is + emitted as a same-named binding (an object when nullary, a factory + otherwise), so a qualified `Enum::Ctor` lowers to that binding. *) + ctors : (string, unit) Hashtbl.t; (* Names bound to a provably-[Int] value in the *current function*: [Int]-typed params plus [let]/assignments whose value is an integer expression. Mutated in source order as statements are emitted (the @@ -110,6 +114,7 @@ let create_ctx host symbols = { local_fns = Hashtbl.create 64; async_fns = Hashtbl.create 32; int_fns = Hashtbl.create 64; + ctors = Hashtbl.create 32; int_vars = Hashtbl.create 16; int_array_vars = Hashtbl.create 16; in_async = false; @@ -1550,6 +1555,10 @@ let rec gen_expr ctx (expr : expr) : string = (match ty.name, ctor.name with | _, "None" -> "None" | _, "Some" -> "Some" | _, "Ok" -> "Ok" | _, "Err" -> "Err" + | _, name when Hashtbl.mem ctx.ctors name -> + (* The emitted binding: correct for nullary (`Msg::Inc`) and + for payload ctors applied as calls (`Msg::SetName(s)`). *) + mangle name | _, name -> Printf.sprintf "({ tag: %S })" name) | ExprSpan (inner, _) -> gen_expr ctx inner | ExprRowRestrict (e, _) -> gen_expr ctx e @@ -2119,6 +2128,9 @@ let generate (host : host_profile) (program : program) (symbols : Symbol.t) : st | _ -> ()) | TopConst { tc_name; _ } -> Hashtbl.replace ctx.local_fns tc_name.name () + | TopType { td_body = TyEnum variants; _ } -> + List.iter (fun (vd : variant_decl) -> + Hashtbl.replace ctx.ctors vd.vd_name.name ()) variants | TopImpl ib -> List.iter (function | ImplFn fd -> @@ -2160,17 +2172,20 @@ let generate (host : host_profile) (program : program) (symbols : Symbol.t) : st Hashtbl.replace tbl k (v :: (try Hashtbl.find tbl k with Not_found -> [])) in List.iter (function | TopFn fd when fd.fd_body <> FnExtern -> + (* The synthesised class is an *additional* JS-facing surface + (`new Point(..)`, `await p.sum_ref()`). Every fn is still + emitted as a plain synchronous export, and AffineScript-level + calls go to that free fn — never rewritten to an async method + call. Struct literals are plain objects, so the old rewrite broke + chained calls (`acc.update(..)` on a literal has no method) and + leaked `await` into sync callers (e.g. a TEA `update`). *) (match receiver_struct ~known:structs fd with | Some (s, rn) -> let js = method_js_name ~struct_name:s fd.fd_name.name in - push methods_of s (rn, js, fd); - Hashtbl.replace ctx.assoc fd.fd_name.name js; - Hashtbl.replace consumed fd.fd_name.name () + push methods_of s (rn, js, fd) | None -> (match returns_struct ~known:structs fd with - | Some s -> - push ctors_of s fd; - Hashtbl.replace consumed fd.fd_name.name () + | Some s -> push ctors_of s fd | None -> ())) | _ -> ()) program.prog_decls; let methods_for s = diff --git a/lib/typecheck.ml b/lib/typecheck.ml index 41ef95c4..778b2f8f 100644 --- a/lib/typecheck.ml +++ b/lib/typecheck.ml @@ -264,6 +264,12 @@ type context = { value-path lowering done by [Resolve.lower_qualified_value_paths] (#178). Populated at [check_program] entry from [prog.prog_imports]. *) + type_arity : (string, int) Hashtbl.t; + (** Number of type parameters of each user-declared parametric enum + (`enum Html` -> 1), so [infer_kind] gives it kind + `Type -> ... -> Type` instead of treating every non-builtin type + constructor as kind `Type` (which rejected `Html` in any + signature with "Too many arguments for kind"). *) mutable in_loop : bool; (** #459: tracks whether the synth/check walker is currently inside a loop body. Set true on entry to a [StmtWhile]/[StmtFor] body, @@ -302,6 +308,7 @@ let create_context (symbols : Symbol.t) : context = declared_effects = Hashtbl.create 16; call_effects = Hashtbl.create 64; module_quals = Hashtbl.create 4; + type_arity = Hashtbl.create 16; in_loop = false; } @@ -452,7 +459,12 @@ let rec infer_kind (ctx : context) (ty : ty) : kind result = begin match name with | "Array" | "Option" | "List" | "Vec" | "Cmd" | "Ref" -> Ok (KArrow (KType, KType)) | "Result" -> Ok (KArrow (KType, KArrow (KType, KType))) - | _ -> Ok KType + | user -> + (match Hashtbl.find_opt ctx.type_arity user with + | Some n -> + let rec arrows k = if k = 0 then KType else KArrow (KType, arrows (k - 1)) in + Ok (arrows n) + | None -> Ok KType) end | TApp (head, args) -> let* k = infer_kind ctx head in @@ -2200,6 +2212,8 @@ let register_type_decl (ctx : context) (td : type_decl) : unit result = let param_names = List.map (fun (tp : type_param) -> tp.tp_name.name) td.td_type_params in + if param_names <> [] then + Hashtbl.replace ctx.type_arity td.td_name.name (List.length param_names); enter_level ctx; let param_tvs = List.map (fun n -> let tv = fresh_tyvar ctx.level in @@ -2429,6 +2443,33 @@ let populate_call_effects (ctx : context) (prog : Ast.program) : unit = ctx.call_effects; Effect_sites.set_async_by_ord async_tbl +(** Learn the arity of every parametric type constructor applied in [ty] + (e.g. `Html` in an imported `text : String -> Html`). Imported + schemes are the only cross-module type information [check_program] + receives, so this is how an imported `enum Html` gets kind + `Type -> Type` in the importer. Builtins keep their fixed kinds. *) +let rec record_type_arities (ctx : context) (ty : ty) : unit = + let go = record_type_arities ctx in + let rec go_row = function + | RExtend (_, t, rest) -> go t; go_row rest + | REmpty | RVar _ -> () + in + match repr ty with + | TApp (TCon name, args) -> + (match name with + | "Array" | "Option" | "List" | "Vec" | "Cmd" | "Ref" | "Result" -> () + | _ -> + if not (Hashtbl.mem ctx.type_arity name) then + Hashtbl.replace ctx.type_arity name (List.length args)); + List.iter go args + | TApp (head, args) -> go head; List.iter go args + | TArrow (a, _, b, _) -> go a; go b + | TTuple ts -> List.iter go ts + | TRecord row | TVariant row -> go_row row + | TForall (_, _, body) | TExists (_, _, body) -> go body + | TRef t | TMut t | TOwn t -> go t + | TVar _ | TCon _ -> () + let check_program ?(import_types : (string, scheme) Hashtbl.t option) (symbols : Symbol.t) (prog : Ast.program) : (context, type_error) Result.t = @@ -2450,7 +2491,9 @@ let check_program ?(import_types : (string, scheme) Hashtbl.t option) | Ast.ImportList _ | Ast.ImportGlob _ -> () ) prog.prog_imports; Option.iter (fun tbl -> - Hashtbl.iter (fun name sc -> Hashtbl.replace ctx.name_types name sc) tbl + Hashtbl.iter (fun name sc -> + Hashtbl.replace ctx.name_types name sc; + record_type_arities ctx sc.sc_body) tbl ) import_types; (* Forward pass: register all types, effects, traits, impls, and function signatures so that mutually recursive declarations resolve. *) diff --git a/test/test_generic_enum_kinds.ml b/test/test_generic_enum_kinds.ml new file mode 100644 index 00000000..d9b4ecb2 --- /dev/null +++ b/test/test_generic_enum_kinds.ml @@ -0,0 +1,102 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(** Kinds of user-declared parametric enums. + + [infer_kind] used to hard-code the kinds of the builtin constructors + (Option, Result, ...) and give every other named type kind [Type], so + naming a user enum applied to arguments anywhere in a signature — + `fn mk(x: M) -> Box` — failed with "Too many arguments for kind". + That made typed UI libraries (`Html`) impossible. These tests pin + the fix in a single module, across a module import, and keep the kind + check honest by planting an over-application that must still fail. *) + +open Affinescript + +(** parse -> resolve (with a loader rooted at [dir]) -> typecheck. *) +let frontend ?(dir = Sys.getcwd ()) (src : string) : (unit, string) result = + let ( let* ) = Result.bind in + let* prog = + try Ok (Parse_driver.parse_string ~file:"" src) + with + | Parse_driver.Parse_error (m, sp) -> + Error (Printf.sprintf "Parse error at %s: %s" (Span.show sp) m) + | e -> Error (Printf.sprintf "Unexpected: %s" (Printexc.to_string e)) + in + let config = { (Module_loader.default_config ()) with current_dir = dir; search_paths = [ dir ] } in + let loader = Module_loader.create config in + let* resolve_ctx, type_ctx = + match Resolve.resolve_program_with_loader prog loader with + | Ok (rc, tc) -> Ok (rc, tc) + | Error (e, _) -> Error ("Resolution error: " ^ Resolve.show_resolve_error e) + in + match + Typecheck.check_program ~import_types:type_ctx.Typecheck.name_types + resolve_ctx.symbols prog + with + | Ok _ -> Ok () + | Error e -> Error ("Type error: " ^ Typecheck.format_type_error e) + +(** Assert [src] type-checks. *) +let passes ?dir src = + match frontend ?dir src with + | Ok () -> () + | Error m -> Alcotest.failf "expected Ok, got: %s" m + +(** Assert [src] is rejected with a message containing [needle]. *) +let fails_with ~needle src = + match frontend src with + | Ok () -> Alcotest.failf "expected a type error mentioning %S, got Ok" needle + | Error m -> + let nl = String.length needle and ml = String.length m in + let rec go i = i + nl <= ml && (String.sub m i nl = needle || go (i + 1)) in + if not (go 0) then Alcotest.failf "expected %S in: %s" needle m + +let generic_return_type () = + passes "pub enum Box { B(M), E }\npub fn mk(x: M) -> Box = B(x);\n" + +let concrete_application_in_signature () = + passes "pub enum Box { B(M), E }\npub fn f(x: Box) -> Int = 1;\n" + +let two_parameter_enum () = + passes + "pub enum Pair { P(A, B) }\n\ + pub fn swap(p: Pair) -> Pair = match p { Pair::P(a, b) => P(b, a) };\n" + +let function_payload () = + passes + "pub enum Attr { On(String, Int -> M) }\n\ + pub fn on_click(f: Int -> M) -> Attr = On(\"click\", f);\n" + +(* Planted negative: the kind check must still reject over-application. *) +let over_application_still_rejected () = + fails_with ~needle:"Too many arguments for kind" + "pub enum Box { B(M), E }\npub fn f(x: Box) -> Int = 1;\n" + +(* Cross-module: the importer only receives value schemes, so the arity of + an imported `enum Html` must be recovered from them. *) +let imported_enum_kind () = + let dir = Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "as_kinds_%d" (Unix.getpid ())) in + (try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + let oc = open_out (Filename.concat dir "Html.affine") in + output_string oc + "module Html;\n\ + pub enum Html { Text(String), Node(String, [Html]) }\n\ + pub fn text(s: String) -> Html = Text(s);\n"; + close_out oc; + passes ~dir + "use Html::{Html, text};\n\ + pub enum Msg { Go }\n\ + pub fn view(n: Int) -> Html = text(\"hi\");\n" + +let tests = + [ + Alcotest.test_case "generic return type" `Quick generic_return_type; + Alcotest.test_case "concrete application in signature" `Quick + concrete_application_in_signature; + Alcotest.test_case "two-parameter enum" `Quick two_parameter_enum; + Alcotest.test_case "function payload" `Quick function_payload; + Alcotest.test_case "over-application still rejected" `Quick + over_application_still_rejected; + Alcotest.test_case "imported enum kind" `Quick imported_enum_kind; + ] diff --git a/test/test_main.ml b/test/test_main.ml index 2006c566..8b5cd881 100644 --- a/test/test_main.ml +++ b/test/test_main.ml @@ -14,6 +14,7 @@ let () = ("Effect-sites (#234, ADR-016)", Test_effect_sites.tests); ("TW L13 isolation (#10)", Test_tw_isolation.tests); ("Qualified paths (#228, ADR-014)", Test_qualified_paths.tests); + ("Generic enum kinds", Test_generic_enum_kinds.tests); ("Module mut (#548)", Test_module_mut.tests); ("Int-div on JS-text backend (#478)", Test_int_div_js.tests); ("Deno builtins ↔ stdlib decls consistency", Test_deno_builtins_consistency.tests); diff --git a/tests/codegen-deno/tea_shape.affine b/tests/codegen-deno/tea_shape.affine new file mode 100644 index 00000000..e0d8651a --- /dev/null +++ b/tests/codegen-deno/tea_shape.affine @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +// TEA-shaped program on the Bun-ESM target. Pins two codegen fixes: +// * a qualified payload constructor (`Msg::SetName(s)`) lowers to the +// constructor factory, not a call on a nullary `{ tag }` object; +// * a receiver-first fn (`update(m: Model, msg)`) stays a synchronous free +// function, so it chains on struct *literals* and never leaks `await` +// into a sync caller (the synthesised class is only an extra surface). + +use prelude::*; + +pub enum Msg { + Inc, + SetName(String), + Move(String, Float, Float), + Batch([Msg]) +} + +struct Model { count: Int, name: String, xs: [Float] } + +pub fn init() -> Model = Model #{ count: 0, name: "n", xs: [] }; + +pub fn update(m: Model, msg: Msg) -> Model { + match msg { + Msg::Inc => Model #{ count: m.count + 1, name: m.name, xs: m.xs }, + Msg::SetName(s) => Model #{ count: m.count, name: s, xs: m.xs }, + Msg::Move(id, x, y) => Model #{ count: m.count, name: id, xs: m.xs ++ [x, y] }, + Msg::Batch(ms) => { + let mut acc = m; + for one in ms { + acc = update(acc, one); + } + acc + } + } +} + +pub fn run() -> String { + let m = update(init(), Msg::Batch([Msg::Inc, Msg::Inc, Msg::SetName("z"), Msg::Move("a", 1.5, 2.0)])); + m.name ++ ":" ++ int_to_string(m.count) ++ ":" ++ int_to_string(len(m.xs)) +} diff --git a/tests/codegen-deno/tea_shape.harness.mjs b/tests/codegen-deno/tea_shape.harness.mjs new file mode 100644 index 00000000..7f0a9fcd --- /dev/null +++ b/tests/codegen-deno/tea_shape.harness.mjs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MPL-2.0 +// TEA-shaped program: qualified payload ctors + sync receiver-first update. +import assert from "node:assert/strict"; +import { run, init, update, Inc, SetName, Batch, Model } from "./tea_shape.bun.js"; + +assert.equal(run(), "a:2:2", "batched update over qualified payload ctors"); + +const m = update(update(init(), Inc), SetName("q")); +assert.ok(!(m instanceof Promise), "update is synchronous"); +assert.deepEqual({ ...m }, { count: 1, name: "q", xs: [] }, "chains on literals"); +assert.equal(update(init(), Batch([Inc, Inc, Inc])).count, 3, "recursive update in a loop"); +assert.equal(typeof Model, "function", "class surface still synthesised"); + +console.log("tea_shape.harness.mjs OK"); From 7832f473aa14cc615751b108b74994d932f9acd3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:43:13 +0100 Subject: [PATCH 02/21] fix(typecheck,bun-esm): thunk lambdas, parametric extern types, math builtins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typecheck: a zero-parameter lambda `fn() => e` was typed as its bare body type in synth mode and checked against the *whole* `() -> T` arrow in check mode, so a thunk could never be passed to a `() -> T` parameter (inline or by name). It is now `Unit -> T` in both modes — the type an explicit `() -> T` already lowers to, and which the zero-argument call rule already consumes. Wrong body types still fail. - typecheck: `extern type Cell` records its arity like a parametric enum, so host-backed generic types can be named in signatures. - bun-esm: lower the stdlib/math.affine header builtins (float, floor, ceil, round, trunc, sqrt, cbrt, pow_float, trig, exp/log family) to `Number`/`Math.*`; they compiled to calls of undefined JS globals. Tests: parametric extern type case in test_generic_enum_kinds; tests/codegen-deno/thunk_math (34/34 Bun-ESM harnesses); WASM corpus 38/38; dune test main suite 551 OK. Co-Authored-By: Claude Opus 5.5 --- lib/codegen_deno.ml | 15 ++++++++++ lib/typecheck.ml | 36 ++++++++++++++++++----- test/test_deno_builtins_consistency.ml | 3 ++ test/test_generic_enum_kinds.ml | 7 +++++ tests/codegen-deno/thunk_math.affine | 21 +++++++++++++ tests/codegen-deno/thunk_math.harness.mjs | 18 ++++++++++++ 6 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/codegen-deno/thunk_math.affine create mode 100644 tests/codegen-deno/thunk_math.harness.mjs diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 74530443..38fe7957 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -1118,6 +1118,21 @@ let () = AffineScript definition exists; the interpreter binds them too), not externs — endsWith/stripSuffix/pathJoin/etc. are NOT here: they are real AffineScript built on `ends_with`/`substring`/`++`. *) + (* ---- numeric builtins (stdlib/math.affine header) ---- + Interpreter builtins with no Bun-ESM lowering compiled to calls of + undefined JS globals (`float(n)` -> ReferenceError). floor/ceil/ + round/trunc return Int, which on JS is a Number with no fraction. *) + b "float" (fun a -> Printf.sprintf "Number(%s)" (arg 0 a)); + List.iter (fun (name, js) -> + b name (fun a -> Printf.sprintf "%s(%s)" js (arg 0 a))) + [ ("floor", "Math.floor"); ("ceil", "Math.ceil"); ("round", "Math.round"); + ("trunc", "Math.trunc"); ("sqrt", "Math.sqrt"); ("cbrt", "Math.cbrt"); + ("sin", "Math.sin"); ("cos", "Math.cos"); ("tan", "Math.tan"); + ("asin", "Math.asin"); ("acos", "Math.acos"); ("atan", "Math.atan"); + ("exp", "Math.exp"); ("log", "Math.log"); ("log10", "Math.log10"); + ("log2", "Math.log2") ]; + b "atan2" (fun a -> Printf.sprintf "Math.atan2(%s, %s)" (arg 0 a) (arg 1 a)); + b "pow_float" (fun a -> Printf.sprintf "Math.pow(%s, %s)" (arg 0 a) (arg 1 a)); b "len" (fun a -> Printf.sprintf "((%s).length)" (arg 0 a)); b "slice" (fun a -> Printf.sprintf "((%s).slice(%s, %s))" (arg 0 a) (arg 1 a) (arg 2 a)); diff --git a/lib/typecheck.ml b/lib/typecheck.ml index 778b2f8f..cc841fdb 100644 --- a/lib/typecheck.ml +++ b/lib/typecheck.ml @@ -1016,6 +1016,13 @@ let rec synth (ctx : context) (expr : expr) : ty result = in (q, param_ty) ) elam_params param_tys in + (* A zero-parameter lambda `fn() => e` is a thunk: type it + `Unit -> T`, the same type an explicit `() -> T` annotation lowers + to (see the zero-argument [ExprApp] case, which consumes either + form). Typing it as the bare `T` made it impossible to pass a thunk + to a `() -> T` parameter. *) + let param_qty_pairs = + if elam_params = [] then [ (Types.QOmega, ty_unit) ] else param_qty_pairs in let ty = List.fold_right (fun (q, param_ty) acc -> TArrow (param_ty, q, acc, eff) ) param_qty_pairs body_ty in @@ -1620,12 +1627,22 @@ and check (ctx : context) (expr : expr) (expected : ty) : unit result = (p.p_name.name, Hashtbl.find_opt ctx.name_types p.p_name.name) ) elam_params in let* () = peel_arrows expected elam_params in - (* Now check the body against the final return type *) - let final_ret = List.fold_left (fun ty _ -> - match repr ty with - | TArrow (_, _, ret, _) -> ret - | _ -> ty - ) expected elam_params in + (* Now check the body against the final return type. A zero-parameter + lambda checked against `() -> T` (= `Unit -> T`) consumes the unit + parameter: its body has type `T`. *) + let* final_ret = + if elam_params = [] then + match repr expected with + | TArrow (param_ty, _, ret, _) -> + let* () = unify_or_err param_ty ty_unit in + Ok ret + | _ -> Ok expected + else + Ok (List.fold_left (fun ty _ -> + match repr ty with + | TArrow (_, _, ret, _) -> ret + | _ -> ty + ) expected elam_params) in let* () = check ctx elam_body final_ret in (* Restore *) List.iter (fun (n, old_sc) -> @@ -2244,7 +2261,12 @@ let register_type_decl (ctx : context) (td : type_decl) : unit result = Ok (TCon td.td_name.name) | TyExtern -> (* Opaque host-supplied type. Register a TCon so user code can name it - in signatures; the body is intentionally absent. *) + in signatures; the body is intentionally absent. A parametric one + (`extern type Cell`) records its arity for [infer_kind], like a + parametric enum. *) + if td.td_type_params <> [] then + Hashtbl.replace ctx.type_arity td.td_name.name + (List.length td.td_type_params); Ok (TCon td.td_name.name) in Hashtbl.replace ctx.type_env td.td_name.name ty; diff --git a/test/test_deno_builtins_consistency.ml b/test/test_deno_builtins_consistency.ml index a9cddb91..788bdd53 100644 --- a/test/test_deno_builtins_consistency.ml +++ b/test/test_deno_builtins_consistency.ml @@ -83,6 +83,9 @@ let codegen_only_names = [ "len"; "panic"; "get"; "set"; "slice"; "show"; "error"; "make_ref"; "int_to_string"; "float_to_string"; "string_to_int"; "parse_int"; "parse_float"; "int_to_char"; "char_to_int"; + (* stdlib/math.affine header builtins (resolver-level, no extern decl); + the rest of that family is registered in a loop the regex skips. *) + "float"; "atan2"; "pow_float"; "string_length"; "string_sub"; "string_get"; "string_find"; "string_char_code_at"; "string_from_char_code"; "to_lowercase"; "to_uppercase"; "trim"; diff --git a/test/test_generic_enum_kinds.ml b/test/test_generic_enum_kinds.ml index d9b4ecb2..ce6a4fec 100644 --- a/test/test_generic_enum_kinds.ml +++ b/test/test_generic_enum_kinds.ml @@ -67,6 +67,12 @@ let function_payload () = "pub enum Attr { On(String, Int -> M) }\n\ pub fn on_click(f: Int -> M) -> Attr = On(\"click\", f);\n" +let parametric_extern_type () = + passes + "pub extern type Cell;\n\ + pub extern fn cell_new(v: T) -> Cell;\n\ + pub fn mk() -> Cell = cell_new(1);\n" + (* Planted negative: the kind check must still reject over-application. *) let over_application_still_rejected () = fails_with ~needle:"Too many arguments for kind" @@ -96,6 +102,7 @@ let tests = concrete_application_in_signature; Alcotest.test_case "two-parameter enum" `Quick two_parameter_enum; Alcotest.test_case "function payload" `Quick function_payload; + Alcotest.test_case "parametric extern type" `Quick parametric_extern_type; Alcotest.test_case "over-application still rejected" `Quick over_application_still_rejected; Alcotest.test_case "imported enum kind" `Quick imported_enum_kind; diff --git a/tests/codegen-deno/thunk_math.affine b/tests/codegen-deno/thunk_math.affine new file mode 100644 index 00000000..b9647cc6 --- /dev/null +++ b/tests/codegen-deno/thunk_math.affine @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MPL-2.0 +// Zero-parameter lambdas are `() -> T` thunks (pass inline, pass by name, +// call later), and the stdlib/math.affine builtins lower on Bun-ESM. + +use prelude::*; +use math::{to_float}; + +pub extern fn host_later(f: () -> Unit) -> Unit; +pub extern fn host_note(s: String) -> Unit; + +pub fn force(f: () -> Int) -> Int = f() + 1; + +pub fn inline_thunk() -> Int = force(fn() => 41); + +pub fn named_thunk() -> Unit { + let f = fn() => host_note("ran"); + host_later(f) +} + +pub fn geometry(x: Float, y: Float) -> Float = + sqrt(x * x + y * y) + to_float(floor(2.7)) + to_float(round(0.5)) + atan2(0.0, 1.0); diff --git a/tests/codegen-deno/thunk_math.harness.mjs b/tests/codegen-deno/thunk_math.harness.mjs new file mode 100644 index 00000000..8dc89314 --- /dev/null +++ b/tests/codegen-deno/thunk_math.harness.mjs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +// Thunk typing + numeric builtin lowering on Bun-ESM. +import assert from "node:assert/strict"; + +const notes = []; +const later = []; +globalThis.host_note = (s) => notes.push(s); +globalThis.host_later = (f) => later.push(f); +const m = await import("./thunk_math.bun.js"); + +assert.equal(m.inline_thunk(), 42, "inline thunk passed to () -> Int"); +m.named_thunk(); +assert.equal(notes.length, 0, "thunk not run eagerly"); +later.forEach((f) => f()); +assert.deepEqual(notes, ["ran"], "named thunk runs when called"); +assert.equal(m.geometry(3, 4), 5 + 2 + 1 + 0, "sqrt/floor/round/atan2/float lower to Math"); + +console.log("thunk_math.harness.mjs OK"); From 5e0279e28a758af1f9f1cd8a274424605e1bf9c6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:44:42 +0100 Subject: [PATCH 03/21] feat(module_loader): $AFFINESCRIPT_PATH package search path Colon-separated directories searched after the current directory and the stdlib, so third-party AffineScript packages (affinescript-tea first) can be imported from outside the importing program's directory. Previously the loader's search_paths was always empty with no way to set it. Test: test/test_module_search_path.ml (absent without the path, found with it; empty entries ignored). dune test main suite 553 OK. Co-Authored-By: Claude Opus 5.5 --- lib/module_loader.ml | 10 ++++++- test/test_main.ml | 1 + test/test_module_search_path.ml | 46 +++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 test/test_module_search_path.ml diff --git a/lib/module_loader.ml b/lib/module_loader.ml index d1cd07e9..64ff198c 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -108,10 +108,18 @@ let discover_stdlib () = else "./stdlib" (* preserves the historical default error path *) (** Create default configuration *) +(** Directories listed in [$AFFINESCRIPT_PATH] (colon-separated, empty + entries ignored): where third-party packages such as affinescript-tea + live. Searched after the current directory and the stdlib. *) +let env_search_paths () : string list = + match Sys.getenv_opt "AFFINESCRIPT_PATH" with + | None -> [] + | Some v -> List.filter (fun d -> d <> "") (String.split_on_char ':' v) + let default_config () : config = { stdlib_path = discover_stdlib (); - search_paths = []; + search_paths = env_search_paths (); current_dir = Sys.getcwd (); } diff --git a/test/test_main.ml b/test/test_main.ml index 8b5cd881..3a212926 100644 --- a/test/test_main.ml +++ b/test/test_main.ml @@ -15,6 +15,7 @@ let () = ("TW L13 isolation (#10)", Test_tw_isolation.tests); ("Qualified paths (#228, ADR-014)", Test_qualified_paths.tests); ("Generic enum kinds", Test_generic_enum_kinds.tests); + ("Module search path ($AFFINESCRIPT_PATH)", Test_module_search_path.tests); ("Module mut (#548)", Test_module_mut.tests); ("Int-div on JS-text backend (#478)", Test_int_div_js.tests); ("Deno builtins ↔ stdlib decls consistency", Test_deno_builtins_consistency.tests); diff --git a/test/test_module_search_path.ml b/test/test_module_search_path.ml new file mode 100644 index 00000000..fdf84840 --- /dev/null +++ b/test/test_module_search_path.ml @@ -0,0 +1,46 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(** [$AFFINESCRIPT_PATH]: colon-separated directories the module loader + searches after the current directory and the stdlib, so third-party + packages (affinescript-tea, ...) can be imported from outside the + importing program's directory. *) + +open Affinescript + +(** A fresh temp directory holding one module file [name].affine. *) +let module_dir name body = + let dir = Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "as_path_%s_%d" name (Unix.getpid ())) in + (try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + let oc = open_out (Filename.concat dir (name ^ ".affine")) in + output_string oc body; + close_out oc; + dir + +(** Run [f] with [$AFFINESCRIPT_PATH] set to [v], restoring it afterwards. *) +let with_path v f = + let old = Sys.getenv_opt "AFFINESCRIPT_PATH" in + Unix.putenv "AFFINESCRIPT_PATH" v; + Fun.protect f ~finally:(fun () -> + Unix.putenv "AFFINESCRIPT_PATH" (Option.value old ~default:"")) + +let parses_entries () = + with_path "/a::/b:" (fun () -> + Alcotest.(check (list string)) "empty entries dropped" [ "/a"; "/b" ] + (Module_loader.env_search_paths ())) + +let finds_module_on_path () = + let dir = module_dir "PathLib" "module PathLib;\npub fn seven() -> Int = 7;\n" in + let loader () = Module_loader.create (Module_loader.default_config ()) in + with_path "" (fun () -> + Alcotest.(check bool) "absent without the path" true + (Module_loader.find_module_file (loader ()) [ "PathLib" ] = None)); + with_path dir (fun () -> + Alcotest.(check bool) "found via AFFINESCRIPT_PATH" true + (Module_loader.find_module_file (loader ()) [ "PathLib" ] <> None)) + +let tests = + [ + Alcotest.test_case "parses entries" `Quick parses_entries; + Alcotest.test_case "finds module on path" `Quick finds_module_on_path; + ] From 9d4e00b114106948ea2ca86690b1a1d82772b821 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:54:42 +0100 Subject: [PATCH 04/21] fix(ast): find_free_vars walks loops, assignments and pattern binders Statements other than `let` and expression statements were skipped, so a name used only inside a `for`/`while` body (or an assignment) was never reported free. Import flattening then dropped the private helper it named: a public `create` calling `apply_attr` in a for-loop compiled to `ReferenceError: apply_attr is not defined` on Bun-ESM. The same walker drives closure conversion, which could likewise miss a capture. Also bind every variable of a destructuring `let` and of match-arm patterns (and walk guards), instead of only plain `PatVar` lets. dune test main suite 553 OK; Bun-ESM 34/34; WASM 38/38. Co-Authored-By: Claude Opus 5.5 --- lib/ast.ml | 46 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/lib/ast.ml b/lib/ast.ml index dd3b5114..45aa3365 100644 --- a/lib/ast.ml +++ b/lib/ast.ml @@ -554,6 +554,22 @@ let fn_body_contains_return : fn_body -> bool = function undefined `h`). Like the other walkers in this module it is deliberately conservative: constructs it does not inspect contribute [] rather than a wrong answer. *) +(** Variables bound by a pattern. *) +let rec pattern_binders (pat : pattern) : string list = + match pat with + | PatWildcard _ | PatLit _ -> [] + | PatVar id -> [id.name] + | PatTuple pats -> List.concat_map pattern_binders pats + | PatRecord (fields, _) -> + List.concat_map (fun (id, pat_opt) -> + match pat_opt with + | Some p -> pattern_binders p + | None -> [id.name] + ) fields + | PatCon (_, pats) -> List.concat_map pattern_binders pats + | PatOr (p1, p2) -> pattern_binders p1 @ pattern_binders p2 + | PatAs (id, pat) -> id.name :: pattern_binders pat + let rec find_free_vars (bound_vars : string list) (expr : expr) : string list = match expr with | ExprLit _ -> [] @@ -574,10 +590,7 @@ let rec find_free_vars (bound_vars : string list) (expr : expr) : string list = | ExprLet lb -> let rhs_free = find_free_vars bound_vars lb.el_value in (* Add bound variable to scope for body *) - let new_bound = match lb.el_pat with - | PatVar id -> id.name :: bound_vars - | _ -> bound_vars - in + let new_bound = pattern_binders lb.el_pat @ bound_vars in let body_free = match lb.el_body with | Some e -> find_free_vars new_bound e | None -> [] @@ -596,14 +609,22 @@ let rec find_free_vars (bound_vars : string list) (expr : expr) : string list = match stmt with | StmtLet sl -> let rhs_free = find_free_vars bound sl.sl_value in - let new_bound = match sl.sl_pat with - | PatVar id -> id.name :: bound - | _ -> bound - in + let new_bound = pattern_binders sl.sl_pat @ bound in (new_bound, acc_free @ rhs_free) | StmtExpr e -> (bound, acc_free @ find_free_vars bound e) - | _ -> (bound, acc_free) + (* Loops and assignments were skipped entirely, so a name used only + inside a `for`/`while` body was never reported: import flattening + then dropped the helper it called (`ReferenceError: apply_attr is + not defined`), and closure conversion could miss a capture. *) + | StmtAssign (lhs, _, rhs) -> + (bound, acc_free @ find_free_vars bound lhs @ find_free_vars bound rhs) + | StmtWhile (cond, body) -> + (bound, acc_free @ find_free_vars bound cond + @ find_free_vars bound (ExprBlock body)) + | StmtFor (pat, iter, body) -> + (bound, acc_free @ find_free_vars bound iter + @ find_free_vars (pattern_binders pat @ bound) (ExprBlock body)) ) (bound_vars, []) blk.blk_stmts in (* The tail expression is in scope of the block's own `let` bindings, so its free vars must exclude them — use the @@ -617,7 +638,12 @@ let rec find_free_vars (bound_vars : string list) (expr : expr) : string list = free @ expr_free | ExprMatch m -> find_free_vars bound_vars m.em_scrutinee @ - List.concat (List.map (fun arm -> find_free_vars bound_vars arm.ma_body) m.em_arms) + List.concat (List.map (fun arm -> + let arm_bound = pattern_binders arm.ma_pat @ bound_vars in + (match arm.ma_guard with + | Some g -> find_free_vars arm_bound g + | None -> []) + @ find_free_vars arm_bound arm.ma_body) m.em_arms) | ExprReturn e_opt -> (match e_opt with Some e -> find_free_vars bound_vars e | None -> []) | ExprTuple exprs | ExprArray exprs -> From c23c4e397b32373563f995ad3b158eadd0991d78 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:09:07 +0100 Subject: [PATCH 05/21] fix(typecheck,resolve,bun-esm): record update typing, imported types, lambdas in methods Found porting nexia-list's UI to AffineScript (a multi-module TEA app): - typecheck: `S #{ ..base, f: v }` ignored the spread when typing, so the result was only the explicit fields and an update could never produce the struct it started from. Now: a closed base keeps every field, each explicit field must match the base's type (an update cannot change a field's type) and new fields extend it; an open or not-yet-known base is constrained to have the explicit fields and the result is its own type (extending an open row there failed the row occurs check). - typecheck/resolve: an imported struct was an opaque name in the importer, because imports carry only value schemes, so its fields could not be read. A module now records each monomorphic type definition under a reserved `name_types` key (NUL-prefixed, disjoint from identifiers), the import paths copy it for imported type symbols (aliases honoured), and `check_program` installs it in the importer's type environment. Local declarations still win. - bun-esm: a lambda inside a synthesised (async) class method inherited the async context, emitting `(x) => (await ...)`, which is a SyntaxError in V8/browsers (Bun's parser accepts it, which hid the bug). Lambda bodies are now emitted in a non-async context. Tests: test/test_records_and_imports.ml (7, incl. wrong-field-type and unknown-imported-field negatives); tests/codegen-deno/method_lambda (fails with the SyntaxError when the lambda fix is reverted). dune test main 560 OK; Bun-ESM 35/35; WASM 38/38. Co-Authored-By: Claude Opus 5.5 --- lib/codegen_deno.ml | 7 +- lib/resolve.ml | 16 +++- lib/typecheck.ml | 80 +++++++++++++++- test/test_main.ml | 1 + test/test_records_and_imports.ml | 96 ++++++++++++++++++++ tests/codegen-deno/method_lambda.affine | 13 +++ tests/codegen-deno/method_lambda.harness.mjs | 7 ++ 7 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 test/test_records_and_imports.ml create mode 100644 tests/codegen-deno/method_lambda.affine create mode 100644 tests/codegen-deno/method_lambda.harness.mjs diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 38fe7957..85a02a63 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -1563,7 +1563,12 @@ let rec gen_expr ctx (expr : expr) : string = | ExprContinue _ -> iife ctx "continue;" | ExprLambda { elam_params; elam_body; elam_ret_ty = _ } -> let ps = List.map (fun (p : param) -> mangle p.p_name.name) elam_params in - "((" ^ String.concat ", " ps ^ ") => " ^ gen_expr ctx elam_body ^ ")" + (* A lambda is a plain (non-async) arrow, so its body must not inherit + the enclosing async context: inside a synthesised (async) method, + `(x) => (await ...)` is a SyntaxError in browsers — Bun's parser + happens to accept it, which hid the bug. *) + "((" ^ String.concat ", " ps ^ ") => " + ^ gen_expr { ctx with in_async = false } elam_body ^ ")" | ExprTry { et_body; et_catch; et_finally } -> gen_try ctx et_body et_catch et_finally | ExprVariant (ty, ctor) -> diff --git a/lib/resolve.ml b/lib/resolve.ml index 4269eca9..9828ecc9 100644 --- a/lib/resolve.ml +++ b/lib/resolve.ml @@ -598,6 +598,17 @@ let lookup_source_scheme | Some sc -> Some sc | None -> Hashtbl.find_opt source_name_types sym.Symbol.sym_name +(** For an imported type symbol, carry its definition (recorded by the + source module under [Typecheck.type_def_key]) so the importer can use the + type's structure — e.g. read an imported struct's fields. *) +let import_type_def ~dest_name_types ~source_name_types + (sym : Symbol.symbol) (bound_name : string) : unit = + if sym.Symbol.sym_kind = Symbol.SKType then + Option.iter (fun sc -> + Hashtbl.replace dest_name_types (Typecheck.type_def_key bound_name) sc) + (Hashtbl.find_opt source_name_types + (Typecheck.type_def_key sym.Symbol.sym_name)) + (** Import symbols from a resolved module into the current context. [dest_name_types] is the destination type checker's name-keyed scheme map; @@ -621,7 +632,9 @@ let import_resolved_symbols Option.iter (fun scheme -> Hashtbl.replace dest_types sym.Symbol.sym_id scheme; Hashtbl.replace dest_name_types sym.Symbol.sym_name scheme - ) (lookup_source_scheme source_types source_name_types sym) + ) (lookup_source_scheme source_types source_name_types sym); + import_type_def ~dest_name_types ~source_name_types sym + sym.Symbol.sym_name | _ -> () (* Private symbols not imported *) ) source_symbols.all_symbols @@ -646,6 +659,7 @@ let import_specific_items let alias = Option.map (fun id -> id.name) item.ii_alias in let _ = Symbol.register_import dest_symbols sym alias in let bound_name = Option.value alias ~default:sym.Symbol.sym_name in + import_type_def ~dest_name_types ~source_name_types sym bound_name; Option.iter (fun scheme -> Hashtbl.replace dest_types sym.Symbol.sym_id scheme; Hashtbl.replace dest_name_types bound_name scheme diff --git a/lib/typecheck.ml b/lib/typecheck.ml index cc841fdb..de6f05bc 100644 --- a/lib/typecheck.ml +++ b/lib/typecheck.ml @@ -1103,7 +1103,55 @@ let rec synth (ctx : context) (expr : expr) : ty result = let kinds = List.map (fun (name, (_off, is_f64)) -> (name, is_f64)) layout in cell_record_sites := (rec_node, kinds) :: !cell_record_sites | _ -> ()); - Ok (TRecord row) + begin match er_spread with + | None -> Ok (TRecord row) + | Some base -> + (* Functional update `#{ ..base, f: v }`: the result has every field + of [base], with each explicitly given field replacing the base's + (same type — a struct update cannot change a field's type) and + any field the base lacks added. The spread used to be ignored, so + the result was typed as only the explicit fields and an update + could never produce the struct it started from. *) + let* base_ty = synth ctx base in + let rec follow (r : row) : row = + match r with + | RVar { contents = RLink r' } -> follow r' + | _ -> r + in + let explicit = List.rev field_tys in + let rec closed (r : row) : bool = + match follow r with + | REmpty -> true + | RExtend (_, _, rest) -> closed rest + | RVar _ -> false + in + begin match repr base_ty with + | TRecord brow when closed brow -> + let rec update_row (r : row) (seen : string list) : (row * string list) result = + match follow r with + | RExtend (l, t, rest) -> + let* () = match List.assoc_opt l explicit with + | Some nt -> unify_or_err t nt + | None -> Ok () + in + let* (rest', seen') = update_row rest (l :: seen) in + Ok (RExtend (l, t, rest'), seen') + | tail -> Ok (tail, seen) + in + let* (updated, base_labels) = update_row brow [] in + let added = List.filter (fun (l, _) -> not (List.mem l base_labels)) explicit in + Ok (TRecord (List.fold_right (fun (l, t) acc -> RExtend (l, t, acc)) added updated)) + | _ -> + (* An open or not-yet-known base: it must have the explicitly + updated fields (same types), and the result is the base's own + type. Extending an open row here would make it contain itself + (row occurs check). *) + let expected = TRecord (List.fold_right (fun (l, t) acc -> RExtend (l, t, acc)) + explicit (fresh_rowvar ctx.level)) in + let* () = unify_or_err base_ty expected in + Ok base_ty + end + end (* Field access — first try record-field projection, then trait method lookup *) | ExprField (obj, { name = field; _ }) as field_node -> @@ -2198,6 +2246,20 @@ let check_fn_decl (ctx : context) (fd : fn_decl) : unit result = Ok () (** Register a type declaration in the context. *) +(** Reserved [name_types] key under which a module records the definition + of its type [name], so importers can name the type with its structure + (an imported struct's fields; imports otherwise carry only value + schemes). The NUL byte keeps it disjoint from every identifier. *) +let type_def_key (name : string) : string = "\000type:" ^ name + +(** Inverse of [type_def_key]: the type name, if [key] is one. *) +let type_of_def_key (key : string) : string option = + let pfx = "\000type:" in + let n = String.length pfx in + if String.length key > n && String.sub key 0 n = pfx + then Some (String.sub key n (String.length key - n)) + else None + let register_type_decl (ctx : context) (td : type_decl) : unit result = let* ty = match td.td_body with | TyAlias te -> @@ -2270,6 +2332,12 @@ let register_type_decl (ctx : context) (td : type_decl) : unit result = Ok (TCon td.td_name.name) in Hashtbl.replace ctx.type_env td.td_name.name ty; + (* Export the definition for importers (see [type_def_key]). Only + monomorphic definitions: a generic struct/alias body mentions its + parameters, which have no meaning outside this declaration. *) + if td.td_type_params = [] then + Hashtbl.replace ctx.name_types (type_def_key td.td_name.name) + { sc_tyvars = []; sc_effvars = []; sc_rowvars = []; sc_body = ty }; Ok () (** Register an effect declaration. *) @@ -2514,8 +2582,14 @@ let check_program ?(import_types : (string, scheme) Hashtbl.t option) ) prog.prog_imports; Option.iter (fun tbl -> Hashtbl.iter (fun name sc -> - Hashtbl.replace ctx.name_types name sc; - record_type_arities ctx sc.sc_body) tbl + match type_of_def_key name with + | Some ty_name -> + (* An imported type's definition. Local declarations, registered in + the forward pass below, replace it. *) + Hashtbl.replace ctx.type_env ty_name sc.sc_body + | None -> + Hashtbl.replace ctx.name_types name sc; + record_type_arities ctx sc.sc_body) tbl ) import_types; (* Forward pass: register all types, effects, traits, impls, and function signatures so that mutually recursive declarations resolve. *) diff --git a/test/test_main.ml b/test/test_main.ml index 3a212926..4bc13ef3 100644 --- a/test/test_main.ml +++ b/test/test_main.ml @@ -16,6 +16,7 @@ let () = ("Qualified paths (#228, ADR-014)", Test_qualified_paths.tests); ("Generic enum kinds", Test_generic_enum_kinds.tests); ("Module search path ($AFFINESCRIPT_PATH)", Test_module_search_path.tests); + ("Record update + imported types", Test_records_and_imports.tests); ("Module mut (#548)", Test_module_mut.tests); ("Int-div on JS-text backend (#478)", Test_int_div_js.tests); ("Deno builtins ↔ stdlib decls consistency", Test_deno_builtins_consistency.tests); diff --git a/test/test_records_and_imports.ml b/test/test_records_and_imports.ml new file mode 100644 index 00000000..82aaaaaf --- /dev/null +++ b/test/test_records_and_imports.ml @@ -0,0 +1,96 @@ +(* SPDX-License-Identifier: MPL-2.0 *) +(* Copyright (c) 2026 Jonathan D.A. Jewell *) +(** Record update typing and imported type definitions. + + - `S #{ ..base, f: v }` used to ignore the spread when typing, so the + result had only the explicit fields and an update could never produce + the struct it started from. + - An imported struct was an opaque name in the importer (imports carry + value schemes only), so its fields could not be read. *) + +open Affinescript + +(** parse -> resolve (loader rooted at [dir]) -> typecheck. *) +let frontend ?(dir = Sys.getcwd ()) (src : string) : (unit, string) result = + let ( let* ) = Result.bind in + let* prog = + try Ok (Parse_driver.parse_string ~file:"" src) + with + | Parse_driver.Parse_error (m, sp) -> + Error (Printf.sprintf "Parse error at %s: %s" (Span.show sp) m) + | e -> Error (Printf.sprintf "Unexpected: %s" (Printexc.to_string e)) + in + let config = { (Module_loader.default_config ()) with current_dir = dir; search_paths = [ dir ] } in + let loader = Module_loader.create config in + let* resolve_ctx, type_ctx = + match Resolve.resolve_program_with_loader prog loader with + | Ok (rc, tc) -> Ok (rc, tc) + | Error (e, _) -> Error ("Resolution error: " ^ Resolve.show_resolve_error e) + in + match + Typecheck.check_program ~import_types:type_ctx.Typecheck.name_types + resolve_ctx.symbols prog + with + | Ok _ -> Ok () + | Error e -> Error ("Type error: " ^ Typecheck.format_type_error e) + +(** Assert [src] type-checks. *) +let passes ?dir src = + match frontend ?dir src with + | Ok () -> () + | Error m -> Alcotest.failf "expected Ok, got: %s" m + +(** Assert [src] is rejected. *) +let fails src = + match frontend src with + | Ok () -> Alcotest.fail "expected a type error, got Ok" + | Error _ -> () + +let p = "struct P { a: Int, b: String }\n" + +let update_keeps_struct_type () = passes (p ^ "pub fn f(q: P) -> P = P #{ ..q, a: 2 };\n") +let spread_only () = passes (p ^ "pub fn f(q: P) -> P = P #{ ..q };\n") +let untouched_field_readable () = + passes (p ^ "pub fn f(q: P) -> String { let r = P #{ ..q, a: 2 }; r.b }\n") +let update_cannot_change_field_type () = fails (p ^ "pub fn f(q: P) -> P = P #{ ..q, a: \"x\" };\n") +let update_through_destructured_tuple () = + passes (p ^ "fn mk(q: P) -> (P, Int) = (q, 1);\n\ + pub fn f(q: P) -> P { let (r, n) = mk(q); P #{ ..r, a: n } }\n") + +(** A temp directory holding [name].affine with [body]. *) +let module_dir name body = + let dir = Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "as_rec_%s_%d" name (Unix.getpid ())) in + (try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); + let oc = open_out (Filename.concat dir (name ^ ".affine")) in + output_string oc body; + close_out oc; + dir + +let imported_struct_fields () = + let dir = module_dir "Shapes" + "module Shapes;\npub struct Point { x: Float, y: Float }\n\ + pub fn origin() -> Point = Point #{ x: 0.0, y: 0.0 };\n" in + passes ~dir + "use Shapes::{Point, origin};\n\ + pub fn sum(p: Point) -> Float = p.x + p.y;\n\ + pub fn moved() -> Point { let o = origin(); Point #{ ..o, x: 1.0 } }\n" + +let imported_struct_unknown_field_rejected () = + let dir = module_dir "Shapes2" + "module Shapes2;\npub struct Point { x: Float, y: Float }\n" in + match frontend ~dir "use Shapes2::{Point};\npub fn f(p: Point) -> Float = p.z;\n" with + | Ok () -> Alcotest.fail "expected unknown field to be rejected" + | Error _ -> () + +let tests = + [ + Alcotest.test_case "update keeps struct type" `Quick update_keeps_struct_type; + Alcotest.test_case "spread only" `Quick spread_only; + Alcotest.test_case "untouched field readable" `Quick untouched_field_readable; + Alcotest.test_case "update cannot change field type" `Quick update_cannot_change_field_type; + Alcotest.test_case "update through destructured tuple" `Quick update_through_destructured_tuple; + Alcotest.test_case "imported struct fields" `Quick imported_struct_fields; + Alcotest.test_case "imported struct unknown field rejected" `Quick + imported_struct_unknown_field_rejected; + ] diff --git a/tests/codegen-deno/method_lambda.affine b/tests/codegen-deno/method_lambda.affine new file mode 100644 index 00000000..7947f5ea --- /dev/null +++ b/tests/codegen-deno/method_lambda.affine @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MPL-2.0 +// A lambda inside a receiver-first fn: the fn is also synthesised as an +// (async) class method, and the lambda body must not inherit that async +// context — `(x) => (await ...)` is a SyntaxError in V8/browsers. + +use prelude::*; + +struct Box { items: [Int], label: String } + +pub fn make() -> Box = Box #{ items: [1, 2, 3], label: "b" }; + +pub fn describe(b: Box) -> [String] = + map(b.items, fn(i) => if i > 1 { b.label } else { "small" }); diff --git a/tests/codegen-deno/method_lambda.harness.mjs b/tests/codegen-deno/method_lambda.harness.mjs new file mode 100644 index 00000000..2181bc3b --- /dev/null +++ b/tests/codegen-deno/method_lambda.harness.mjs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Importing the module at all proves it parses under V8 (Node). +import assert from "node:assert/strict"; +import { make, describe } from "./method_lambda.bun.js"; + +assert.deepEqual(describe(make()), ["small", "b", "b"]); +console.log("method_lambda.harness.mjs OK"); From 65dd7ca2610ef32db5c9e10e547d87883cf16376 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:55:28 +0100 Subject: [PATCH 06/21] feat(affinescript-tea): The Elm Architecture written in AffineScript A browser TEA runtime for the Bun-ESM target, in AffineScript: - src/Tea.affine: typed virtual DOM (`Html`, `Attr` with attributes, properties, styles, keys and `Event -> Option` decoders; HTML and SVG), a reconciler with keyed child moves (DOM nodes of surviving keys are reused and moved), `Cmd` (none/batch/send/run_cmd) and `Sub` (window events, animation frames, intervals) diffed by key after every update, and `run(selector, init, update, view, subscriptions)` with in-order message processing and frame-batched renders. - src/tea_host.js: the only JavaScript, a host carve-out implementing the primitive externs (DOM ops, handler slots, rAF/timers, cells, key index). - examples/todo: the reference app. - e2e: Playwright (pinned 1.62.1) drives the app in headless Chromium; 8 tests, including keyed reorder preserving node identity (verified to fail with keyed diffing disabled). Wired into ci.yml's build job. Depends on the compiler fixes in #777 (generic enum/extern kinds, thunks, qualified ctors, free-vars, $AFFINESCRIPT_PATH). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 + .gitignore | 3 + affinescript-tea/README.adoc | 122 ++++- affinescript-tea/e2e/bun.lock | 19 + affinescript-tea/e2e/package.json | 12 + affinescript-tea/e2e/todo.test.js | 157 ++++++ affinescript-tea/examples/todo/Todo.affine | 101 ++++ affinescript-tea/src/Tea.affine | 537 +++++++++++++++++++++ affinescript-tea/src/tea_host.js | 249 ++++++++++ 9 files changed, 1209 insertions(+), 1 deletion(-) create mode 100644 affinescript-tea/e2e/bun.lock create mode 100644 affinescript-tea/e2e/package.json create mode 100644 affinescript-tea/e2e/todo.test.js create mode 100644 affinescript-tea/examples/todo/Todo.affine create mode 100644 affinescript-tea/src/Tea.affine create mode 100644 affinescript-tea/src/tea_host.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cfea1e7..5f406929 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,16 @@ jobs: run: opam exec -- ./tools/run_codegen_deno_tests.sh - name: Run native Bun-ESM tests (issue #734) run: opam exec -- ./tools/run_codegen_bun_tests.sh + - name: affinescript-tea browser tests (headless Chromium) + # The AffineScript TEA runtime (affinescript-tea/src/Tea.affine) driven + # through its reference app in a real browser: mount, controlled + # input, async commands, keyed reorder preserving DOM-node identity, + # subscriptions. Playwright is pinned in e2e/bun.lock. + working-directory: affinescript-tea/e2e + run: | + bun install --frozen-lockfile + bunx --no-install --bun playwright install --with-deps chromium + opam exec -- bun test --timeout 60000 - name: Run face-transformer regression tests run: opam exec -- ./tools/run_face_transformer_tests.sh - name: Issue #35 Phase 3 — block extension.ts regression diff --git a/.gitignore b/.gitignore index b1445fa0..c336bf2f 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,6 @@ tools/vendor/ /.claude/worktrees/ .editorconfig + +# affinescript-tea browser test deps +/affinescript-tea/e2e/node_modules/ diff --git a/affinescript-tea/README.adoc b/affinescript-tea/README.adoc index ab8731c8..a0f7dac4 100644 --- a/affinescript-tea/README.adoc +++ b/affinescript-tea/README.adoc @@ -9,7 +9,16 @@ AffineScript modules. INT-07 (issue #182). toc::[] -== What this is +This package holds two TEA runtimes: + +* *`src/Tea.affine`* — The Elm Architecture *written in AffineScript*, for + browser applications compiled with the Bun-ESM backend: typed virtual + DOM, keyed reconciler, commands, subscriptions. See + <>. +* *`mod.js`* — the host-side run loop for WASM modules that expose the + `lib/tea_bridge.ml` ABI (below). + +== What this is (WASM bridge runtime) The compiler-internal `lib/tea_bridge.ml` defines a TEA runtime ABI a conforming WASM module exposes: `affinescript_init()`, @@ -64,3 +73,114 @@ INT-07 first general runtime: `TeaApp` (`load`/`init`/`dispatch`/ canonical bridge (`affinescript tea-bridge`) and a hand-built re-entrancy fixture — see `mod_test.js` (9 tests). The router/navigation runtime is a separate satellite (INT-09, `lib/tea_router.ml`). + +[[affinescript-tea-runtime]] +== The AffineScript runtime (`src/Tea.affine`) + +A browser application is an initial state and three pure functions: + +[source,affinescript] +---- +use Tea::{Html, Cmd, Sub, run, div, button, text, on_click, none, no_sub}; + +pub enum Msg { Inc, Dec } + +pub fn update(msg: Msg, n: Int) -> (Int, Cmd) = match msg { + Msg::Inc => (n + 1, none()), + Msg::Dec => (n - 1, none()) +}; + +pub fn view(n: Int) -> Html = + div([], [button([on_click(Msg::Dec)], [text("-")]), + text(int_to_string(n)), + button([on_click(Msg::Inc)], [text("+")])]); + +pub fn main() -> Unit = + run("#root", (0, none()), update, view, fn(n) => no_sub()); +---- + +What the runtime provides: + +* *`Html` / `Attr`* — elements (HTML and SVG), attributes, DOM + properties, inline styles, keys, and event decoders + (`On(event, Event -> Option)`; returning `None` ignores the event, as + `on_enter` does for non-Enter keys). `map_html` re-tags a component's + messages. +* *Keyed reconciliation* — when every child carries a `key`, surviving + children keep their DOM node and are *moved*, so focus, scroll and + in-progress input survive reordering. Otherwise children patch by position. + Event handlers are re-pointed in place: the DOM listener is installed once + per node and event. +* *`Cmd`* — `none`, `batch`, `send(msg)` and `run_cmd(fn(dispatch) => …)`, + which expresses any effect (timers, IndexedDB, WebAssembly calls); `map_cmd`. +* *`Sub`* — `on_window(event, decode)`, `on_frame(f)`, `every(ms, f)`, + `subs([...])`. They are diffed by key after every update, so a + subscription returned only while some state holds is added and removed + with that state. +* *Ordering* — messages are processed one at a time, in order. A message + dispatched during an update is queued. Renders are batched to the next + animation frame. +* *Linear list helpers* — `tea_map`, `tea_filter`, `tea_concat`. The + prelude's `map`/`filter` append with `++` and are quadratic on the JS + target. + +=== Building and running an app + +[source,sh] +---- +export AFFINESCRIPT_PATH=/path/to/affinescript/affinescript-tea/src +affinescript compile --bun-esm App.affine -o app.bun.js +---- + +Then serve a page that loads the host before the app: + +[source,html] +---- +
+ +---- + +`tea_host.js` is the runtime's only JavaScript: a host carve-out that +implements the primitive `extern fn`s (create node, set attribute, +`requestAnimationFrame`, timers, cells) as globals. Every decision is made in +`Tea.affine`: what to render, diffing, keyed moves, message order, and +subscription sets. + +=== Tests + +`examples/todo/Todo.affine` is the reference app. `e2e/todo.test.js` drives +it in headless Chromium and covers: + +* mount and the init command; +* a controlled input with Enter; +* an asynchronous command; +* keyed reorder preserving DOM-node identity; +* toggle and remove; +* a subscription that starts and stops with the model; +* no runtime errors. + +The keyed-reorder test is known to fail when keyed diffing is disabled. + +[source,sh] +---- +dune build # at the repo root: the compiler +cd affinescript-tea/e2e +bun install --frozen-lockfile +bun test +---- + +Playwright is pinned to 1.62.1 (Chromium revision 1234). + +=== Limits (current compiler) + +* `run` takes the initial `(Model, Cmd)` *value*, not an `init` + function. A zero-parameter function declaration is typed as its bare + result, so it cannot be passed where a `() -> T` thunk is expected. +* Generic *structs* are not supported by the type checker, so the program + is passed as separate functions rather than one record. +* A qualified pattern on an imported enum (`Html::Element(..)`) needs the + constructor imported by name, not just the type. diff --git a/affinescript-tea/e2e/bun.lock b/affinescript-tea/e2e/bun.lock new file mode 100644 index 00000000..4cade477 --- /dev/null +++ b/affinescript-tea/e2e/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "affinescript-tea-e2e", + "devDependencies": { + "playwright": "1.62.1", + }, + }, + }, + "packages": { + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + } +} diff --git a/affinescript-tea/e2e/package.json b/affinescript-tea/e2e/package.json new file mode 100644 index 00000000..5732ca56 --- /dev/null +++ b/affinescript-tea/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "affinescript-tea-e2e", + "private": true, + "type": "module", + "description": "Browser tests for the AffineScript TEA runtime (Tea.affine + tea_host.js).", + "scripts": { + "test": "bun test --timeout 60000" + }, + "devDependencies": { + "playwright": "1.62.1" + } +} diff --git a/affinescript-tea/e2e/todo.test.js b/affinescript-tea/e2e/todo.test.js new file mode 100644 index 00000000..0fca6634 --- /dev/null +++ b/affinescript-tea/e2e/todo.test.js @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Browser tests for the AffineScript TEA runtime, driven through the Todo +// reference app (../examples/todo/Todo.affine) in headless Chromium. +// +// Run: cd affinescript-tea/e2e && bun install --frozen-lockfile && bun test +// Needs the compiler built (`dune build` at the repo root) and Playwright's +// Chromium (`bunx playwright install chromium` if not cached). + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtempSync, copyFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium } from "playwright"; + +const repo = new URL("../../", import.meta.url).pathname; +const teaSrc = join(repo, "affinescript-tea/src"); +const site = mkdtempSync(join(tmpdir(), "tea-e2e-")); + +let server; +let browser; +let page; +const consoleErrors = []; + +/** Compile the Todo example to Bun-ESM into the served directory. */ +function compileTodo() { + const out = Bun.spawnSync({ + cmd: [join(repo, "_build/default/bin/main.exe"), "compile", "--bun-esm", + join(repo, "affinescript-tea/examples/todo/Todo.affine"), "-o", join(site, "todo.bun.js")], + cwd: join(repo, "affinescript-tea/examples/todo"), + env: { ...process.env, AFFINESCRIPT_STDLIB: join(repo, "stdlib"), AFFINESCRIPT_PATH: teaSrc }, + }); + if (out.exitCode !== 0) { + throw new Error(`compile failed:\n${out.stdout}\n${out.stderr}`); + } +} + +beforeAll(async () => { + compileTodo(); + copyFileSync(join(teaSrc, "tea_host.js"), join(site, "tea_host.js")); + writeFileSync( + join(site, "index.html"), + `todo
+`, + ); + server = Bun.serve({ + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + const file = Bun.file(join(site, path === "/" ? "index.html" : path.slice(1))); + return new Response(file); + }, + }); + browser = await chromium.launch(); + page = await browser.newPage(); + page.on("console", (m) => { + if (m.type() === "error") consoleErrors.push(m.text()); + }); + page.on("pageerror", (e) => consoleErrors.push(String(e))); + await page.goto(`http://localhost:${server.port}/`); + await page.waitForSelector("#app"); +}); + +afterAll(async () => { + await browser?.close(); + server?.stop(true); +}); + +/** + * Poll `read` until `check(value)` passes (or 5 s elapse), then assert it. + * Rendering is batched to the next animation frame, so DOM reads after an + * interaction must wait for it. + */ +async function eventually(read, check) { + const deadline = Date.now() + 5000; + let value = await read(); + while (Date.now() < deadline) { + try { + check(value); + return; + } catch { + await new Promise((r) => setTimeout(r, 20)); + value = await read(); + } + } + check(value); +} + +/** Text of every item title, in DOM order. */ +const titles = () => page.$$eval("#items .title", (els) => els.map((e) => e.textContent)); + +/** Add an item by typing it and pressing Enter. */ +async function add(title) { + await page.fill("#draft", title); + await page.press("#draft", "Enter"); + await eventually(titles, (v) => expect(v).toContain(title)); +} + +test("mounts the initial view and runs the init command", async () => { + expect(await page.textContent("h1")).toBe("Todo"); + expect(await page.textContent("#count")).toBe("0 left"); + await eventually(() => page.textContent("#notice"), (v) => expect(v).toBe("ready")); +}); + +test("controlled input + Enter adds an item and clears the draft", async () => { + await add("milk"); + expect(await page.inputValue("#draft")).toBe(""); + expect(await page.textContent("#count")).toBe("1 left"); + await eventually(() => page.textContent("#notice"), (v) => expect(v).toBe("added milk")); +}); + +test("the add button and an empty draft", async () => { + await page.click("#add"); + expect(await titles()).toEqual(["milk"]); + await page.fill("#draft", "eggs"); + await page.click("#add"); + await eventually(titles, (v) => expect(v).toEqual(["milk", "eggs"])); +}); + +test("keyed reorder moves the existing DOM node instead of recreating it", async () => { + await add("bread"); + await page.$eval('#items li[data-id="3"]', (li) => { + li.__marker = "kept"; + }); + await page.click('#items li[data-id="3"] .top'); + await eventually(titles, (v) => expect(v).toEqual(["bread", "milk", "eggs"])); + const marker = await page.$eval("#items li:first-child", (li) => li.__marker ?? null); + expect(marker).toBe("kept"); +}); + +test("toggling updates the checkbox, class and count", async () => { + await page.click('#items li[data-id="1"] input[type=checkbox]'); + await eventually(() => page.textContent("#count"), (v) => expect(v).toBe("2 left")); + expect(await page.getAttribute('#items li[data-id="1"]', "class")).toBe("item done"); + expect(await page.isChecked('#items li[data-id="1"] input[type=checkbox]')).toBe(true); +}); + +test("removing an item removes exactly its node", async () => { + await page.click('#items li[data-id="2"] .remove'); + await eventually(titles, (v) => expect(v).toEqual(["bread", "milk"])); + expect(await page.$$eval("#items li", (els) => els.length)).toBe(2); +}); + +test("a subscription starts and stops with the model", async () => { + await page.click("#tick"); + await eventually(async () => Number(await page.textContent("#ticks")), (v) => expect(v).toBeGreaterThan(3)); + await page.click("#tick"); + await page.waitForTimeout(60); + const stopped = Number(await page.textContent("#ticks")); + await page.waitForTimeout(150); + expect(Number(await page.textContent("#ticks"))).toBe(stopped); +}); + +test("no runtime errors were reported", () => { + expect(consoleErrors).toEqual([]); +}); diff --git a/affinescript-tea/examples/todo/Todo.affine b/affinescript-tea/examples/todo/Todo.affine new file mode 100644 index 00000000..f296d328 --- /dev/null +++ b/affinescript-tea/examples/todo/Todo.affine @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Todo — the affinescript-tea reference application, and the program the +// browser test suite (../../e2e) drives: a keyed list (add, toggle, delete, +// move to top), a controlled input submitted with Enter, a command that +// dispatches later, and a subscription that is switched on and off by the +// model. + +module Todo; + +use prelude::*; +use Tea::{Html, Attr, Cmd, Sub, Event, run, text, div, span, button, input, ul, li, h1, p, + class, id, value, key, attr, on_click, on_input, on_enter, on_check, none, send, + every, no_sub, tea_map, tea_filter, run_cmd, tea_defer}; + +pub struct Item { id: Int, title: String, done: Bool } + +pub struct Model { items: [Item], draft: String, next_id: Int, ticking: Bool, ticks: Int, notice: String } + +pub enum Msg { + Draft(String), + Add, + Toggle(Int), + Remove(Int), + ToTop(Int), + SetTicking(Bool), + Tick(Float), + Notice(String) +} + +pub fn init() -> (Model, Cmd) = + (Model #{ items: [], draft: "", next_id: 1, ticking: false, ticks: 0, notice: "" }, + send(Msg::Notice("ready"))); + +fn with_items(m: Model, items: [Item]) -> Model = + Model #{ items: items, draft: m.draft, next_id: m.next_id, ticking: m.ticking, ticks: m.ticks, notice: m.notice }; + +fn toggled(it: Item, target: Int) -> Item = + if it.id == target { Item #{ id: it.id, title: it.title, done: !it.done } } else { it }; + +pub fn update(msg: Msg, m: Model) -> (Model, Cmd) { + match msg { + Msg::Draft(s) => + (Model #{ items: m.items, draft: s, next_id: m.next_id, ticking: m.ticking, ticks: m.ticks, notice: m.notice }, none()), + Msg::Add => { + if m.draft == "" { + return (m, none()); + } + let item = Item #{ id: m.next_id, title: m.draft, done: false }; + (Model #{ items: m.items ++ [item], draft: "", next_id: m.next_id + 1, ticking: m.ticking, + ticks: m.ticks, notice: m.notice }, + // A command that dispatches asynchronously, after this update. + run_cmd(fn(dispatch) => tea_defer(fn() => dispatch(Msg::Notice("added " ++ item.title))))) + }, + Msg::Toggle(target) => (with_items(m, tea_map(m.items, fn(it) => toggled(it, target))), none()), + Msg::Remove(target) => (with_items(m, tea_filter(m.items, fn(it) => it.id != target)), none()), + Msg::ToTop(target) => { + let picked = tea_filter(m.items, fn(it) => it.id == target); + let rest = tea_filter(m.items, fn(it) => it.id != target); + (with_items(m, picked ++ rest), none()) + }, + Msg::SetTicking(on) => + (Model #{ items: m.items, draft: m.draft, next_id: m.next_id, ticking: on, ticks: m.ticks, notice: m.notice }, none()), + Msg::Tick(_) => + (Model #{ items: m.items, draft: m.draft, next_id: m.next_id, ticking: m.ticking, ticks: m.ticks + 1, notice: m.notice }, none()), + Msg::Notice(s) => + (Model #{ items: m.items, draft: m.draft, next_id: m.next_id, ticking: m.ticking, ticks: m.ticks, notice: s }, none()) + } +} + +fn view_item(it: Item) -> Html { + let sid = int_to_string(it.id); + li([key(sid), class(if it.done { "item done" } else { "item" }), attr("data-id", sid)], [ + input([attr("type", "checkbox"), attr("aria-label", "done " ++ it.title), + Attr::Property("checked", if it.done { "true" } else { "false" }), on_check(fn(b) => Msg::Toggle(it.id))]), + span([class("title")], [text(it.title)]), + button([class("top"), on_click(Msg::ToTop(it.id))], [text("top")]), + button([class("remove"), on_click(Msg::Remove(it.id))], [text("x")]) + ]) +} + +pub fn view(m: Model) -> Html = + div([id("app")], [ + h1([], [text("Todo")]), + input([id("draft"), attr("placeholder", "What needs doing?"), value(m.draft), + on_input(fn(s) => Msg::Draft(s)), on_enter(Msg::Add)]), + button([id("add"), on_click(Msg::Add)], [text("add")]), + ul([id("items")], tea_map(m.items, fn(it) => view_item(it))), + p([id("count")], [text(int_to_string(len(tea_filter(m.items, fn(it) => !it.done))) + ++ " left")]), + button([id("tick"), on_click(Msg::SetTicking(!m.ticking))], + [text(if m.ticking { "stop" } else { "start" })]), + span([id("ticks")], [text(int_to_string(m.ticks))]), + p([id("notice")], [text(m.notice)]) + ]); + +pub fn subscriptions(m: Model) -> Sub = + if m.ticking { every(20, fn(t) => Msg::Tick(t)) } else { no_sub() }; + +pub fn main() -> Unit = run("#root", init(), update, view, subscriptions); diff --git a/affinescript-tea/src/Tea.affine b/affinescript-tea/src/Tea.affine new file mode 100644 index 00000000..59968cd4 --- /dev/null +++ b/affinescript-tea/src/Tea.affine @@ -0,0 +1,537 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Tea.affine — The Elm Architecture for AffineScript on the Bun-ESM target. +// +// A browser application is an initial state and three pure functions: +// +// init : (Model, Cmd) +// update : (Msg, Model) -> (Model, Cmd) +// view : Model -> Html +// subscriptions : Model -> Sub +// +// handed to `run`. Everything stateful — the current model, the rendered +// virtual DOM, live subscriptions — is owned here; the program itself never +// touches the DOM. The reconciler (keyed and unkeyed child diffing) is +// AffineScript; the host (`tea_host.js`) supplies only primitive DOM, timer +// and storage-cell operations, declared below as `extern fn`. +// +// Build: put this directory on $AFFINESCRIPT_PATH, `use Tea::{...}`, compile +// the app with `affinescript compile --bun-esm`, and load `tea_host.js` +// before the compiled module. + +module Tea; + +use prelude::*; + +// ── Host primitives ───────────────────────────────────────────────────────── + +/// A real DOM node, owned by the host. +pub extern type Node; +/// A DOM (or synthetic timer/frame) event, owned by the host. +pub extern type Event; +/// A host-allocated mutable cell: the runtime's only mutable state. +pub extern type Cell; + +pub extern fn tea_cell(v: T) -> Cell; +pub extern fn tea_get(c: Cell) -> T; +pub extern fn tea_put(c: Cell, v: T) -> Unit; + +pub extern fn tea_query(selector: String) -> Node; +pub extern fn tea_create(tag: String, ns: String) -> Node; +pub extern fn tea_create_text(s: String) -> Node; +pub extern fn tea_set_text(n: Node, s: String) -> Unit; +pub extern fn tea_set_attr(n: Node, k: String, v: String) -> Unit; +pub extern fn tea_remove_attr(n: Node, k: String) -> Unit; +pub extern fn tea_set_prop(n: Node, k: String, v: String) -> Unit; +pub extern fn tea_set_style(n: Node, k: String, v: String) -> Unit; +pub extern fn tea_remove_style(n: Node, k: String) -> Unit; +pub extern fn tea_children(parent: Node) -> [Node]; +pub extern fn tea_child_at(parent: Node, i: Int) -> Node; +pub extern fn tea_append(parent: Node, child: Node) -> Unit; +pub extern fn tea_insert_at(parent: Node, child: Node, index: Int) -> Unit; +pub extern fn tea_remove(parent: Node, child: Node) -> Unit; +pub extern fn tea_replace(parent: Node, fresh: Node, old: Node) -> Unit; +pub extern fn tea_clear(parent: Node) -> Unit; +pub extern fn tea_set_handler(n: Node, event: String, h: Event -> Unit) -> Unit; +pub extern fn tea_clear_handler(n: Node, event: String) -> Unit; +pub extern fn tea_next_frame(f: () -> Unit) -> Unit; +pub extern fn tea_sub_set(key: String, kind: String, arg: String, h: Event -> Unit) -> Unit; +pub extern fn tea_sub_clear(key: String) -> Unit; +pub extern fn tea_defer(f: () -> Unit) -> Unit; +pub extern fn tea_report(context: String, message: String) -> Unit; + +// Event accessors. +pub extern fn ev_value(e: Event) -> String; +pub extern fn ev_checked(e: Event) -> Bool; +pub extern fn ev_key(e: Event) -> String; +pub extern fn ev_client_x(e: Event) -> Float; +pub extern fn ev_client_y(e: Event) -> Float; +pub extern fn ev_delta_y(e: Event) -> Float; +pub extern fn ev_button(e: Event) -> Int; +pub extern fn ev_ctrl(e: Event) -> Bool; +pub extern fn ev_shift(e: Event) -> Bool; +pub extern fn ev_meta(e: Event) -> Bool; +pub extern fn ev_time(e: Event) -> Float; +pub extern fn ev_target_is_self(e: Event) -> Bool; +pub extern fn ev_prevent(e: Event) -> Unit; +pub extern fn ev_stop(e: Event) -> Unit; + +// Linear-time list building (the prelude's `map`/`filter` append with `++` +// and are quadratic on the JS target; views over hundreds of nodes need +// these). +pub extern fn tea_map(xs: [A], f: A -> B) -> [B]; +pub extern fn tea_filter(xs: [A], keep: A -> Bool) -> [A]; +pub extern fn tea_concat(xss: [[A]]) -> [A]; + +// ── Virtual DOM ───────────────────────────────────────────────────────────── + +/// One attribute of an element. `On` holds an event decoder: returning +/// `None` ignores the event (e.g. a keydown that is not Enter). +pub enum Attr { + Attribute(String, String), + Property(String, String), + Style(String, String), + On(String, Event -> Option), + Key(String) +} + +/// A virtual DOM node. `Element(tag, namespace, attrs, children)`; the +/// namespace is "" for HTML or an XML namespace URI (SVG). +pub enum Html { + Text(String), + Element(String, String, [Attr], [Html]) +} + +pub fn text(s: String) -> Html = Text(s); +pub fn node(tag: String, attrs: [Attr], kids: [Html]) -> Html = Element(tag, "", attrs, kids); +pub fn svg_node(tag: String, attrs: [Attr], kids: [Html]) -> Html = + Element(tag, "http://www.w3.org/2000/svg", attrs, kids); + +pub fn div(attrs: [Attr], kids: [Html]) -> Html = node("div", attrs, kids); +pub fn span(attrs: [Attr], kids: [Html]) -> Html = node("span", attrs, kids); +pub fn button(attrs: [Attr], kids: [Html]) -> Html = node("button", attrs, kids); +pub fn input(attrs: [Attr]) -> Html = node("input", attrs, []); +pub fn textarea(attrs: [Attr]) -> Html = node("textarea", attrs, []); +pub fn label(attrs: [Attr], kids: [Html]) -> Html = node("label", attrs, kids); +pub fn ul(attrs: [Attr], kids: [Html]) -> Html = node("ul", attrs, kids); +pub fn li(attrs: [Attr], kids: [Html]) -> Html = node("li", attrs, kids); +pub fn h1(attrs: [Attr], kids: [Html]) -> Html = node("h1", attrs, kids); +pub fn h2(attrs: [Attr], kids: [Html]) -> Html = node("h2", attrs, kids); +pub fn p(attrs: [Attr], kids: [Html]) -> Html = node("p", attrs, kids); +pub fn section(attrs: [Attr], kids: [Html]) -> Html = node("section", attrs, kids); +pub fn header(attrs: [Attr], kids: [Html]) -> Html = node("header", attrs, kids); +pub fn main_(attrs: [Attr], kids: [Html]) -> Html = node("main", attrs, kids); +pub fn nav(attrs: [Attr], kids: [Html]) -> Html = node("nav", attrs, kids); +pub fn aside(attrs: [Attr], kids: [Html]) -> Html = node("aside", attrs, kids); +pub fn form(attrs: [Attr], kids: [Html]) -> Html = node("form", attrs, kids); +pub fn details(attrs: [Attr], kids: [Html]) -> Html = node("details", attrs, kids); +pub fn summary(attrs: [Attr], kids: [Html]) -> Html = node("summary", attrs, kids); +pub fn output(attrs: [Attr], kids: [Html]) -> Html = node("output", attrs, kids); + +pub fn attr(k: String, v: String) -> Attr = Attribute(k, v); +pub fn prop(k: String, v: String) -> Attr = Property(k, v); +pub fn style(k: String, v: String) -> Attr = Style(k, v); +pub fn key(k: String) -> Attr = Key(k); +pub fn class(c: String) -> Attr = Attribute("class", c); +pub fn id(i: String) -> Attr = Attribute("id", i); +pub fn value(v: String) -> Attr = Property("value", v); +pub fn aria(name: String, v: String) -> Attr = Attribute("aria-" ++ name, v); +pub fn data(name: String, v: String) -> Attr = Attribute("data-" ++ name, v); + +/// A raw decoder: full control over which events produce a message. +pub fn on(event: String, decode: Event -> Option) -> Attr = On(event, decode); +pub fn on_click(msg: M) -> Attr = On("click", fn(e) => Some(msg)); +pub fn on_input(f: String -> M) -> Attr = On("input", fn(e) => Some(f(ev_value(e)))); +pub fn on_change(f: String -> M) -> Attr = On("change", fn(e) => Some(f(ev_value(e)))); +pub fn on_check(f: Bool -> M) -> Attr = On("change", fn(e) => Some(f(ev_checked(e)))); +pub fn on_submit(msg: M) -> Attr = + On("submit", fn(e) => { ev_prevent(e); Some(msg) }); +/// Enter (without Shift) produces `msg`; any other key is ignored. +pub fn on_enter(msg: M) -> Attr = + On("keydown", fn(e) => if ev_key(e) == "Enter" && !ev_shift(e) { ev_prevent(e); Some(msg) } else { None }); + +/// Re-tag a view's messages, so a component's `Html` embeds in a parent's +/// `Html`. +pub fn map_html(f: A -> B, h: Html) -> Html { + match h { + Html::Text(s) => Text(s), + Html::Element(tag, ns, attrs, kids) => + Element(tag, ns, tea_map(attrs, fn(a) => map_attr(f, a)), tea_map(kids, fn(k) => map_html(f, k))) + } +} + +pub fn map_attr(f: A -> B, a: Attr) -> Attr { + match a { + Attr::Attribute(k, v) => Attribute(k, v), + Attr::Property(k, v) => Property(k, v), + Attr::Style(k, v) => Style(k, v), + Attr::Key(k) => Key(k), + Attr::On(ev, decode) => On(ev, fn(e) => match decode(e) { Some(m) => Some(f(m)), None => None }) + } +} + +// ── Commands and subscriptions ────────────────────────────────────────────── + +/// A side effect requested by `update`. `Run` receives the dispatcher and +/// may call it any number of times, synchronously or later (timers, +/// IndexedDB, WebAssembly calls) — every effect is expressible as one. +pub enum Cmd { + NoCmd, + Batch([Cmd]), + Run((M -> Unit) -> Unit) +} + +pub fn none() -> Cmd = NoCmd; +pub fn batch(cmds: [Cmd]) -> Cmd = Batch(cmds); +pub fn run_cmd(job: (M -> Unit) -> Unit) -> Cmd = Run(job); +/// Deliver `msg` as a fresh update after the current one completes. +pub fn send(msg: M) -> Cmd = Run(fn(dispatch) => tea_defer(fn() => dispatch(msg))); + +pub fn map_cmd(f: A -> B, c: Cmd) -> Cmd { + match c { + Cmd::NoCmd => NoCmd, + Cmd::Batch(cs) => Batch(tea_map(cs, fn(x) => map_cmd(f, x))), + Cmd::Run(job) => Run(fn(dispatch) => job(fn(a) => dispatch(f(a)))) + } +} + +/// A standing interest in outside events. Subscriptions are diffed by key +/// after every update: listeners are added, re-pointed or removed so the +/// live set always equals `subscriptions(model)`. +pub enum Sub { + NoSub, + Subs([Sub]), + OnWindow(String, Event -> Option), + OnFrame(Float -> M), + Every(Int, Float -> M) +} + +pub fn no_sub() -> Sub = NoSub; +pub fn subs(xs: [Sub]) -> Sub = Subs(xs); +pub fn on_window(event: String, decode: Event -> Option) -> Sub = OnWindow(event, decode); +pub fn on_frame(f: Float -> M) -> Sub = OnFrame(f); +pub fn every(ms: Int, f: Float -> M) -> Sub = Every(ms, f); + +// ── Reconciler ────────────────────────────────────────────────────────────── + +/// Wrap a decoder as a host event handler that dispatches its message. +fn event_handler(decode: Event -> Option, dispatch: M -> Unit) -> Event -> Unit = + fn(e) => match decode(e) { Some(m) => dispatch(m), None => () }; + +fn apply_attr(n: Node, a: Attr, dispatch: M -> Unit) -> Unit { + match a { + Attr::Attribute(k, v) => tea_set_attr(n, k, v), + Attr::Property(k, v) => tea_set_prop(n, k, v), + Attr::Style(k, v) => tea_set_style(n, k, v), + Attr::On(ev, decode) => tea_set_handler(n, ev, event_handler(decode, dispatch)), + Attr::Key(_) => () + } +} + +/// Whether `attrs` still sets the attribute/property/style/handler named by +/// `a` (so `a` must not be removed). +fn still_present(a: Attr, attrs: [Attr]) -> Bool { + for b in attrs { + let same = match (a, b) { + (Attr::Attribute(k, _), Attr::Attribute(k2, _)) => k == k2, + (Attr::Style(k, _), Attr::Style(k2, _)) => k == k2, + (Attr::Property(k, _), Attr::Property(k2, _)) => k == k2, + (Attr::On(ev, _), Attr::On(ev2, _)) => ev == ev2, + _ => false + }; + if same { + return true; + } + } + false +} + +fn remove_attr(n: Node, a: Attr) -> Unit { + match a { + Attr::Attribute(k, _) => tea_remove_attr(n, k), + Attr::Property(k, _) => tea_set_prop(n, k, ""), + Attr::Style(k, _) => tea_remove_style(n, k), + Attr::On(ev, _) => tea_clear_handler(n, ev), + Attr::Key(_) => () + } +} + +/// Bring `n`'s attributes from `old` to `fresh`: drop what disappeared, then +/// (re)apply everything current. Handlers are always re-pointed, since a +/// decoder closure may capture values from the latest model. +fn patch_attrs(n: Node, old: [Attr], fresh: [Attr], dispatch: M -> Unit) -> Unit { + for a in old { + if !still_present(a, fresh) { + remove_attr(n, a); + } + } + for a in fresh { + apply_attr(n, a, dispatch); + } +} + +/// Create the DOM for a virtual node. +pub fn create(h: Html, dispatch: M -> Unit) -> Node { + match h { + Html::Text(s) => tea_create_text(s), + Html::Element(tag, ns, attrs, kids) => { + let n = tea_create(tag, ns); + for a in attrs { + apply_attr(n, a, dispatch); + } + for k in kids { + tea_append(n, create(k, dispatch)); + } + n + } + } +} + +/// The `Key` of a virtual node, or "" when it has none. +pub fn key_of(h: Html) -> String { + match h { + Html::Text(_) => "", + Html::Element(_, _, attrs, _) => { + for a in attrs { + match a { + Attr::Key(k) => { return k; }, + _ => () + } + } + "" + } + } +} + +fn all_keyed(kids: [Html]) -> Bool { + if len(kids) == 0 { + return false; + } + for k in kids { + if key_of(k) == "" { + return false; + } + } + true +} + +/// Whether two virtual nodes can be patched in place (same kind, tag, +/// namespace and key) rather than replaced. +fn same_shape(a: Html, b: Html) -> Bool { + match (a, b) { + (Html::Text(_), Html::Text(_)) => true, + (Html::Element(t1, n1, _, _), Html::Element(t2, n2, _, _)) => + t1 == t2 && n1 == n2 && key_of(a) == key_of(b), + _ => false + } +} + +/// Patch DOM node `n` (currently rendering `old`) to render `fresh`. Returns +/// the node now in the tree (a replacement when the shapes differ). +pub fn patch(parent: Node, n: Node, old: Html, fresh: Html, dispatch: M -> Unit) -> Node { + if !same_shape(old, fresh) { + let created = create(fresh, dispatch); + tea_replace(parent, created, n); + return created; + } + match (old, fresh) { + (Html::Text(a), Html::Text(b)) => { + if a != b { + tea_set_text(n, b); + } + n + }, + (Html::Element(_, _, oa, ok), Html::Element(_, _, fa, fk)) => { + patch_attrs(n, oa, fa, dispatch); + if all_keyed(ok) && all_keyed(fk) { + patch_keyed(n, ok, fk, dispatch); + } else { + patch_children(n, ok, fk, dispatch); + } + n + }, + _ => n + } +} + +/// Unkeyed children: patch pairwise by position, then append or remove the +/// surplus. +fn patch_children(n: Node, old: [Html], fresh: [Html], dispatch: M -> Unit) -> Unit { + let doms = tea_children(n); + let common = if len(old) < len(fresh) { len(old) } else { len(fresh) }; + let mut i = 0; + while i < common { + patch(n, doms[i], old[i], fresh[i], dispatch); + i = i + 1; + } + while i < len(fresh) { + tea_append(n, create(fresh[i], dispatch)); + i = i + 1; + } + let mut j = len(old) - 1; + while j >= common { + tea_remove(n, doms[j]); + j = j - 1; + } +} + +/// Keyed children: reuse the DOM node of each surviving key (patched and +/// moved into place), create new keys, and remove vanished ones. Linear in +/// the number of children (a host string map indexes the old keys). +fn patch_keyed(n: Node, old: [Html], fresh: [Html], dispatch: M -> Unit) -> Unit { + let doms = tea_children(n); + let index = tea_key_index(tea_map(old, fn(h) => key_of(h))); + let used = tea_cell(tea_map(old, fn(h) => false)); + let mut i = 0; + while i < len(fresh) { + let k = key_of(fresh[i]); + let j = tea_key_lookup(index, k); + if j >= 0 { + let live = patch(n, doms[j], old[j], fresh[i], dispatch); + tea_put(used, tea_set_at(tea_get(used), j, true)); + tea_move_to(n, live, i); + } else { + tea_insert_at(n, create(fresh[i], dispatch), i); + } + i = i + 1; + } + let flags = tea_get(used); + let mut j = 0; + while j < len(old) { + if !flags[j] { + tea_remove(n, doms[j]); + } + j = j + 1; + } +} + +/// Opaque string → index map over a list of keys (first occurrence wins). +pub extern type KeyIndex; +pub extern fn tea_key_index(keys: [String]) -> KeyIndex; +/// Index of `k`, or -1. +pub extern fn tea_key_lookup(ix: KeyIndex, k: String) -> Int; +/// `xs` with position `i` set to `v` (the host may update in place: the +/// array never escapes `patch_keyed`). +pub extern fn tea_set_at(xs: [A], i: Int, v: A) -> [A]; +/// Move `child` (already a child of `parent`) to position `index`, if it is +/// not there already. +pub extern fn tea_move_to(parent: Node, child: Node, index: Int) -> Unit; + +// ── Runtime ───────────────────────────────────────────────────────────────── + +/// Run a command, delivering its messages through `dispatch`. +pub fn exec(c: Cmd, dispatch: M -> Unit) -> Unit { + match c { + Cmd::NoCmd => (), + Cmd::Batch(cs) => { + for x in cs { + exec(x, dispatch); + } + }, + Cmd::Run(job) => job(dispatch) + } +} + +/// A flattened subscription: host key, kind, argument and handler. +struct LiveSub { key: String, kind: String, arg: String, h: Event -> Unit } + +fn flatten_subs(s: Sub, dispatch: M -> Unit) -> [LiveSub] { + match s { + Sub::NoSub => [], + Sub::Subs(xs) => tea_concat(tea_map(xs, fn(x) => flatten_subs(x, dispatch))), + Sub::OnWindow(ev, decode) => + [LiveSub #{ key: "window:" ++ ev, kind: "window", arg: ev, h: event_handler(decode, dispatch) }], + Sub::OnFrame(f) => + [LiveSub #{ key: "frame", kind: "frame", arg: "", h: fn(e) => dispatch(f(ev_time(e))) }], + Sub::Every(ms, f) => + [LiveSub #{ key: "every:" ++ int_to_string(ms), kind: "every", arg: int_to_string(ms), + h: fn(e) => dispatch(f(ev_time(e))) }] + } +} + +/// Make the host's live subscriptions equal `wanted`; returns the new key set. +fn sync_subs(active: [String], wanted: [LiveSub]) -> [String] { + let keys = tea_map(wanted, fn(s) => s.key); + let ix = tea_key_index(keys); + for k in active { + if tea_key_lookup(ix, k) < 0 { + tea_sub_clear(k); + } + } + for s in wanted { + tea_sub_set(s.key, s.kind, s.arg, s.h); + } + keys +} + +/// Mount a program on the element matching `selector` and run it. +/// +/// Messages are processed one at a time, in order: `update` runs, its +/// command executes, subscriptions are re-synced, and a render is scheduled +/// for the next animation frame (several messages in one frame render once). +/// A message dispatched while another is being processed is queued. +pub fn run( + selector: String, + init: (Model, Cmd), + update: (Msg, Model) -> (Model, Cmd), + view: Model -> Html, + subscriptions: Model -> Sub +) -> Unit { + let root = tea_query(selector); + let (model0, cmd0) = init; + let model = tea_cell(model0); + let busy = tea_cell(false); + let queue = tea_cell([]); + let scheduled = tea_cell(false); + let active = tea_cell([]); + let noop: Msg -> Unit = fn(m) => (); + let dispatch_cell = tea_cell(noop); + let dispatch = fn(msg: Msg) => (tea_get(dispatch_cell))(msg); + + let first = view(model0); + tea_clear(root); + tea_append(root, create(first, dispatch)); + let vdom = tea_cell(first); + + let render = fn() => { + tea_put(scheduled, false); + let fresh = view(tea_get(model)); + let live = tea_child_at(root, 0); + patch(root, live, tea_get(vdom), fresh, dispatch); + tea_put(vdom, fresh); + }; + + let step = fn(msg: Msg) => { + let (m2, c2) = update(msg, tea_get(model)); + tea_put(model, m2); + exec(c2, dispatch); + tea_put(active, sync_subs(tea_get(active), flatten_subs(subscriptions(m2), dispatch))); + if !tea_get(scheduled) { + tea_put(scheduled, true); + tea_next_frame(render); + } + }; + + let process = fn(msg: Msg) => { + if tea_get(busy) { + tea_put(queue, tea_get(queue) ++ [msg]); + } else { + tea_put(busy, true); + step(msg); + while len(tea_get(queue)) > 0 { + let q = tea_get(queue); + tea_put(queue, tea_slice(q, 1)); + step(q[0]); + } + tea_put(busy, false); + } + }; + tea_put(dispatch_cell, process); + + tea_put(active, sync_subs([], flatten_subs(subscriptions(model0), dispatch))); + exec(cmd0, dispatch); +} + +/// `xs` without its first `from` elements. +pub extern fn tea_slice(xs: [A], from: Int) -> [A]; diff --git a/affinescript-tea/src/tea_host.js b/affinescript-tea/src/tea_host.js new file mode 100644 index 00000000..48bf068c --- /dev/null +++ b/affinescript-tea/src/tea_host.js @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// tea_host.js — browser host primitives for Tea.affine. +// +// Host carve-out: this file is the *only* JavaScript in the TEA runtime. It +// implements the `extern fn`s declared in Tea.affine as plain globals (the +// Bun-ESM backend lowers an unknown extern to a same-named global call). +// Every decision — what to render, diffing, keyed moves, message ordering, +// subscription sets — is made in AffineScript; these functions only perform +// the primitive operation they are named for. Load this module before the +// compiled application module. + +const g = globalThis; + +/** Install `fns` as globals, refusing to silently shadow an existing one. */ +function install(fns) { + for (const [name, fn] of Object.entries(fns)) { + if (name in g && g[name] !== fn) { + throw new Error(`tea_host: global ${name} is already defined`); + } + g[name] = fn; + } +} + +// ── Mutable cells ────────────────────────────────────────────────────────── + +install({ + /** A new mutable cell holding `v`. */ + tea_cell: (v) => ({ v }), + /** The value in cell `c`. */ + tea_get: (c) => c.v, + /** Replace the value in cell `c`. */ + tea_put: (c, v) => { + c.v = v; + }, +}); + +// ── DOM ──────────────────────────────────────────────────────────────────── + +/** Per-node current handler per event type; one real listener per pair. */ +const HANDLERS = Symbol("tea.handlers"); + +/** Properties that are booleans in the DOM (set from "true"/"false"). */ +const BOOL_PROPS = new Set(["checked", "disabled", "selected", "readOnly", "hidden", "open"]); + +install({ + /** The element matching `selector`; throws if there is none. */ + tea_query: (selector) => { + const el = document.querySelector(selector); + if (!el) throw new Error(`tea: no element matches ${selector}`); + return el; + }, + /** A new element; `ns` is "" for HTML or a namespace URI (SVG). */ + tea_create: (tag, ns) => (ns ? document.createElementNS(ns, tag) : document.createElement(tag)), + /** A new text node. */ + tea_create_text: (s) => document.createTextNode(s), + /** Replace a text node's content. */ + tea_set_text: (n, s) => { + n.data = s; + }, + /** Set an attribute (skipped when unchanged). */ + tea_set_attr: (n, k, v) => { + if (n.getAttribute(k) !== v) n.setAttribute(k, v); + }, + /** Remove an attribute. */ + tea_remove_attr: (n, k) => n.removeAttribute(k), + /** + * Set a DOM property. Boolean properties take "true"/"false"; `value` is + * only written when it differs, so typing does not reset the caret. + */ + tea_set_prop: (n, k, v) => { + if (BOOL_PROPS.has(k)) { + const b = v === "true"; + if (n[k] !== b) n[k] = b; + } else if (n[k] !== v) { + n[k] = v; + } + }, + /** Set one inline style declaration (custom properties included). */ + tea_set_style: (n, k, v) => { + if (n.style.getPropertyValue(k) !== v) n.style.setProperty(k, v); + }, + /** Remove one inline style declaration. */ + tea_remove_style: (n, k) => n.style.removeProperty(k), + /** A snapshot array of a node's children. */ + tea_children: (parent) => Array.from(parent.childNodes), + /** The child at position `i`. */ + tea_child_at: (parent, i) => parent.childNodes[i], + /** Append `child` to `parent`. */ + tea_append: (parent, child) => { + parent.appendChild(child); + }, + /** Insert `child` at position `index` (appending past the end). */ + tea_insert_at: (parent, child, index) => { + parent.insertBefore(child, parent.childNodes[index] ?? null); + }, + /** Move an existing child to position `index` unless it is already there. */ + tea_move_to: (parent, child, index) => { + const at = parent.childNodes[index] ?? null; + if (at !== child) parent.insertBefore(child, at); + }, + /** Remove `child` from `parent`. */ + tea_remove: (parent, child) => { + parent.removeChild(child); + }, + /** Replace `old` with `fresh` in `parent`. */ + tea_replace: (parent, fresh, old) => { + parent.replaceChild(fresh, old); + }, + /** Remove every child of `parent`. */ + tea_clear: (parent) => parent.replaceChildren(), + /** + * Point `n`'s handler for `event` at `h`. The DOM listener is added once + * and forwards to the current handler, so re-rendering only swaps a slot. + */ + tea_set_handler: (n, event, h) => { + const slots = n[HANDLERS] ?? (n[HANDLERS] = new Map()); + if (!slots.has(event)) { + n.addEventListener(event, (e) => { + const current = slots.get(event); + if (current) current(e); + }); + } + slots.set(event, h); + }, + /** Stop handling `event` on `n`. */ + tea_clear_handler: (n, event) => { + n[HANDLERS]?.set(event, null); + }, +}); + +// ── Scheduling and subscriptions ─────────────────────────────────────────── + +/** Live subscriptions by key: { kind, arg, h, stop }. */ +const SUBS = new Map(); + +/** Start a host source for a subscription; returns its stop function. */ +function start(sub) { + switch (sub.kind) { + case "window": { + const listener = (e) => sub.h(e); + window.addEventListener(sub.arg, listener); + return () => window.removeEventListener(sub.arg, listener); + } + case "frame": { + let id = requestAnimationFrame(function tick(t) { + sub.h({ timeStamp: t }); + id = requestAnimationFrame(tick); + }); + return () => cancelAnimationFrame(id); + } + case "every": { + const id = setInterval(() => sub.h({ timeStamp: performance.now() }), Number(sub.arg)); + return () => clearInterval(id); + } + default: + throw new Error(`tea: unknown subscription kind ${sub.kind}`); + } +} + +install({ + /** Run `f` before the next paint. */ + tea_next_frame: (f) => { + requestAnimationFrame(() => f()); + }, + /** Run `f` after the current task (a microtask). */ + tea_defer: (f) => { + queueMicrotask(() => f()); + }, + /** Ensure subscription `key` is live with handler `h`. */ + tea_sub_set: (key, kind, arg, h) => { + const live = SUBS.get(key); + if (live && live.kind === kind && live.arg === arg) { + live.h = h; + return; + } + if (live) live.stop(); + const sub = { kind, arg, h, stop: () => {} }; + sub.stop = start(sub); + SUBS.set(key, sub); + }, + /** End subscription `key` if it is live. */ + tea_sub_clear: (key) => { + SUBS.get(key)?.stop(); + SUBS.delete(key); + }, + /** Report a runtime problem (never thrown into the app). */ + tea_report: (context, message) => console.error(`[tea] ${context}: ${message}`), +}); + +// ── Events ───────────────────────────────────────────────────────────────── + +install({ + /** `event.target.value` ("" when absent). */ + ev_value: (e) => e.target?.value ?? "", + /** `event.target.checked`. */ + ev_checked: (e) => Boolean(e.target?.checked), + /** `event.key` ("" when absent). */ + ev_key: (e) => e.key ?? "", + /** Pointer x in viewport coordinates. */ + ev_client_x: (e) => e.clientX ?? 0, + /** Pointer y in viewport coordinates. */ + ev_client_y: (e) => e.clientY ?? 0, + /** Wheel delta in pixels (line/page deltas normalised). */ + ev_delta_y: (e) => (e.deltaY ?? 0) * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 800 : 1), + /** Mouse button. */ + ev_button: (e) => e.button ?? 0, + /** Modifier keys. */ + ev_ctrl: (e) => Boolean(e.ctrlKey), + ev_shift: (e) => Boolean(e.shiftKey), + ev_meta: (e) => Boolean(e.metaKey), + /** Event (or frame/interval) timestamp in ms. */ + ev_time: (e) => e.timeStamp ?? performance.now(), + /** Whether the event originated on the element the handler is bound to. */ + ev_target_is_self: (e) => e.target === e.currentTarget, + /** `preventDefault()`. */ + ev_prevent: (e) => e.preventDefault?.(), + /** `stopPropagation()`. */ + ev_stop: (e) => e.stopPropagation?.(), +}); + +// ── Linear-time list helpers ─────────────────────────────────────────────── + +install({ + /** `xs.map(f)` without exposing the index argument to `f`. */ + tea_map: (xs, f) => xs.map((x) => f(x)), + /** `xs.filter(keep)`. */ + tea_filter: (xs, keep) => xs.filter((x) => keep(x)), + /** Concatenate a list of lists. */ + tea_concat: (xss) => xss.flat(1), + /** `xs` without its first `from` elements. */ + tea_slice: (xs, from) => xs.slice(from), + /** A key → first-index map. */ + tea_key_index: (keys) => { + const m = new Map(); + keys.forEach((k, i) => { + if (!m.has(k)) m.set(k, i); + }); + return m; + }, + /** Index of `k` in a key index, or -1. */ + tea_key_lookup: (ix, k) => ix.get(k) ?? -1, + /** Set `xs[i] = v` and return `xs` (only used on runtime-private arrays). */ + tea_set_at: (xs, i, v) => { + xs[i] = v; + return xs; + }, +}); From 5f16144e74466b727e6103e19e7603a626f56a08 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:57:43 +0100 Subject: [PATCH 07/21] feat(affinescript-tea): local pointer coords, editable-target test, viewport size ev_local_x/ev_local_y (pointer relative to the handler's element, e.g. double-click-to-create on a canvas), ev_target_editable (global shortcuts skip keys typed into text fields) and tea_viewport_width/height (viewport culling). Host primitives only; e2e 8/8. Co-Authored-By: Claude Opus 5.5 --- affinescript-tea/src/Tea.affine | 9 +++++++++ affinescript-tea/src/tea_host.js | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/affinescript-tea/src/Tea.affine b/affinescript-tea/src/Tea.affine index 59968cd4..d9405668 100644 --- a/affinescript-tea/src/Tea.affine +++ b/affinescript-tea/src/Tea.affine @@ -74,6 +74,15 @@ pub extern fn ev_shift(e: Event) -> Bool; pub extern fn ev_meta(e: Event) -> Bool; pub extern fn ev_time(e: Event) -> Float; pub extern fn ev_target_is_self(e: Event) -> Bool; +/// Pointer position relative to the element the handler is bound to. +pub extern fn ev_local_x(e: Event) -> Float; +pub extern fn ev_local_y(e: Event) -> Float; +/// Whether the event's target is a text field or contenteditable (global +/// shortcuts usually ignore keys typed into one). +pub extern fn ev_target_editable(e: Event) -> Bool; +/// The viewport (window) size in CSS pixels. +pub extern fn tea_viewport_width() -> Float; +pub extern fn tea_viewport_height() -> Float; pub extern fn ev_prevent(e: Event) -> Unit; pub extern fn ev_stop(e: Event) -> Unit; diff --git a/affinescript-tea/src/tea_host.js b/affinescript-tea/src/tea_host.js index 48bf068c..f4d295cc 100644 --- a/affinescript-tea/src/tea_host.js +++ b/affinescript-tea/src/tea_host.js @@ -185,6 +185,10 @@ install({ SUBS.get(key)?.stop(); SUBS.delete(key); }, + /** Viewport width in CSS pixels. */ + tea_viewport_width: () => window.innerWidth, + /** Viewport height in CSS pixels. */ + tea_viewport_height: () => window.innerHeight, /** Report a runtime problem (never thrown into the app). */ tea_report: (context, message) => console.error(`[tea] ${context}: ${message}`), }); @@ -214,6 +218,17 @@ install({ ev_time: (e) => e.timeStamp ?? performance.now(), /** Whether the event originated on the element the handler is bound to. */ ev_target_is_self: (e) => e.target === e.currentTarget, + /** Pointer x relative to the handler's element (its bounding box). */ + ev_local_x: (e) => (e.clientX ?? 0) - (e.currentTarget?.getBoundingClientRect?.().left ?? 0), + /** Pointer y relative to the handler's element (its bounding box). */ + ev_local_y: (e) => (e.clientY ?? 0) - (e.currentTarget?.getBoundingClientRect?.().top ?? 0), + /** Whether the target is a text input, textarea, select or contenteditable. */ + ev_target_editable: (e) => { + const t = e.target; + if (!t || !t.tagName) return false; + const tag = t.tagName.toLowerCase(); + return tag === "input" || tag === "textarea" || tag === "select" || Boolean(t.isContentEditable); + }, /** `preventDefault()`. */ ev_prevent: (e) => e.preventDefault?.(), /** `stopPropagation()`. */ From e822cef3070ec9a69dd7e3d5739707847e7fc3e7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:09:18 +0100 Subject: [PATCH 08/21] fix(affinescript-tea): pages import the app module, never call main() The Bun-ESM backend runs a module's `main` on load, so a page that also called `main()` mounted a second instance on the same root. The two runtimes then patched one DOM tree against different virtual trees (duplicated subtrees in nexia-list's port; hidden in the Todo example because both instances rendered identical trees). The e2e page and README now only import the app; a test asserts exactly one instance is mounted (9/9). Co-Authored-By: Claude Opus 5.5 --- affinescript-tea/README.adoc | 7 ++++--- affinescript-tea/e2e/todo.test.js | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/affinescript-tea/README.adoc b/affinescript-tea/README.adoc index a0f7dac4..d63b3eb7 100644 --- a/affinescript-tea/README.adoc +++ b/affinescript-tea/README.adoc @@ -132,15 +132,16 @@ export AFFINESCRIPT_PATH=/path/to/affinescript/affinescript-tea/src affinescript compile --bun-esm App.affine -o app.bun.js ---- -Then serve a page that loads the host before the app: +Then serve a page that loads the host before the app. The Bun-ESM backend +runs a module's `main` when the module is loaded, so the page only imports +it. Calling `main()` again would mount a second instance on the same root. [source,html] ----
---- diff --git a/affinescript-tea/e2e/todo.test.js b/affinescript-tea/e2e/todo.test.js index 0fca6634..a0c6a9cc 100644 --- a/affinescript-tea/e2e/todo.test.js +++ b/affinescript-tea/e2e/todo.test.js @@ -42,7 +42,7 @@ beforeAll(async () => { writeFileSync( join(site, "index.html"), `todo
-`, +`, ); server = Bun.serve({ port: 0, @@ -152,6 +152,11 @@ test("a subscription starts and stops with the model", async () => { expect(Number(await page.textContent("#ticks"))).toBe(stopped); }); +test("exactly one app instance is mounted", async () => { + expect(await page.$$eval("#app", (els) => els.length)).toBe(1); + expect(await page.$$eval("h1", (els) => els.length)).toBe(1); +}); + test("no runtime errors were reported", () => { expect(consoleErrors).toEqual([]); }); From f069138c0da1e3d5f8b6888d599fe1874d87a789 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:14:20 +0100 Subject: [PATCH 09/21] fix(module_loader): carry directly-imported enums when flattening An enum's constructors reached an importer's flattened (non-Wasm) output only if some carried *function* constructed them; patterns do not count. So `use Geometry::{Up}` + `Some(Up)` compiled to a reference to an undefined `Up` (a ReferenceError at the first keypress in nexia-list's port). Public enums named in a `use M::{...}` list (by type or by any constructor), and all public enums for `use M`/globs, are now carried; enums made only of the preamble's Option/Result constructors never are. The Bun-ESM corpus runner now puts the corpus on AFFINESCRIPT_PATH so fixtures can be multi-module. Test: imported_ctor + DirLib (fails with `ReferenceError: North is not defined` without this change). dune main 560 OK; Bun-ESM 36/36; WASM 38/38; native Bun 1/1. Co-Authored-By: Claude Opus 5.5 --- lib/module_loader.ml | 27 ++++++++++++++++++++ tests/codegen-deno/DirLib.affine | 13 ++++++++++ tests/codegen-deno/imported_ctor.affine | 10 ++++++++ tests/codegen-deno/imported_ctor.harness.mjs | 9 +++++++ tools/run_codegen_deno_tests.sh | 5 +++- 5 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/codegen-deno/DirLib.affine create mode 100644 tests/codegen-deno/imported_ctor.affine create mode 100644 tests/codegen-deno/imported_ctor.harness.mjs diff --git a/lib/module_loader.ml b/lib/module_loader.ml index 64ff198c..d1f74d03 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -447,6 +447,33 @@ let flatten_imports (loader : t) (prog : program) : program = (fun (n, dk) -> (bound_name_of n, rename_decl (bound_name_of n) dk)) (direct @ extras) in + (* Public enums the importer names directly. A constructor in the + import list (`use Geometry::{Up, Down}`) or a type name the + importer constructs was only carried when some carried *function* + happened to construct it, so `Some(Up)` in the importer compiled + to a reference to an undefined `Up`. Enums made only of the + preamble's Option/Result constructors are never carried (every + non-Wasm backend's runtime preamble already defines those). *) + let preamble_only = [ "Some"; "None"; "Ok"; "Err" ] in + let public_enums = List.filter_map (fun decl -> + match decl with + | TopType ({ td_body = TyEnum variants; _ } as td) + when (td.td_vis = Public || td.td_vis = PubCrate) + && List.exists (fun (vd : variant_decl) -> + not (List.mem vd.vd_name.name preamble_only)) variants -> + Some (td.td_name.name, + List.map (fun (vd : variant_decl) -> vd.vd_name.name) variants, + decl) + | _ -> None) lm.mod_program.prog_decls in + let named = match imp with + | ImportList (_, items) -> + List.filter (fun (ty, ctors, _) -> + List.exists (fun item -> + let n = item.ii_name.name in n = ty || List.mem n ctors) items) + public_enums + | ImportSimple _ | ImportGlob _ -> public_enums + in + List.iter (fun (ty, _, decl) -> add_imported ty (`Type decl)) named; List.iter (fun (name, decl_kind) -> add_imported name decl_kind) select ) prog.prog_imports; let imported_decls = diff --git a/tests/codegen-deno/DirLib.affine b/tests/codegen-deno/DirLib.affine new file mode 100644 index 00000000..e2413923 --- /dev/null +++ b/tests/codegen-deno/DirLib.affine @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MPL-2.0 +// Library half of the imported_ctor fixture: an enum and a function that only +// *matches* on it (patterns do not reference constructors, so nothing here +// causes the enum to be carried into an importer's flattened output). +module DirLib; + +pub enum Dir { North, South, Step(Int) } + +pub fn name(d: Dir) -> String = match d { + Dir::North => "north", + Dir::South => "south", + Dir::Step(n) => "step " ++ int_to_string(n) +}; diff --git a/tests/codegen-deno/imported_ctor.affine b/tests/codegen-deno/imported_ctor.affine new file mode 100644 index 00000000..03f9c0a9 --- /dev/null +++ b/tests/codegen-deno/imported_ctor.affine @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MPL-2.0 +// Constructors of an imported enum, named directly in the import list, must +// exist in the flattened output (they compiled to undefined references when +// no carried function happened to construct them). +use prelude::*; +use DirLib::{Dir, North, South, Step, name}; + +pub fn pick(up: Bool) -> Option
= if up { Some(North) } else { Some(Step(3)) }; + +pub fn both() -> String = name(North) ++ "," ++ name(Step(0 - 3)) ++ "," ++ name(South); diff --git a/tests/codegen-deno/imported_ctor.harness.mjs b/tests/codegen-deno/imported_ctor.harness.mjs new file mode 100644 index 00000000..4e6e0b31 --- /dev/null +++ b/tests/codegen-deno/imported_ctor.harness.mjs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 +// Imported enum constructors are emitted and usable. +import assert from "node:assert/strict"; +import { pick, both } from "./imported_ctor.bun.js"; + +assert.equal(pick(true).value.tag, "North"); +assert.equal(pick(false).value.tag, "Step"); +assert.equal(both(), "north,step -3,south"); +console.log("imported_ctor.harness.mjs OK"); diff --git a/tools/run_codegen_deno_tests.sh b/tools/run_codegen_deno_tests.sh index ae769c9b..ba14a14f 100755 --- a/tools/run_codegen_deno_tests.sh +++ b/tools/run_codegen_deno_tests.sh @@ -31,7 +31,10 @@ compile_failures=() for src in "$TEST_DIR"/*.affine; do out="${src%.affine}.bun.js" echo "Compiling $(basename "$src") -> $(basename "$out")" - if ! "${COMPILE_CMD[@]}" "$src" -o "$out" --bun-esm; then + # The corpus directory is on the module path so multi-module fixtures + # (e.g. imported_ctor + DirLib) resolve their sibling modules. + if ! AFFINESCRIPT_PATH="$TEST_DIR${AFFINESCRIPT_PATH:+:$AFFINESCRIPT_PATH}" \ + "${COMPILE_CMD[@]}" "$src" -o "$out" --bun-esm; then compile_failures+=("$(basename "$src")") fi done From efa86835384c4ee0640c695d6e4746846f306c1c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:34:03 +0100 Subject: [PATCH 10/21] fix(bun-esm,typecheck): Int division in loops; generic externs used before declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bun-esm (#478 follow-up): OCaml evaluates `^` operands right to left, so a `while`/`for` body's tail expression was generated before its statements. The int-tracking for `Int / Int` truncation then saw the tail's assignments (`lo = mid + 1`) before the `let mid = ...` they depend on, forgot `lo` was an Int, and emitted float division: a binary search computed `(lo + hi) / 2 = 2.5`. Block parts are now generated in source order. Fixture int_div_loop fails (1 vs 3) without this. - typecheck: the forward pass bound every function to a fresh, ungeneralised type variable, so a generic extern used before its declaration had its type fixed by the first use and a second instantiation failed (`TypeMismatch (Int, Bool)`). Extern signatures are complete, so their generalised schemes are now registered in the forward pass (after all types). Ordinary generic fns keep the old behaviour (their effects are inferred) — a documented follow-up. dune main 561 OK; Bun-ESM 37/37; WASM 38/38; native Bun 1/1. Co-Authored-By: Claude Opus 5.5 --- lib/codegen_deno.ml | 23 ++++++++++++------- lib/typecheck.ml | 11 +++++++++ test/test_generic_enum_kinds.ml | 7 ++++++ tests/codegen-deno/int_div_loop.affine | 25 +++++++++++++++++++++ tests/codegen-deno/int_div_loop.harness.mjs | 12 ++++++++++ 5 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 tests/codegen-deno/int_div_loop.affine create mode 100644 tests/codegen-deno/int_div_loop.harness.mjs diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 85a02a63..2a315bf8 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -1887,11 +1887,16 @@ and gen_stmt ctx (stmt : stmt) : string = | _ -> ()); js | StmtWhile (cond, body) -> - "while (" ^ gen_expr ctx cond ^ ") { " - ^ String.concat " " (List.map (gen_stmt ctx) body.blk_stmts) - ^ (match body.blk_expr with - | Some e -> " " ^ gen_stmt_expr ctx e | None -> "") - ^ " }" + (* Generate strictly in source order: OCaml evaluates `^` operands + right to left, which generated the block's tail before its + statements — so int-tracking (#478) saw the tail's assignments + before the `let`s they depend on, and `(lo + hi) / 2` lost its + truncation. *) + let cond_js = gen_expr ctx cond in + let stmts_js = String.concat " " (List.map (gen_stmt ctx) body.blk_stmts) in + let tail_js = match body.blk_expr with + | Some e -> " " ^ gen_stmt_expr ctx e | None -> "" in + "while (" ^ cond_js ^ ") { " ^ stmts_js ^ tail_js ^ " }" | StmtFor (pat, iter, body) -> (* The iterable is evaluated in the outer scope, so emit it first. *) let iter_str = gen_expr ctx iter in @@ -1911,9 +1916,11 @@ and gen_stmt ctx (stmt : stmt) : string = | _ -> fun () -> () in let body_js = - String.concat " " (List.map (gen_stmt ctx) body.blk_stmts) - ^ (match body.blk_expr with - | Some e -> " " ^ gen_stmt_expr ctx e | None -> "") + (* Source order (see StmtWhile): statements before the tail. *) + let stmts_js = String.concat " " (List.map (gen_stmt ctx) body.blk_stmts) in + let tail_js = match body.blk_expr with + | Some e -> " " ^ gen_stmt_expr ctx e | None -> "" in + stmts_js ^ tail_js in restore (); "for (const " ^ pat_str ^ " of " ^ iter_str ^ ") { " ^ body_js ^ " }" diff --git a/lib/typecheck.ml b/lib/typecheck.ml index de6f05bc..d14334f7 100644 --- a/lib/typecheck.ml +++ b/lib/typecheck.ml @@ -2616,6 +2616,17 @@ let check_program ?(import_types : (string, scheme) Hashtbl.t option) Ok () | _ -> Ok () ) (Ok ()) prog.prog_decls in + (* Extern functions are fully described by their signatures, so register + their generalised schemes now (after every type is known) rather than + the fresh monomorphic placeholder above: a generic extern used before + its declaration otherwise had its type fixed by the first use, and a + second instantiation failed (`TypeMismatch (Int, Bool)`). *) + let* () = List.fold_left (fun acc decl -> + let* () = acc in + match decl with + | TopFn fd when fd.fd_body = FnExtern -> check_fn_decl ctx fd + | _ -> Ok () + ) (Ok ()) prog.prog_decls in (* #559: trait coherence — now that every impl is registered, reject overlapping impls of the same trait (self types that unify). Done before the check pass so an ambiguous instance base is reported up front. *) diff --git a/test/test_generic_enum_kinds.ml b/test/test_generic_enum_kinds.ml index ce6a4fec..8e9cf64d 100644 --- a/test/test_generic_enum_kinds.ml +++ b/test/test_generic_enum_kinds.ml @@ -73,6 +73,11 @@ let parametric_extern_type () = pub extern fn cell_new(v: T) -> Cell;\n\ pub fn mk() -> Cell = cell_new(1);\n" +let generic_extern_used_before_declaration () = + passes + "pub fn both() -> Bool { let a = mk(1); let b = mk(true); b[0] }\n\ + pub extern fn mk(v: A) -> [A];\n" + (* Planted negative: the kind check must still reject over-application. *) let over_application_still_rejected () = fails_with ~needle:"Too many arguments for kind" @@ -103,6 +108,8 @@ let tests = Alcotest.test_case "two-parameter enum" `Quick two_parameter_enum; Alcotest.test_case "function payload" `Quick function_payload; Alcotest.test_case "parametric extern type" `Quick parametric_extern_type; + Alcotest.test_case "generic extern used before declaration" `Quick + generic_extern_used_before_declaration; Alcotest.test_case "over-application still rejected" `Quick over_application_still_rejected; Alcotest.test_case "imported enum kind" `Quick imported_enum_kind; diff --git a/tests/codegen-deno/int_div_loop.affine b/tests/codegen-deno/int_div_loop.affine new file mode 100644 index 00000000..8f959c0f --- /dev/null +++ b/tests/codegen-deno/int_div_loop.affine @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MPL-2.0 +// #478 regression: integer division inside a loop whose body ends in a +// tail-position `if` that reassigns the operands (a binary search). The +// block tail used to be generated before its statements, so the operands +// were forgotten as Int and `(lo + hi) / 2` became float division. + +pub fn lower_bound(xs: [Int], x: Int) -> Int { + let mut lo = 0; + let mut hi = len(xs); + while lo < hi { + let mid = (lo + hi) / 2; + if xs[mid] < x { lo = mid + 1; } else { hi = mid; } + } + lo +} + +pub fn halves(n: Int) -> Int { + let mut steps = 0; + let mut k = n; + for _x in [1, 2, 3, 4, 5, 6, 7, 8] { + let half = k / 2; + if half > 0 { k = half; steps = steps + 1; } else { k = 0; } + } + steps +} diff --git a/tests/codegen-deno/int_div_loop.harness.mjs b/tests/codegen-deno/int_div_loop.harness.mjs new file mode 100644 index 00000000..f41ca1ef --- /dev/null +++ b/tests/codegen-deno/int_div_loop.harness.mjs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MPL-2.0 +// Integer division in loop bodies truncates (#478). +import assert from "node:assert/strict"; +import { lower_bound, halves } from "./int_div_loop.bun.js"; + +const xs = [1, 3, 5, 7, 9, 11]; +assert.equal(lower_bound(xs, 7), 3); +assert.equal(lower_bound(xs, 8), 4); +assert.equal(lower_bound(xs, 0), 0); +assert.equal(lower_bound(xs, 99), 6); +assert.equal(halves(100), 6); +console.log("int_div_loop.harness.mjs OK"); From c5de72c2eebbfff3c7c55de7bec4a1be9fd098cf Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:34:07 +0100 Subject: [PATCH 11/21] wip(affinescript-tea): lazy subtrees, LIS keyed diff (tests pending) --- affinescript-tea/e2e/todo.test.js | 60 +++++++++ affinescript-tea/examples/todo/Todo.affine | 8 +- affinescript-tea/src/Tea.affine | 150 +++++++++++++++++---- affinescript-tea/src/tea_host.js | 8 ++ 4 files changed, 195 insertions(+), 31 deletions(-) diff --git a/affinescript-tea/e2e/todo.test.js b/affinescript-tea/e2e/todo.test.js index a0c6a9cc..3dac6db4 100644 --- a/affinescript-tea/e2e/todo.test.js +++ b/affinescript-tea/e2e/todo.test.js @@ -157,6 +157,66 @@ test("exactly one app instance is mounted", async () => { expect(await page.$$eval("h1", (els) => els.length)).toBe(1); }); +test("keyed diffing: random edits keep order and identity, with minimal moves", async () => { + const result = await page.evaluate(async () => { + const m = await import("./todo.bun.js"); + let seed = 7; + const rand = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; + const vnode = (keys) => m.node("ul", [], keys.map((k) => m.node("li", [m.key(k)], [m.text(k)]))); + const host = document.createElement("div"); + document.body.appendChild(host); + let keys = Array.from({ length: 30 }, (_, i) => `k${i}`); + let next = 30; + let old = vnode(keys); + host.appendChild(m.create(old, () => {})); + const ul = host.firstChild; + for (const li of ul.children) li.__id = li.textContent; + let moves = 0; + const observer = new MutationObserver((records) => { + for (const r of records) moves += r.addedNodes.length; + }); + observer.observe(ul, { childList: true }); + for (let round = 0; round < 200; round++) { + const before = new Map(Array.from(ul.children, (li) => [li.textContent, li])); + let fresh = keys.filter(() => rand() > 0.1); + for (let i = 0; i < 3; i++) fresh.splice(Math.floor(rand() * (fresh.length + 1)), 0, `k${next++}`); + for (let i = 0; i < 4; i++) { + const a = Math.floor(rand() * fresh.length); + const b = Math.floor(rand() * fresh.length); + [fresh[a], fresh[b]] = [fresh[b], fresh[a]]; + } + const neu = vnode(fresh); + m.patch(host, ul, old, neu, () => {}); + const got = Array.from(ul.children, (li) => li.textContent); + if (JSON.stringify(got) !== JSON.stringify(fresh)) return { ok: false, round, got, fresh }; + for (const li of ul.children) { + const prev = before.get(li.textContent); + if (prev && prev !== li) return { ok: false, round, reason: `recreated ${li.textContent}` }; + } + keys = fresh; + old = neu; + } + observer.disconnect(); + // Moving the last item to the front needs exactly one DOM move. + const rotated = [keys[keys.length - 1], ...keys.slice(0, -1)]; + let single = 0; + const o2 = new MutationObserver((rs) => { + for (const r of rs) single += r.addedNodes.length; + }); + o2.observe(ul, { childList: true }); + m.patch(host, ul, old, vnode(rotated), () => {}); + await new Promise((r) => setTimeout(r, 0)); + o2.disconnect(); + host.remove(); + return { ok: true, moves, single }; + }); + expect(result).toMatchObject({ ok: true }); + expect(result.single).toBe(1); +}); + test("no runtime errors were reported", () => { expect(consoleErrors).toEqual([]); }); diff --git a/affinescript-tea/examples/todo/Todo.affine b/affinescript-tea/examples/todo/Todo.affine index f296d328..9afaa3cc 100644 --- a/affinescript-tea/examples/todo/Todo.affine +++ b/affinescript-tea/examples/todo/Todo.affine @@ -11,7 +11,7 @@ module Todo; use prelude::*; use Tea::{Html, Attr, Cmd, Sub, Event, run, text, div, span, button, input, ul, li, h1, p, - class, id, value, key, attr, on_click, on_input, on_enter, on_check, none, send, + class, id, value, key, attr, lazy, on_click, on_input, on_enter, on_check, none, send, every, no_sub, tea_map, tea_filter, run_cmd, tea_defer}; pub struct Item { id: Int, title: String, done: Bool } @@ -86,7 +86,11 @@ pub fn view(m: Model) -> Html = input([id("draft"), attr("placeholder", "What needs doing?"), value(m.draft), on_input(fn(s) => Msg::Draft(s)), on_enter(Msg::Add)]), button([id("add"), on_click(Msg::Add)], [text("add")]), - ul([id("items")], tea_map(m.items, fn(it) => view_item(it))), + // Memoised rows: a row is rebuilt only when its id, title or done flag + // changes (the keyed reorder test moves memoised rows). + ul([id("items")], tea_map(m.items, fn(it) => + lazy(int_to_string(it.id), int_to_string(it.id) ++ "|" ++ it.title ++ "|" ++ (if it.done { "1" } else { "0" }), + fn() => view_item(it)))), p([id("count")], [text(int_to_string(len(tea_filter(m.items, fn(it) => !it.done))) ++ " left")]), button([id("tick"), on_click(Msg::SetTicking(!m.ticking))], diff --git a/affinescript-tea/src/Tea.affine b/affinescript-tea/src/Tea.affine index d9405668..77862cea 100644 --- a/affinescript-tea/src/Tea.affine +++ b/affinescript-tea/src/Tea.affine @@ -93,6 +93,30 @@ pub extern fn tea_map(xs: [A], f: A -> B) -> [B]; pub extern fn tea_filter(xs: [A], keep: A -> Bool) -> [A]; pub extern fn tea_concat(xss: [[A]]) -> [A]; +// Runtime-private array and key helpers (declared before use: a generic +// extern referenced before its declaration is not yet generalised). +/// `[0, 1, ..., n - 1]`. +pub extern fn tea_range(n: Int) -> [Int]; +/// A fresh array of `n` copies of `v` (runtime-private; updated in place). +pub extern fn tea_filled(n: Int, v: A) -> [A]; +/// Insert (or move) `child` immediately before `before` in `parent`. +pub extern fn tea_insert_before(parent: Node, child: Node, before: Node) -> Unit; + +/// Opaque string → index map over a list of keys (first occurrence wins). +pub extern type KeyIndex; +pub extern fn tea_key_index(keys: [String]) -> KeyIndex; +/// Index of `k`, or -1. +pub extern fn tea_key_lookup(ix: KeyIndex, k: String) -> Int; +/// `xs` with position `i` set to `v` (the host may update in place: the +/// array never escapes `patch_keyed`). +pub extern fn tea_set_at(xs: [A], i: Int, v: A) -> [A]; +/// Move `child` (already a child of `parent`) to position `index`, if it is +/// not there already. +pub extern fn tea_move_to(parent: Node, child: Node, index: Int) -> Unit; + +/// `xs` without its first `from` elements. +pub extern fn tea_slice(xs: [A], from: Int) -> [A]; + // ── Virtual DOM ───────────────────────────────────────────────────────────── /// One attribute of an element. `On` holds an event decoder: returning @@ -106,13 +130,23 @@ pub enum Attr { } /// A virtual DOM node. `Element(tag, namespace, attrs, children)`; the -/// namespace is "" for HTML or an XML namespace URI (SVG). +/// namespace is "" for HTML or an XML namespace URI (SVG). `Lazy(key, memo, +/// view)` defers building a subtree: when a re-render finds the same `memo` +/// at the same place, the subtree is neither rebuilt nor diffed (see `lazy`). pub enum Html { Text(String), - Element(String, String, [Attr], [Html]) + Element(String, String, [Attr], [Html]), + Lazy(String, String, () -> Html) } pub fn text(s: String) -> Html = Text(s); +/// A memoised subtree. `view` runs only when `memo` differs from the previous +/// render's memo at the same position, so `memo` must change whenever +/// anything `view` reads changes (the way Elm's `Html.Lazy` compares +/// arguments). `key` is the subtree's key in a keyed child list ("" if none). +/// Event handlers inside a skipped subtree keep the closures from the render +/// that built it, so they must depend only on what `memo` covers. +pub fn lazy(key: String, memo: String, view: () -> Html) -> Html = Lazy(key, memo, view); pub fn node(tag: String, attrs: [Attr], kids: [Html]) -> Html = Element(tag, "", attrs, kids); pub fn svg_node(tag: String, attrs: [Attr], kids: [Html]) -> Html = Element(tag, "http://www.w3.org/2000/svg", attrs, kids); @@ -165,6 +199,7 @@ pub fn on_enter(msg: M) -> Attr = pub fn map_html(f: A -> B, h: Html) -> Html { match h { Html::Text(s) => Text(s), + Html::Lazy(k, memo, view) => Lazy(k, memo, fn() => map_html(f, view())), Html::Element(tag, ns, attrs, kids) => Element(tag, ns, tea_map(attrs, fn(a) => map_attr(f, a)), tea_map(kids, fn(k) => map_html(f, k))) } @@ -284,6 +319,7 @@ fn patch_attrs(n: Node, old: [Attr], fresh: [Attr], dispatch: M -> Unit pub fn create(h: Html, dispatch: M -> Unit) -> Node { match h { Html::Text(s) => tea_create_text(s), + Html::Lazy(_, _, view) => create(view(), dispatch), Html::Element(tag, ns, attrs, kids) => { let n = tea_create(tag, ns); for a in attrs { @@ -301,6 +337,7 @@ pub fn create(h: Html, dispatch: M -> Unit) -> Node { pub fn key_of(h: Html) -> String { match h { Html::Text(_) => "", + Html::Lazy(k, _, _) => k, Html::Element(_, _, attrs, _) => { for a in attrs { match a { @@ -339,6 +376,21 @@ fn same_shape(a: Html, b: Html) -> Bool { /// Patch DOM node `n` (currently rendering `old`) to render `fresh`. Returns /// the node now in the tree (a replacement when the shapes differ). pub fn patch(parent: Node, n: Node, old: Html, fresh: Html, dispatch: M -> Unit) -> Node { + // Memoised subtrees: an unchanged memo means nothing to do; otherwise + // diff the expansions (re-running the old view rebuilds exactly the tree + // that is in the DOM, since it closes over the old inputs). + match (old, fresh) { + (Html::Lazy(_, m1, _), Html::Lazy(_, m2, _)) => { if m1 == m2 { return n; } }, + _ => () + }; + match old { + Html::Lazy(_, _, view) => { return patch(parent, n, view(), fresh, dispatch); }, + _ => () + }; + match fresh { + Html::Lazy(_, _, view) => { return patch(parent, n, old, view(), dispatch); }, + _ => () + }; if !same_shape(old, fresh) { let created = create(fresh, dispatch); tea_replace(parent, created, n); @@ -385,47 +437,89 @@ fn patch_children(n: Node, old: [Html], fresh: [Html], dispatch: M -> U } } -/// Keyed children: reuse the DOM node of each surviving key (patched and -/// moved into place), create new keys, and remove vanished ones. Linear in -/// the number of children (a host string map indexes the old keys). +/// Flags marking one longest increasing subsequence of `xs` (negative +/// entries are never in it). In keyed diffing these are the surviving +/// children already in relative order, which never need to move. +/// Patience sorting with predecessor links: O(n log n). +pub fn lis_flags(xs: [Int]) -> [Bool] { + let n = len(xs); + let mut tails = tea_filled(n, 0); + let mut prev = tea_filled(n, 0 - 1); + let mut size = 0; + let mut i = 0; + while i < n { + let x = xs[i]; + if x >= 0 { + let mut lo = 0; + let mut hi = size; + while lo < hi { + let mid = (lo + hi) / 2; + if xs[tails[mid]] < x { lo = mid + 1; } else { hi = mid; } + } + if lo > 0 { + prev = tea_set_at(prev, i, tails[lo - 1]); + } + tails = tea_set_at(tails, lo, i); + if lo == size { + size = size + 1; + } + } + i = i + 1; + } + let mut flags = tea_filled(n, false); + if size > 0 { + let mut k = tails[size - 1]; + while k >= 0 { + flags = tea_set_at(flags, k, true); + k = prev[k]; + } + } + flags +} + +/// Keyed children: reuse the DOM node of each surviving key (patched in +/// place), create new keys, remove vanished ones, and move as few nodes as +/// possible — the survivors on a longest increasing subsequence of their old +/// positions stay put, everything else is inserted before its right-hand +/// neighbour. A duplicate key is matched once; later duplicates are new. fn patch_keyed(n: Node, old: [Html], fresh: [Html], dispatch: M -> Unit) -> Unit { let doms = tea_children(n); let index = tea_key_index(tea_map(old, fn(h) => key_of(h))); - let used = tea_cell(tea_map(old, fn(h) => false)); + let mut claimed = tea_filled(len(old), false); + let mut srcs = tea_filled(len(fresh), 0 - 1); let mut i = 0; while i < len(fresh) { - let k = key_of(fresh[i]); - let j = tea_key_lookup(index, k); - if j >= 0 { - let live = patch(n, doms[j], old[j], fresh[i], dispatch); - tea_put(used, tea_set_at(tea_get(used), j, true)); - tea_move_to(n, live, i); - } else { - tea_insert_at(n, create(fresh[i], dispatch), i); + let j = tea_key_lookup(index, key_of(fresh[i])); + if j >= 0 && !claimed[j] { + srcs = tea_set_at(srcs, i, j); + claimed = tea_set_at(claimed, j, true); } i = i + 1; } - let flags = tea_get(used); + let lives = tea_map(tea_range(len(fresh)), fn(f) => + if srcs[f] >= 0 { patch(n, doms[srcs[f]], old[srcs[f]], fresh[f], dispatch) } else { create(fresh[f], dispatch) }); let mut j = 0; while j < len(old) { - if !flags[j] { + if !claimed[j] { tea_remove(n, doms[j]); } j = j + 1; } + let stable = lis_flags(srcs); + let mut anchor = n; + let mut has_anchor = false; + let mut k = len(fresh) - 1; + while k >= 0 { + let node = lives[k]; + if srcs[k] < 0 || !stable[k] { + if has_anchor { tea_insert_before(n, node, anchor); } else { tea_append(n, node); } + } + anchor = node; + has_anchor = true; + k = k - 1; + } } -/// Opaque string → index map over a list of keys (first occurrence wins). -pub extern type KeyIndex; -pub extern fn tea_key_index(keys: [String]) -> KeyIndex; -/// Index of `k`, or -1. -pub extern fn tea_key_lookup(ix: KeyIndex, k: String) -> Int; -/// `xs` with position `i` set to `v` (the host may update in place: the -/// array never escapes `patch_keyed`). -pub extern fn tea_set_at(xs: [A], i: Int, v: A) -> [A]; -/// Move `child` (already a child of `parent`) to position `index`, if it is -/// not there already. -pub extern fn tea_move_to(parent: Node, child: Node, index: Int) -> Unit; // ── Runtime ───────────────────────────────────────────────────────────────── @@ -542,5 +636,3 @@ pub fn run( exec(cmd0, dispatch); } -/// `xs` without its first `from` elements. -pub extern fn tea_slice(xs: [A], from: Int) -> [A]; diff --git a/affinescript-tea/src/tea_host.js b/affinescript-tea/src/tea_host.js index f4d295cc..3d9dc545 100644 --- a/affinescript-tea/src/tea_host.js +++ b/affinescript-tea/src/tea_host.js @@ -95,6 +95,10 @@ install({ tea_insert_at: (parent, child, index) => { parent.insertBefore(child, parent.childNodes[index] ?? null); }, + /** Insert (or move) `child` immediately before `before`. */ + tea_insert_before: (parent, child, before) => { + if (child.nextSibling !== before || child.parentNode !== parent) parent.insertBefore(child, before); + }, /** Move an existing child to position `index` unless it is already there. */ tea_move_to: (parent, child, index) => { const at = parent.childNodes[index] ?? null; @@ -246,6 +250,10 @@ install({ tea_concat: (xss) => xss.flat(1), /** `xs` without its first `from` elements. */ tea_slice: (xs, from) => xs.slice(from), + /** `[0, 1, ..., n - 1]`. */ + tea_range: (n) => Array.from({ length: n }, (_, i) => i), + /** A fresh array of `n` copies of `v`. */ + tea_filled: (n, v) => new Array(n).fill(v), /** A key → first-index map. */ tea_key_index: (keys) => { const m = new Map(); From ac4afa4854dd57625ab0b41a48caf2da27af943f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:36:07 +0100 Subject: [PATCH 12/21] fix(typecheck): scope a function body's let-bindings, not just its params check_fn_decl restored only parameter bindings after checking a body, so a block-local `let node = ...` overwrote the module-level `node` in name_types, and importers saw the local's type as the module's export (`Expected a function type, got Node` when affinescript-tea's `node` helper was imported after its keyed diff gained a local `node`). The name table is now snapshotted after the function's own recursive binding and restored wholesale after the body. Test: local binding does not shadow export (fails without the fix). dune main 562 OK; Bun-ESM 37/37; WASM 38/38. Co-Authored-By: Claude Opus 5.5 --- lib/typecheck.ml | 22 +++++++++++----------- test/test_records_and_imports.ml | 10 ++++++++++ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/lib/typecheck.ml b/lib/typecheck.ml index d14334f7..4c1a80f4 100644 --- a/lib/typecheck.ml +++ b/lib/typecheck.ml @@ -2199,12 +2199,15 @@ let check_fn_decl (ctx : context) (fd : fn_decl) : unit result = ) param_tys fd.fd_params ret_ty in (* Bind the function name (allows recursion) *) bind_var ctx fd.fd_name.name fn_ty; + (* Everything the body binds — parameters and block-local `let`s — is + scoped to it. Only parameters used to be restored, so a local + `let node = ...` overwrote the module-level `node` in [name_types], + and importers saw the local's type as the module's export. Snapshot + here and restore the whole table after the body. *) + let scope_snapshot = Hashtbl.copy ctx.name_types in (* Bind parameters *) - let old = List.map2 (fun (p : param) ty -> - let old = Hashtbl.find_opt ctx.name_types p.p_name.name in - bind_var ctx p.p_name.name ty; - (p.p_name.name, old) - ) fd.fd_params param_tys in + List.iter2 (fun (p : param) ty -> bind_var ctx p.p_name.name ty) + fd.fd_params param_tys; (* issue #59 — effect inference spine: infer this body's effect row into a fresh accumulator, then (only if a row was explicitly declared) require the inferred row to be a subset of it. An @@ -2219,12 +2222,9 @@ let check_fn_decl (ctx : context) (fd : fn_decl) : unit result = | FnExpr e -> check ctx e ret_ty end in - (* Restore parameter bindings *) - List.iter (fun (n, old_sc) -> - match old_sc with - | Some sc -> Hashtbl.replace ctx.name_types n sc - | None -> Hashtbl.remove ctx.name_types n - ) old; + (* Restore every binding the body introduced (see [scope_snapshot]). *) + Hashtbl.reset ctx.name_types; + Hashtbl.iter (fun n sc -> Hashtbl.replace ctx.name_types n sc) scope_snapshot; let inferred_eff = ctx.current_eff in ctx.current_eff <- saved_eff; let* () = diff --git a/test/test_records_and_imports.ml b/test/test_records_and_imports.ml index 82aaaaaf..7ffffd65 100644 --- a/test/test_records_and_imports.ml +++ b/test/test_records_and_imports.ml @@ -83,6 +83,14 @@ let imported_struct_unknown_field_rejected () = | Ok () -> Alcotest.fail "expected unknown field to be rejected" | Error _ -> () +(* A function-local binding must not replace the module-level binding of + the same name in what importers see. *) +let local_binding_does_not_shadow_export () = + let dir = module_dir "Shadow" + "module Shadow;\npub fn node(x: Int) -> Int = x + 1;\n\ + pub fn uses() -> Bool { let node = true; node }\n" in + passes ~dir "use Shadow::{node};\npub fn f() -> Int = node(41);\n" + let tests = [ Alcotest.test_case "update keeps struct type" `Quick update_keeps_struct_type; @@ -93,4 +101,6 @@ let tests = Alcotest.test_case "imported struct fields" `Quick imported_struct_fields; Alcotest.test_case "imported struct unknown field rejected" `Quick imported_struct_unknown_field_rejected; + Alcotest.test_case "local binding does not shadow export" `Quick + local_binding_does_not_shadow_export; ] From 7c8ea316202327c8b384df09aacd03572a3854e7 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:46:38 +0100 Subject: [PATCH 13/21] fix(module_loader): flatten imports transitively Flattening carried an imported module's own declarations but not what that module imported, so a program importing Router.on_url_change (whose body calls Tea.subs) emitted an undefined `subs`. An imported module's imports are now flattened first (with a cycle guard), so the dependency closure reaches across modules. Fixture chain_import -> ChainLib -> DirLib fails with `ReferenceError: name is not defined` without this change. dune main 562 OK; Bun-ESM 38/38; WASM 38/38. Co-Authored-By: Claude Opus 5.5 --- lib/module_loader.ml | 19 +++++++++++++++++-- tests/codegen-deno/ChainLib.affine | 8 ++++++++ tests/codegen-deno/chain_import.affine | 7 +++++++ tests/codegen-deno/chain_import.harness.mjs | 7 +++++++ 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/codegen-deno/ChainLib.affine create mode 100644 tests/codegen-deno/chain_import.affine create mode 100644 tests/codegen-deno/chain_import.harness.mjs diff --git a/lib/module_loader.ml b/lib/module_loader.ml index d1f74d03..e6833358 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -277,7 +277,8 @@ let clear_cache (loader : t) : unit = this function retains the same last-import policy as a defensive fallback for callers that flatten an already-loaded program directly. Local decls in [prog.prog_decls] always win over imported ones. *) -let flatten_imports (loader : t) (prog : program) : program = +let rec flatten_imports_from (visiting : string list list) (loader : t) + (prog : program) : program = (* Local-decl names suppress same-named imports of any kind. *) let local_name_list = List.filter_map (function @@ -310,7 +311,16 @@ let flatten_imports (loader : t) (prog : program) : program = in match Hashtbl.find_opt loader.loaded mod_path with | None -> () - | Some lm -> + | Some lm0 -> + (* Flatten the imported module's own imports first, so the closure + below can reach what *it* uses from other modules: `Nav` importing + `Router.on_url_change`, whose body calls `Tea.subs`, emitted an + undefined `subs`. [visiting] stops an import cycle. *) + let lm = + if List.mem mod_path visiting then lm0 + else { lm0 with mod_program = + flatten_imports_from (mod_path :: visiting) loader lm0.mod_program } + in let public_decls = List.filter_map (fun decl -> match decl with | TopFn fd when fd.fd_vis = Public || fd.fd_vis = PubCrate -> @@ -498,3 +508,8 @@ let flatten_imports (loader : t) (prog : program) : program = Re-introducing type-carrying for *user-defined* cross-module enums would need per-backend constructor dedup first. *) { prog with prog_decls = imported_decls @ prog.prog_decls } + +(** Inline the declarations [prog]'s imports need (transitively) into [prog], + for the backends that compile one flattened program. *) +let flatten_imports (loader : t) (prog : program) : program = + flatten_imports_from [] loader prog diff --git a/tests/codegen-deno/ChainLib.affine b/tests/codegen-deno/ChainLib.affine new file mode 100644 index 00000000..b576e26a --- /dev/null +++ b/tests/codegen-deno/ChainLib.affine @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MPL-2.0 +// Middle module of the chain_import fixture: re-uses DirLib. +module ChainLib; + +use DirLib::{Dir, North, Step, name}; + +pub fn describe_north() -> String = "it is " ++ name(North); +pub fn describe_steps(n: Int) -> String = name(Step(n)); diff --git a/tests/codegen-deno/chain_import.affine b/tests/codegen-deno/chain_import.affine new file mode 100644 index 00000000..27c72868 --- /dev/null +++ b/tests/codegen-deno/chain_import.affine @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Transitive imports: this program imports only ChainLib, whose functions +// call DirLib's. Flattening used to stop at the first hop, so DirLib's +// `name` was undefined in the emitted module. +use ChainLib::{describe_north, describe_steps}; + +pub fn both() -> String = describe_north() ++ ";" ++ describe_steps(2); diff --git a/tests/codegen-deno/chain_import.harness.mjs b/tests/codegen-deno/chain_import.harness.mjs new file mode 100644 index 00000000..411e2cdc --- /dev/null +++ b/tests/codegen-deno/chain_import.harness.mjs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +// Transitively imported functions are emitted. +import assert from "node:assert/strict"; +import { both } from "./chain_import.bun.js"; + +assert.equal(both(), "it is north;step 2"); +console.log("chain_import.harness.mjs OK"); From f3368d98a84768fc63eaf9b059d2040770a8b5c6 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:47:07 +0100 Subject: [PATCH 14/21] feat(affinescript-router): URL routing for affinescript-tea apps Router.affine (AffineScript): current_url, on_url_change (popstate + hashchange), navigate/replace/back commands that also deliver the new URL (the History API fires nothing for programmatic changes), match_route with :param / trailing * captures, query_param (form decoding), href, parse_url. router_host.js is the host carve-out (Location/History primitives). Tests: test/router.test.js (pure functions, 5); e2e/nav.test.js drives the Nav example in headless Chromium (6: push, replace, back/forward incl. the browser's, manual hash edit, deep link, no errors). Wired into ci.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 + .gitignore | 1 + affinescript-router/README.adoc | 77 +++++++++ affinescript-router/e2e/bun.lock | 19 +++ affinescript-router/e2e/nav.test.js | 102 ++++++++++++ affinescript-router/e2e/package.json | 12 ++ affinescript-router/examples/nav/Nav.affine | 63 ++++++++ affinescript-router/src/Router.affine | 164 ++++++++++++++++++++ affinescript-router/src/router_host.js | 47 ++++++ affinescript-router/test/router.test.js | 68 ++++++++ 10 files changed, 561 insertions(+) create mode 100644 affinescript-router/README.adoc create mode 100644 affinescript-router/e2e/bun.lock create mode 100644 affinescript-router/e2e/nav.test.js create mode 100644 affinescript-router/e2e/package.json create mode 100644 affinescript-router/examples/nav/Nav.affine create mode 100644 affinescript-router/src/Router.affine create mode 100644 affinescript-router/src/router_host.js create mode 100644 affinescript-router/test/router.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f406929..47df3606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,6 +126,14 @@ jobs: bun install --frozen-lockfile bunx --no-install --bun playwright install --with-deps chromium opam exec -- bun test --timeout 60000 + - name: affinescript-router unit + browser tests + # Router.affine's pure functions (Bun-ESM) and the Nav example in + # headless Chromium: navigate/replace/back, hash edits, deep links. + run: | + opam exec -- bun test affinescript-router/test + cd affinescript-router/e2e + bun install --frozen-lockfile + opam exec -- bun test --timeout 60000 - name: Run face-transformer regression tests run: opam exec -- ./tools/run_face_transformer_tests.sh - name: Issue #35 Phase 3 — block extension.ts regression diff --git a/.gitignore b/.gitignore index c336bf2f..bcbc7208 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,4 @@ tools/vendor/ # affinescript-tea browser test deps /affinescript-tea/e2e/node_modules/ +/affinescript-router/e2e/node_modules/ diff --git a/affinescript-router/README.adoc b/affinescript-router/README.adoc new file mode 100644 index 00000000..8c5e63cd --- /dev/null +++ b/affinescript-router/README.adoc @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath += affinescript-router +:toc: macro + +URL routing for link:../affinescript-tea[affinescript-tea] applications, +written in AffineScript. It maps the browser URL to application state and +back. It is unrelated to link:../affinescript-cadre[affinescript-cadre], +which is a WASM screen back-stack for games. + +toc::[] + +== Usage + +[source,affinescript] +---- +use Router::{Url, current_url, on_url_change, navigate, replace, match_route, query_param, href}; + +pub enum Msg { UrlChanged(Url), Open(String) } + +pub fn route(u: Url) -> Page = match match_route("/note/:id", u.path) { + Some(caps) => Page::Note(caps[0]), + None => Page::Home +}; + +// init: (Model #{ page: route(current_url()) }, none()) +// update: Msg::Open(id) => (m, navigate(href(["note", id]), fn(u) => Msg::UrlChanged(u))) +// subscriptions: on_url_change(fn(u) => Msg::UrlChanged(u)) +---- + +What the package provides: + +* *`current_url()`* reads the location for `init`. +* *`on_url_change(f)`* is a subscription. It fires on back and forward, and when + the user edits the address bar. +* *`navigate(href, f)`* and *`replace(href, f)`* change the URL, either adding a + history entry or replacing the current one, and deliver the new `Url` through + `f`. The History API fires no event for programmatic changes, so the command + dispatches the new URL itself, as Elm's `Browser.Navigation` does. +* *`back()`* goes back one entry. +* *`match_route(pattern, path)`* matches a path against a pattern, returning the + decoded captures. Pattern segments can be: +** a literal; +** `:name`, which captures one segment; +** a final `*`, which captures the rest. +* *`query_param(query, key)`* decodes a query value (form encoding: `+` is a + space). *`href(segments)`* builds an encoded path. *`parse_url(href)`* + splits a URL into path, query and hash. + +Routes live after `#` by default (hash mode), which suits static hosting and +`file://`. `current_path_url()` reads the real pathname instead. + +== Building + +Put both `affinescript-tea/src` and `affinescript-router/src` on +`$AFFINESCRIPT_PATH`. The page loads `tea_host.js`, then `router_host.js`, +then the compiled app. The app module runs its own `main`; do not call it again. + +== Tests + +* `test/router.test.js` is a set of unit tests of the pure functions, compiled + with Bun-ESM. +* `e2e/nav.test.js` runs `examples/nav/Nav.affine` in headless Chromium. It + checks that: +** navigate pushes history and delivers a decoded route; +** replace adds no entry; +** back and forward work, in the app and through the browser; +** a manual hash edit is picked up; +** a deep link loads directly; +** no runtime errors occur. + +[source,sh] +---- +dune build # at the repo root +bun test affinescript-router/test +cd affinescript-router/e2e && bun install --frozen-lockfile && bun test +---- diff --git a/affinescript-router/e2e/bun.lock b/affinescript-router/e2e/bun.lock new file mode 100644 index 00000000..452af2d7 --- /dev/null +++ b/affinescript-router/e2e/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "affinescript-router-e2e", + "devDependencies": { + "playwright": "1.62.1", + }, + }, + }, + "packages": { + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + } +} diff --git a/affinescript-router/e2e/nav.test.js b/affinescript-router/e2e/nav.test.js new file mode 100644 index 00000000..371259e0 --- /dev/null +++ b/affinescript-router/e2e/nav.test.js @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Browser tests for affinescript-router, driven through the Nav example +// (../examples/nav/Nav.affine) in headless Chromium. +// +// Run: cd affinescript-router/e2e && bun install --frozen-lockfile && bun test + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { copyFileSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium } from "playwright"; + +const repo = new URL("../../", import.meta.url).pathname; +const site = mkdtempSync(join(tmpdir(), "router-e2e-")); +let server; +let browser; +let page; +let base; +const errors = []; + +beforeAll(async () => { + const r = Bun.spawnSync({ + cmd: [join(repo, "_build/default/bin/main.exe"), "compile", "--bun-esm", "Nav.affine", "-o", join(site, "nav.bun.js")], + cwd: join(repo, "affinescript-router/examples/nav"), + env: { + ...process.env, + AFFINESCRIPT_STDLIB: join(repo, "stdlib"), + AFFINESCRIPT_PATH: `${join(repo, "affinescript-tea/src")}:${join(repo, "affinescript-router/src")}`, + }, + }); + if (r.exitCode !== 0) throw new Error(`compile failed:\n${r.stdout}${r.stderr}`); + copyFileSync(join(repo, "affinescript-tea/src/tea_host.js"), join(site, "tea_host.js")); + copyFileSync(join(repo, "affinescript-router/src/router_host.js"), join(site, "router_host.js")); + writeFileSync( + join(site, "index.html"), + `
+`, + ); + server = Bun.serve({ + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + return new Response(Bun.file(join(site, path === "/" ? "index.html" : path.slice(1)))); + }, + }); + base = `http://localhost:${server.port}/`; + browser = await chromium.launch(); + page = await browser.newPage(); + page.on("pageerror", (e) => errors.push(String(e))); + page.on("console", (m) => m.type() === "error" && errors.push(m.text())); + await page.goto(base); + await page.waitForSelector("#app"); +}); + +afterAll(async () => { + await browser?.close(); + server?.stop(true); +}); + +/** Wait until the rendered page label equals `label`. */ +const pageIs = (label) => page.waitForFunction((l) => document.querySelector("#page")?.textContent === l, label); + +test("initial route is read from the URL", async () => { + expect(await page.textContent("#page")).toBe("home"); +}); + +test("navigate pushes a history entry and delivers the decoded route", async () => { + await page.click("#to-item"); + await pageIs("item a b/c"); + expect(new URL(page.url()).hash).toBe("#/item/a%20b%2Fc"); + await page.click("#to-search"); + await pageIs("search hello world"); +}); + +test("replace swaps the entry without adding one; back returns", async () => { + await page.click("#swap"); + await pageIs("item 7"); + await page.click("#back"); + await pageIs("item a b/c"); + await page.goBack(); + await pageIs("home"); + await page.goForward(); + await pageIs("item a b/c"); +}); + +test("editing the hash (address bar) is picked up", async () => { + await page.evaluate(() => { + location.hash = "#/nowhere"; + }); + await pageIs("missing /nowhere"); +}); + +test("a deep link loads directly", async () => { + await page.goto(`${base}#/search?q=deep+link`); + await pageIs("search deep link"); +}); + +test("no runtime errors", () => { + expect(errors).toEqual([]); +}); diff --git a/affinescript-router/e2e/package.json b/affinescript-router/e2e/package.json new file mode 100644 index 00000000..5733bf47 --- /dev/null +++ b/affinescript-router/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "affinescript-router-e2e", + "private": true, + "type": "module", + "description": "Browser tests for affinescript-router (Router.affine + router_host.js) on affinescript-tea.", + "scripts": { + "test": "bun test --timeout 60000" + }, + "devDependencies": { + "playwright": "1.62.1" + } +} diff --git a/affinescript-router/examples/nav/Nav.affine b/affinescript-router/examples/nav/Nav.affine new file mode 100644 index 00000000..d0640ea6 --- /dev/null +++ b/affinescript-router/examples/nav/Nav.affine @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Nav — affinescript-router's reference app and the program its browser +// tests drive: routes "/", "/item/:id" and "/search?q=...", links that push +// history, a replace, back, and the URL-change subscription. + +module Nav; + +use prelude::*; +use Tea::{Html, Cmd, Sub, run, text, div, button, p, id, on_click, none, batch}; +use Router::{Url, current_url, on_url_change, navigate, replace, back, match_route, query_param, href}; + +pub enum Page { Home, Item(String), Search(String), Missing(String) } + +pub struct Model { page: Page, visits: Int } + +pub enum Msg { UrlChanged(Url), Go(String), Swap(String), Back } + +/// The page a URL denotes. +pub fn route(u: Url) -> Page { + if u.path == "/" { return Page::Home; } + match match_route("/item/:id", u.path) { + Some(caps) => { return Page::Item(caps[0]); }, + None => () + }; + match match_route("/search", u.path) { + Some(_) => Page::Search(match query_param(u.query, "q") { Some(q) => q, None => "" }), + None => Page::Missing(u.path) + } +} + +pub fn init() -> (Model, Cmd) = (Model #{ page: route(current_url()), visits: 1 }, none()); + +pub fn update(msg: Msg, m: Model) -> (Model, Cmd) { + match msg { + Msg::UrlChanged(u) => (Model #{ page: route(u), visits: m.visits + 1 }, none()), + Msg::Go(h) => (m, navigate(h, fn(u) => Msg::UrlChanged(u))), + Msg::Swap(h) => (m, replace(h, fn(u) => Msg::UrlChanged(u))), + Msg::Back => (m, back()) + } +} + +pub fn view(m: Model) -> Html { + let label = match m.page { + Page::Home => "home", + Page::Item(i) => "item " ++ i, + Page::Search(q) => "search " ++ q, + Page::Missing(path) => "missing " ++ path + }; + div([id("app")], [ + p([id("page")], [text(label)]), + p([id("visits")], [text(int_to_string(m.visits))]), + button([id("to-item"), on_click(Msg::Go(href(["item", "a b/c"])))], [text("item")]), + button([id("to-search"), on_click(Msg::Go("/search?q=hello+world"))], [text("search")]), + button([id("swap"), on_click(Msg::Swap("/item/7"))], [text("swap")]), + button([id("back"), on_click(Msg::Back)], [text("back")]) + ]) +} + +pub fn subscriptions(m: Model) -> Sub = on_url_change(fn(u) => Msg::UrlChanged(u)); + +pub fn main() -> Unit = run("#root", init(), update, view, subscriptions); diff --git a/affinescript-router/src/Router.affine b/affinescript-router/src/Router.affine new file mode 100644 index 00000000..6aef809a --- /dev/null +++ b/affinescript-router/src/Router.affine @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Router.affine — URL routing for affinescript-tea applications. +// +// Not to be confused with affinescript-cadre (a WASM screen back-stack for +// games): this maps the browser URL to application state and back. +// +// * `current_url()` reads the location (for `init`); +// * `on_url_change(f)` is a subscription firing on back/forward and on +// manual edits of the address bar; +// * `navigate(href, f)` / `replace(href, f)` change the URL (adding or +// replacing a history entry) and deliver the new URL through `f` — the +// History API fires no event for programmatic changes, so the command +// dispatches it, the way Elm's Browser.Navigation does; +// * `match_route("/note/:id", url.path)` matches a pattern, returning the +// captured parameters (decoded), and `query_param` reads the query. +// +// Mode: in hash mode (the default, for static hosting and file://) the route +// lives after `#`; in path mode it is the real pathname. + +module Router; + +use prelude::*; +use string::{split, join, starts_with}; +use Tea::{Sub, Cmd, Event, on_window, subs, run_cmd}; + +/// A parsed URL. `path` always starts with "/"; `query` excludes the `?`. +pub struct Url { path: String, query: String, hash: String } + +// ── Host primitives (router_host.js) ──────────────────────────────────────── + +/// The current location as a `Url`; in hash mode the route after `#`. +pub extern fn router_location(hash_mode: Bool) -> Url; +/// Add a history entry for `href` (hash mode: `#href`). +pub extern fn router_push(hash_mode: Bool, href: String) -> Unit; +/// Replace the current history entry with `href`. +pub extern fn router_replace(hash_mode: Bool, href: String) -> Unit; +/// Go back one history entry. +pub extern fn router_back() -> Unit; +/// `decodeURIComponent` (`+` is left alone; `query_param` handles it), +/// returning the input unchanged if malformed. +pub extern fn router_decode(s: String) -> String; +/// `encodeURIComponent`. +pub extern fn router_encode(s: String) -> String; + +// ── Reading and changing the URL ──────────────────────────────────────────── + +/// The current URL (hash mode). +pub fn current_url() -> Url = router_location(true); +/// The current URL from the real pathname (path mode). +pub fn current_path_url() -> Url = router_location(false); + +/// A subscription delivering the URL whenever the user navigates (back, +/// forward, or editing the address bar). +pub fn on_url_change(f: Url -> M) -> Sub = + subs([on_window("popstate", fn(e) => Some(f(router_location(true)))), + on_window("hashchange", fn(e) => Some(f(router_location(true))))]); + +/// Go to `href`, adding a history entry, and deliver the new URL via `f`. +pub fn navigate(href: String, f: Url -> M) -> Cmd = + run_cmd(fn(dispatch) => { router_push(true, href); dispatch(f(router_location(true))) }); + +/// Go to `href` without adding a history entry (e.g. reflecting a selection). +pub fn replace(href: String, f: Url -> M) -> Cmd = + run_cmd(fn(dispatch) => { router_replace(true, href); dispatch(f(router_location(true))) }); + +/// Go back one entry (the subscription then reports the URL). +pub fn back() -> Cmd = run_cmd(fn(dispatch) => router_back()); + +// ── Parsing and matching (pure) ───────────────────────────────────────────── + +/// Parse an href such as "/note/42?tab=links#top" into a `Url`. +pub fn parse_url(href: String) -> Url { + let hash_parts = split(href, "#"); + let before_hash = if len(hash_parts) > 0 { hash_parts[0] } else { "" }; + let hash = if len(hash_parts) > 1 { join(tail_from(hash_parts, 1), "#") } else { "" }; + let query_parts = split(before_hash, "?"); + let raw_path = if len(query_parts) > 0 { query_parts[0] } else { "" }; + let query = if len(query_parts) > 1 { join(tail_from(query_parts, 1), "?") } else { "" }; + let path = if starts_with(raw_path, "/") { raw_path } else { "/" ++ raw_path }; + Url #{ path: path, query: query, hash: hash } +} + +/// `xs` from index `from` on. +fn tail_from(xs: [String], from: Int) -> [String] { + let mut out = []; + let mut i = from; + while i < len(xs) { + out = out ++ [xs[i]]; + i = i + 1; + } + out +} + +/// The non-empty path segments: "/note//42/" -> ["note", "42"] (undecoded). +pub fn segments(path: String) -> [String] { + let mut out = []; + for s in split(path, "/") { + if s != "" { + out = out ++ [s]; + } + } + out +} + +/// Match `path` against `pattern`. Pattern segments are literals, `:name` +/// (captures one decoded segment) or a final `*` (captures the decoded rest, +/// joined with "/"). Returns the captures in order, or `None`. +pub fn match_route(pattern: String, path: String) -> Option<[String]> { + let ps = segments(pattern); + let xs = segments(path); + let mut caps = []; + let mut i = 0; + while i < len(ps) { + let p = ps[i]; + if p == "*" && i == len(ps) - 1 { + let rest = tea_map_decode(tail_from(xs, i)); + return Some(caps ++ [join(rest, "/")]); + } + if i >= len(xs) { + return None; + } + if starts_with(p, ":") { + caps = caps ++ [router_decode(xs[i])]; + } else { + if p != router_decode(xs[i]) { + return None; + } + } + i = i + 1; + } + if len(xs) == len(ps) { Some(caps) } else { None } +} + +fn tea_map_decode(xs: [String]) -> [String] { + let mut out = []; + for x in xs { + out = out ++ [router_decode(x)]; + } + out +} + +/// The decoded value of query parameter `key` ("a=1&b=two"), or `None`. +/// A key without `=` has the value "". +pub fn query_param(query: String, key: String) -> Option { + for pair in split(query, "&") { + let kv = split(pair, "="); + if len(kv) > 0 && router_decode(kv[0]) == key { + // In a query string `+` encodes a space (form encoding). + return Some(if len(kv) > 1 { router_decode(join(split(join(tail_from(kv, 1), "="), "+"), " ")) } else { "" }); + } + } + None +} + +/// An href from path segments (each encoded): ["note", "a b"] -> "/note/a%20b". +pub fn href(parts: [String]) -> String { + let mut out = ""; + for p in parts { + out = out ++ "/" ++ router_encode(p); + } + if out == "" { "/" } else { out } +} diff --git a/affinescript-router/src/router_host.js b/affinescript-router/src/router_host.js new file mode 100644 index 00000000..ff1e9780 --- /dev/null +++ b/affinescript-router/src/router_host.js @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// router_host.js — browser primitives for Router.affine (host carve-out: the +// History/Location API calls the AffineScript module declares as externs). +// Load after tea_host.js and before the compiled app. + +/** Split a route string ("/a/b?q=1#h") into Router.affine's `Url` record. */ +function toUrl(route) { + const hashAt = route.indexOf("#"); + const beforeHash = hashAt < 0 ? route : route.slice(0, hashAt); + const hash = hashAt < 0 ? "" : route.slice(hashAt + 1); + const queryAt = beforeHash.indexOf("?"); + const path = queryAt < 0 ? beforeHash : beforeHash.slice(0, queryAt); + const query = queryAt < 0 ? "" : beforeHash.slice(queryAt + 1); + return { path: path.startsWith("/") ? path : `/${path}`, query, hash }; +} + +/** The history-API URL for `href` in the given mode. */ +const target = (hashMode, href) => (hashMode ? `${location.pathname}${location.search}#${href}` : href); + +const fns = { + /** The current location; in hash mode, the route after `#`. */ + router_location: (hashMode) => + hashMode ? toUrl(location.hash.slice(1) || "/") : toUrl(`${location.pathname}${location.search}${location.hash}`), + /** Add a history entry (no event fires; Router.navigate dispatches). */ + router_push: (hashMode, href) => history.pushState(null, "", target(hashMode, href)), + /** Replace the current history entry. */ + router_replace: (hashMode, href) => history.replaceState(null, "", target(hashMode, href)), + /** Go back one entry (fires popstate). */ + router_back: () => history.back(), + /** decodeURIComponent, tolerant of malformed input. */ + router_decode: (s) => { + try { + return decodeURIComponent(s); + } catch { + return s; + } + }, + /** encodeURIComponent. */ + router_encode: (s) => encodeURIComponent(s), +}; + +for (const [name, fn] of Object.entries(fns)) { + if (name in globalThis) throw new Error(`router_host: global ${name} is already defined`); + globalThis[name] = fn; +} diff --git a/affinescript-router/test/router.test.js b/affinescript-router/test/router.test.js new file mode 100644 index 00000000..01ee169d --- /dev/null +++ b/affinescript-router/test/router.test.js @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// Unit tests for Router.affine's pure functions, compiled with the Bun-ESM +// backend (the host's encode/decode primitives are installed as globals). + +import { beforeAll, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const repo = new URL("../../", import.meta.url).pathname; +let R; + +beforeAll(async () => { + globalThis.router_decode = (s) => { + try { + return decodeURIComponent(s); + } catch { + return s; + } + }; + globalThis.router_encode = (s) => encodeURIComponent(s); + const out = join(mkdtempSync(join(tmpdir(), "router-")), "router.bun.js"); + const r = Bun.spawnSync({ + cmd: [join(repo, "_build/default/bin/main.exe"), "compile", "--bun-esm", "Router.affine", "-o", out], + cwd: join(repo, "affinescript-router/src"), + env: { ...process.env, AFFINESCRIPT_STDLIB: join(repo, "stdlib"), AFFINESCRIPT_PATH: join(repo, "affinescript-tea/src") }, + }); + if (r.exitCode !== 0) throw new Error(`compile failed:\n${r.stdout}${r.stderr}`); + R = await import(out); +}); + +/** Unwrap an AffineScript Option into value-or-null. */ +const opt = (o) => (o && o.tag === "Some" ? o.value : null); + +test("parse_url splits path, query and hash", () => { + expect({ ...R.parse_url("/note/42?tab=links&x=1#top") }).toEqual({ path: "/note/42", query: "tab=links&x=1", hash: "top" }); + expect({ ...R.parse_url("") }).toEqual({ path: "/", query: "", hash: "" }); + expect({ ...R.parse_url("search?q=a?b#x#y") }).toEqual({ path: "/search", query: "q=a?b", hash: "x#y" }); +}); + +test("segments drop empty parts", () => { + expect(R.segments("/note//42/")).toEqual(["note", "42"]); + expect(R.segments("/")).toEqual([]); +}); + +test("match_route captures decoded params, literals must match, * takes the rest", () => { + expect(opt(R.match_route("/note/:id", "/note/a%20b"))).toEqual(["a b"]); + expect(opt(R.match_route("/note/:id", "/note"))).toBeNull(); + expect(opt(R.match_route("/note/:id", "/note/1/extra"))).toBeNull(); + expect(opt(R.match_route("/note/:id", "/notes/1"))).toBeNull(); + expect(opt(R.match_route("/", "/"))).toEqual([]); + expect(opt(R.match_route("/files/*", "/files/a/b%2Fc"))).toEqual(["a/b/c"]); + expect(opt(R.match_route("/u/:a/p/:b", "/u/x/p/y"))).toEqual(["x", "y"]); +}); + +test("query_param decodes values and form-encoded spaces", () => { + expect(opt(R.query_param("q=hello+world&n=1", "q"))).toBe("hello world"); + expect(opt(R.query_param("a=1&flag&b=x%3Dy", "flag"))).toBe(""); + expect(opt(R.query_param("a=1&b=x%3Dy", "b"))).toBe("x=y"); + expect(opt(R.query_param("a=1", "z"))).toBeNull(); +}); + +test("href encodes each segment; matching round-trips", () => { + const h = R.href(["item", "a b/c"]); + expect(h).toBe("/item/a%20b%2Fc"); + expect(opt(R.match_route("/item/:id", h))).toEqual(["a b/c"]); + expect(R.href([])).toBe("/"); +}); From 7e23984b515c7815b6c664208a83b84269c851d5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:53:14 +0100 Subject: [PATCH 15/21] feat(affinescript-crdt): state-based CRDTs for local-first apps Crdt.affine (pure AffineScript): Lamport Clock/Stamp; Lww register; LwwMap (string keys, timestamped tombstones); OrSet (observed-remove, add wins over a concurrent remove); PnCounter. States are sorted plain values, so merges are linear merge-joins and serialise directly. Property tests (test/crdt.test.js, 7): three replicas x 300 random ops with random gossip converge in all merge orders (map, set, counter); map values equal the highest-stamped write; merge laws on random states; OR-set and PN-counter semantics; merge cost reported. Breaking map_merge's tie rule fails the convergence and law tests. Wired into ci.yml. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +- affinescript-crdt/README.adoc | 88 +++++++ affinescript-crdt/src/Crdt.affine | 375 ++++++++++++++++++++++++++++ affinescript-crdt/test/crdt.test.js | 222 ++++++++++++++++ 4 files changed, 690 insertions(+), 3 deletions(-) create mode 100644 affinescript-crdt/README.adoc create mode 100644 affinescript-crdt/src/Crdt.affine create mode 100644 affinescript-crdt/test/crdt.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47df3606..c628a630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,11 +126,13 @@ jobs: bun install --frozen-lockfile bunx --no-install --bun playwright install --with-deps chromium opam exec -- bun test --timeout 60000 - - name: affinescript-router unit + browser tests - # Router.affine's pure functions (Bun-ESM) and the Nav example in - # headless Chromium: navigate/replace/back, hash edits, deep links. + - name: affinescript-router + affinescript-crdt tests + # Router.affine's pure functions and Crdt.affine's property tests + # (convergence, merge laws) on Bun-ESM; the Nav example in headless + # Chromium: navigate/replace/back, hash edits, deep links. run: | opam exec -- bun test affinescript-router/test + opam exec -- bun test affinescript-crdt/test cd affinescript-router/e2e bun install --frozen-lockfile opam exec -- bun test --timeout 60000 diff --git a/affinescript-crdt/README.adoc b/affinescript-crdt/README.adoc new file mode 100644 index 00000000..fc3449ac --- /dev/null +++ b/affinescript-crdt/README.adoc @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath += affinescript-crdt +:toc: macro + +Conflict-free replicated data types for local-first AffineScript apps. +Replicas update their own copy and exchange whole states. `merge` is +commutative, associative and idempotent, so replicas that have seen the same +updates hold equal states, whatever order they merged in and however many +times. + +toc::[] + +== Types + +[cols="1,3"] +|=== +| `Clock`, `Stamp` +| A Lamport clock and its timestamps, totally ordered by `(counter, replica)`. + `tick(clock)` stamps a local write. `observe(clock, stamp)` accounts for a + stamp seen from another replica, so later local writes are ordered after it. + +| `Lww` +| A last-writer-wins register (`lww`, `lww_set`, `lww_value`, `lww_merge`). + +| `LwwMap` +| A string-keyed map of LWW entries (`map_set`, `map_remove`, `map_get`, + `map_keys`, `map_merge`). Removal is a timestamped tombstone, so a later set + revives the key and an earlier concurrent set loses. + +| `OrSet` +| An observed-remove set of strings (`or_add`, `or_remove`, `or_contains`, + `or_elements`, `or_merge`). An add wins over a concurrent remove; a remove + deletes only the adds it has observed. + +| `PnCounter` +| An increment/decrement counter (`pn_add`, `pn_value`, `pn_merge`). +|=== + +States are plain values (sorted arrays of tuples and records), so they +serialise directly, e.g. to JSON, for storage and sync. Each replica needs a +unique id; stamps from one replica's clock are unique by construction, which +is the precondition every LWW type relies on. + +== Example + +[source,affinescript] +---- +use Crdt::{clock, tick, map_empty, map_set, map_merge, map_get}; + +let (c1, s1) = tick(clock("laptop")); +let laptop = map_set(map_empty(), "title", "Draft", s1); +let (c2, s2) = tick(clock("phone")); +let phone = map_set(map_empty(), "title", "Final", s2); +// Both replicas converge on the same winner ("phone" breaks the tie): +map_get(map_merge(laptop, phone), "title") == map_get(map_merge(phone, laptop), "title") +---- + +== Performance + +Merges are linear merge-joins over sorted entries. On the JS target, though, +building an array with `++` copies it, so construction and merges cost +O(n²) in the number of entries. A merge of a 2,000-entry map with a +1,000-entry one measured 7–47 ms locally. That's fine for sync events; for +very large states, shard by key. + +== Tests + +`test/crdt.test.js` contains property tests on the module compiled with +Bun-ESM. Each uses deterministic seeds, and they cover: + +* three replicas applying 300 random operations each with random partial + gossip, then converging in all six merge orders (LWW map, OR-set, + PN-counter); +* LWW maps whose key values equal the highest-stamped write across all + replicas; +* the merge laws (commutative, associative, idempotent) on random states for + every type; +* OR-set add-wins and observed-remove semantics; +* PN-counter sums. + +Breaking `map_merge`'s tie rule makes the convergence and law tests fail. + +[source,sh] +---- +dune build # at the repo root +bun test affinescript-crdt/test +---- diff --git a/affinescript-crdt/src/Crdt.affine b/affinescript-crdt/src/Crdt.affine new file mode 100644 index 00000000..599ecaaa --- /dev/null +++ b/affinescript-crdt/src/Crdt.affine @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Crdt.affine — conflict-free replicated data types for local-first apps. +// +// Every type here is a *state-based* CRDT: replicas update their own copy +// and exchange whole states; `merge` is commutative, associative and +// idempotent, so replicas that have seen the same updates hold equal states +// whatever order (or how many times) they merged them in. +// +// Clock / Stamp Lamport clock and timestamps, totally ordered by +// (counter, replica) — the tie-break that makes LWW safe. +// Lww last-writer-wins register. +// LwwMap string-keyed map of LWW registers; removal is a +// timestamped tombstone, so a later set revives the key. +// OrSet observed-remove set of strings: an add wins over a +// concurrent remove; a remove deletes the adds it observed. +// PnCounter increment/decrement counter (per-replica totals). +// +// Pure AffineScript; no host code. States are plain values (sorted arrays), +// so they serialise directly (e.g. to JSON) for exchange and storage. +// Complexity: merges are linear merge-joins over the sorted entries, but on +// the JS target array building is O(n²) in the number of entries (each +// append copies), which is ~tens of ms at a few thousand entries. + +module Crdt; + +use prelude::*; + +// ── Lamport clock ─────────────────────────────────────────────────────────── + +/// A write's timestamp. Ordered by `counter`, ties broken by `replica`. +pub struct Stamp { counter: Int, replica: String } + +/// A replica's Lamport clock. +pub struct Clock { counter: Int, replica: String } + +/// A fresh clock for replica `replica` (ids must be unique per replica). +pub fn clock(replica: String) -> Clock = Clock #{ counter: 0, replica: replica }; + +/// Advance the clock for a local write; returns the new clock and the +/// write's stamp. +pub fn tick(c: Clock) -> (Clock, Stamp) { + let next = c.counter + 1; + (Clock #{ counter: next, replica: c.replica }, Stamp #{ counter: next, replica: c.replica }) +} + +/// Account for a stamp seen from another replica, so later local writes are +/// ordered after it. +pub fn observe(c: Clock, s: Stamp) -> Clock = + if s.counter > c.counter { Clock #{ counter: s.counter, replica: c.replica } } else { c }; + +/// Whether `a` is strictly later than `b`. +pub fn after(a: Stamp, b: Stamp) -> Bool = + a.counter > b.counter || (a.counter == b.counter && a.replica > b.replica); + +/// The later of two stamps. +pub fn later(a: Stamp, b: Stamp) -> Stamp = if after(b, a) { b } else { a }; + +/// `"replica#counter"` — a unique tag for a write. +pub fn tag(s: Stamp) -> String = s.replica ++ "#" ++ int_to_string(s.counter); + +// ── LWW register ──────────────────────────────────────────────────────────── + +/// A last-writer-wins register: a value and the stamp that wrote it. +pub enum Lww { Reg(T, Stamp) } + +pub fn lww(value: T, s: Stamp) -> Lww = Reg(value, s); + +pub fn lww_value(r: Lww) -> T = match r { Lww::Reg(v, _) => v }; + +pub fn lww_stamp(r: Lww) -> Stamp = match r { Lww::Reg(_, s) => s }; + +/// Write `value` at stamp `s` (ignored if the register holds a later write). +pub fn lww_set(r: Lww, value: T, s: Stamp) -> Lww = + if after(s, lww_stamp(r)) { Reg(value, s) } else { r }; + +/// Merge two registers: the later write wins. +pub fn lww_merge(a: Lww, b: Lww) -> Lww = + if after(lww_stamp(b), lww_stamp(a)) { b } else { a }; + +// ── LWW map ───────────────────────────────────────────────────────────────── + +/// A string-keyed map of LWW entries, sorted by key. `None` is a tombstone. +pub enum LwwMap { Entries([(String, Option, Stamp)]) } + +pub fn map_empty() -> LwwMap = Entries([]); + +fn map_entries(m: LwwMap) -> [(String, Option, Stamp)] = match m { LwwMap::Entries(es) => es }; + +/// Put an entry for `key` (a value or a tombstone) at stamp `s`, keeping key +/// order; an older write than the stored one is ignored. +fn map_put(m: LwwMap, key: String, v: Option, s: Stamp) -> LwwMap { + let es = map_entries(m); + let mut out = []; + let mut placed = false; + for e in es { + let (k, old, os) = e; + if !placed && key < k { + out = out ++ [(key, v, s)]; + placed = true; + } + if k == key { + out = out ++ [if after(s, os) { (key, v, s) } else { (k, old, os) }]; + placed = true; + } else { + out = out ++ [(k, old, os)]; + } + } + if !placed { + out = out ++ [(key, v, s)]; + } + Entries(out) +} + +/// Set `key` to `value` at stamp `s`. +pub fn map_set(m: LwwMap, key: String, value: V, s: Stamp) -> LwwMap = map_put(m, key, Some(value), s); + +/// Remove `key` at stamp `s` (a tombstone, so a concurrent older set loses). +pub fn map_remove(m: LwwMap, key: String, s: Stamp) -> LwwMap = map_put(m, key, None, s); + +/// The live value of `key`, if any. +pub fn map_get(m: LwwMap, key: String) -> Option { + for e in map_entries(m) { + let (k, v, _s) = e; + if k == key { + return v; + } + } + None +} + +/// The live keys, in order. +pub fn map_keys(m: LwwMap) -> [String] { + let mut out = []; + for e in map_entries(m) { + let (k, v, _s) = e; + match v { + Some(_) => { out = out ++ [k]; }, + None => () + }; + } + out +} + +/// Merge two maps: per key, the later write (value or tombstone) wins. +pub fn map_merge(a: LwwMap, b: LwwMap) -> LwwMap { + let xs = map_entries(a); + let ys = map_entries(b); + let mut out = []; + let mut i = 0; + let mut j = 0; + while i < len(xs) || j < len(ys) { + if j >= len(ys) { + out = out ++ [xs[i]]; + i = i + 1; + } else { + if i >= len(xs) { + out = out ++ [ys[j]]; + j = j + 1; + } else { + let (kx, vx, sx) = xs[i]; + let (ky, vy, sy) = ys[j]; + if kx < ky { + out = out ++ [xs[i]]; + i = i + 1; + } else { + if ky < kx { + out = out ++ [ys[j]]; + j = j + 1; + } else { + out = out ++ [if after(sy, sx) { ys[j] } else { xs[i] }]; + i = i + 1; + j = j + 1; + } + } + } + } + } + Entries(out) +} + +// ── Observed-remove set (of strings) ──────────────────────────────────────── + +/// `adds` holds (element, tag) pairs sorted by element then tag; `removed` +/// holds the sorted tags of adds that a remove observed. +pub struct OrSet { adds: [(String, String)], removed: [String] } + +pub fn or_empty() -> OrSet = OrSet #{ adds: [], removed: [] }; + +fn pair_lt(a: (String, String), b: (String, String)) -> Bool { + let (ea, ta) = a; + let (eb, tb) = b; + ea < eb || (ea == eb && ta < tb) +} + +/// Sorted union of two sorted pair lists. +fn union_pairs(xs: [(String, String)], ys: [(String, String)]) -> [(String, String)] { + let mut out = []; + let mut i = 0; + let mut j = 0; + while i < len(xs) || j < len(ys) { + if j >= len(ys) || (i < len(xs) && pair_lt(xs[i], ys[j])) { + out = out ++ [xs[i]]; + i = i + 1; + } else { + if i >= len(xs) || pair_lt(ys[j], xs[i]) { + out = out ++ [ys[j]]; + j = j + 1; + } else { + out = out ++ [xs[i]]; + i = i + 1; + j = j + 1; + } + } + } + out +} + +/// Sorted union of two sorted string lists. +fn union_strings(xs: [String], ys: [String]) -> [String] { + let mut out = []; + let mut i = 0; + let mut j = 0; + while i < len(xs) || j < len(ys) { + if j >= len(ys) || (i < len(xs) && xs[i] < ys[j]) { + out = out ++ [xs[i]]; + i = i + 1; + } else { + if i >= len(xs) || ys[j] < xs[i] { + out = out ++ [ys[j]]; + j = j + 1; + } else { + out = out ++ [xs[i]]; + i = i + 1; + j = j + 1; + } + } + } + out +} + +fn has_string(xs: [String], x: String) -> Bool { + for y in xs { + if y == x { + return true; + } + } + false +} + +/// Add `elem`, tagged with the unique stamp `s`. +pub fn or_add(set: OrSet, elem: String, s: Stamp) -> OrSet = + OrSet #{ adds: union_pairs(set.adds, [(elem, tag(s))]), removed: set.removed }; + +/// Remove `elem`: every add of it this replica has observed is cancelled. A +/// concurrent add (not yet observed) survives the merge. +pub fn or_remove(set: OrSet, elem: String) -> OrSet { + let mut tags = []; + for p in set.adds { + let (e, t) = p; + if e == elem && !has_string(set.removed, t) { + tags = union_strings(tags, [t]); + } + } + OrSet #{ adds: set.adds, removed: union_strings(set.removed, tags) } +} + +/// Whether `elem` is in the set. +pub fn or_contains(set: OrSet, elem: String) -> Bool { + for p in set.adds { + let (e, t) = p; + if e == elem && !has_string(set.removed, t) { + return true; + } + } + false +} + +/// The elements, sorted and distinct. +pub fn or_elements(set: OrSet) -> [String] { + let mut out = []; + for p in set.adds { + let (e, t) = p; + if !has_string(set.removed, t) && (len(out) == 0 || out[len(out) - 1] != e) { + out = out ++ [e]; + } + } + out +} + +/// Merge two sets: union of adds and of observed removals. +pub fn or_merge(a: OrSet, b: OrSet) -> OrSet = + OrSet #{ adds: union_pairs(a.adds, b.adds), removed: union_strings(a.removed, b.removed) }; + +// ── PN-counter ────────────────────────────────────────────────────────────── + +/// Per-replica totals of increments (`pos`) and decrements (`neg`), sorted +/// by replica. +pub struct PnCounter { pos: [(String, Int)], neg: [(String, Int)] } + +pub fn pn_empty() -> PnCounter = PnCounter #{ pos: [], neg: [] }; + +/// Add `amount` to `replica`'s total in a sorted per-replica list. +fn bump(xs: [(String, Int)], replica: String, amount: Int) -> [(String, Int)] { + let mut out = []; + let mut placed = false; + for p in xs { + let (r, n) = p; + if !placed && replica < r { + out = out ++ [(replica, amount)]; + placed = true; + } + if r == replica { + out = out ++ [(r, n + amount)]; + placed = true; + } else { + out = out ++ [(r, n)]; + } + } + if !placed { + out = out ++ [(replica, amount)]; + } + out +} + +/// Per replica, the larger total (each replica's totals only grow). +fn max_merge(xs: [(String, Int)], ys: [(String, Int)]) -> [(String, Int)] { + let mut out = []; + let mut i = 0; + let mut j = 0; + while i < len(xs) || j < len(ys) { + if j >= len(ys) { + out = out ++ [xs[i]]; + i = i + 1; + } else { + if i >= len(xs) { + out = out ++ [ys[j]]; + j = j + 1; + } else { + let (rx, nx) = xs[i]; + let (ry, ny) = ys[j]; + if rx < ry { + out = out ++ [xs[i]]; + i = i + 1; + } else { + if ry < rx { + out = out ++ [ys[j]]; + j = j + 1; + } else { + out = out ++ [(rx, if nx > ny { nx } else { ny })]; + i = i + 1; + j = j + 1; + } + } + } + } + } + out +} + +/// Change the counter by `amount` (negative to decrement) on `replica`. +pub fn pn_add(c: PnCounter, replica: String, amount: Int) -> PnCounter = + if amount >= 0 { PnCounter #{ pos: bump(c.pos, replica, amount), neg: c.neg } } + else { PnCounter #{ pos: c.pos, neg: bump(c.neg, replica, 0 - amount) } }; + +pub fn pn_value(c: PnCounter) -> Int { + let mut total = 0; + for p in c.pos { let (_r, n) = p; total = total + n; } + for p in c.neg { let (_r, n) = p; total = total - n; } + total +} + +pub fn pn_merge(a: PnCounter, b: PnCounter) -> PnCounter = + PnCounter #{ pos: max_merge(a.pos, b.pos), neg: max_merge(a.neg, b.neg) }; diff --git a/affinescript-crdt/test/crdt.test.js b/affinescript-crdt/test/crdt.test.js new file mode 100644 index 00000000..d6e348be --- /dev/null +++ b/affinescript-crdt/test/crdt.test.js @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Property tests for Crdt.affine (compiled with the Bun-ESM backend): +// convergence of replicas under random operations and gossip, the merge +// laws (commutative, associative, idempotent), and each type's semantics. +// Deterministic seeds; states compare as JSON (entries are kept sorted). + +import { beforeAll, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const repo = new URL("../../", import.meta.url).pathname; +let C; + +beforeAll(async () => { + const out = join(mkdtempSync(join(tmpdir(), "crdt-")), "crdt.bun.js"); + const r = Bun.spawnSync({ + cmd: [join(repo, "_build/default/bin/main.exe"), "compile", "--bun-esm", "Crdt.affine", "-o", out], + cwd: join(repo, "affinescript-crdt/src"), + env: { ...process.env, AFFINESCRIPT_STDLIB: join(repo, "stdlib") }, + }); + if (r.exitCode !== 0) throw new Error(`compile failed:\n${r.stdout}${r.stderr}`); + C = await import(out); +}); + +/** Deterministic PRNG (mulberry32). */ +function rng(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), a | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +const pick = (rand, xs) => xs[Math.floor(rand() * xs.length)]; +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const unwrap = (o) => (o.tag === "Some" ? o.value : null); + +/** Three replicas applying random ops with random partial gossip. */ +function simulate(seed, ops, init, apply, merge) { + const rand = rng(seed); + const reps = ["r1", "r2", "r3"].map((id) => ({ id, clock: C.clock(id), state: init() })); + const log = []; + for (let i = 0; i < ops; i++) { + const r = pick(rand, reps); + const [clock, stamp] = C.tick(r.clock); + r.clock = clock; + r.state = apply(r, stamp, rand, log); + if (rand() < 0.2) { + const other = pick(rand, reps); + if (other !== r) { + other.state = merge(other.state, r.state); + other.clock = C.observe(other.clock, stamp); + } + } + } + return { reps, log }; +} + +/** Merge three states in every order; all results must be equal. */ +function allOrders(merge, [a, b, c]) { + const orders = [ + [a, b, c], [a, c, b], [b, a, c], [b, c, a], [c, a, b], [c, b, a], + ].map(([x, y, z]) => merge(merge(x, y), z)); + orders.push(merge(merge(merge(a, b), merge(b, c)), merge(c, a))); + return orders; +} + +test("Lamport stamps are totally ordered and observe() moves the clock forward", () => { + const s = (counter, replica) => ({ counter, replica }); + expect(C.after(s(2, "a"), s(1, "z"))).toBe(true); + expect(C.after(s(1, "b"), s(1, "a"))).toBe(true); + expect(C.after(s(1, "a"), s(1, "a"))).toBe(false); + const [, stamp] = C.tick(C.observe(C.clock("x"), s(41, "y"))); + expect(stamp).toEqual({ counter: 42, replica: "x" }); +}); + +test("LWW map: replicas converge and each key holds its latest write", () => { + for (const seed of [1, 2, 3, 4]) { + const keys = ["a", "b", "c", "d", "e", "f"]; + const { reps, log } = simulate( + seed, + 300, + () => C.map_empty(), + (r, stamp, rand, log) => { + const key = pick(rand, keys); + if (rand() < 0.25) { + log.push({ key, value: null, stamp }); + return C.map_remove(r.state, key, stamp); + } + const value = Math.floor(rand() * 1000); + log.push({ key, value, stamp }); + return C.map_set(r.state, key, value, stamp); + }, + C.map_merge, + ); + const results = allOrders(C.map_merge, reps.map((r) => r.state)); + for (const m of results) expect(same(m, results[0])).toBe(true); + const final = results[0]; + for (const key of keys) { + const writes = log.filter((w) => w.key === key); + if (writes.length === 0) continue; + const latest = writes.reduce((best, w) => (C.after(w.stamp, best.stamp) ? w : best)); + expect(unwrap(C.map_get(final, key))).toBe(latest.value); + } + expect(C.map_keys(final)).toEqual(keys.filter((k) => unwrap(C.map_get(final, k)) !== null)); + } +}); + +test("LWW register merge keeps the later write", () => { + const r1 = C.lww("old", { counter: 1, replica: "a" }); + const r2 = C.lww("new", { counter: 2, replica: "a" }); + expect(C.lww_value(C.lww_merge(r1, r2))).toBe("new"); + expect(C.lww_value(C.lww_merge(r2, r1))).toBe("new"); + expect(C.lww_value(C.lww_set(r2, "stale", { counter: 1, replica: "z" }))).toBe("new"); +}); + +test("OR-set: replicas converge; add wins over a concurrent remove", () => { + for (const seed of [5, 6, 7]) { + const elems = ["x", "y", "z", "w"]; + const { reps } = simulate( + seed, + 300, + () => C.or_empty(), + (r, stamp, rand) => (rand() < 0.35 ? C.or_remove(r.state, pick(rand, elems)) : C.or_add(r.state, pick(rand, elems), stamp)), + C.or_merge, + ); + const results = allOrders(C.or_merge, reps.map((r) => r.state)); + for (const s of results) expect(same(s, results[0])).toBe(true); + } + // Concurrent add (unobserved by the remover) survives the merge. + let a = C.or_add(C.or_empty(), "k", { counter: 1, replica: "a" }); + let b = C.or_merge(C.or_empty(), a); + b = C.or_remove(b, "k"); + a = C.or_add(a, "k", { counter: 2, replica: "a" }); + expect(C.or_contains(C.or_merge(a, b), "k")).toBe(true); + // A remove that observed every add deletes the element everywhere. + const c = C.or_remove(C.or_merge(a, b), "k"); + expect(C.or_contains(C.or_merge(c, a), "k")).toBe(false); + expect(C.or_elements(C.or_add(C.or_add(C.or_empty(), "b", { counter: 1, replica: "r" }), "a", { counter: 2, replica: "r" }))).toEqual(["a", "b"]); +}); + +test("PN-counter: replicas converge to the sum of all changes", () => { + for (const seed of [8, 9]) { + let total = 0; + const { reps } = simulate( + seed, + 300, + () => C.pn_empty(), + (r, _stamp, rand) => { + const n = Math.floor(rand() * 21) - 10; + total += n; + return C.pn_add(r.state, r.id, n); + }, + C.pn_merge, + ); + const results = allOrders(C.pn_merge, reps.map((r) => r.state)); + for (const s of results) expect(same(s, results[0])).toBe(true); + expect(C.pn_value(results[0])).toBe(total); + } +}); + +test("merge laws: commutative, associative, idempotent", () => { + const rand = rng(42); + // Stamps are unique per write (what replica clocks guarantee); counters are + // shuffled so the generated states disagree about which write is latest. + let next = 0; + const stamp = () => ({ counter: 1 + Math.floor(rand() * 50) * 1000 + ++next, replica: pick(rand, ["a", "b", "c"]) }); + const randMap = () => { + let m = C.map_empty(); + for (let i = 0; i < 20; i++) { + const k = pick(rand, ["p", "q", "r", "s"]); + m = rand() < 0.3 ? C.map_remove(m, k, stamp()) : C.map_set(m, k, Math.floor(rand() * 9), stamp()); + } + return m; + }; + const randSet = () => { + let s = C.or_empty(); + for (let i = 0; i < 15; i++) { + const e = pick(rand, ["e1", "e2", "e3"]); + s = rand() < 0.3 ? C.or_remove(s, e) : C.or_add(s, e, stamp()); + } + return s; + }; + const randPn = () => { + let c = C.pn_empty(); + for (let i = 0; i < 10; i++) c = C.pn_add(c, pick(rand, ["a", "b", "c"]), Math.floor(rand() * 11) - 5); + return c; + }; + for (const [gen, merge] of [[randMap, C.map_merge], [randSet, C.or_merge], [randPn, C.pn_merge]]) { + for (let i = 0; i < 50; i++) { + const [x, y, z] = [gen(), gen(), gen()]; + expect(same(merge(x, y), merge(y, x))).toBe(true); + expect(same(merge(merge(x, y), z), merge(x, merge(y, z)))).toBe(true); + expect(same(merge(x, x), x)).toBe(true); + } + } +}); + +test("merge cost at 2,000 entries (reported)", () => { + let a = C.map_empty(); + let b = C.map_empty(); + // Build in key order so construction is linear; measure the merge. + const keys = Array.from({ length: 2000 }, (_, i) => `k${String(i).padStart(5, "0")}`); + const ea = []; + const eb = []; + keys.forEach((k, i) => { + ea.push([k, { tag: "Some", value: i }, { counter: i + 1, replica: "a" }]); + if (i % 2 === 0) eb.push([k, { tag: "Some", value: -i }, { counter: i + 2, replica: "b" }]); + }); + a = { tag: "Entries", value: ea }; + b = { tag: "Entries", value: eb }; + const t0 = performance.now(); + const m = C.map_merge(a, b); + const ms = performance.now() - t0; + console.log(`map_merge 2000 + 1000 entries: ${ms.toFixed(1)} ms`); + expect(C.map_keys(m).length).toBe(2000); + expect(unwrap(C.map_get(m, "k00002"))).toBe(-2); +}); From 2f080d93a152b7d1829d8abaf8b2093a2fee8044 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:53:38 +0100 Subject: [PATCH 16/21] docs(ecosystem): register Tea.affine runtime, affinescript-router, affinescript-crdt Co-Authored-By: Claude Opus 5.5 --- docs/ECOSYSTEM.adoc | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/ECOSYSTEM.adoc b/docs/ECOSYSTEM.adoc index df08e0b0..98031ee1 100644 --- a/docs/ECOSYSTEM.adoc +++ b/docs/ECOSYSTEM.adoc @@ -179,6 +179,24 @@ Deno-ESM target. JS-host ESM is Bun-ESM (`--bun-esm`). `parseTeaLayout`). Generic over `affinescript.tea_layout`; enforces the Linear-msg invariant; reuses the INT-02 loader. 9 Deno tests vs the canonical `affinescript tea-bridge` + a re-entrancy fixture. +*Also* (2026-10-05) `src/Tea.affine`: The Elm Architecture written in +AffineScript for browser apps on Bun-ESM. It provides a typed virtual DOM, +`lazy` subtrees, keyed reconciliation that moves the fewest nodes (an LIS +over surviving positions), `Cmd`/`Sub`, and `run`. `tea_host.js` is the +host carve-out. There are 10 browser tests (Playwright), including a +randomized keyed-diff property test. It is used by hyperpolymath/nexia-list. + +|`affinescript-router` |library |URL routing for `affinescript-tea` apps +(2026-10-05): `current_url`, `on_url_change`, `navigate`/`replace`/`back` +commands that deliver the new URL, `match_route` with `:param`/`*` +captures, `query_param`, `href`. There are 5 unit tests and 6 browser +tests. It is unrelated to `affinescript-cadre` (a screen back-stack). + +|`affinescript-crdt` |library |State-based CRDTs (2026-10-05): a Lamport +clock and stamps, an LWW register, an LWW map with tombstones, an +observed-remove set and a PN-counter, in pure AffineScript. There are 7 +property tests: convergence of three replicas under random ops and gossip, +merge laws, and semantics. |`affinescript-cadre` |runtime |Cadre Router navigation satellite wrapper. Consumes `lib/tea_router.ml` generated WASM module. From 8cb8c5318d882035dd1d92e247109560fd0f2276 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:09:02 +0100 Subject: [PATCH 17/21] ci(affinescript-tea): use `bun x` (the pinned Bun zip has no bunx shim) The digest-verified Bun release zip installed by scripts/install-bun.sh ships only the `bun` binary, so `bunx` was not found (exit 127) in the TEA browser-test step. Also install the e2e packages with --ignore-scripts. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c628a630..93d5c01f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,9 +122,11 @@ jobs: # input, async commands, keyed reorder preserving DOM-node identity, # subscriptions. Playwright is pinned in e2e/bun.lock. working-directory: affinescript-tea/e2e + # `bun x`, not `bunx`: the digest-verified Bun release zip that + # scripts/install-bun.sh installs ships only the `bun` binary. run: | - bun install --frozen-lockfile - bunx --no-install --bun playwright install --with-deps chromium + bun install --frozen-lockfile --ignore-scripts + bun x --no-install --bun playwright install --with-deps chromium opam exec -- bun test --timeout 60000 - name: affinescript-router + affinescript-crdt tests # Router.affine's pure functions and Crdt.affine's property tests @@ -134,7 +136,7 @@ jobs: opam exec -- bun test affinescript-router/test opam exec -- bun test affinescript-crdt/test cd affinescript-router/e2e - bun install --frozen-lockfile + bun install --frozen-lockfile --ignore-scripts opam exec -- bun test --timeout 60000 - name: Run face-transformer regression tests run: opam exec -- ./tools/run_face_transformer_tests.sh From 7eddd39bae78e0f79c75be4292c03bff4adc0d93 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:15:44 +0100 Subject: [PATCH 18/21] fix(bun-esm): emit enum constructor bindings before top-level consts; stricter negative tests Review follow-up (CodeRabbit on #777): - Since qualified constructors lower to their binding (`Msg::Inc` -> `Inc`), a top-level const naming a variant of an enum declared later in the file read the binding in its temporal dead zone (ReferenceError). Enum bindings are now emitted before the source-order pass. Fixture const_before_enum fails with `Cannot access 'Fast' before initialization` without this. - test_records_and_imports: negative tests now require a *type* error (a parse/resolution failure no longer counts), the unknown-imported-field test checks the field is named, and the field-type-change test returns `String` from `r.a` so only validating the update can reject it. dune main 562 OK; Bun-ESM 39/39; WASM 38/38. Co-Authored-By: Claude Opus 5.5 --- lib/codegen_deno.ml | 8 ++++++ test/test_records_and_imports.ml | 27 +++++++++++++------ tests/codegen-deno/const_before_enum.affine | 13 +++++++++ .../const_before_enum.harness.mjs | 8 ++++++ 4 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 tests/codegen-deno/const_before_enum.affine create mode 100644 tests/codegen-deno/const_before_enum.harness.mjs diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 2a315bf8..426ed16f 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -2238,8 +2238,16 @@ let generate (host : host_profile) (program : program) (symbols : Symbol.t) : st Hashtbl.replace emitted_class s () end in + (* Enum constructor bindings first: a qualified constructor lowers to its + binding (`Msg::Inc` -> `Inc`), and the checker lets a top-level `const` + name a variant declared later in the file — emitting in source order + would read the binding in its temporal dead zone (ReferenceError). *) + List.iter (function + | TopType ({ td_body = TyEnum _; _ } as td) -> gen_type_decl ctx td + | _ -> ()) program.prog_decls; List.iter (fun top -> match top with + | TopType { td_body = TyEnum _; _ } -> () (* emitted above *) | TopFn fd when fd.fd_body <> FnExtern -> if not (Hashtbl.mem consumed fd.fd_name.name) then gen_function ctx fd diff --git a/test/test_records_and_imports.ml b/test/test_records_and_imports.ml index 7ffffd65..a78b6977 100644 --- a/test/test_records_and_imports.ml +++ b/test/test_records_and_imports.ml @@ -40,11 +40,20 @@ let passes ?dir src = | Ok () -> () | Error m -> Alcotest.failf "expected Ok, got: %s" m -(** Assert [src] is rejected. *) -let fails src = - match frontend src with +(** Assert [src] is rejected by the *type checker* (a parse or resolution + error would hide whether field typing ran), with a message containing + every string in [needles]. *) +let fails ?dir ?(needles = []) src = + match frontend ?dir src with | Ok () -> Alcotest.fail "expected a type error, got Ok" - | Error _ -> () + | Error m -> + let has n = + let nl = String.length n and ml = String.length m in + let rec go i = i + nl <= ml && (String.sub m i nl = n || go (i + 1)) in + go 0 + in + if not (has "Type error") then Alcotest.failf "expected a type error, got: %s" m; + List.iter (fun n -> if not (has n) then Alcotest.failf "expected %S in: %s" n m) needles let p = "struct P { a: Int, b: String }\n" @@ -52,7 +61,11 @@ let update_keeps_struct_type () = passes (p ^ "pub fn f(q: P) -> P = P #{ ..q, a let spread_only () = passes (p ^ "pub fn f(q: P) -> P = P #{ ..q };\n") let untouched_field_readable () = passes (p ^ "pub fn f(q: P) -> String { let r = P #{ ..q, a: 2 }; r.b }\n") -let update_cannot_change_field_type () = fails (p ^ "pub fn f(q: P) -> P = P #{ ..q, a: \"x\" };\n") +(* The result type is `String` (read from `r.a`), which the old + spread-ignoring typing would also accept: rejection here comes only from + validating the incompatible update itself. *) +let update_cannot_change_field_type () = + fails (p ^ "pub fn f(q: P) -> String { let r = P #{ ..q, a: \"x\" }; r.a }\n") let update_through_destructured_tuple () = passes (p ^ "fn mk(q: P) -> (P, Int) = (q, 1);\n\ pub fn f(q: P) -> P { let (r, n) = mk(q); P #{ ..r, a: n } }\n") @@ -79,9 +92,7 @@ let imported_struct_fields () = let imported_struct_unknown_field_rejected () = let dir = module_dir "Shapes2" "module Shapes2;\npub struct Point { x: Float, y: Float }\n" in - match frontend ~dir "use Shapes2::{Point};\npub fn f(p: Point) -> Float = p.z;\n" with - | Ok () -> Alcotest.fail "expected unknown field to be rejected" - | Error _ -> () + fails ~dir ~needles:[ "z" ] "use Shapes2::{Point};\npub fn f(p: Point) -> Float = p.z;\n" (* A function-local binding must not replace the module-level binding of the same name in what importers see. *) diff --git a/tests/codegen-deno/const_before_enum.affine b/tests/codegen-deno/const_before_enum.affine new file mode 100644 index 00000000..782a5fc7 --- /dev/null +++ b/tests/codegen-deno/const_before_enum.affine @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MPL-2.0 +// A top-level const naming a variant of an enum declared later in the file: +// the constructor binding must exist before the const is evaluated. + +pub const DEFAULT: Mode = Mode::Fast; +pub const START: Mode = Mode::Slow(3); + +pub enum Mode { Fast, Slow(Int) } + +pub fn describe(m: Mode) -> String = match m { + Mode::Fast => "fast", + Mode::Slow(n) => "slow " ++ int_to_string(n) +}; diff --git a/tests/codegen-deno/const_before_enum.harness.mjs b/tests/codegen-deno/const_before_enum.harness.mjs new file mode 100644 index 00000000..cf008969 --- /dev/null +++ b/tests/codegen-deno/const_before_enum.harness.mjs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MPL-2.0 +// Enum bindings are emitted before top-level consts (no TDZ ReferenceError). +import assert from "node:assert/strict"; +import { DEFAULT, START, describe } from "./const_before_enum.bun.js"; + +assert.equal(describe(DEFAULT), "fast"); +assert.equal(describe(START), "slow 3"); +console.log("const_before_enum.harness.mjs OK"); From a060ca604bcf8104a088946a6e01e082e61f7ac2 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:17:02 +0100 Subject: [PATCH 19/21] fix(affinescript-tea,router): review/scanner findings - Hypatia js_eval (false positive on Playwright's $eval/$$eval in todo.test.js): use locator().evaluate/evaluateAll/count instead; no JS eval exists in the PR. - SonarCloud: modern DOM operations in tea_host.js (before/append/remove/ replaceWith), a lookup table instead of a nested ternary for wheel units, an optional chain; router tests now assert the page label explicitly after waiting for it. TEA e2e 10/10; router e2e 6/6. Co-Authored-By: Claude Opus 5.5 --- affinescript-router/e2e/nav.test.js | 7 +++++-- affinescript-tea/e2e/todo.test.js | 12 ++++++------ affinescript-tea/src/tea_host.js | 27 +++++++++++++++++---------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/affinescript-router/e2e/nav.test.js b/affinescript-router/e2e/nav.test.js index 371259e0..459ccfcd 100644 --- a/affinescript-router/e2e/nav.test.js +++ b/affinescript-router/e2e/nav.test.js @@ -59,8 +59,11 @@ afterAll(async () => { server?.stop(true); }); -/** Wait until the rendered page label equals `label`. */ -const pageIs = (label) => page.waitForFunction((l) => document.querySelector("#page")?.textContent === l, label); +/** Wait until the rendered page label equals `label`, then assert it. */ +async function pageIs(label) { + await page.waitForFunction((l) => document.querySelector("#page")?.textContent === l, label); + expect(await page.textContent("#page")).toBe(label); +} test("initial route is read from the URL", async () => { expect(await page.textContent("#page")).toBe("home"); diff --git a/affinescript-tea/e2e/todo.test.js b/affinescript-tea/e2e/todo.test.js index 3dac6db4..d1292a6b 100644 --- a/affinescript-tea/e2e/todo.test.js +++ b/affinescript-tea/e2e/todo.test.js @@ -88,7 +88,7 @@ async function eventually(read, check) { } /** Text of every item title, in DOM order. */ -const titles = () => page.$$eval("#items .title", (els) => els.map((e) => e.textContent)); +const titles = () => page.locator("#items .title").evaluateAll((els) => els.map((e) => e.textContent)); /** Add an item by typing it and pressing Enter. */ async function add(title) { @@ -120,12 +120,12 @@ test("the add button and an empty draft", async () => { test("keyed reorder moves the existing DOM node instead of recreating it", async () => { await add("bread"); - await page.$eval('#items li[data-id="3"]', (li) => { + await page.locator('#items li[data-id="3"]').evaluate((li) => { li.__marker = "kept"; }); await page.click('#items li[data-id="3"] .top'); await eventually(titles, (v) => expect(v).toEqual(["bread", "milk", "eggs"])); - const marker = await page.$eval("#items li:first-child", (li) => li.__marker ?? null); + const marker = await page.locator("#items li:first-child").evaluate((li) => li.__marker ?? null); expect(marker).toBe("kept"); }); @@ -139,7 +139,7 @@ test("toggling updates the checkbox, class and count", async () => { test("removing an item removes exactly its node", async () => { await page.click('#items li[data-id="2"] .remove'); await eventually(titles, (v) => expect(v).toEqual(["bread", "milk"])); - expect(await page.$$eval("#items li", (els) => els.length)).toBe(2); + expect(await page.locator("#items li").count()).toBe(2); }); test("a subscription starts and stops with the model", async () => { @@ -153,8 +153,8 @@ test("a subscription starts and stops with the model", async () => { }); test("exactly one app instance is mounted", async () => { - expect(await page.$$eval("#app", (els) => els.length)).toBe(1); - expect(await page.$$eval("h1", (els) => els.length)).toBe(1); + expect(await page.locator("#app").count()).toBe(1); + expect(await page.locator("h1").count()).toBe(1); }); test("keyed diffing: random edits keep order and identity, with minimal moves", async () => { diff --git a/affinescript-tea/src/tea_host.js b/affinescript-tea/src/tea_host.js index 3d9dc545..addb0fae 100644 --- a/affinescript-tea/src/tea_host.js +++ b/affinescript-tea/src/tea_host.js @@ -93,24 +93,28 @@ install({ }, /** Insert `child` at position `index` (appending past the end). */ tea_insert_at: (parent, child, index) => { - parent.insertBefore(child, parent.childNodes[index] ?? null); + const at = parent.childNodes[index]; + if (at) at.before(child); + else parent.append(child); }, /** Insert (or move) `child` immediately before `before`. */ tea_insert_before: (parent, child, before) => { - if (child.nextSibling !== before || child.parentNode !== parent) parent.insertBefore(child, before); + if (child.nextSibling !== before || child.parentNode !== parent) before.before(child); }, /** Move an existing child to position `index` unless it is already there. */ tea_move_to: (parent, child, index) => { - const at = parent.childNodes[index] ?? null; - if (at !== child) parent.insertBefore(child, at); + const at = parent.childNodes[index]; + if (at === child) return; + if (at) at.before(child); + else parent.append(child); }, /** Remove `child` from `parent`. */ - tea_remove: (parent, child) => { - parent.removeChild(child); + tea_remove: (_parent, child) => { + child.remove(); }, /** Replace `old` with `fresh` in `parent`. */ - tea_replace: (parent, fresh, old) => { - parent.replaceChild(fresh, old); + tea_replace: (_parent, fresh, old) => { + old.replaceWith(fresh); }, /** Remove every child of `parent`. */ tea_clear: (parent) => parent.replaceChildren(), @@ -197,6 +201,9 @@ install({ tea_report: (context, message) => console.error(`[tea] ${context}: ${message}`), }); +/** Pixels per wheel `deltaMode` unit: pixel, line, page. */ +const WHEEL_UNIT = [1, 16, 800]; + // ── Events ───────────────────────────────────────────────────────────────── install({ @@ -211,7 +218,7 @@ install({ /** Pointer y in viewport coordinates. */ ev_client_y: (e) => e.clientY ?? 0, /** Wheel delta in pixels (line/page deltas normalised). */ - ev_delta_y: (e) => (e.deltaY ?? 0) * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 800 : 1), + ev_delta_y: (e) => (e.deltaY ?? 0) * WHEEL_UNIT[e.deltaMode ?? 0], /** Mouse button. */ ev_button: (e) => e.button ?? 0, /** Modifier keys. */ @@ -229,7 +236,7 @@ install({ /** Whether the target is a text input, textarea, select or contenteditable. */ ev_target_editable: (e) => { const t = e.target; - if (!t || !t.tagName) return false; + if (!t?.tagName) return false; const tag = t.tagName.toLowerCase(); return tag === "input" || tag === "textarea" || tag === "select" || Boolean(t.isContentEditable); }, From 267192811ceccf61b0be8ae638b89bba90060b64 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:35:14 +0100 Subject: [PATCH 20/21] fix(module_loader): memoise transitive flattening; assert field-not-found Review follow-up (CodeRabbit on #777): a module shared by several import paths (a diamond) was re-flattened once per path, which grows exponentially with depth; completed flattened programs are now cached for the duration of one flatten_imports call (the cycle guard is unchanged). The imported-struct negative test now asserts the specific "Field 'z' not found" diagnostic. dune main 562 OK; Bun-ESM 39/39; WASM 38/38. Co-Authored-By: Claude Opus 5.5 --- lib/module_loader.ml | 20 ++++++++++++++++---- test/test_records_and_imports.ml | 3 ++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/module_loader.ml b/lib/module_loader.ml index e6833358..4b43b91b 100644 --- a/lib/module_loader.ml +++ b/lib/module_loader.ml @@ -277,7 +277,9 @@ let clear_cache (loader : t) : unit = this function retains the same last-import policy as a defensive fallback for callers that flatten an already-loaded program directly. Local decls in [prog.prog_decls] always win over imported ones. *) -let rec flatten_imports_from (visiting : string list list) (loader : t) +let rec flatten_imports_from + (cache : (string list, program) Hashtbl.t) + (visiting : string list list) (loader : t) (prog : program) : program = (* Local-decl names suppress same-named imports of any kind. *) let local_name_list = @@ -318,8 +320,18 @@ let rec flatten_imports_from (visiting : string list list) (loader : t) undefined `subs`. [visiting] stops an import cycle. *) let lm = if List.mem mod_path visiting then lm0 - else { lm0 with mod_program = - flatten_imports_from (mod_path :: visiting) loader lm0.mod_program } + else + (* Memoised per flatten call: a module shared by several import + paths (a diamond) is flattened once, not once per path. *) + let flat = match Hashtbl.find_opt cache mod_path with + | Some p -> p + | None -> + let p = flatten_imports_from cache (mod_path :: visiting) loader + lm0.mod_program in + Hashtbl.replace cache mod_path p; + p + in + { lm0 with mod_program = flat } in let public_decls = List.filter_map (fun decl -> match decl with @@ -512,4 +524,4 @@ let rec flatten_imports_from (visiting : string list list) (loader : t) (** Inline the declarations [prog]'s imports need (transitively) into [prog], for the backends that compile one flattened program. *) let flatten_imports (loader : t) (prog : program) : program = - flatten_imports_from [] loader prog + flatten_imports_from (Hashtbl.create 8) [] loader prog diff --git a/test/test_records_and_imports.ml b/test/test_records_and_imports.ml index a78b6977..c7bb0f04 100644 --- a/test/test_records_and_imports.ml +++ b/test/test_records_and_imports.ml @@ -92,7 +92,8 @@ let imported_struct_fields () = let imported_struct_unknown_field_rejected () = let dir = module_dir "Shapes2" "module Shapes2;\npub struct Point { x: Float, y: Float }\n" in - fails ~dir ~needles:[ "z" ] "use Shapes2::{Point};\npub fn f(p: Point) -> Float = p.z;\n" + fails ~dir ~needles:[ "Field 'z' not found" ] + "use Shapes2::{Point};\npub fn f(p: Point) -> Float = p.z;\n" (* A function-local binding must not replace the module-level binding of the same name in what importers see. *) From b8fbb48e7b8dd032d0de1e017c8acd3381ed7b41 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:38:01 +0100 Subject: [PATCH 21/21] ci(affinescript-tea): download Chromium via the e2e package's browsers script SonarCloud S6505 read `playwright install` (a browser download) as a package install lacking --ignore-scripts; the download now runs as `bun run browsers` from affinescript-tea/e2e/package.json. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +++--- affinescript-tea/e2e/package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93d5c01f..cd706653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,11 +122,11 @@ jobs: # input, async commands, keyed reorder preserving DOM-node identity, # subscriptions. Playwright is pinned in e2e/bun.lock. working-directory: affinescript-tea/e2e - # `bun x`, not `bunx`: the digest-verified Bun release zip that - # scripts/install-bun.sh installs ships only the `bun` binary. + # Chromium comes from the e2e package's `browsers` script (the pinned + # Playwright's own download; no package lifecycle scripts run). run: | bun install --frozen-lockfile --ignore-scripts - bun x --no-install --bun playwright install --with-deps chromium + bun run browsers opam exec -- bun test --timeout 60000 - name: affinescript-router + affinescript-crdt tests # Router.affine's pure functions and Crdt.affine's property tests diff --git a/affinescript-tea/e2e/package.json b/affinescript-tea/e2e/package.json index 5732ca56..73ab8f70 100644 --- a/affinescript-tea/e2e/package.json +++ b/affinescript-tea/e2e/package.json @@ -4,7 +4,8 @@ "type": "module", "description": "Browser tests for the AffineScript TEA runtime (Tea.affine + tea_host.js).", "scripts": { - "test": "bun test --timeout 60000" + "test": "bun test --timeout 60000", + "browsers": "playwright install --with-deps chromium" }, "devDependencies": { "playwright": "1.62.1"