diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cfea1e7..cd706653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,28 @@ 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 + # 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 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 + # (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 --ignore-scripts + 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..bcbc7208 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,7 @@ tools/vendor/ /.claude/worktrees/ .editorconfig + +# affinescript-tea browser test deps +/affinescript-tea/e2e/node_modules/ +/affinescript-router/e2e/node_modules/ 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); +}); 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..459ccfcd --- /dev/null +++ b/affinescript-router/e2e/nav.test.js @@ -0,0 +1,105 @@ +// 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`, 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"); +}); + +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("/"); +}); diff --git a/affinescript-tea/README.adoc b/affinescript-tea/README.adoc index ab8731c8..d63b3eb7 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,115 @@ 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. 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] +---- +
+ +---- + +`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..73ab8f70 --- /dev/null +++ b/affinescript-tea/e2e/package.json @@ -0,0 +1,13 @@ +{ + "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", + "browsers": "playwright install --with-deps chromium" + }, + "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..d1292a6b --- /dev/null +++ b/affinescript-tea/e2e/todo.test.js @@ -0,0 +1,222 @@ +// 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.locator("#items .title").evaluateAll((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.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.locator("#items li:first-child").evaluate((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.locator("#items li").count()).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("exactly one app instance is mounted", async () => { + 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 () => { + 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 new file mode 100644 index 00000000..9afaa3cc --- /dev/null +++ b/affinescript-tea/examples/todo/Todo.affine @@ -0,0 +1,105 @@ +// 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, 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 } + +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")]), + // 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))], + [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..77862cea --- /dev/null +++ b/affinescript-tea/src/Tea.affine @@ -0,0 +1,638 @@ +// 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; +/// 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; + +// 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]; + +// 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 +/// `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). `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]), + 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); + +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::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))) + } +} + +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::Lazy(_, _, view) => create(view(), dispatch), + 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::Lazy(k, _, _) => k, + 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 { + // 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); + 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; + } +} + +/// 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 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 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 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 !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; + } +} + + +// ── 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); +} + diff --git a/affinescript-tea/src/tea_host.js b/affinescript-tea/src/tea_host.js new file mode 100644 index 00000000..addb0fae --- /dev/null +++ b/affinescript-tea/src/tea_host.js @@ -0,0 +1,279 @@ +// 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) => { + 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) 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]; + if (at === child) return; + if (at) at.before(child); + else parent.append(child); + }, + /** Remove `child` from `parent`. */ + tea_remove: (_parent, child) => { + child.remove(); + }, + /** Replace `old` with `fresh` in `parent`. */ + tea_replace: (_parent, fresh, old) => { + old.replaceWith(fresh); + }, + /** 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); + }, + /** 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}`), +}); + +/** Pixels per wheel `deltaMode` unit: pixel, line, page. */ +const WHEEL_UNIT = [1, 16, 800]; + +// ── 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) * WHEEL_UNIT[e.deltaMode ?? 0], + /** 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, + /** 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?.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()`. */ + 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), + /** `[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(); + 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; + }, +}); 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. 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 -> diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 0b677d04..426ed16f 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; @@ -1113,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)); @@ -1543,13 +1563,22 @@ 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) -> (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 @@ -1858,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 @@ -1882,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 ^ " }" @@ -2119,6 +2155,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 +2199,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 = @@ -2196,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/lib/module_loader.ml b/lib/module_loader.ml index d1cd07e9..4b43b91b 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 (); } @@ -269,7 +277,10 @@ 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 + (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 = List.filter_map (function @@ -302,7 +313,26 @@ 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 + (* 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 | TopFn fd when fd.fd_vis = Public || fd.fd_vis = PubCrate -> @@ -439,6 +469,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 = @@ -463,3 +520,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 (Hashtbl.create 8) [] loader prog 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 41ef95c4..4c1a80f4 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 @@ -1004,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 @@ -1084,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 -> @@ -1608,12 +1675,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) -> @@ -2122,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 @@ -2142,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* () = @@ -2169,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 -> @@ -2200,6 +2291,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 @@ -2230,10 +2323,21 @@ 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; + (* 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. *) @@ -2429,6 +2533,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 +2581,15 @@ 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 -> + 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. *) @@ -2477,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_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 new file mode 100644 index 00000000..8e9cf64d --- /dev/null +++ b/test/test_generic_enum_kinds.ml @@ -0,0 +1,116 @@ +(* 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" + +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" + +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" + "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 "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/test/test_main.ml b/test/test_main.ml index 2006c566..4bc13ef3 100644 --- a/test/test_main.ml +++ b/test/test_main.ml @@ -14,6 +14,9 @@ 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 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_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; + ] diff --git a/test/test_records_and_imports.ml b/test/test_records_and_imports.ml new file mode 100644 index 00000000..c7bb0f04 --- /dev/null +++ b/test/test_records_and_imports.ml @@ -0,0 +1,118 @@ +(* 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 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 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" + +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") +(* 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") + +(** 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 + 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. *) +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; + 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; + Alcotest.test_case "local binding does not shadow export" `Quick + local_binding_does_not_shadow_export; + ] 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/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/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"); 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"); 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/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"); 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"); 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"); 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"); 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