diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b49e099 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +# `cargo lint`: clippy over the whole workspace, warnings as errors (as CI runs it). +lint = "clippy --workspace --all-targets -- -D warnings" diff --git a/.changeset/state-machine-wasm.md b/.changeset/state-machine-wasm.md new file mode 100644 index 0000000..43b6617 --- /dev/null +++ b/.changeset/state-machine-wasm.md @@ -0,0 +1,22 @@ +--- +'@dunky.dev/state-machine-wasm': minor +--- + +New package: use a machine written in Rust from TypeScript. `fromWasm` wraps a class +exported with `dunky_wasm::export_machine!` in the same `Machine` interface the TS +engine returns, so `connect()`, `useMachine` and every target take it unchanged, next +to TS machines. + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import init, { PaletteMachine } from './pkg/my_machines.js' // your wasm-bindgen output + +await init() +const palette = fromWasm(new PaletteMachine(commands)) // typed from the Rust types +``` + +The machine runs entirely in Rust, on the Rust engine (`dunky-core`), which follows the +same spec as the TS engine. Its TS types are generated from its Rust types and inferred +by `fromWasm`, so they are never written twice. In JS a TS machine is a little faster, +because its context already lives in JS: write a machine in Rust to reuse it in Rust +programs and on React Native. diff --git a/.changeset/use-machine-accepts-machine.md b/.changeset/use-machine-accepts-machine.md new file mode 100644 index 0000000..6b0c40e --- /dev/null +++ b/.changeset/use-machine-accepts-machine.md @@ -0,0 +1,29 @@ +--- +'@dunky.dev/state-machine': minor +'@dunky.dev/react-state-machine': minor +'@dunky.dev/solid-state-machine': minor +--- + +`useMachine` now accepts a ready machine as well as a config. The first argument +may return either, so a machine built elsewhere — for example a machine written +in Rust and wrapped with `fromWasm` — plugs into the same hook, connector and +component effects: + +```ts +// a config (unchanged) +useMachine(commandPaletteMachineConfig, connect, effects, props) + +// a ready machine, e.g. one written in Rust (fromWasm: @dunky.dev/state-machine-wasm) +useMachine(props => fromWasm(new PaletteMachine(props.commands)), connect, effects, props) +``` + +A ready machine must come fresh from the factory (one per component instance): the +hook owns its lifecycle, as it does for a config-built machine. + +Core exports the helper both bridges use, `toMachine(source)`, and its +`MachineSource` type: a config is built into a stopped service, a ready +machine is returned as is. + +Core also exports `makeBroadcast` and `makeSelection`, the building blocks of its +subscriptions, so a machine that runs outside this engine (such as a Rust machine) +notifies and dedupes exactly like a TS one. diff --git a/.claude/skills/benchmark/SKILL.md b/.claude/skills/benchmark/SKILL.md index 88461dd..d444595 100644 --- a/.claude/skills/benchmark/SKILL.md +++ b/.claude/skills/benchmark/SKILL.md @@ -17,8 +17,8 @@ explicit yes — benchmark numbers are noisy and the user may just want a look. ## Step 1 — run it -The suite is its own workspace package under `benchmark/`, but the root has a -delegating script, so this works from the repo root or from `benchmark/`: +The TS suite is its own workspace package under `benchmark/ts/`, but the root has a +delegating script, so this works from the repo root or from `benchmark/ts/`: ```bash pnpm benchmark @@ -41,7 +41,7 @@ rendering 1000-row mount + re-render). After showing the numbers, ask the user — use the AskUserQuestion tool — whether to update the documented results. Offer at least: -- **Update all docs** — refresh numbers in the benchmark README, the core package +- **Update all docs** — refresh numbers in the TS benchmark README, the core package README, and the website benchmark page. - **Don't update** — just keep the run output. @@ -51,7 +51,7 @@ Do not proceed to Step 3 unless the user says yes. Four files carry benchmark numbers. Update all four in one pass: -### A. `benchmark/README.md` — full tables (source of truth) +### A. `benchmark/ts/README.md` — full tables (source of truth) Each `## N.` section has its table inline, followed by a one-line `→` takeaway. Update the numbers in those tables and **recheck each takeaway's "X faster" claim** @@ -100,7 +100,7 @@ The website benchmark page (at that path from the repo root) carries: the numbers and **recheck the section heading ratios** against the fresh figures (e.g. if throughput is now 6.1 M vs 897 K, the heading becomes "~7×", not "~8×"). -Apply the same K/M notation rules as `benchmark/README.md`. +Apply the same K/M notation rules as `benchmark/ts/README.md`. ### D. root `README.md` — the "Fast at scale" headline table @@ -115,3 +115,10 @@ notation rules. - Run `pnpm format` at the repo root so formatting matches the repo style. - Remind the user these are first-look numbers from one machine — a single run is a snapshot, not a verdict. + +## The Rust suite + +The Rust engine has its own suite in `benchmark/rust` (`cargo bench -p dunky-benchmark`). +When the user asks for Rust numbers, run it the same way: show the tables first, then +ask before updating `benchmark/rust/README.md`. Its "TS engine" column is copied from +`benchmark/ts/README.md`, so refresh it when the TS tables change. diff --git a/.github/actions/wasm-setup/action.yml b/.github/actions/wasm-setup/action.yml new file mode 100644 index 0000000..364ebd2 --- /dev/null +++ b/.github/actions/wasm-setup/action.yml @@ -0,0 +1,36 @@ +name: Setup wasm +description: Build the wasm of the sandbox machines written in Rust (pnpm build:wasm) for jobs that import them. Runs after ci-setup. + +runs: + using: composite + steps: + - name: setup rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: cache cargo + uses: Swatinem/rust-cache@v2 + with: + # Every job that uses this action builds the same wasm: share one cache. + shared-key: wasm + + - name: read wasm-bindgen version + id: wasm-bindgen + shell: bash + # The CLI must match the wasm-bindgen crate exactly. Read the version from + # Cargo.lock instead of pinning it a second time here. + run: | + version=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p') + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: setup wasm-bindgen + uses: taiki-e/install-action@v2 + with: + tool: wasm-bindgen@${{ steps.wasm-bindgen.outputs.version }} + + # wasm-opt (binaryen) isn't installed, so the script skips its size pass: the + # binary is bigger but behaves the same. + - name: build wasm + shell: bash + run: pnpm build:wasm diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 7874ee7..30fe9da 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -33,6 +33,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup + # The sandbox machines written in Rust (crates/sandbox -> wasm). + - uses: ./.github/actions/wasm-setup - run: pnpm test:ci typecheck: @@ -41,6 +43,8 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/ci-setup + # The sandbox machines written in Rust (crates/sandbox -> wasm). + - uses: ./.github/actions/wasm-setup - run: pnpm typecheck lint: @@ -59,6 +63,32 @@ jobs: - uses: ./.github/actions/ci-setup - run: pnpm format:check + rust: + # The Rust engine (crates/core): its own implementation of packages/core/SPEC.md. + # The Cargo workspace needs no Node, so no ci-setup. + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + crates/uniffi -> target + - run: cargo fmt --all --check + - run: cargo lint + - run: cargo test --workspace + # Again in release: overflow checks and debug assertions are off there. + - run: cargo test --workspace --release + # crates/uniffi (React Native over JSI) sits outside the workspace with its own + # lockfile; its checks run on the host and need no mobile toolchain. + - run: cargo fmt --manifest-path crates/uniffi/Cargo.toml --check + - run: cargo clippy --manifest-path crates/uniffi/Cargo.toml --all-targets -- -D warnings + - run: cargo test --manifest-path crates/uniffi/Cargo.toml + versioning: # The release PR consumes changesets rather than adding one — never flag it. if: github.head_ref != 'changeset-release/main' diff --git a/.github/workflows/deploy-benchmark.yml b/.github/workflows/deploy-benchmark.yml index 88ad315..e8e6a98 100644 --- a/.github/workflows/deploy-benchmark.yml +++ b/.github/workflows/deploy-benchmark.yml @@ -29,13 +29,13 @@ jobs: # the same path, so the base must match. env: BASE_PATH: /state-machine/benchmark/demo/ - run: pnpm -C benchmark/demo build + run: pnpm -C benchmark/ts/demo build - uses: actions/configure-pages@v5 - uses: actions/upload-pages-artifact@v3 with: - path: benchmark/demo/dist + path: benchmark/ts/demo/dist deploy: needs: build diff --git a/.gitignore b/.gitignore index f107741..2febef2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,12 @@ dist # local-only Claude settings (never committed; keep it out of format/lint too) .claude/settings.local.json + +# Rust +/target +# wasm-bindgen output (built by `pnpm build:wasm`) +crates/sandbox/pkg + +# Expo prebuild output (`expo prebuild` / `expo run:*` in the RN sandbox) +sandbox/native/ios +sandbox/native/android diff --git a/.oxlintrc.json b/.oxlintrc.json index f1dd1ef..07f4324 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -45,5 +45,5 @@ } }, "globals": {}, - "ignorePatterns": ["**/dist/**", "website/src/snippets/**"] + "ignorePatterns": ["**/dist/**", "website/src/snippets/**", "crates/sandbox/pkg/**"] } diff --git a/AGENTS.md b/AGENTS.md index 030105b..41a5033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,8 @@ This is Dunky's state-machine monorepo: UI behavior authored once as plain TypeScript state machines (`packages/core`), rendered anywhere through thin per-substrate targets (`react`, `solid`, `native`, `opentui`), with a benchmark suite, per-substrate sandboxes, and the docs website -alongside. +alongside. The same machines also exist in Rust (`crates/core`): a second +engine that follows the same spec, for Rust programs and native bindings. ## Preflight @@ -24,12 +25,13 @@ editing files in that scope — it overrides anything here for that scope ## Scopes -| Scope | Path | What it is | -| --------- | ------------- | ----------------------------------------------------------------------------------------- | -| Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals | -| Benchmark | `benchmark/` | Perf suite comparing against competitor libraries | -| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | -| Website | `website/` | The docs site | +| Scope | Path | What it is | +| --------- | ------------- | -------------------------------------------------------------------------------------------------------------------- | +| Packages | `packages/**` | The core machine, substrate targets (react, solid, native, opentui), and shared internals | +| Benchmark | `benchmark/` | Perf suites: `ts/` (the TS engine against competitor libraries) and `rust/` (the Rust engine) | +| Sandbox | `sandbox/` | Per-substrate demo apps for manual verification | +| Website | `website/` | The docs site | +| Crates | `crates/**` | The Rust engine (`core`, `macros`), its JS binding (`wasm`), the sandbox machines in Rust, the RN binding (`uniffi`) | Some changes are cross-scope: a change in `core/` may need follow-up in the targets, sandboxes, and docs — and vice versa. Check what else your @@ -54,6 +56,26 @@ layered model: Props-free, platform-free effects belong in the core machine config's `effects` (authored via `setup()`). +## Two engines, one spec + +The machine behavior is implemented twice: in TypeScript (`packages/core`) +and in Rust (`crates/core`). Neither runs on the other: each is optimized +for its own runtime, with no trade-off made for the other. +`packages/core/SPEC.md` is the one behavior contract both follow. + +- **A behavior change is one PR for both.** Update `packages/core/SPEC.md`, + the TS engine, the Rust engine, and both test suites together. A change + that lands in one engine only is not done. +- **Tests are twins.** `crates/core/tests` is ported from + `packages/core/tests`, file by file: a new behavior test on one side gets + its twin on the other. +- **Check parity before shipping.** Read the diff of each engine against + the spec, and say in the PR which behavior changed and where each engine + handles it. +- **Engine-only things stay out of the shared spec.** TS-only typing + (`setup()`) and Rust-only authoring (derives, timers as commands) live in + their own docs: `packages/core/README.md` and `crates/core/SPEC.md`. + ## Workflow `SPEC -> TEST -> IMPLEMENT -> RECONCILE` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54cec89..e43cfd9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -99,6 +99,12 @@ Zag, whose machines read props directly.) | `packages/shared/bindings/` | Substrate-agnostic event + attr vocabulary (onPress, role, …) | | `packages/shared/utils/` | mergeProps, composeHandlers | | `packages//` | Hook + normalize per substrate (react, solid, native, opentui, …) | +| `crates/core/` | The Rust engine: the same spec, in Rust (`dunky-core`) | +| `crates/macros/` | `#[derive(State, Event, Context)]` for Rust machines | +| `crates/wasm/` | Rust machines as JS classes (`export_machine!`) | +| `packages/wasm/` | `fromWasm`: a Rust machine behind the TS `Machine` interface | +| `crates/sandbox/` | The sandbox machines in Rust, twins of `sandbox/shared` ones | +| `crates/uniffi/` | The Rust engine for React Native over JSI (in progress) | ## The map @@ -138,6 +144,32 @@ Three package groups, three jobs: - **`/`** — _the substrate side_. One folder per renderer (`react`, `solid`, `native`, `opentui`). Owns its runtime bridge and its props translator. +## Two engines, one spec + +``` + packages/core/SPEC.md + (the one behavior contract) + / \ + v v +packages/core (TypeScript) crates/core (Rust) +the engine for JS and every the engine for Rust programs and +target; pure TS, no wasm native bindings (crates/uniffi) + | | +packages/core/tests <-- twins --> crates/core/tests +``` + +The two engines are independent: each is tuned for its own runtime. They stay +in sync through the spec, twin test suites, and one rule: a behavior change +updates the spec, both engines and both test suites in the same PR (see +[AGENTS.md](AGENTS.md#two-engines-one-spec)). The sandbox machines have Rust +twins too (`crates/sandbox`). + +A machine written in Rust can run in JS too: `export_machine!` (`crates/wasm`) makes +it a JS class in its own wasm module, and `fromWasm` (`@dunky.dev/state-machine-wasm`) +puts it behind the same `Machine` interface, so targets take it like a config +(`useMachine` accepts a ready machine). It still runs on the Rust engine; its TS types +are generated from its Rust types. + ## The machine parts `machine.ts` is the state graph (states, transitions, action impls). It diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eeb6a21..e949811 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,22 @@ pnpm install Target one package or one file instead of the whole workspace: +### Rust + +The Rust engine (`crates/`) is a plain Cargo workspace; run cargo at the repo root: + +| Command | What it does | +| -------------------------------- | --------------------------------------------------- | +| `cargo test` | Every crate's tests | +| `cargo lint` | `clippy`, warnings as errors (`.cargo/config.toml`) | +| `cargo fmt` / `--check` | `rustfmt` | +| `cargo bench -p dunky-benchmark` | The Rust engine's benchmark (`benchmark/rust`) | + +`pnpm build:wasm` builds the sandbox machines written in Rust to wasm (`crates/sandbox/pkg`), +with their TS types: the sandbox's `?machine=rust` mode and its Rust tests need it. It +needs the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` at the version +`Cargo.toml` pins. + ## Sandbox Each sandbox renders the same command-palette machine on a different diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..076a3ad --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,284 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "dunky-benchmark" +version = "0.1.0" +dependencies = [ + "dunky-core", +] + +[[package]] +name = "dunky-core" +version = "0.1.0" +dependencies = [ + "dunky-macros", + "serde", + "serde_json", +] + +[[package]] +name = "dunky-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunky-sandbox" +version = "0.1.0" +dependencies = [ + "dunky-core", + "dunky-wasm", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "dunky-wasm" +version = "0.1.0" +dependencies = [ + "dunky-core", + "js-sys", + "serde", + "serde-wasm-bindgen", + "wasm-bindgen", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fad3fc5 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +# The Rust engine: its own implementation of packages/core/SPEC.md, next to the TS one, +# and its JS binding (crates/wasm). +# Members are listed explicitly so a half-built crate never breaks +# `cargo test --workspace` for the others. +[workspace] +resolver = "3" +members = ["crates/core", "crates/macros", "crates/wasm", "crates/sandbox", "benchmark/rust"] +# Built by its own pipeline (uniffi-bindgen-react-native) for iOS/Android. +exclude = ["crates/uniffi"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/dunky-dev/state-machine" +rust-version = "1.85" + +[workspace.dependencies] +dunky-core = { path = "crates/core" } +dunky-macros = { path = "crates/macros" } +dunky-wasm = { path = "crates/wasm" } +dunky-sandbox = { path = "crates/sandbox" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Must match the installed wasm-bindgen CLI exactly (wasm-bindgen --version). +wasm-bindgen = "=0.2.129" +js-sys = "0.3" +serde-wasm-bindgen = "0.6" + +[profile.release] +# Function names in profiles of the benchmark (no effect on speed). +debug = "line-tables-only" +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" + +# Size-tuned profile for the shipped .wasm. +[profile.wasm] +inherits = "release" +opt-level = "z" +strip = true diff --git a/README.md b/README.md index 44d33c1..c9ee144 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ engine is built for it: | Memory / machine, 64-field (KB) | **4.4** | 4.1 | **134** | → **~7× XState's throughput**, on par with XState for memory but at least **2× lighter than Zag** — and the gap widens as context grows, because memory stays ~flat in field count (no per-field cell). ᵃ Zag uses async ops, so a synchronous ops/s loop can't time it. Full methodology + per-scenario tables in the -**[benchmark README](./benchmark/README.md)**. +**[benchmark README](./benchmark/ts/README.md)**. **▶ [Try the live benchmark demo](https://dunky.dev/state-machine/benchmark/demo)** — watch all three engines run in your browser. @@ -57,7 +57,9 @@ elements — the left two are agnostic, the right three are per-target: ![How Dunky works, from agnostic to substrate: the dunky engine powers, the machine decides, the binding connects, the behavior appears](./website/src/assets/diagrams/flow-animated.svg) - **core** — the state-machine engine. Pure behavior: states, transitions, - context, effects. Knows nothing about a renderer. + context, effects. Knows nothing about a renderer. The same machines also + exist in Rust ([`crates/core`](./crates/core)): a second engine that + follows the same spec, for Rust programs and native bindings. - **connector** — turns machine state into agnostic _bindings_ and keeps that view in sync as the machine changes. - **normalize** — per target, translates those bindings into real props @@ -71,7 +73,7 @@ The full layered model and the "the machine never sees props" rule are in: - **[`ARCHITECTURE.md`](./ARCHITECTURE.md)** — the big-picture map and the layered model. - **[`packages/core/README.md`](./packages/core/README.md)** — the state machine engine and its full API. -- **[`benchmark/README.md`](./benchmark/README.md)** — what's measured, the methodology, and results vs. XState & Zag. +- **[`benchmark/`](./benchmark/README.md)** — the TS suite (methodology, results vs. XState & Zag) and the Rust suite. - **[`ACCESSIBILITY.md`](./ACCESSIBILITY.md)** — the external specs every package answers to. - **[`AGENTS.md`](./AGENTS.md)** — the contributor / agent contract. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..be66589 --- /dev/null +++ b/TODO.md @@ -0,0 +1,35 @@ +# TODO + +Parked work, with the decisions already made. The earlier shape, where the TS engine +ran on the Rust engine, is on the local branch `rust-engine-under-ts` (head `4e492a4`). + +## Rust machines on React Native (JSI) + +- [ ] `crates/uniffi` is a Rust machine as a JS object over JSI (today: the palette + only). It needs the same facade `fromWasm` gives a wasm class: the TS `Machine` + interface over the handle. Then `connect()`, the bindings and `packages/native` + work unchanged; it does not need to mirror `packages/shared/bindings`, which sit + above the machine. +- [ ] Rework the handle onto the protocol `crates/wasm` speaks (notify per change, + timers as host calls) instead of JSON strings and a pulled change mask. + +## Rust machines in JS (wasm) — done, notes + +- `@dunky.dev/state-machine-wasm` (`packages/wasm`) holds `fromWasm`; the TS engine + stays pure TS. `crates/wasm` is Dunky's JS binding (`export_machine!`), Dunky-specific, + in this repo because its protocol and the facade change together. +- A user's crate depends on `dunky-core` + `dunky-wasm` + `wasm-bindgen` and builds its + own `.wasm`; each module carries its own copy of the engine. +- The binding hears changes through the engine's own `subscribe()` / `take_changes()` + and runs timers from `take_commands()`: the engine has no hook for JS. +- Types come from Rust (`TsType`, `typescript(&config)`, the `__types` member); + `internal { }` computed values stay out of the types. A type test pins the Rust + palette's types to the TS palette's. +- Measured on the earlier shape: in JS a Rust machine handles ~1.3× fewer events than + its TS twin (8.5 M vs 10.7 M events/s, one event); a guard walk is ~1.1× faster in Rust. + +## Later + +- [ ] Review what else to bring back from `rust-engine-under-ts` on the TS side. +- [x] Benchmarks split by engine: `benchmark/ts` (the TS suite) and `benchmark/rust` (the + Rust engine, natively, on the same scenarios). diff --git a/benchmark/README.md b/benchmark/README.md index f0fe616..a9d22c6 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -1,294 +1,11 @@ -# Benchmark suite +# Benchmarks -A performance harness for `@dunky.dev/state-machine`. It measures the engine's -hot paths in isolation and compares the runnable parts against -[XState](https://stately.ai/docs) and [Zag](https://zagjs.com/). +Dunky has two engines that follow one spec, and each has its own suite: -Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures -vary by machine, Node version, and thermal state — **run it yourself** — but the -ranking and the scaling shape are what hold. +| Suite | What it measures | Run | +| ----------------- | ------------------------------------------------------------------------------------------------------- | -------------------------------- | +| [`ts/`](./ts) | The TS engine (`@dunky.dev/state-machine`) against XState and Zag, plus React rendering and a live demo | `pnpm benchmark` | +| [`rust/`](./rust) | The Rust engine (`dunky-core`), natively, on the same scenarios, plus a 60 fps frame budget | `cargo bench -p dunky-benchmark` | -> Want to _see_ it instead of read tables? **`pnpm benchmark:demo`** opens a live -> side-by-side where each engine drives a grid of real per-cell machines under a -> ramping load — watch which panels' backlogs grow as the load climbs. See -> [`demo/`](./demo/README.md). - -## Running - -```bash -pnpm benchmark # from the repo root (delegates here) -# or -cd benchmark && pnpm benchmark -``` - -Either runs the whole suite in one Node process with `--expose-gc` (the memory -bench needs accurate GC; the flag is harmless for the rest). Output is a series -of `console.table`s grouped by section. - -Each section is also exported as a `run*()` function, so you can run one in -isolation: - -```bash -node --expose-gc --import tsx -e "import('./tests/memory').then(m => m.runMemory())" -``` - -## Who's compared, and why not always all three - -The engines don't all fit every test, because they don't all run the same way: - -| Engine | How `send` works | Where it's measured | -| ---------- | ----------------------------- | -------------------------------------------------- | -| **Dunky** | synchronous | everywhere | -| **XState** | synchronous | the ops/sec loops, construction, memory, rendering | -| **Zag** | **async** (microtask-batched) | construction, memory, and React rendering only | - -A `tinybench` ops/sec loop counts synchronous iterations, so it can only compare -**synchronous** engines fairly. Zag's headless `send` is microtask-batched, so it -would either deadlock or report meaningless numbers in a tight sync loop — it -appears only where it runs synchronously (construction, memory via the headless -`VanillaMachine`) and in the React arena, where it runs natively via -`@zag-js/react`. - -Two XState variants appear in the fine-grain tables: - -- **`xstate`** — `actor.subscribe` with a hand-written `value` diff in the - listener (the same dedup Dunky does for free). -- **`xstate-raw`** — stock `actor.subscribe`, which fires on _every_ snapshot - change with no diff. - -Showing both separates "what XState costs out of the box" from "what it costs -once you add a differ." - -A key fairness rule across construction + memory: **all engines share one -module-level config across instances** — the shape a real app has (a component's -machine config is a `const`; every instance reuses it). So those loops time -_machine construction_, not config-literal allocation. - -> **How to read the ops/sec tables.** `ops/sec` (higher is better) is the headline; -> `±rme %` is the run-to-run noise floor — a gap between two rows is only real if -> it clears both rows' margins. The `(anti-DCE SINK: …)` lines in the raw output -> are just proof the JIT didn't dead-code-eliminate the work — ignore them. - -**Every comparison table lists all three engines**, so you always see the full -field. Where one can't run a given test, the cell is marked — same marker -everywhere: - -- **`n/a ᵃ`** — _async._ Zag's `send` is microtask-batched, so it can't run in a - synchronous ops/sec or `flushSync` loop. -- **`n/a ᶠ`** — _no equivalent feature._ The engine has no first-class primitive - for this scenario (e.g. XState has no lazy/memoized `computed`), so there's - nothing comparable to time. - ---- - -## 1. Fan-out / fine-grain / throughput (`tests/fan-out.ts`) - -The selection layer at scale — the thing that decides whether thousands of -machines stay cheap. Zag can't run these (async `send`). - -**A. Propagation — change 1 of N.** ONE machine, N fields, N observers (one per -field). Bump one field. Dunky's `select` is a coarse bus: every selection -re-evaluates its selector on each notify and value-compares, so only the touched -field's _listener_ fires (downstream is O(changed)) — but the re-eval pass itself -is O(N observers) per write. The table shows _how_ that degrades with N versus -XState's coarse `actor.subscribe`. - -| Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | -| ------------- | --------------: | ---------------: | ----: | -| 100 | 658.5 K | 437.8 K | n/a ᵃ | -| 1000 | 56.8 K | 49.2 K | n/a ᵃ | -| 5000 | **15.5 K** | 1.3 K | n/a ᵃ | - -→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's -coarse subscribe degrades much faster as the observer set grows. - -**B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The -dedup layer re-evaluates and value-compares, so no listener fires — the -subscriber-side cost is ~zero. This is the "irrelevant write" that a cell-per-field -model gets for free and a coarse bus has to work to ignore. - -| Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | -| ------------------------- | --------------: | ---------------: | ----: | -| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | -| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | - -→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 -cells); the value-deduping bus skips waking observers entirely. - -**C. Throughput — single machine, one event.** Per-transition cost with no -selection scaling — the raw `send` price. - -| Single machine, one event | ops/sec | -| ------------------------- | ---------: | -| Dunky | **11.6 M** | -| XState (raw) | 1.6 M | -| XState (diffed) | 1.6 M | -| Zag | n/a ᵃ | - -→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place, -so a transition allocates nothing; XState builds a fresh snapshot per event. - -## 2. Compose / synced machines (`tests/compose.ts`) - -The cross-region machinery `compose` adds, scaled by member count. No competitor -column: neither XState nor Zag has a first-class orthogonal-region primitive that -maps to `combine`/`sync`, so there's nothing equivalent to time (**n/a ᶠ** for -both). - -**A. combine** — one value-deduped `Selection` derived across M members but reading -only `m0`. It re-evaluates on _any_ member change but fires its listener only when -`m0` changes. **B. sync** — a coarse cross-region rule that wakes on _any_ member -change (the O(members) path by design). - -| Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | -| ------- | ----------------------: | -------------------: | ------ | ----- | -| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ | -| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ | -| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ | - -→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50 -synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50. - -> A third "chain" sub-test (a sync rule that `send()`s downstream every change) was -> removed: under a tight loop it shows superlinear slowdown. That's a real -> `compose.sync` + cross-machine-send interaction worth investigating on its own, -> not a benchmark-tuning artifact — see the note in `tests/compose.ts`. - -## 3. Computed (`tests/computed.ts`) - -The most machinery-heavy subsystem — read-key tracking via proxies, memoization -against a dep snapshot, glitch-free computed→computed chains. This is a subsystem -profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Zag's -`send` is async (**n/a ᵃ**), so neither has a comparable primitive to time. - -| Scenario | Dunky (ops/sec) | XState | Zag | -| ------------------------------------ | --------------: | ------ | ----- | -| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ | -| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ | -| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ | -| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ | - -→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the -computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means -you only pay the recompute when an input you actually read changes. - -## 4. Engine hot paths (`tests/engine.ts`) - -The parts of `send` that do real statechart work (everything else in the suite -stays in one state and only mutates context). These probe Dunky internals in -isolation — there's no comparable isolated path to time in XState (**n/a ᶠ**), and -Zag's `send` is async (**n/a ᵃ**). - -| Scenario | Dunky (ops/sec) | XState | Zag | -| -------------------------------------- | --------------: | ------ | ----- | -| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | -| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ | -| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ | -| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ | -| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ | -| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ | - -→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full -state transitions with entry/exit actions, and effect boot/cleanup every -transition all stay in the same order of magnitude as a bare `send`. - -## 5. Construction cost (`tests/construct.ts`) - -Wall-clock to build + `start()` N machines, no events sent (matches a real mount). -Synchronous for all three, so it's a fair three-way table. Median of 5 passes, JIT -warmed first. - -| Build + start | Dunky (µs/machine) | XState | Zag | -| ------------- | -----------------: | -----: | ---: | -| 10 000 | 1.58 | 1.35 | 5.06 | - -→ Construction is the one axis where Dunky **doesn't** win — XState spins up -~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; -it's still ~3.2× faster than Zag's per-field reactive cells. - -## 6. Memory per machine (`tests/memory.ts`) - -Build 5000 machines, hold them live, report retained heap per machine (`heapMB()` -double-GCs before sampling). Two context widths — **thin** (2 fields) and **fat** -(64 fields) — because the whole point of the plain-object model is that memory -stays ~flat in field count. Rows below are the **written** mode (one `hit` each — -the footprint a churny app actually pays). - -| Context | Dunky (KB/machine) | XState | Zag | -| -------- | -----------------: | -----: | ------: | -| 2-field | 3.85 | 3.61 | 8.92 | -| 64-field | 4.35 | 4.10 | **134** | - -→ Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with -the data you store, not with a per-field cell. **Zag is the contrast**: one reactive -cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than -Dunky. - -**Idle vs written.** Dunky owns its context copy from construction and mutates -it in place forever, so its idle and written footprints match by design — while a -lazy-copy scheme steps up once writes start: - -| 64-field, 5000 machines | Dunky | XState | Zag | -| ----------------------- | ----: | -----: | --: | -| Idle (never written) | 4.35 | 3.55 | 130 | -| Written (1 event each) | 4.35 | 4.10 | 134 | - -→ Dunky idle ≡ written; XState's first `assign` allocates a per-actor context, -so its written row grows. - -## 7. React rendering (`tests/rendering/`) - -The thing that actually hurts in an app — how many React components render — under -jsdom. A list of N rows, 50 highlight moves. Two numbers: **rows woken / move** -(the fine-grained payoff) and **wall-clock** per phase. Strategies: `selector` -(shared machine + `useSelector` per row), `naive` (whole-snapshot — the -anti-pattern), `core/instance` (one machine per row), plus `xstate/selector` and -`zag/instance`. - -List of 1000 rows: - -| Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | -| -------------------- | ----------------: | ---------: | ------------------: | -| Dunky/instance | **2** | 3.6 | **2.4** | -| Dunky/selector | 2 | 4.9 | 3.8 | -| xstate/selector | 2 | 3.7 | 4.3 | -| zag/instance | 2 | 3.9 | n/a ᵃ | -| naive (anti-pattern) | **980** | 4.5 | 38.3 | - -→ Every properly-set-up engine wakes only the **2** rows that changed (vs. the -naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and -~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8× -faster than XState**. - -Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the -microtask-batched `send` can't be timed under a synchronous `flushSync` loop, so -only its row-count is comparable. - -## Where this actually matters - -The loads here (and in the demo) are deliberately extreme to force the engines to -diverge. That only reflects real software when a single view holds **thousands of -independently-stateful, live-updating cells in one frame budget**. That's not a -hypothetical — these are whole product categories: - -| Real workload | Example products | Where the count comes from | Live cells | -| ----------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- | ---------: | -| **Full L2 order book / DOM ladder** | Bookmap, Sierra Chart, ATAS | 500–2,000 price levels per book × several books, each level a live cell | 3k–15k | -| **Options chain / vol surface** | thinkorswim, Tastytrade, OptionStrat | hundreds of strikes × calls+puts × 8–16 live fields (bid/ask/IV/Δ/Γ/Θ/V) | 5k–20k | -| **Screeners / heatmaps** | TradingView screener, CoinMarketCap, Finviz | 1,000–5,000 symbols × a few live fields (price, %chg, flash, sparkline) | 3k–15k | -| **Live-data spreadsheets** | Excel/Sheets + market add-ins, Bloomberg BQNT | a visible sheet is 5k–20k cells, many bound to streaming feeds + recompute | 5k–20k | -| **Observability walls** | Grafana, Datadog, Netdata | hundreds of panels × series, or a per-second host grid | 5k–50k | -| **Live log / trace tails** | Datadog Live Tail, Kibana, Sentry | streaming rows, each a tiny stateful unit, into a long virtualized buffer | 5k–50k | -| **Dense collaborative canvases** | Figma, tldraw | 5k–50k nodes; a `pointermove` / cursor stream fans out to all visible shapes | 5k–50k | -| **NOC / k8s / network walls** | k9s, Lens, traffic grids | thousands of pods/nodes/flows, each a live cell | 5k–20k | - -The sharpest target is a **dense live financial surface** (full order book or -options chain): genuinely 5k–20k live cells, updating tens of thousands of times a -second, often needed on **web _and_ native** from one codebase, where flat -per-cell memory matters — every property the benchmark measures pays at once. - -> **What does _not_ need this:** a handful-of-symbols chart view, a typical SaaS -> dashboard, a chat app — dozens to a few hundred live elements. The engine works -> there too, but so does anything; at that scale you'd pick it for the -> agnostic-render and bundle/memory reasons, not the throughput these tables show. +The Rust rows are twins of TS rows, so a change to one engine can be measured against the +same work on the other. diff --git a/benchmark/rust/Cargo.toml b/benchmark/rust/Cargo.toml new file mode 100644 index 0000000..98e41e0 --- /dev/null +++ b/benchmark/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-benchmark" +description = "The Rust engine's benchmark: the TS suite's engine scenarios, run natively on dunky-core." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +dunky-core = { workspace = true } + +[[bench]] +name = "engine" +harness = false diff --git a/benchmark/rust/README.md b/benchmark/rust/README.md new file mode 100644 index 0000000..e5c2536 --- /dev/null +++ b/benchmark/rust/README.md @@ -0,0 +1,90 @@ +# Rust benchmark suite + +The Rust engine (`dunky-core`), measured natively. Release build, Apple M5 Pro; medians are +the average of two runs, and the frame table's p99 and worst columns take the **higher** of +the two runs. Absolute figures vary by machine and thermal state — **run it yourself**. + +```bash +cargo bench -p dunky-benchmark +``` + +## It does not cost a frame + +At 60 fps one frame is **16.67 ms**. Each workload below runs inside one frame, timed frame +by frame over **600 frames** (10 seconds of 60 fps): + +| Workload per frame | Median (of 16.67 ms) | p99 | Worst | Allocations | +| ---------------------------------------- | -------------------: | -------: | -------: | ----------: | +| 1,000 machines, 1 event each | 0.014 ms (0.1%) | 0.031 ms | 0.072 ms | **0** | +| 10,000 machines, 1 event each | 0.151 ms (0.9%) | 0.215 ms | 0.435 ms | **0** | +| 1 machine, 10,000 events | 0.105 ms (0.6%) | 0.131 ms | 0.195 ms | **0** | +| 1,000 transitions (exit, entry, effects) | 0.017 ms (0.1%) | 0.026 ms | 0.048 ms | **0** | +| 1,000 computed recomputes | 0.015 ms (0.1%) | 0.019 ms | 0.033 ms | **0** | +| 64 observers, 1,000 field changes | 0.044 ms (0.3%) | 0.060 ms | 0.069 ms | **0** | + +→ **No workload goes past 3% of a frame, even in its worst frame.** 10,000 machines +reacting to an event every frame take 0.15 ms at the median, leaving more than 97% of the +frame for rendering and the rest of the app. + +→ **Zero allocations per frame.** The steady state allocates nothing — no events, no +notifications, no recomputes — so the engine adds no allocator pauses and no jitter. + +→ **~1.6 million events fit in one frame** on a single machine (96 M events/sec ÷ 60). + +## Throughput + +| Scenario | ops/sec | ns/op | +| ----------------------------- | ---------: | ----: | +| One event (observed field) | **95.6 M** | 10.5 | +| Unobserved write, 1,000 cells | 97.6 M | 10.2 | +| Unobserved write, 5,000 cells | 97.7 M | 10.2 | +| 1 change, 64 observers | 22.6 M | 44.2 | + +"Observed" means a value-deduped selection watches the field (`select_field`); a write to +another field skips it with one bit test. A Rust context is a typed struct with at most 64 +fields, so the widest fan-out is one selection on each of 64 fields: one change re-checks +only the selection of the field that changed. + +## Computed values + +| Scenario | ops/sec | ns/op | +| ------------------------------------ | ----------: | ----: | +| Cached read (no change) | **344.1 M** | 2.9 | +| Fine-grain (change unread, re-read) | 105.0 M | 9.5 | +| Recompute (change read field) | 68.7 M | 14.6 | +| 4-deep chain (change root, read tip) | 18.0 M | 55.6 | + +A cached read is a stamp comparison. Changing a field the value does not read keeps it +cached; a recompute reuses the value's memory in place when nothing else holds it. + +## Engine paths + +| Scenario | ops/sec | ns/op | +| --------------------------------------------- | ----------: | ----: | +| Guard fallthrough, 2 candidates | **171.2 M** | 5.8 | +| Guard fallthrough, 8 candidates | 61.7 M | 16.2 | +| Guard fallthrough, 32 candidates | 16.4 M | 61.0 | +| State churn (exit + entry every event) | 88.2 M | 11.3 | +| Effect churn (boot + cleanup each transition) | 63.3 M | 15.8 | + +## Construction and memory + +| Scenario | Value | +| ----------------------------------------- | ----------: | +| Build + start, per machine (10,000) | **23 ns** | +| Memory, 2-field context, written (5,000) | **0.36 KB** | +| Memory, 64-field context, written (5,000) | **0.60 KB** | + +→ 10,000 machines cost about 3.6 MB with a 2-field context, and build in about 0.23 ms. + +## How it measures + +- **ops/sec**: the median of 5 samples of ~100 ms, after a 100 ms warmup. +- **Frames**: each workload runs 60 warmup frames, then 600 timed frames, each timed on + its own; the table shows the median, the 99th percentile and the slowest frame. +- **Memory and allocations**: a counting global allocator; memory is live heap bytes per + machine, allocations are counted across the 600 frames. +- The machines are in [`src/lib.rs`](./src/lib.rs), the runner in + [`benches/engine.rs`](./benches/engine.rs). Each scenario is the twin of one in the TS + suite ([`../ts`](../ts/README.md)), so a change to one engine can be checked on the + same work. diff --git a/benchmark/rust/benches/engine.rs b/benchmark/rust/benches/engine.rs new file mode 100644 index 0000000..84f21d5 --- /dev/null +++ b/benchmark/rust/benches/engine.rs @@ -0,0 +1,332 @@ +//! The Rust engine's benchmark: the TS suite's engine scenarios, run natively. +//! +//! cargo bench -p dunky-benchmark +//! DUNKY_BENCH=guard cargo bench -p dunky-benchmark # only the rows that match +//! +//! Each row is the twin of a row in `benchmark/ts`, so the two READMEs compare the same +//! work. ops/sec is the median of 5 samples of ~100 ms, after a 100 ms warmup. + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use dunky_benchmark::*; +use dunky_core::Machine; + +/// Counts live heap bytes (the memory rows) and allocations (the frame rows). +struct Counting; + +static LIVE: AtomicUsize = AtomicUsize::new(0); +static ALLOCS: AtomicUsize = AtomicUsize::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + LIVE.fetch_add(layout.size(), Ordering::Relaxed); + ALLOCS.fetch_add(1, Ordering::Relaxed); + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + LIVE.fetch_sub(layout.size(), Ordering::Relaxed); + unsafe { System.dealloc(ptr, layout) } + } +} + +#[global_allocator] +static ALLOC: Counting = Counting; + +const SAMPLES: usize = 5; +const SAMPLE: Duration = Duration::from_millis(100); + +/// `DUNKY_BENCH_LONG=1` samples for 2 s each: long enough to attach a profiler. +fn sample_time() -> Duration { + if std::env::var_os("DUNKY_BENCH_LONG").is_some() { + Duration::from_secs(2) + } else { + SAMPLE + } +} +const BATCH: u64 = 1_000; + +fn ops_per_sec(mut op: impl FnMut()) -> f64 { + let sample = sample_time(); + let warm = Instant::now(); + while warm.elapsed() < sample { + for _ in 0..BATCH { + op(); + } + } + let mut samples = Vec::with_capacity(SAMPLES); + for _ in 0..SAMPLES { + let start = Instant::now(); + let mut n = 0u64; + while start.elapsed() < sample { + for _ in 0..BATCH { + op(); + } + n += BATCH; + } + samples.push(n as f64 / start.elapsed().as_secs_f64()); + } + samples.sort_by(f64::total_cmp); + samples[SAMPLES / 2] +} + +fn human(ops: f64) -> String { + if ops >= 1e6 { + format!("{:.1} M", ops / 1e6) + } else if ops >= 1e3 { + format!("{:.1} K", ops / 1e3) + } else { + format!("{ops:.0}") + } +} + +fn section(title: &str) { + println!("\n### {title}"); + println!( + "| {:<46} | {:>10} | {:>8} |", + "scenario", "ops/sec", "ns/op" + ); + println!("| {:-<46} | {:->10} | {:->8} |", "", "", ""); +} + +/// The `DUNKY_BENCH` filter: run only the rows whose name contains it. +fn selected(name: &str) -> bool { + std::env::var("DUNKY_BENCH").map_or(true, |filter| name.contains(&filter)) +} + +fn row(name: &str, op: impl FnMut()) { + if !selected(name) { + return; + } + let ops = ops_per_sec(op); + println!("| {name:<46} | {:>10} | {:>8.1} |", human(ops), 1e9 / ops); +} + +/// One frame at 60 fps. +const FRAME_MS: f64 = 1000.0 / 60.0; +/// Frames timed per workload (10 s of 60 fps), after a warmup of 60. +const FRAMES: usize = 600; + +/// Time `work` (one frame's worth of machine work) frame by frame: the median, p99 and +/// worst frame, as a share of the 16.67 ms budget, and the allocations per frame. +fn frame(name: &str, mut work: impl FnMut()) { + if !selected(name) { + return; + } + for _ in 0..60 { + work(); + } + let mut times = Vec::with_capacity(FRAMES); + let allocs_before = ALLOCS.load(Ordering::Relaxed); + for _ in 0..FRAMES { + let start = Instant::now(); + work(); + times.push(start.elapsed().as_secs_f64() * 1e3); + } + let allocs = (ALLOCS.load(Ordering::Relaxed) - allocs_before) as f64 / FRAMES as f64; + times.sort_by(f64::total_cmp); + let median = times[FRAMES / 2]; + let p99 = times[FRAMES * 99 / 100]; + let worst = times[FRAMES - 1]; + let pct = |ms: f64| ms / FRAME_MS * 100.0; + println!( + "| {name:<40} | {median:>6.3} ms ({:>4.1}%) | {p99:>6.3} ms ({:>4.1}%) | {worst:>6.3} ms ({:>4.1}%) | {allocs:>6.1} |", + pct(median), + pct(p99), + pct(worst), + ); +} + +fn frame_budget() { + println!("\n### Frame budget at 60 fps (one frame = {FRAME_MS:.2} ms, {FRAMES} frames each)"); + println!( + "| {:<40} | {:>17} | {:>17} | {:>17} | {:>6} |", + "workload per frame", "median", "p99", "worst", "allocs" + ); + println!( + "| {:-<40} | {:->17} | {:->17} | {:->17} | {:->6} |", + "", "", "", "", "" + ); + + let cell = cell_config(); + for n in [1_000, 10_000] { + let cells: Vec<_> = (0..n).map(|_| observed_cell(&cell)).collect(); + let label = if n == 1_000 { "1,000" } else { "10,000" }; + frame(&format!("{label} machines, 1 event each"), || { + for c in &cells { + c.send(CellEvent::Hit); + } + }); + } + let one = observed_cell(&cell); + frame("1 machine, 10,000 events", || { + for _ in 0..10_000 { + one.send(CellEvent::Hit); + } + }); + let churn = effect_churn_config(); + let machines: Vec<_> = (0..1_000) + .map(|_| { + let m = Machine::new(&churn); + m.start(); + m + }) + .collect(); + frame("1,000 transitions (exit, entry, effects)", || { + for m in &machines { + m.send(Go::Go); + } + }); + let (config, keys) = computed_config(); + let calc = Machine::new(&config); + calc.start(); + frame("1,000 computed recomputes", || { + for _ in 0..1_000 { + calc.send(ComputedEvent::BumpA); + black_box(calc.computed(keys.sum)); + } + }); + let wide = Machine::new(&wide_config()); + wide.start(); + for k in 0..64 { + std::mem::forget(wide_select(&wide, k).subscribe(|_| bump())); + } + let mut i = 0; + frame("64 observers, 1,000 field changes", || { + for _ in 0..1_000 { + wide.send(WideEvent::Set { key: i % 64 }); + i += 1; + } + }); +} + +fn main() { + println!("Rust engine benchmark (dunky-core, native, release build)"); + + frame_budget(); + + section("Throughput and fan-out"); + let cell = cell_config(); + let m = observed_cell(&cell); + row("one event (observed field)", || m.send(CellEvent::Hit)); + for n in [1_000, 5_000] { + let cells: Vec<_> = (0..n).map(|_| observed_cell(&cell)).collect(); + let mut i = 0; + let label = if n == 1_000 { "1,000" } else { "5,000" }; + row(&format!("unobserved write, {label} cells"), || { + cells[i % n].send(CellEvent::Miss); + i += 1; + }); + } + let wide = Machine::new(&wide_config()); + wide.start(); + for k in 0..64 { + let sel = wide_select(&wide, k); + std::mem::forget(sel.subscribe(|_| bump())); + } + let mut i = 0; + row("1 change, 64 observers", || { + wide.send(WideEvent::Set { key: i % 64 }); + i += 1; + }); + + section("Computed"); + let (config, keys) = computed_config(); + let m = Machine::new(&config); + m.start(); + m.send(ComputedEvent::BumpA); + black_box(m.computed(keys.sum)); + row("cached read (no change)", || { + black_box(m.computed(keys.sum)); + }); + row("fine-grain (change unread, re-read)", || { + m.send(ComputedEvent::BumpUnrelated); + black_box(m.computed(keys.sum)); + }); + row("recompute (change read field)", || { + m.send(ComputedEvent::BumpA); + black_box(m.computed(keys.sum)); + }); + row("4-deep chain (change root, read tip)", || { + m.send(ComputedEvent::BumpA); + black_box(m.computed(keys.c4)); + }); + + section("Engine paths"); + for k in [2, 8, 32] { + let m = Machine::new(&guards_config(k)); + m.start(); + row(&format!("guard fallthrough, {k} candidates"), || { + m.send(Go::Go) + }); + } + let m = Machine::new(&state_churn_config()); + m.start(); + row("state churn (exit + entry every event)", || m.send(Go::Go)); + let m = Machine::new(&effect_churn_config()); + m.start(); + row("effect churn (boot + cleanup each transition)", || { + m.send(Go::Go) + }); + + println!("\n### Construction and memory"); + println!("| {:<46} | {:>10} |", "scenario", "value"); + println!("| {:-<46} | {:->10} |", "", ""); + let mut passes: Vec = (0..5) + .map(|_| { + let start = Instant::now(); + let machines: Vec<_> = (0..10_000) + .map(|_| { + let m = Machine::new(&cell); + m.start(); + m + }) + .collect(); + let ns = start.elapsed().as_secs_f64() * 1e9 / 10_000.0; + black_box(machines); + ns + }) + .collect(); + passes.sort_by(f64::total_cmp); + println!( + "| {:<46} | {:>7.0} ns |", + "build + start, per machine (10,000)", passes[2] + ); + + let n = 5_000; + let before = LIVE.load(Ordering::Relaxed); + let thin: Vec<_> = (0..n) + .map(|_| { + let m = Machine::new(&cell); + m.start(); + m.send(CellEvent::Hit); + m + }) + .collect(); + let per = (LIVE.load(Ordering::Relaxed) - before) as f64 / n as f64 / 1024.0; + println!( + "| {:<46} | {:>7.2} KB |", + "memory, 2-field context, written (5,000)", per + ); + drop(thin); + let wide = wide_config(); + let before = LIVE.load(Ordering::Relaxed); + let fat: Vec<_> = (0..n) + .map(|_| { + let m = Machine::new(&wide); + m.start(); + m.send(WideEvent::Set { key: 0 }); + m + }) + .collect(); + let per = (LIVE.load(Ordering::Relaxed) - before) as f64 / n as f64 / 1024.0; + println!( + "| {:<46} | {:>7.2} KB |", + "memory, 64-field context, written (5,000)", per + ); + drop(fat); + + println!("\n(sink: {})", sink()); +} diff --git a/benchmark/rust/src/lib.rs b/benchmark/rust/src/lib.rs new file mode 100644 index 0000000..08ef462 --- /dev/null +++ b/benchmark/rust/src/lib.rs @@ -0,0 +1,512 @@ +//! The machines of the Rust benchmark: twins of the TS suite's machines +//! (`benchmark/ts/competitors.ts` and `benchmark/ts/tests`), so a row measures the same +//! work on each engine. `benches/engine.rs` runs them. + +use std::cell::Cell; + +use dunky_core::{ComputedKey, Config, Context, Event, Machine, Selection, State, Types}; + +thread_local! { + static SINK: Cell = const { Cell::new(0) }; +} + +/// Side work an action or a listener does, so nothing is optimized away. +pub fn bump() { + SINK.with(|s| s.set(s.get() + 1)); +} + +pub fn sink() -> u64 { + SINK.with(Cell::get) +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum Idle { + Idle, +} + +// --------------------------------------------------------------------------------- +// The cell: `hit` bumps `value`, `miss` bumps `other` (makeCoreCell). + +#[derive(Event, Clone, Debug)] +pub enum CellEvent { + Hit, + Miss, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct CellCtx { + pub value: u32, + pub other: u32, +} + +pub struct CellMachine; +impl Types for CellMachine { + type State = Idle; + type Event = CellEvent; + type Context = CellCtx; +} + +pub fn cell_config() -> Config { + let mut b = Config::::builder(Idle::Idle, CellCtx { value: 0, other: 0 }); + b.state(Idle::Idle, |s| { + s.on(CellEventKind::Hit, |t| { + t.act(|p| CellCtx::patch().value(p.context().value + 1)) + }) + .on(CellEventKind::Miss, |t| { + t.act(|p| CellCtx::patch().other(p.context().other + 1)) + }) + }); + b.build() +} + +/// A started cell whose `value` is observed (a value-deduped selection), like the TS cell. +pub fn observed_cell(config: &Config) -> Machine { + let m = Machine::new(config); + m.start(); + std::mem::forget(m.select_field(CellCtx::VALUE).subscribe(|_| bump())); + m +} + +// --------------------------------------------------------------------------------- +// The widest context: 64 fields, one observer per field (makeCoreFanout, at the most a +// typed Rust context holds). + +#[derive(Event, Clone, Debug)] +pub enum WideEvent { + Set { key: usize }, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct W64 { + pub f0: u32, + pub f1: u32, + pub f2: u32, + pub f3: u32, + pub f4: u32, + pub f5: u32, + pub f6: u32, + pub f7: u32, + pub f8: u32, + pub f9: u32, + pub f10: u32, + pub f11: u32, + pub f12: u32, + pub f13: u32, + pub f14: u32, + pub f15: u32, + pub f16: u32, + pub f17: u32, + pub f18: u32, + pub f19: u32, + pub f20: u32, + pub f21: u32, + pub f22: u32, + pub f23: u32, + pub f24: u32, + pub f25: u32, + pub f26: u32, + pub f27: u32, + pub f28: u32, + pub f29: u32, + pub f30: u32, + pub f31: u32, + pub f32: u32, + pub f33: u32, + pub f34: u32, + pub f35: u32, + pub f36: u32, + pub f37: u32, + pub f38: u32, + pub f39: u32, + pub f40: u32, + pub f41: u32, + pub f42: u32, + pub f43: u32, + pub f44: u32, + pub f45: u32, + pub f46: u32, + pub f47: u32, + pub f48: u32, + pub f49: u32, + pub f50: u32, + pub f51: u32, + pub f52: u32, + pub f53: u32, + pub f54: u32, + pub f55: u32, + pub f56: u32, + pub f57: u32, + pub f58: u32, + pub f59: u32, + pub f60: u32, + pub f61: u32, + pub f62: u32, + pub f63: u32, +} + +pub struct Wide; +impl Types for Wide { + type State = Idle; + type Event = WideEvent; + type Context = W64; +} + +/// A selection of field `k` of the wide context (TS: `select.context(`f${k}`)`). +pub fn wide_select(m: &Machine, k: usize) -> Selection { + match k % 64 { + 0 => m.select_field(W64::F0), + 1 => m.select_field(W64::F1), + 2 => m.select_field(W64::F2), + 3 => m.select_field(W64::F3), + 4 => m.select_field(W64::F4), + 5 => m.select_field(W64::F5), + 6 => m.select_field(W64::F6), + 7 => m.select_field(W64::F7), + 8 => m.select_field(W64::F8), + 9 => m.select_field(W64::F9), + 10 => m.select_field(W64::F10), + 11 => m.select_field(W64::F11), + 12 => m.select_field(W64::F12), + 13 => m.select_field(W64::F13), + 14 => m.select_field(W64::F14), + 15 => m.select_field(W64::F15), + 16 => m.select_field(W64::F16), + 17 => m.select_field(W64::F17), + 18 => m.select_field(W64::F18), + 19 => m.select_field(W64::F19), + 20 => m.select_field(W64::F20), + 21 => m.select_field(W64::F21), + 22 => m.select_field(W64::F22), + 23 => m.select_field(W64::F23), + 24 => m.select_field(W64::F24), + 25 => m.select_field(W64::F25), + 26 => m.select_field(W64::F26), + 27 => m.select_field(W64::F27), + 28 => m.select_field(W64::F28), + 29 => m.select_field(W64::F29), + 30 => m.select_field(W64::F30), + 31 => m.select_field(W64::F31), + 32 => m.select_field(W64::F32), + 33 => m.select_field(W64::F33), + 34 => m.select_field(W64::F34), + 35 => m.select_field(W64::F35), + 36 => m.select_field(W64::F36), + 37 => m.select_field(W64::F37), + 38 => m.select_field(W64::F38), + 39 => m.select_field(W64::F39), + 40 => m.select_field(W64::F40), + 41 => m.select_field(W64::F41), + 42 => m.select_field(W64::F42), + 43 => m.select_field(W64::F43), + 44 => m.select_field(W64::F44), + 45 => m.select_field(W64::F45), + 46 => m.select_field(W64::F46), + 47 => m.select_field(W64::F47), + 48 => m.select_field(W64::F48), + 49 => m.select_field(W64::F49), + 50 => m.select_field(W64::F50), + 51 => m.select_field(W64::F51), + 52 => m.select_field(W64::F52), + 53 => m.select_field(W64::F53), + 54 => m.select_field(W64::F54), + 55 => m.select_field(W64::F55), + 56 => m.select_field(W64::F56), + 57 => m.select_field(W64::F57), + 58 => m.select_field(W64::F58), + 59 => m.select_field(W64::F59), + 60 => m.select_field(W64::F60), + 61 => m.select_field(W64::F61), + 62 => m.select_field(W64::F62), + 63 => m.select_field(W64::F63), + _ => unreachable!(), + } +} + +pub fn wide_config() -> Config { + let mut b = Config::::builder( + Idle::Idle, + W64 { + f0: 0, + f1: 0, + f2: 0, + f3: 0, + f4: 0, + f5: 0, + f6: 0, + f7: 0, + f8: 0, + f9: 0, + f10: 0, + f11: 0, + f12: 0, + f13: 0, + f14: 0, + f15: 0, + f16: 0, + f17: 0, + f18: 0, + f19: 0, + f20: 0, + f21: 0, + f22: 0, + f23: 0, + f24: 0, + f25: 0, + f26: 0, + f27: 0, + f28: 0, + f29: 0, + f30: 0, + f31: 0, + f32: 0, + f33: 0, + f34: 0, + f35: 0, + f36: 0, + f37: 0, + f38: 0, + f39: 0, + f40: 0, + f41: 0, + f42: 0, + f43: 0, + f44: 0, + f45: 0, + f46: 0, + f47: 0, + f48: 0, + f49: 0, + f50: 0, + f51: 0, + f52: 0, + f53: 0, + f54: 0, + f55: 0, + f56: 0, + f57: 0, + f58: 0, + f59: 0, + f60: 0, + f61: 0, + f62: 0, + f63: 0, + }, + ); + b.state(Idle::Idle, |s| { + s.on(WideEventKind::Set, |t| { + t.act(|p| { + let Some(WideEvent::Set { key }) = p.event() else { + return W64::patch(); + }; + let c = p.context(); + match key % 64 { + 0 => W64::patch().f0(c.f0 + 1), + 1 => W64::patch().f1(c.f1 + 1), + 2 => W64::patch().f2(c.f2 + 1), + 3 => W64::patch().f3(c.f3 + 1), + 4 => W64::patch().f4(c.f4 + 1), + 5 => W64::patch().f5(c.f5 + 1), + 6 => W64::patch().f6(c.f6 + 1), + 7 => W64::patch().f7(c.f7 + 1), + 8 => W64::patch().f8(c.f8 + 1), + 9 => W64::patch().f9(c.f9 + 1), + 10 => W64::patch().f10(c.f10 + 1), + 11 => W64::patch().f11(c.f11 + 1), + 12 => W64::patch().f12(c.f12 + 1), + 13 => W64::patch().f13(c.f13 + 1), + 14 => W64::patch().f14(c.f14 + 1), + 15 => W64::patch().f15(c.f15 + 1), + 16 => W64::patch().f16(c.f16 + 1), + 17 => W64::patch().f17(c.f17 + 1), + 18 => W64::patch().f18(c.f18 + 1), + 19 => W64::patch().f19(c.f19 + 1), + 20 => W64::patch().f20(c.f20 + 1), + 21 => W64::patch().f21(c.f21 + 1), + 22 => W64::patch().f22(c.f22 + 1), + 23 => W64::patch().f23(c.f23 + 1), + 24 => W64::patch().f24(c.f24 + 1), + 25 => W64::patch().f25(c.f25 + 1), + 26 => W64::patch().f26(c.f26 + 1), + 27 => W64::patch().f27(c.f27 + 1), + 28 => W64::patch().f28(c.f28 + 1), + 29 => W64::patch().f29(c.f29 + 1), + 30 => W64::patch().f30(c.f30 + 1), + 31 => W64::patch().f31(c.f31 + 1), + 32 => W64::patch().f32(c.f32 + 1), + 33 => W64::patch().f33(c.f33 + 1), + 34 => W64::patch().f34(c.f34 + 1), + 35 => W64::patch().f35(c.f35 + 1), + 36 => W64::patch().f36(c.f36 + 1), + 37 => W64::patch().f37(c.f37 + 1), + 38 => W64::patch().f38(c.f38 + 1), + 39 => W64::patch().f39(c.f39 + 1), + 40 => W64::patch().f40(c.f40 + 1), + 41 => W64::patch().f41(c.f41 + 1), + 42 => W64::patch().f42(c.f42 + 1), + 43 => W64::patch().f43(c.f43 + 1), + 44 => W64::patch().f44(c.f44 + 1), + 45 => W64::patch().f45(c.f45 + 1), + 46 => W64::patch().f46(c.f46 + 1), + 47 => W64::patch().f47(c.f47 + 1), + 48 => W64::patch().f48(c.f48 + 1), + 49 => W64::patch().f49(c.f49 + 1), + 50 => W64::patch().f50(c.f50 + 1), + 51 => W64::patch().f51(c.f51 + 1), + 52 => W64::patch().f52(c.f52 + 1), + 53 => W64::patch().f53(c.f53 + 1), + 54 => W64::patch().f54(c.f54 + 1), + 55 => W64::patch().f55(c.f55 + 1), + 56 => W64::patch().f56(c.f56 + 1), + 57 => W64::patch().f57(c.f57 + 1), + 58 => W64::patch().f58(c.f58 + 1), + 59 => W64::patch().f59(c.f59 + 1), + 60 => W64::patch().f60(c.f60 + 1), + 61 => W64::patch().f61(c.f61 + 1), + 62 => W64::patch().f62(c.f62 + 1), + 63 => W64::patch().f63(c.f63 + 1), + _ => unreachable!(), + } + }) + }) + }); + b.build() +} + +// --------------------------------------------------------------------------------- +// Computed: `sum` reads a + b; a 4-deep chain off `a` (benchmark/ts/tests/computed.ts). + +#[derive(Event, Clone, Debug)] +pub enum ComputedEvent { + BumpA, + BumpUnrelated, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct ComputedCtx { + pub a: u32, + pub b: u32, + pub unrelated: u32, +} + +pub struct Computed; +impl Types for Computed { + type State = Idle; + type Event = ComputedEvent; + type Context = ComputedCtx; +} + +pub struct ComputedKeys { + pub sum: ComputedKey, + pub c4: ComputedKey, +} + +pub fn computed_config() -> (Config, ComputedKeys) { + let mut b = Config::::builder( + Idle::Idle, + ComputedCtx { + a: 0, + b: 0, + unrelated: 0, + }, + ); + let sum = b.computed("sum", |c| c.context.a() + c.context.b()); + let c1 = b.computed("c1", |c| c.context.a() + 1); + let c2 = b.computed("c2", move |c| *c.computed(c1) + 1); + let c3 = b.computed("c3", move |c| *c.computed(c2) + 1); + let c4 = b.computed("c4", move |c| *c.computed(c3) + 1); + b.state(Idle::Idle, |s| { + s.on(ComputedEventKind::BumpA, |t| { + t.act(|p| ComputedCtx::patch().a(p.context().a + 1)) + }) + .on(ComputedEventKind::BumpUnrelated, |t| { + t.act(|p| ComputedCtx::patch().unrelated(p.context().unrelated + 1)) + }) + }); + (b.build(), ComputedKeys { sum, c4 }) +} + +// --------------------------------------------------------------------------------- +// Engine paths (benchmark/ts/tests/engine.ts). + +#[derive(Event, Clone, Debug)] +pub enum Go { + Go, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct PickCtx { + pub pick: u32, +} + +pub struct Guards; +impl Types for Guards { + type State = Idle; + type Event = Go; + type Context = PickCtx; +} + +/// `k` guarded candidates for `go`; the last one passes, so every send walks them all. +pub fn guards_config(k: u32) -> Config { + let mut b = Config::::builder(Idle::Idle, PickCtx { pick: k - 1 }); + b.state(Idle::Idle, |mut s| { + for i in 0..k { + s = s.on(GoKind::Go, move |t| { + t.guard_fn(move |g| g.context().pick == i) + }); + } + s + }); + b.build() +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum PingPong { + Ping, + Pong, +} + +#[derive(Context, Clone, Debug, PartialEq)] +pub struct NCtx { + pub n: u32, +} + +pub struct Churn; +impl Types for Churn { + type State = PingPong; + type Event = Go; + type Context = NCtx; +} + +/// Ping <-> pong; each state has entry and exit actions. +pub fn state_churn_config() -> Config { + let mut b = Config::::builder(PingPong::Ping, NCtx { n: 0 }); + for (from, to) in [ + (PingPong::Ping, PingPong::Pong), + (PingPong::Pong, PingPong::Ping), + ] { + b.state(from, |s| { + s.entry_run(|_| bump()) + .exit_run(|_| bump()) + .on(GoKind::Go, move |t| t.target(to)) + }); + } + b.build() +} + +/// Ping <-> pong; each state boots an effect on entry and runs its cleanup on exit. +pub fn effect_churn_config() -> Config { + let mut b = Config::::builder(PingPong::Ping, NCtx { n: 0 }); + for (from, to) in [ + (PingPong::Ping, PingPong::Pong), + (PingPong::Pong, PingPong::Ping), + ] { + b.state(from, |s| { + s.effect_run(|_| { + bump(); + Some(Box::new(bump)) + }) + .on(GoKind::Go, move |t| t.target(to)) + }); + } + b.build() +} diff --git a/benchmark/CHANGELOG.md b/benchmark/ts/CHANGELOG.md similarity index 100% rename from benchmark/CHANGELOG.md rename to benchmark/ts/CHANGELOG.md diff --git a/benchmark/ts/README.md b/benchmark/ts/README.md new file mode 100644 index 0000000..7af911d --- /dev/null +++ b/benchmark/ts/README.md @@ -0,0 +1,294 @@ +# Benchmark suite + +A performance harness for `@dunky.dev/state-machine`. It measures the engine's +hot paths in isolation and compares the runnable parts against +[XState](https://stately.ai/docs) and [Zag](https://zagjs.com/). + +Numbers below are averaged over four clean runs (Node 24, Apple Silicon). Absolute figures +vary by machine, Node version, and thermal state — **run it yourself** — but the +ranking and the scaling shape are what hold. + +> Want to _see_ it instead of read tables? **`pnpm benchmark:demo`** opens a live +> side-by-side where each engine drives a grid of real per-cell machines under a +> ramping load — watch which panels' backlogs grow as the load climbs. See +> [`demo/`](./demo/README.md). + +## Running + +```bash +pnpm benchmark # from the repo root (delegates here) +# or +cd benchmark/ts && pnpm benchmark +``` + +Either runs the whole suite in one Node process with `--expose-gc` (the memory +bench needs accurate GC; the flag is harmless for the rest). Output is a series +of `console.table`s grouped by section. + +Each section is also exported as a `run*()` function, so you can run one in +isolation: + +```bash +node --expose-gc --import tsx -e "import('./tests/memory').then(m => m.runMemory())" +``` + +## Who's compared, and why not always all three + +The engines don't all fit every test, because they don't all run the same way: + +| Engine | How `send` works | Where it's measured | +| ---------- | ----------------------------- | -------------------------------------------------- | +| **Dunky** | synchronous | everywhere | +| **XState** | synchronous | the ops/sec loops, construction, memory, rendering | +| **Zag** | **async** (microtask-batched) | construction, memory, and React rendering only | + +A `tinybench` ops/sec loop counts synchronous iterations, so it can only compare +**synchronous** engines fairly. Zag's headless `send` is microtask-batched, so it +would either deadlock or report meaningless numbers in a tight sync loop — it +appears only where it runs synchronously (construction, memory via the headless +`VanillaMachine`) and in the React arena, where it runs natively via +`@zag-js/react`. + +Two XState variants appear in the fine-grain tables: + +- **`xstate`** — `actor.subscribe` with a hand-written `value` diff in the + listener (the same dedup Dunky does for free). +- **`xstate-raw`** — stock `actor.subscribe`, which fires on _every_ snapshot + change with no diff. + +Showing both separates "what XState costs out of the box" from "what it costs +once you add a differ." + +A key fairness rule across construction + memory: **all engines share one +module-level config across instances** — the shape a real app has (a component's +machine config is a `const`; every instance reuses it). So those loops time +_machine construction_, not config-literal allocation. + +> **How to read the ops/sec tables.** `ops/sec` (higher is better) is the headline; +> `±rme %` is the run-to-run noise floor — a gap between two rows is only real if +> it clears both rows' margins. The `(anti-DCE SINK: …)` lines in the raw output +> are just proof the JIT didn't dead-code-eliminate the work — ignore them. + +**Every comparison table lists all three engines**, so you always see the full +field. Where one can't run a given test, the cell is marked — same marker +everywhere: + +- **`n/a ᵃ`** — _async._ Zag's `send` is microtask-batched, so it can't run in a + synchronous ops/sec or `flushSync` loop. +- **`n/a ᶠ`** — _no equivalent feature._ The engine has no first-class primitive + for this scenario (e.g. XState has no lazy/memoized `computed`), so there's + nothing comparable to time. + +--- + +## 1. Fan-out / fine-grain / throughput (`tests/fan-out.ts`) + +The selection layer at scale — the thing that decides whether thousands of +machines stay cheap. Zag can't run these (async `send`). + +**A. Propagation — change 1 of N.** ONE machine, N fields, N observers (one per +field). Bump one field. Dunky's `select` is a coarse bus: every selection +re-evaluates its selector on each notify and value-compares, so only the touched +field's _listener_ fires (downstream is O(changed)) — but the re-eval pass itself +is O(N observers) per write. The table shows _how_ that degrades with N versus +XState's coarse `actor.subscribe`. + +| Change 1 of N | Dunky (ops/sec) | XState (ops/sec) | Zag | +| ------------- | --------------: | ---------------: | ----: | +| 100 | 658.5 K | 437.8 K | n/a ᵃ | +| 1000 | 56.8 K | 49.2 K | n/a ᵃ | +| 5000 | **15.5 K** | 1.3 K | n/a ᵃ | + +→ Roughly par at small N, but Dunky **~11× faster at 5000 observers** — XState's +coarse subscribe degrades much faster as the observer set grows. + +**B. Fine-grain — change an UNOBSERVED field.** Change a field nobody selects. The +dedup layer re-evaluates and value-compares, so no listener fires — the +subscriber-side cost is ~zero. This is the "irrelevant write" that a cell-per-field +model gets for free and a coarse bus has to work to ignore. + +| Irrelevant write, N cells | Dunky (ops/sec) | XState (ops/sec) | Zag | +| ------------------------- | --------------: | ---------------: | ----: | +| 1000 | **5.4 M** | 987.9 K | n/a ᵃ | +| 5000 | **4.4 M** | 844.1 K | n/a ᵃ | + +→ Dunky is **~6× faster** at shrugging off a write nobody is watching (1000 +cells); the value-deduping bus skips waking observers entirely. + +**C. Throughput — single machine, one event.** Per-transition cost with no +selection scaling — the raw `send` price. + +| Single machine, one event | ops/sec | +| ------------------------- | ---------: | +| Dunky | **11.6 M** | +| XState (raw) | 1.6 M | +| XState (diffed) | 1.6 M | +| Zag | n/a ᵃ | + +→ Dunky pushes **~7× the events/sec** of XState. Context is mutated in place, +so a transition allocates nothing; XState builds a fresh snapshot per event. + +## 2. Compose / synced machines (`tests/compose.ts`) + +The cross-region machinery `compose` adds, scaled by member count. No competitor +column: neither XState nor Zag has a first-class orthogonal-region primitive that +maps to `combine`/`sync`, so there's nothing equivalent to time (**n/a ᶠ** for +both). + +**A. combine** — one value-deduped `Selection` derived across M members but reading +only `m0`. It re-evaluates on _any_ member change but fires its listener only when +`m0` changes. **B. sync** — a coarse cross-region rule that wakes on _any_ member +change (the O(members) path by design). + +| Members | Dunky combine (ops/sec) | Dunky sync (ops/sec) | XState | Zag | +| ------- | ----------------------: | -------------------: | ------ | ----- | +| 2 | 11.0 M | 11.7 M | n/a ᶠ | n/a ᶠ | +| 10 | 10.1 M | 10.5 M | n/a ᶠ | n/a ᶠ | +| 50 | 8.7 M | 10.2 M | n/a ᶠ | n/a ᶠ | + +→ Cross-region coordination stays in the **~8.7–11.7 M ops/sec** band even at 50 +synced members — the O(M) re-eval pass costs ~13–21% going from 2 to 50. + +> A third "chain" sub-test (a sync rule that `send()`s downstream every change) was +> removed: under a tight loop it shows superlinear slowdown. That's a real +> `compose.sync` + cross-machine-send interaction worth investigating on its own, +> not a benchmark-tuning artifact — see the note in `tests/compose.ts`. + +## 3. Computed (`tests/computed.ts`) + +The most machinery-heavy subsystem — read-key tracking via proxies, memoization +against a dep snapshot, glitch-free computed→computed chains. This is a subsystem +profile: XState has no first-class lazy/memoized `computed` (**n/a ᶠ**), and Zag's +`send` is async (**n/a ᵃ**), so neither has a comparable primitive to time. + +| Scenario | Dunky (ops/sec) | XState | Zag | +| ------------------------------------ | --------------: | ------ | ----- | +| Cached read (no change) | **44.0 M** | n/a ᶠ | n/a ᵃ | +| Fine-grain (change unread, re-read) | 10.7 M | n/a ᶠ | n/a ᵃ | +| Recompute (change read field) | 4.9 M | n/a ᶠ | n/a ᵃ | +| 4-deep chain (change root, read tip) | 1.5 M | n/a ᶠ | n/a ᵃ | + +→ A cached read is **~44 M/sec** (near-free memo hit), and changing a field the +computed _doesn't_ read stays a memo hit at ~10.7 M/sec — read-key tracking means +you only pay the recompute when an input you actually read changes. + +## 4. Engine hot paths (`tests/engine.ts`) + +The parts of `send` that do real statechart work (everything else in the suite +stays in one state and only mutates context). These probe Dunky internals in +isolation — there's no comparable isolated path to time in XState (**n/a ᶠ**), and +Zag's `send` is async (**n/a ᵃ**). + +| Scenario | Dunky (ops/sec) | XState | Zag | +| -------------------------------------- | --------------: | ------ | ----- | +| Guard fallthrough — 2 candidates | 5.2 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 8 candidates | 4.5 M | n/a ᶠ | n/a ᵃ | +| Guard fallthrough — 32 candidates | 3.2 M | n/a ᶠ | n/a ᵃ | +| State churn — exit+entry every event | 8.8 M | n/a ᶠ | n/a ᵃ | +| Effect churn — boot+cleanup each trans | 8.2 M | n/a ᶠ | n/a ᵃ | +| Sub churn — stable set | 11.1 M | n/a ᶠ | n/a ᵃ | +| Sub churn — churning set (rebuild) | 8.0 M | n/a ᶠ | n/a ᵃ | + +→ Even the heavy paths hold **~3–11 M ops/sec**: a 32-candidate guard walk, full +state transitions with entry/exit actions, and effect boot/cleanup every +transition all stay in the same order of magnitude as a bare `send`. + +## 5. Construction cost (`tests/construct.ts`) + +Wall-clock to build + `start()` N machines, no events sent (matches a real mount). +Synchronous for all three, so it's a fair three-way table. Median of 5 passes, JIT +warmed first. + +| Build + start | Dunky (µs/machine) | XState | Zag | +| ------------- | -----------------: | -----: | ---: | +| 10 000 | 1.58 | 1.35 | 5.06 | + +→ Construction is the one axis where Dunky **doesn't** win — XState spins up +~1.2× faster. Dunky's bet is flat memory + hot-path throughput, not spin-up; +it's still ~3.2× faster than Zag's per-field reactive cells. + +## 6. Memory per machine (`tests/memory.ts`) + +Build 5000 machines, hold them live, report retained heap per machine (`heapMB()` +double-GCs before sampling). Two context widths — **thin** (2 fields) and **fat** +(64 fields) — because the whole point of the plain-object model is that memory +stays ~flat in field count. Rows below are the **written** mode (one `hit` each — +the footprint a churny app actually pays). + +| Context | Dunky (KB/machine) | XState | Zag | +| -------- | -----------------: | -----: | ------: | +| 2-field | 3.85 | 3.61 | 8.92 | +| 64-field | 4.35 | 4.10 | **134** | + +→ Going 2 → 64 fields costs Dunky only **~0.5 KB/machine** — memory grows with +the data you store, not with a per-field cell. **Zag is the contrast**: one reactive +cell per field balloons the 64-field context to ~134 KB/machine — **~31× more** than +Dunky. + +**Idle vs written.** Dunky owns its context copy from construction and mutates +it in place forever, so its idle and written footprints match by design — while a +lazy-copy scheme steps up once writes start: + +| 64-field, 5000 machines | Dunky | XState | Zag | +| ----------------------- | ----: | -----: | --: | +| Idle (never written) | 4.35 | 3.55 | 130 | +| Written (1 event each) | 4.35 | 4.10 | 134 | + +→ Dunky idle ≡ written; XState's first `assign` allocates a per-actor context, +so its written row grows. + +## 7. React rendering (`tests/rendering/`) + +The thing that actually hurts in an app — how many React components render — under +jsdom. A list of N rows, 50 highlight moves. Two numbers: **rows woken / move** +(the fine-grained payoff) and **wall-clock** per phase. Strategies: `selector` +(shared machine + `useSelector` per row), `naive` (whole-snapshot — the +anti-pattern), `core/instance` (one machine per row), plus `xstate/selector` and +`zag/instance`. + +List of 1000 rows: + +| Strategy | Rows woken / move | Mount (ms) | Re-render wall (ms) | +| -------------------- | ----------------: | ---------: | ------------------: | +| Dunky/instance | **2** | 3.6 | **2.4** | +| Dunky/selector | 2 | 4.9 | 3.8 | +| xstate/selector | 2 | 3.7 | 4.3 | +| zag/instance | 2 | 3.9 | n/a ᵃ | +| naive (anti-pattern) | **980** | 4.5 | 38.3 | + +→ Every properly-set-up engine wakes only the **2** rows that changed (vs. the +naive whole-snapshot subscription, which re-renders all **980** — a ~490× gap and +~16× the wall time). Among the surgical strategies Dunky re-renders **~1.8× +faster than XState**. + +Zag mounts and wakes the same **2** rows, but its re-render wall is **n/a ᵃ** — the +microtask-batched `send` can't be timed under a synchronous `flushSync` loop, so +only its row-count is comparable. + +## Where this actually matters + +The loads here (and in the demo) are deliberately extreme to force the engines to +diverge. That only reflects real software when a single view holds **thousands of +independently-stateful, live-updating cells in one frame budget**. That's not a +hypothetical — these are whole product categories: + +| Real workload | Example products | Where the count comes from | Live cells | +| ----------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------- | ---------: | +| **Full L2 order book / DOM ladder** | Bookmap, Sierra Chart, ATAS | 500–2,000 price levels per book × several books, each level a live cell | 3k–15k | +| **Options chain / vol surface** | thinkorswim, Tastytrade, OptionStrat | hundreds of strikes × calls+puts × 8–16 live fields (bid/ask/IV/Δ/Γ/Θ/V) | 5k–20k | +| **Screeners / heatmaps** | TradingView screener, CoinMarketCap, Finviz | 1,000–5,000 symbols × a few live fields (price, %chg, flash, sparkline) | 3k–15k | +| **Live-data spreadsheets** | Excel/Sheets + market add-ins, Bloomberg BQNT | a visible sheet is 5k–20k cells, many bound to streaming feeds + recompute | 5k–20k | +| **Observability walls** | Grafana, Datadog, Netdata | hundreds of panels × series, or a per-second host grid | 5k–50k | +| **Live log / trace tails** | Datadog Live Tail, Kibana, Sentry | streaming rows, each a tiny stateful unit, into a long virtualized buffer | 5k–50k | +| **Dense collaborative canvases** | Figma, tldraw | 5k–50k nodes; a `pointermove` / cursor stream fans out to all visible shapes | 5k–50k | +| **NOC / k8s / network walls** | k9s, Lens, traffic grids | thousands of pods/nodes/flows, each a live cell | 5k–20k | + +The sharpest target is a **dense live financial surface** (full order book or +options chain): genuinely 5k–20k live cells, updating tens of thousands of times a +second, often needed on **web _and_ native** from one codebase, where flat +per-cell memory matters — every property the benchmark measures pays at once. + +> **What does _not_ need this:** a handful-of-symbols chart view, a typical SaaS +> dashboard, a chat app — dozens to a few hundred live elements. The engine works +> there too, but so does anything; at that scale you'd pick it for the +> agnostic-render and bundle/memory reasons, not the throughput these tables show. diff --git a/benchmark/competitors.ts b/benchmark/ts/competitors.ts similarity index 100% rename from benchmark/competitors.ts rename to benchmark/ts/competitors.ts diff --git a/benchmark/demo/CHANGELOG.md b/benchmark/ts/demo/CHANGELOG.md similarity index 100% rename from benchmark/demo/CHANGELOG.md rename to benchmark/ts/demo/CHANGELOG.md diff --git a/benchmark/demo/README.md b/benchmark/ts/demo/README.md similarity index 99% rename from benchmark/demo/README.md rename to benchmark/ts/demo/README.md index eac46ff..138c5e8 100644 --- a/benchmark/demo/README.md +++ b/benchmark/ts/demo/README.md @@ -9,7 +9,7 @@ panels' backlogs grow as the load climbs. ```bash pnpm benchmark:demo # from the repo root # or -cd benchmark/demo && pnpm dev +cd benchmark/ts/demo && pnpm dev ``` Then open the printed URL (default ). It's idle until you diff --git a/benchmark/demo/index.html b/benchmark/ts/demo/index.html similarity index 100% rename from benchmark/demo/index.html rename to benchmark/ts/demo/index.html diff --git a/benchmark/demo/package.json b/benchmark/ts/demo/package.json similarity index 100% rename from benchmark/demo/package.json rename to benchmark/ts/demo/package.json diff --git a/benchmark/demo/src/app.tsx b/benchmark/ts/demo/src/app.tsx similarity index 100% rename from benchmark/demo/src/app.tsx rename to benchmark/ts/demo/src/app.tsx diff --git a/benchmark/demo/src/engines.ts b/benchmark/ts/demo/src/engines.ts similarity index 100% rename from benchmark/demo/src/engines.ts rename to benchmark/ts/demo/src/engines.ts diff --git a/benchmark/demo/src/feed.ts b/benchmark/ts/demo/src/feed.ts similarity index 100% rename from benchmark/demo/src/feed.ts rename to benchmark/ts/demo/src/feed.ts diff --git a/benchmark/demo/src/main.tsx b/benchmark/ts/demo/src/main.tsx similarity index 100% rename from benchmark/demo/src/main.tsx rename to benchmark/ts/demo/src/main.tsx diff --git a/benchmark/demo/src/metrics.ts b/benchmark/ts/demo/src/metrics.ts similarity index 100% rename from benchmark/demo/src/metrics.ts rename to benchmark/ts/demo/src/metrics.ts diff --git a/benchmark/demo/src/panels.tsx b/benchmark/ts/demo/src/panels.tsx similarity index 100% rename from benchmark/demo/src/panels.tsx rename to benchmark/ts/demo/src/panels.tsx diff --git a/benchmark/demo/vite.config.ts b/benchmark/ts/demo/vite.config.ts similarity index 91% rename from benchmark/demo/vite.config.ts rename to benchmark/ts/demo/vite.config.ts index f9db889..a8294ee 100644 --- a/benchmark/demo/vite.config.ts +++ b/benchmark/ts/demo/vite.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ // the engine package is workspace-linked; aliasing straight to its `src` // means Vite serves the real TS source (no build step, edits hot-reload) alias: { - '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine': resolve(__dirname, '../../../packages/core/src'), }, dedupe: ['react', 'react-dom'], }, diff --git a/benchmark/index.ts b/benchmark/ts/index.ts similarity index 93% rename from benchmark/index.ts rename to benchmark/ts/index.ts index 05db838..f8d7f25 100644 --- a/benchmark/index.ts +++ b/benchmark/ts/index.ts @@ -7,7 +7,7 @@ * The single command passes --expose-gc (memory needs it; harmless elsewhere). * * Each bench is also exported standalone if you want to run one in isolation: - * node --expose-gc --import tsx -e "import('./benchmark/tests/memory').then(m=>m.runMemory())" + * node --expose-gc --import tsx -e "import('./benchmark/ts/tests/memory').then(m=>m.runMemory())" * (or import the run* fn from its file). The rendering bench bootstraps jsdom * itself, so it's safe to run alone too. * diff --git a/benchmark/package.json b/benchmark/ts/package.json similarity index 100% rename from benchmark/package.json rename to benchmark/ts/package.json diff --git a/benchmark/report.ts b/benchmark/ts/report.ts similarity index 100% rename from benchmark/report.ts rename to benchmark/ts/report.ts diff --git a/benchmark/tests/compose.ts b/benchmark/ts/tests/compose.ts similarity index 100% rename from benchmark/tests/compose.ts rename to benchmark/ts/tests/compose.ts diff --git a/benchmark/tests/computed.ts b/benchmark/ts/tests/computed.ts similarity index 100% rename from benchmark/tests/computed.ts rename to benchmark/ts/tests/computed.ts diff --git a/benchmark/tests/construct.ts b/benchmark/ts/tests/construct.ts similarity index 100% rename from benchmark/tests/construct.ts rename to benchmark/ts/tests/construct.ts diff --git a/benchmark/tests/engine.ts b/benchmark/ts/tests/engine.ts similarity index 100% rename from benchmark/tests/engine.ts rename to benchmark/ts/tests/engine.ts diff --git a/benchmark/tests/fan-out.ts b/benchmark/ts/tests/fan-out.ts similarity index 100% rename from benchmark/tests/fan-out.ts rename to benchmark/ts/tests/fan-out.ts diff --git a/benchmark/tests/memory.ts b/benchmark/ts/tests/memory.ts similarity index 100% rename from benchmark/tests/memory.ts rename to benchmark/ts/tests/memory.ts diff --git a/benchmark/tests/rendering/bench.tsx b/benchmark/ts/tests/rendering/bench.tsx similarity index 100% rename from benchmark/tests/rendering/bench.tsx rename to benchmark/ts/tests/rendering/bench.tsx diff --git a/benchmark/tests/rendering/run.ts b/benchmark/ts/tests/rendering/run.ts similarity index 100% rename from benchmark/tests/rendering/run.ts rename to benchmark/ts/tests/rendering/run.ts diff --git a/benchmark/tsconfig.json b/benchmark/ts/tsconfig.json similarity index 66% rename from benchmark/tsconfig.json rename to benchmark/ts/tsconfig.json index c85f0b0..cfe49c6 100644 --- a/benchmark/tsconfig.json +++ b/benchmark/ts/tsconfig.json @@ -1,16 +1,16 @@ { // Typecheck the benchmark suite + the live demo. They run via tsx/vite (which // don't typecheck), so this is the only thing that catches type errors here. - "extends": "../tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { // the base sets no `jsx` (it's per-project); the benchmark is React-flavored "jsx": "react-jsx", // paths in `extends` resolve relative to THIS file, so redeclare them "paths": { - "@dunky.dev/state-machine": ["../packages/core/src"], - "@dunky.dev/react-state-machine": ["../packages/react/src"], - "@dunky.dev/native-state-machine": ["../packages/native/src"], - "@dunky.dev/state-machine-utils": ["../packages/shared/utils/src"] + "@dunky.dev/state-machine": ["../../packages/core/src"], + "@dunky.dev/react-state-machine": ["../../packages/react/src"], + "@dunky.dev/native-state-machine": ["../../packages/native/src"], + "@dunky.dev/state-machine-utils": ["../../packages/shared/utils/src"] }, // the benchmark touches Node globals (process, global) + DOM (jsdom, react-dom) "types": ["@types/node", "@types/react", "@types/react-dom", "@types/jsdom"] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..7292ccf --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "dunky-core" +description = "Substrate-agnostic state-machine engine — the Rust port of @dunky.dev/state-machine." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[features] +default = ["derive", "serde"] +# #[derive(State, Event, Context)] +derive = ["dep:dunky-macros"] +# Per-field serialization (SerializeFields) for bindings that mirror context into JS. +serde = ["dep:serde"] + +[dependencies] +dunky-macros = { workspace = true, optional = true } +serde = { workspace = true, optional = true } + +[dev-dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/core/README.md b/crates/core/README.md new file mode 100644 index 0000000..1adba7c --- /dev/null +++ b/crates/core/README.md @@ -0,0 +1,46 @@ +# dunky-core + +Dunky's state machines in Rust: the twin of the TypeScript engine, +[`@dunky.dev/state-machine`](../../packages/core). Both follow one behavior contract, +[`packages/core/SPEC.md`](../../packages/core/SPEC.md); the Rust tests in `tests/` are +ported from `packages/core/tests`. Use it in Rust programs and native bindings. + +```rust +use dunky_core::{Config, Context, Event, Machine, State, Types}; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +enum DialogState { Closed, Open } + +#[derive(Event, Clone, Debug)] +enum DialogEvent { Open, Close } + +#[derive(Context, Clone, Debug, PartialEq)] +struct DialogCtx { open_count: u32 } + +struct Dialog; +impl Types for Dialog { + type State = DialogState; + type Event = DialogEvent; + type Context = DialogCtx; +} + +let mut b = Config::::builder(DialogState::Closed, DialogCtx { open_count: 0 }); +b.action("count", |p| { + let n = p.context().open_count + 1; + p.set_context(DialogCtx::patch().open_count(n)); +}); +b.state(DialogState::Closed, |s| s.on(DialogEventKind::Open, |t| t.target(DialogState::Open).action("count"))); +b.state(DialogState::Open, |s| s.on(DialogEventKind::Close, |t| t.target(DialogState::Closed))); +let config = b.build(); + +let dialog = Machine::new(&config); +dialog.start(); +dialog.send(DialogEvent::Open); +assert_eq!(dialog.state(), DialogState::Open); +``` + +## Learn more + +- [`SPEC.md`](./SPEC.md) — what the crate guarantees: authoring in Rust, timers as + commands, change reporting, and how it stays in sync with the TS engine. The behavior + contract both engines follow is [`packages/core/SPEC.md`](../../packages/core/SPEC.md). diff --git a/crates/core/SPEC.md b/crates/core/SPEC.md new file mode 100644 index 0000000..d2549a6 --- /dev/null +++ b/crates/core/SPEC.md @@ -0,0 +1,139 @@ +# SPEC - `dunky-core` + +## Overview + +`dunky-core` is the Rust implementation of Dunky's state machines. The TypeScript +implementation is [`@dunky.dev/state-machine`](../../packages/core). They are two +engines with one behavior contract, +[`packages/core/SPEC.md`](../../packages/core/SPEC.md): flat states, run-to-completion, +guards, actions, effects, timed transitions, derived data, data-reactions, observation, +the connector, composition, and the cross-instance store. + +This SPEC covers only what is specific to the crate: authoring a machine in Rust, and +timers without a clock. + +``` + Rust types + config builder + | + | build once + v + config (shared by every machine of the type) + | + v + machine ------ timer commands out ------> host clock (an event loop, a test clock) + ^ | | + | +-------- changes out (state, fields) | + | | + +----------- events in, due timers in <-----+ +``` + +## Intent + +Bring the same machines to Rust programs and native bindings (React Native over JSI), +optimized for Rust with no trade-off made for another runtime. The engine stays free of +IO and platform, so one build behaves the same everywhere; a host supplies the clock. + +## Scope & boundaries + +**In scope.** The engine runtime; typed authoring of machines; timers as commands; change +reporting. + +**Out of scope — invariants, not preferences.** + +- **No IO, no clock, no platform.** The engine never reads time and never schedules work. + A host does. +- **No JS.** Bindings live in their own crates (`crates/uniffi` for React Native). +- **One thread.** A machine and what it shares stay on one thread. A binding that crosses + threads enforces that itself. +- **At most 64 context fields** per machine: each field is one bit of the change mask. + +## Staying in sync with the TS engine + +- A behavior change lands in `packages/core/SPEC.md`, in both engines, and in both test + suites, in the same PR. +- The Rust tests in `tests/` are ported from `packages/core/tests`, file by file: a new + behavior test on one side gets its twin on the other. +- What only one engine has (this SPEC, or a TS-only API like `setup()` typing) stays out + of the shared contract. + +## Authoring a machine in Rust + +- A machine type names three types: its **states** (an enum without data), its + **events** (an enum: each variant is a kind, and a variant may carry a payload), and its + **context** (a struct). +- Derives generate the glue: the state names and indices; one event kind per variant, + which keys the handlers; and for the context, a patch builder that sets fields by name, + one typed handle per field (`Field`: its bit and how to read it), and a reader + that records which fields a derived value reads. +- A selection of one field (`select_field`, the twin of TS `select.context(key)`) reads + through the field's handle. +- A **config** is built once, with a builder, from the initial state and the seed + context. It is cheap to share: every machine of the type uses the same config. +- Guards, actions, effects and delays are inline closures or registered names; a name + resolves when the config is built. +- A derived value is declared with a typed key, numbered in declaration order, and an + equality (`PartialEq` by default). +- With the `serde` feature, a context field serializes by index and an event rebuilds + from its kind and its payload: what a binding needs to cross into another runtime. + +## The behavior contract + +The contract is `packages/core/SPEC.md`'s. These guarantees are specific to the crate. + +### Timers are commands + +- Scheduling a timed transition emits a **start** command: a timer id and a delay. + Leaving the state emits a **cancel** command for each of its pending timers. +- The host takes the commands after each call, runs them on its clock, and reports each + due timer by id. A report for a cancelled, fired, or stale timer is ignored. +- A timer id is unique among the machine's pending timers. + +### Change reporting + +- Each effective change stamps what it touched: the state, or a set of context fields. +- A binding can take what changed since its last take — a state flag and a field mask — + to mirror only those values into another runtime. + +### Re-entrancy + +- User code (guards, actions, effects, delays, derived definitions) gets params — the + context, the event, derived values, a way to write context, a way to dispatch — not + the machine. +- Observers (subscriptions, selections, lifecycle listeners, effect cleanups) run while + the engine holds no borrow: they may read, dispatch, or write context. A dispatch made + during a run-to-completion step is queued. + +### Equality + +- A context write changes a field only when the value differs (`PartialEq`). A derived + value changes by its declared equality. + +### Failures + +- A missing named implementation panics in debug builds, and warns and does nothing in + release builds. +- A runaway step (more than 10,000 items in one run-to-completion step) panics in debug + builds and drops the queue in release builds. +- A panicking cleanup does not leak the others: every cleanup runs, then the first panic + resumes. + +## Performance guarantees + +- The steady state allocates nothing: events, notifications, selections and recomputes + reuse their memory. +- A field selection wakes only on a write to its field (and a state selection only on a + state change): every other notification skips it with one bit test, in subscription + order. Which selections fire, and when, is exactly the contract's. +- A derived value is fresh by a stamp comparison: a read re-reads and compares no input + value. +- One config per machine type, shared by every instance. +- A machine pays only for what it uses: field stamps grow with the fields it writes, + broadcasts exist from the first subscriber, and states without effects or timers do no + effect work. + +## Edge cases that carry design meaning + +- An observer may dispatch; the event runs after the current item. +- A due timer reported after stop, after the state was left, or after the state was + entered again is ignored. +- A context with more than 64 fields is rejected when the machine is built. diff --git a/crates/core/src/broadcast.rs b/crates/core/src/broadcast.rs new file mode 100644 index 0000000..4e117b4 --- /dev/null +++ b/crates/core/src/broadcast.rs @@ -0,0 +1,130 @@ +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +type Listener = Rc; + +/// Wakes on every notification. +pub(crate) const ALL: u64 = u64::MAX; + +/// Payload-less one-to-all notify. +/// +/// Steady-state notifies allocate nothing: iteration runs over a cached snapshot, rebuilt +/// only when membership changes. Mid-pass (un)subscribes still take effect within the pass — +/// a dirty flag flips iteration to membership-checked mode, and a swapped snapshot (from a +/// nested notify) counts as mid-pass churn too. +pub(crate) struct Broadcast { + /// Per listener: its id, the change bits it wakes on, and the listener. + listeners: RefCell>, + snapshot: RefCell>, + /// Bumped each time the snapshot is rebuilt, so a pass sees a nested rebuild with one + /// load per listener. + generation: Cell, + dirty: Cell, + next_id: Cell, +} + +impl Broadcast { + pub(crate) fn new() -> Rc { + Rc::new(Self { + listeners: RefCell::new(Vec::new()), + snapshot: RefCell::new(Rc::from(Vec::new())), + generation: Cell::new(0), + dirty: Cell::new(false), + next_id: Cell::new(0), + }) + } + + pub(crate) fn add(self: &Rc, listener: Listener) -> Subscription { + self.add_waking(ALL, listener) + } + + /// A listener that skips notifications whose change bits miss `wake` (a selection of + /// one field cannot change when another field does). Order stays subscription order. + pub(crate) fn add_waking(self: &Rc, wake: u64, listener: Listener) -> Subscription { + let id = self.next_id.get(); + self.next_id.set(id + 1); + self.listeners.borrow_mut().push((id, wake, listener)); + self.dirty.set(true); + let weak: Weak = Rc::downgrade(self); + Subscription::new(move || { + if let Some(bus) = weak.upgrade() { + bus.remove(id); + } + }) + } + + fn remove(&self, id: u64) { + let mut listeners = self.listeners.borrow_mut(); + if let Some(pos) = listeners.iter().position(|(i, _, _)| *i == id) { + listeners.remove(pos); + self.dirty.set(true); + } + } + + fn has(&self, id: u64) -> bool { + self.listeners.borrow().iter().any(|(i, _, _)| *i == id) + } + + pub(crate) fn notify(&self) { + self.notify_changed(ALL); + } + + /// Notify the listeners whose wake bits meet `changed`. + pub(crate) fn notify_changed(&self, changed: u64) { + if self.dirty.get() { + let fresh: Rc<[(u64, u64, Listener)]> = + self.listeners.borrow().iter().cloned().collect(); + *self.snapshot.borrow_mut() = fresh; + self.generation.set(self.generation.get() + 1); + self.dirty.set(false); + } + let snap = self.snapshot.borrow().clone(); + let generation = self.generation.get(); + for (id, wake, listener) in snap.iter() { + if wake & changed == 0 { + continue; + } + let undisturbed = !self.dirty.get() && self.generation.get() == generation; + if undisturbed || self.has(*id) { + listener(); + } + } + } + + pub(crate) fn clear(&self) { + self.listeners.borrow_mut().clear(); + self.dirty.set(true); + } + + pub(crate) fn len(&self) -> usize { + self.listeners.borrow().len() + } +} + +/// A live subscription. Call [`Subscription::unsubscribe`] to detach; dropping the handle +/// does NOT detach (like the bare unsubscribe function the TS API returns). +#[must_use = "dropping a Subscription keeps the listener attached; call unsubscribe() to detach"] +pub struct Subscription { + off: Option>, +} + +impl Subscription { + /// A subscription whose `unsubscribe` runs `off` (for custom [`Member`](crate::Member) + /// implementations). + pub fn new(off: impl FnOnce() + 'static) -> Self { + Self { + off: Some(Box::new(off)), + } + } + pub fn unsubscribe(mut self) { + if let Some(off) = self.off.take() { + off(); + } + } +} + +impl std::fmt::Debug for Subscription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Subscription") + } +} diff --git a/crates/core/src/compose.rs b/crates/core/src/compose.rs new file mode 100644 index 0000000..36f5834 --- /dev/null +++ b/crates/core/src/compose.rs @@ -0,0 +1,159 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crate::broadcast::Subscription; +use crate::machine::Machine; +use crate::traits::Types; + +/// The lifecycle + observation surface a composition needs from any machine. +pub trait Member { + fn start(&self); + fn stop(&self); + fn subscribe_any(&self, listener: Rc) -> Subscription; +} + +impl Member for Machine { + fn start(&self) { + Machine::start(self) + } + fn stop(&self) { + Machine::stop(self) + } + fn subscribe_any(&self, listener: Rc) -> Subscription { + self.subscribe(move || listener()) + } +} + +type Registry = Rc)>>>; + +/// Several machines run as one unit (orthogonal regions): shared start/stop plus +/// cross-member `sync` and `combine`. Members stay independent — keep your own handles +/// to read and send to each. +pub struct Composition { + members: Vec>, + registry: Registry, + next_id: RefCell, +} + +impl Default for Composition { + fn default() -> Self { + Self::new() + } +} + +impl Composition { + pub fn new() -> Self { + Self { + members: Vec::new(), + registry: Rc::new(RefCell::new(Vec::new())), + next_id: RefCell::new(0), + } + } + + /// Add a member. Members start in the order they are added. + pub fn with(mut self, member: &M) -> Self { + self.members.push(Rc::new(member.clone())); + self + } + + pub fn start(&self) { + for m in &self.members { + m.start(); + } + } + + /// Dispose every sync/combine subscription, then stop members in reverse order. + pub fn stop(&self) { + let all = std::mem::take(&mut *self.registry.borrow_mut()); + for (_, subs) in all { + for s in subs { + s.unsubscribe(); + } + } + for m in self.members.iter().rev() { + m.stop(); + } + } + + fn register(&self, subs: Vec) -> Subscription { + let id = { + let mut next = self.next_id.borrow_mut(); + *next += 1; + *next + }; + self.registry.borrow_mut().push((id, subs)); + let registry = Rc::downgrade(&self.registry); + Subscription::new(move || { + let Some(registry) = registry.upgrade() else { + return; + }; + let found = { + let mut reg = registry.borrow_mut(); + reg.iter() + .position(|(i, _)| *i == id) + .map(|pos| reg.remove(pos)) + }; + if let Some((_, subs)) = found { + for s in subs { + s.unsubscribe(); + } + } + }) + } + + /// Run `reaction` whenever any member changes. Does not fire on setup; disposed on stop. + pub fn sync(&self, reaction: impl Fn() + 'static) -> Subscription { + let reaction: Rc = Rc::new(reaction); + let subs = self + .members + .iter() + .map(|m| m.subscribe_any(reaction.clone())) + .collect(); + self.register(subs) + } + + /// A value-deduped derivation across members. + pub fn combine(&self, selector: impl Fn() -> V + 'static) -> Combined<'_, V> { + Combined { + composition: self, + selector: Rc::new(selector), + } + } +} + +/// A value-deduped selection across a composition's members. +pub struct Combined<'c, V> { + composition: &'c Composition, + selector: Rc V>, +} + +impl Combined<'_, V> { + pub fn value(&self) -> V { + (self.selector)() + } + + /// Fires only when the combined value changes. Disposed with the composition's stop. + pub fn subscribe(&self, listener: impl Fn(&V) + 'static) -> Subscription { + let selector = self.selector.clone(); + // `Rc` so the listener runs with no borrow of `prev` held (it may send to a member, + // which notifies and re-enters this wake). + let prev = Rc::new(RefCell::new(Rc::new(selector()))); + let listener = Rc::new(listener); + let wake: Rc = Rc::new(move || { + let next = selector(); + if **prev.borrow() == next { + return; + } + let next = Rc::new(next); + *prev.borrow_mut() = next.clone(); + listener(&next); + }); + let subs = self + .composition + .members + .iter() + .map(|m| m.subscribe_any(wake.clone())) + .collect(); + self.composition.register(subs) + } +} diff --git a/crates/core/src/computed.rs b/crates/core/src/computed.rs new file mode 100644 index 0000000..fdec4ed --- /dev/null +++ b/crates/core/src/computed.rs @@ -0,0 +1,230 @@ +//! Derived data: lazy, memoized, dependency-tracked per field. +//! +//! Each computed value records what it read on its last evaluation — context fields (as a +//! bit mask, through the tracked reader), other computed values, and whether it read the +//! state. It recomputes only when one of those inputs changed since. Change detection is +//! by tick: every effective write bumps a machine-wide tick and stamps the written fields; +//! a computed value is stale iff an input was stamped after the value was last validated. + +use std::any::Any; +use std::cell::{Cell, RefCell}; +use std::marker::PhantomData; +use std::rc::Rc; + +use crate::machine::{Core, Inner}; +use crate::traits::{Context, Types}; + +/// Evaluate a computed value into its slot, and say whether it changed (`PartialEq`). +/// The slot's value is reused in place when nothing else holds it, so a recompute +/// allocates nothing. +pub(crate) type ComputedEval = + Rc Fn(&ComputedParams<'a, T>, &mut Option>) -> bool>; + +pub(crate) struct ComputedDef { + pub(crate) name: &'static str, + pub(crate) eval: ComputedEval, +} + +/// A typed handle to one computed value, returned by `ConfigBuilder::computed`. +pub struct ComputedKey { + id: usize, + _value: PhantomData V>, +} + +impl ComputedKey { + /// Keys are numbered in definition order; a const key must match that order. + pub const fn new(id: usize) -> Self { + Self { + id, + _value: PhantomData, + } + } + pub const fn id(self) -> usize { + self.id + } +} + +impl Clone for ComputedKey { + fn clone(&self) -> Self { + *self + } +} +impl Copy for ComputedKey {} +impl std::fmt::Debug for ComputedKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ComputedKey({})", self.id) + } +} + +/// What a computed definition reads. Reads are tracked: `context.()`, `state()` and +/// `computed(key)` each become an input of this value. +pub struct ComputedParams<'a, T: Types> { + pub context: ::Reader<'a>, + core: &'a Core, + inner: &'a Inner, + computed_deps: &'a RefCell>, + reads_state: &'a Cell, +} + +impl ComputedParams<'_, T> { + pub fn state(&self) -> T::State { + self.reads_state.set(true); + self.core.state + } + pub fn computed(&self, key: ComputedKey) -> Rc { + { + let mut deps = self.computed_deps.borrow_mut(); + if !deps.contains(&key.id) { + deps.push(key.id); + } + } + downcast(get(self.core, self.inner, key.id), self.inner, key.id) + } +} + +#[derive(Default)] +pub(crate) struct Slot { + value: Option>, + /// Tick at which the value was last computed or validated. + validated_at: u64, + /// Tick at which the value last changed (by `eq`). + changed_at: u64, + ctx_deps: u64, + reads_state: bool, + computed_deps: Vec, + evaluating: bool, +} + +pub(crate) struct ComputedRuntime { + slots: Vec>, +} + +impl ComputedRuntime { + pub(crate) fn new(count: usize) -> Self { + Self { + slots: (0..count).map(|_| RefCell::new(Slot::default())).collect(), + } + } +} + +pub(crate) fn downcast( + value: Rc, + inner: &Inner, + id: usize, +) -> Rc { + value.downcast::().unwrap_or_else(|_| { + panic!( + "[machine] computed \"{}\" read with the wrong type", + inner.config.0.computed[id].name + ) + }) +} + +/// The current value of computed `id`, recomputing it only if an input changed. +pub(crate) fn get(core: &Core, inner: &Inner, id: usize) -> Rc { + if let Some(value) = fresh(core, inner, id) { + return value; + } + recompute(core, inner, id) +} + +/// The tick at which computed `id` last changed value (after bringing it up to date). +/// Bindings compare it to skip re-reading an unchanged value. +pub(crate) fn changed_at(core: &Core, inner: &Inner, id: usize) -> u64 { + get(core, inner, id); + core.computed.slots[id].borrow().changed_at +} + +/// The value of `id` if no input changed since it was validated (re-validating it). +fn fresh(core: &Core, inner: &Inner, id: usize) -> Option> { + let slots = &core.computed.slots; + let (at, n) = { + let slot = slots[id].borrow(); + slot.value.as_ref()?; + if slot.validated_at == core.tick { + return slot.value.clone(); + } + let at = slot.validated_at; + let mut bits = slot.ctx_deps; + while bits != 0 { + let i = bits.trailing_zeros() as usize; + if core + .field_changed_at + .get(i) + .is_some_and(|&changed| changed > at) + { + return None; + } + bits &= bits - 1; + } + if slot.reads_state && core.state_changed_at > at { + return None; + } + (at, slot.computed_deps.len()) + }; + // A computed input resolves its own staleness first, so changes surface transitively. + for k in 0..n { + let dep = slots[id].borrow().computed_deps[k]; + get(core, inner, dep); + if slots[dep].borrow().changed_at > at { + return None; + } + } + let mut slot = slots[id].borrow_mut(); + slot.validated_at = core.tick; + slot.value.clone() +} + +fn recompute(core: &Core, inner: &Inner, id: usize) -> Rc { + let def = &inner.config.0.computed[id]; + let slot_cell = &core.computed.slots[id]; + // The value leaves the slot while it is evaluated: the definition writes the new one + // into it. + let (buffer, mut value) = { + let mut slot = slot_cell.borrow_mut(); + if slot.evaluating { + panic!("[machine] computed \"{}\" depends on itself", def.name); + } + slot.evaluating = true; + let mut buffer = std::mem::take(&mut slot.computed_deps); + buffer.clear(); + (buffer, slot.value.take()) + }; + // A panicking definition must not leave the slot flagged as evaluating. + struct Reset<'s>(&'s RefCell); + impl Drop for Reset<'_> { + fn drop(&mut self) { + if let Ok(mut slot) = self.0.try_borrow_mut() { + if slot.evaluating { + slot.evaluating = false; + slot.value = None; + } + } + } + } + let reset = Reset(slot_cell); + + let ctx_deps = Cell::new(0u64); + let computed_deps = RefCell::new(buffer); + let reads_state = Cell::new(false); + let params = ComputedParams { + context: core.ctx.reader(&ctx_deps), + core, + inner, + computed_deps: &computed_deps, + reads_state: &reads_state, + }; + let changed = (def.eval)(¶ms, &mut value); + let mut slot = slot_cell.borrow_mut(); + slot.evaluating = false; + std::mem::forget(reset); + slot.value = value; + if changed { + slot.changed_at = core.tick; + } + slot.validated_at = core.tick; + slot.ctx_deps = ctx_deps.get(); + slot.reads_state = reads_state.get(); + slot.computed_deps = computed_deps.into_inner(); + slot.value.clone().expect("computed value just set") +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs new file mode 100644 index 0000000..800b324 --- /dev/null +++ b/crates/core/src/config.rs @@ -0,0 +1,669 @@ +use std::any::Any; +use std::collections::HashMap; +use std::rc::Rc; + +use crate::computed::{ComputedDef, ComputedKey, ComputedParams}; +use crate::params::{ActionParams, GuardParams}; +use crate::traits::{Context, EventEnum, Field, StateEnum, Types}; + +pub type ActionFn = Rc)>; +pub type GuardFn = Rc) -> bool>; +pub type EffectFn = Rc) -> Option>; +pub type DelayFn = Rc) -> u32>; +/// What an effect returns to undo itself; run when its state is left or the machine stops. +pub type Cleanup = Box; + +/// A reference to a named implementation. Resolved once, when the config is built. +pub struct Named { + pub(crate) name: &'static str, + pub(crate) imp: Option, +} + +impl Clone for Named { + fn clone(&self) -> Self { + Self { + name: self.name, + imp: self.imp.clone(), + } + } +} + +/// A guard: an inline predicate, a named one, or a combination. +pub enum Guard { + Fn(GuardFn), + Named(Named>), + /// True iff every guard passes. Empty → true. + And(Vec>), + /// True iff any guard passes. Empty → false. + Or(Vec>), + Not(Box>), +} + +impl Guard { + pub fn when(f: impl Fn(&GuardParams<'_, T>) -> bool + 'static) -> Self { + Guard::Fn(Rc::new(f)) + } + pub fn named(name: &'static str) -> Self { + Guard::Named(Named { name, imp: None }) + } + pub fn and(guards: impl IntoIterator>) -> Self { + Guard::And(guards.into_iter().collect()) + } + pub fn or(guards: impl IntoIterator>) -> Self { + Guard::Or(guards.into_iter().collect()) + } + #[allow(clippy::should_implement_trait)] + pub fn not(guard: Guard) -> Self { + Guard::Not(Box::new(guard)) + } +} + +impl From<&'static str> for Guard { + fn from(name: &'static str) -> Self { + Guard::named(name) + } +} + +/// An action: an inline fn, a named one, or a conditional (`one_of`). +pub enum Action { + Fn(ActionFn), + Named(Named>), + OneOf(Vec>), +} + +impl Action { + pub fn run(f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + Action::Fn(Rc::new(f)) + } + pub fn named(name: &'static str) -> Self { + Action::Named(Named { name, imp: None }) + } + /// Write-sugar for the most common action: patch the context. + pub fn act( + patch: impl Fn(&ActionParams<'_, T>) -> ::Patch + 'static, + ) -> Self { + Action::Fn(Rc::new(move |p: &mut ActionParams<'_, T>| { + let next = patch(p); + p.set_context(next); + })) + } + /// Run the first branch whose guard passes; a guardless branch is the fallback. + pub fn one_of(branches: impl IntoIterator>) -> Self { + Action::OneOf(branches.into_iter().collect()) + } +} + +impl From<&'static str> for Action { + fn from(name: &'static str) -> Self { + Action::named(name) + } +} + +/// One branch of a `one_of`. +pub struct Branch { + pub(crate) guard: Option>, + pub(crate) actions: Vec>, +} + +impl Branch { + pub fn when(guard: impl Into>, actions: impl IntoIterator>) -> Self { + Self { + guard: Some(guard.into()), + actions: actions.into_iter().collect(), + } + } + pub fn otherwise(actions: impl IntoIterator>) -> Self { + Self { + guard: None, + actions: actions.into_iter().collect(), + } + } +} + +/// An effect: started when its state is entered, cleaned up when it is left. +pub enum Effect { + Fn(EffectFn), + Named(Named>), +} + +impl Effect { + pub fn run(f: impl Fn(&mut ActionParams<'_, T>) -> Option + 'static) -> Self { + Effect::Fn(Rc::new(f)) + } + pub fn named(name: &'static str) -> Self { + Effect::Named(Named { name, imp: None }) + } +} + +impl From<&'static str> for Effect { + fn from(name: &'static str) -> Self { + Effect::named(name) + } +} + +/// A delay for an `after` transition: fixed milliseconds or a named, dynamic delay. +pub enum Delay { + Ms(u32), + Named(Named>), +} + +impl From for Delay { + fn from(ms: u32) -> Self { + Delay::Ms(ms) + } +} + +impl From<&'static str> for Delay { + fn from(name: &'static str) -> Self { + Delay::Named(Named { name, imp: None }) + } +} + +/// One transition: optional target, optional guard, actions in order. +pub struct Transition { + pub(crate) target: Option, + pub(crate) guard: Option>, + pub(crate) actions: Vec>, +} + +/// Builds one transition candidate inside `StateBuilder::on` / `after`. +pub struct TransitionBuilder(Transition); + +impl TransitionBuilder { + pub fn target(mut self, state: T::State) -> Self { + self.0.target = Some(state); + self + } + /// A named guard. + pub fn guard(mut self, name: &'static str) -> Self { + self.0.guard = Some(Guard::named(name)); + self + } + /// An inline guard. + pub fn guard_fn(mut self, f: impl Fn(&GuardParams<'_, T>) -> bool + 'static) -> Self { + self.0.guard = Some(Guard::when(f)); + self + } + /// Any guard value, e.g. `Guard::and([...])`. + pub fn guard_expr(mut self, guard: Guard) -> Self { + self.0.guard = Some(guard); + self + } + /// A named action. + pub fn action(mut self, name: &'static str) -> Self { + self.0.actions.push(Action::named(name)); + self + } + /// An inline action. + pub fn run(mut self, f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + self.0.actions.push(Action::run(f)); + self + } + /// An inline context patch. + pub fn act( + mut self, + patch: impl Fn(&ActionParams<'_, T>) -> ::Patch + 'static, + ) -> Self { + self.0.actions.push(Action::act(patch)); + self + } + /// Any action value, e.g. `Action::one_of([...])`. + pub fn push(mut self, action: Action) -> Self { + self.0.actions.push(action); + self + } +} + +/// A handler for one event (or one `after` delay): candidates in order, first passing guard wins. +pub(crate) struct Entry { + candidates: Vec>, +} + +impl Default for Entry { + fn default() -> Self { + Self { + candidates: Vec::new(), + } + } +} + +impl std::ops::Deref for Entry { + type Target = Vec>; + fn deref(&self) -> &Self::Target { + &self.candidates + } +} + +impl std::ops::DerefMut for Entry { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.candidates + } +} + +pub(crate) struct StateNode { + /// Indexed by event kind. + pub(crate) on: Vec>, + pub(crate) entry: Vec>, + pub(crate) exit: Vec>, + pub(crate) effects: Vec>, + pub(crate) after: Vec<(Delay, Entry)>, + pub(crate) tags: Vec<&'static str>, +} + +impl StateNode { + fn new(kinds: usize) -> Self { + Self { + on: (0..kinds).map(|_| Entry::default()).collect(), + entry: Vec::new(), + exit: Vec::new(), + effects: Vec::new(), + after: Vec::new(), + tags: Vec::new(), + } + } +} + +/// Builds one state inside `ConfigBuilder::state`. +pub struct StateBuilder(StateNode); + +impl StateBuilder { + /// Add a candidate for `kind`. Calling `on` again for the same kind appends the next + /// candidate (guard fallthrough: the first whose guard passes wins). + pub fn on( + mut self, + kind: ::Kind, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> Self { + let t = build(TransitionBuilder(empty_transition())); + self.0.on[::kind_index(kind)].push(t.0); + self + } + pub fn entry(mut self, action: impl Into>) -> Self { + self.0.entry.push(action.into()); + self + } + pub fn entry_run(mut self, f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + self.0.entry.push(Action::run(f)); + self + } + pub fn exit(mut self, action: impl Into>) -> Self { + self.0.exit.push(action.into()); + self + } + pub fn exit_run(mut self, f: impl Fn(&mut ActionParams<'_, T>) + 'static) -> Self { + self.0.exit.push(Action::run(f)); + self + } + pub fn effect(mut self, effect: impl Into>) -> Self { + self.0.effects.push(effect.into()); + self + } + pub fn effect_run( + mut self, + f: impl Fn(&mut ActionParams<'_, T>) -> Option + 'static, + ) -> Self { + self.0.effects.push(Effect::run(f)); + self + } + /// A timed transition while in this state. Calling `after` again with an equal delay + /// adds an independent timer (like a second key in the TS `after` map). + pub fn after( + mut self, + delay: impl Into>, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> Self { + let t = build(TransitionBuilder(empty_transition())); + let mut entry = Entry::default(); + entry.push(t.0); + self.0.after.push((delay.into(), entry)); + self + } + /// Add a fallthrough candidate to the most recent `after`. + pub fn after_or( + mut self, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> Self { + let t = build(TransitionBuilder(empty_transition())); + self.0 + .after + .last_mut() + .expect("after_or() needs a preceding after()") + .1 + .push(t.0); + self + } + pub fn tag(mut self, tag: &'static str) -> Self { + self.0.tags.push(tag); + self + } +} + +fn empty_transition() -> Transition { + Transition { + target: None, + guard: None, + actions: Vec::new(), + } +} + +pub(crate) enum WatchSource { + Field(usize), + Computed(usize), +} + +pub(crate) struct Watch { + pub(crate) source: WatchSource, + pub(crate) actions: Vec>, +} + +pub(crate) struct ConfigInner { + pub(crate) initial: T::State, + pub(crate) context: T::Context, + pub(crate) states: Vec>, + /// Any-state handlers, indexed by event kind. + pub(crate) on_any: Vec>, + pub(crate) computed: Vec>, + pub(crate) watch: Vec>, + pub(crate) guards: HashMap<&'static str, GuardFn>, +} + +/// A built machine config — static description, shared by every machine built from it. +pub struct Config(pub(crate) Rc>); + +impl Clone for Config { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Config { + pub fn builder(initial: T::State, context: T::Context) -> ConfigBuilder { + Self::builder_with_counts( + initial, + context, + ::NAMES.len(), + ::KIND_NAMES.len(), + ) + } + + fn builder_with_counts( + initial: T::State, + context: T::Context, + states: usize, + kinds: usize, + ) -> ConfigBuilder { + ConfigBuilder { + inner: ConfigInner { + initial, + context, + states: (0..states).map(|_| StateNode::new(kinds)).collect(), + on_any: (0..kinds).map(|_| Entry::default()).collect(), + computed: Vec::new(), + watch: Vec::new(), + guards: HashMap::new(), + }, + actions: HashMap::new(), + effects: HashMap::new(), + delays: HashMap::new(), + kinds, + } + } + + pub fn initial(&self) -> T::State { + self.0.initial + } + + /// The seed context every new machine copies. + pub fn context(&self) -> &T::Context { + &self.0.context + } + + pub fn computed_names(&self) -> impl Iterator + '_ { + self.0.computed.iter().map(|d| d.name) + } + + pub fn tags(&self, state: T::State) -> &[&'static str] { + &self.0.states[state.index()].tags + } + + /// The handler for `kind` in `state`: the state's own, else the any-state one. + pub(crate) fn lookup(&self, state: T::State, kind: usize) -> Option<&Entry> { + let own = &self.0.states[state.index()].on[kind]; + if !own.is_empty() { + return Some(own); + } + let any = &self.0.on_any[kind]; + (!any.is_empty()).then_some(any) + } +} + +/// Builds a [`Config`]. Register named implementations and computed values first, then +/// describe the states; `build()` resolves every name. +pub struct ConfigBuilder { + inner: ConfigInner, + actions: HashMap<&'static str, ActionFn>, + effects: HashMap<&'static str, EffectFn>, + delays: HashMap<&'static str, DelayFn>, + /// Number of event types (each state's `on` table has one slot per type). + kinds: usize, +} + +impl ConfigBuilder { + pub fn state( + &mut self, + state: T::State, + build: impl FnOnce(StateBuilder) -> StateBuilder, + ) -> &mut Self { + let index = state.index(); + let kinds = self.kinds; + let node = std::mem::replace(&mut self.inner.states[index], StateNode::new(kinds)); + self.inner.states[index] = build(StateBuilder(node)).0; + self + } + + /// An any-state handler. A state's own handler for the same kind wins. + pub fn on( + &mut self, + kind: ::Kind, + build: impl FnOnce(TransitionBuilder) -> TransitionBuilder, + ) -> &mut Self { + let t = build(TransitionBuilder(empty_transition())); + self.inner.on_any[::kind_index(kind)].push(t.0); + self + } + + pub fn guard( + &mut self, + name: &'static str, + f: impl Fn(&GuardParams<'_, T>) -> bool + 'static, + ) -> &mut Self { + self.inner.guards.insert(name, Rc::new(f)); + self + } + + pub fn action( + &mut self, + name: &'static str, + f: impl Fn(&mut ActionParams<'_, T>) + 'static, + ) -> &mut Self { + self.actions.insert(name, Rc::new(f)); + self + } + + pub fn effect( + &mut self, + name: &'static str, + f: impl Fn(&mut ActionParams<'_, T>) -> Option + 'static, + ) -> &mut Self { + self.effects.insert(name, Rc::new(f)); + self + } + + pub fn delay( + &mut self, + name: &'static str, + f: impl Fn(&GuardParams<'_, T>) -> u32 + 'static, + ) -> &mut Self { + self.delays.insert(name, Rc::new(f)); + self + } + + /// Derived data: lazy, memoized, recomputed only when a field (or computed value, or the + /// state) it read has changed. Returns the typed key used to read it. + pub fn computed( + &mut self, + name: &'static str, + f: impl for<'a> Fn(&ComputedParams<'a, T>) -> V + 'static, + ) -> ComputedKey { + let id = self.inner.computed.len(); + self.inner.computed.push(ComputedDef { + name, + eval: Rc::new( + move |p: &ComputedParams<'_, T>, slot: &mut Option>| { + let next = f(p); + if let Some(rc) = slot { + if let Some(old) = Rc::get_mut(rc).and_then(|old| old.downcast_mut::()) { + if *old == next { + return false; + } + *old = next; + return true; + } + if rc.downcast_ref::().is_some_and(|old| *old == next) { + return false; + } + } + *slot = Some(Rc::new(next)); + true + }, + ), + }); + ComputedKey::new(id) + } + + /// Data-reaction on a context field: its actions run (deferred) whenever the field + /// changes, in any state, while the machine runs. + pub fn watch( + &mut self, + field: Field, + actions: impl IntoIterator>, + ) -> &mut Self { + self.inner.watch.push(Watch { + source: WatchSource::Field(field.index()), + actions: actions.into_iter().collect(), + }); + self + } + + /// Data-reaction on a computed value. + pub fn watch_computed( + &mut self, + key: ComputedKey, + actions: impl IntoIterator>, + ) -> &mut Self { + self.inner.watch.push(Watch { + source: WatchSource::Computed(key.id()), + actions: actions.into_iter().collect(), + }); + self + } + + /// Resolve every named reference. A name with no implementation stays unresolved and + /// fails when it runs: a panic in debug builds, a warning + no-op in release builds. + pub fn build(&mut self) -> Config { + let kinds = self.kinds; + let empty = ConfigInner { + initial: self.inner.initial, + context: self.inner.context.clone(), + states: Vec::new(), + on_any: (0..kinds).map(|_| Entry::default()).collect(), + computed: Vec::new(), + watch: Vec::new(), + guards: HashMap::new(), + }; + let mut inner = std::mem::replace(&mut self.inner, empty); + let reg = Registry { + guards: &inner.guards.clone(), + actions: &self.actions, + effects: &self.effects, + delays: &self.delays, + }; + for node in &mut inner.states { + for entry in &mut node.on { + reg.entry(entry); + } + reg.actions(&mut node.entry); + reg.actions(&mut node.exit); + for effect in &mut node.effects { + if let Effect::Named(n) = effect { + n.imp = reg.effects.get(n.name).cloned(); + } + } + for (delay, entry) in &mut node.after { + if let Delay::Named(n) = delay { + n.imp = reg.delays.get(n.name).cloned(); + } + reg.entry(entry); + } + } + for entry in &mut inner.on_any { + reg.entry(entry); + } + for w in &mut inner.watch { + reg.actions(&mut w.actions); + } + Config(Rc::new(inner)) + } +} + +struct Registry<'r, T: Types> { + guards: &'r HashMap<&'static str, GuardFn>, + actions: &'r HashMap<&'static str, ActionFn>, + effects: &'r HashMap<&'static str, EffectFn>, + delays: &'r HashMap<&'static str, DelayFn>, +} + +impl Registry<'_, T> { + fn entry(&self, entry: &mut Entry) { + for t in entry.iter_mut() { + if let Some(g) = &mut t.guard { + self.guard(g); + } + self.actions(&mut t.actions); + } + } + fn guard(&self, guard: &mut Guard) { + match guard { + Guard::Fn(_) => {} + Guard::Named(n) => n.imp = self.guards.get(n.name).cloned(), + Guard::And(gs) | Guard::Or(gs) => gs.iter_mut().for_each(|g| self.guard(g)), + Guard::Not(g) => self.guard(g), + } + } + fn actions(&self, actions: &mut [Action]) { + for a in actions { + match a { + Action::Fn(_) => {} + Action::Named(n) => n.imp = self.actions.get(n.name).cloned(), + Action::OneOf(branches) => { + for b in branches { + if let Some(g) = &mut b.guard { + self.guard(g); + } + self.actions(&mut b.actions); + } + } + } + } + } +} + +/// The dev/prod rule for a missing named implementation, shared by every resolver. +pub(crate) fn missing(kind: &str, name: &str) { + let msg = format!("[machine] no {kind} \"{name}\""); + if cfg!(debug_assertions) { + panic!("{msg}"); + } + #[cfg(not(target_arch = "wasm32"))] + eprintln!("{msg}"); +} diff --git a/crates/core/src/connector.rs b/crates/core/src/connector.rs new file mode 100644 index 0000000..141425d --- /dev/null +++ b/crates/core/src/connector.rs @@ -0,0 +1,165 @@ +use std::cell::RefCell; +use std::rc::{Rc, Weak}; + +use crate::broadcast::{Broadcast, Subscription}; +use crate::machine::{Machine, Sender}; +use crate::params::View; +use crate::traits::Types; + +/// What a connect() reads: the live machine plus the consumer props. +pub struct ConnectSnapshot<'a, T: Types, P> { + pub view: View<'a, T>, + pub props: &'a P, + pub sender: Sender, +} + +type SetupReaction = Rc, Weak>) -> Subscription>; + +/// "A selected machine value" → "a consumer callback", wired on start, torn down on stop. +pub struct Reaction { + setup: SetupReaction, +} + +impl Reaction { + pub fn new( + selector: impl Fn(&View<'_, T>) -> V + 'static, + callback: impl Fn(&V, &P) + 'static, + ) -> Self { + let selector = Rc::new(selector); + let callback = Rc::new(callback); + Self { + setup: Rc::new(move |machine: &Machine, props: Weak>| { + let selector = selector.clone(); + let callback = callback.clone(); + machine + .select(move |v| selector(v)) + .subscribe(move |value| { + if let Some(props) = props.upgrade() { + callback(value, &props.borrow()); + } + }) + }), + } + } +} + +type ConnectFn = Box) -> A>; + +struct ConnectorInner { + machine: Machine, + connect: ConnectFn, + props: Rc>, + cached: RefCell>>, + bus: Rc, + reactions: Vec>, + reaction_subs: RefCell>, + machine_subs: RefCell>, +} + +/// The view boundary: a memoized view surface over a machine + props. Passive — the +/// bridge owns the machine's lifecycle. +pub struct Connector(Rc>); + +impl Connector { + pub fn new( + machine: &Machine, + connect: impl Fn(&ConnectSnapshot<'_, T, P>) -> A + 'static, + props: P, + reactions: Vec>, + ) -> Self { + let inner = Rc::new(ConnectorInner { + machine: machine.clone(), + connect: Box::new(connect), + props: Rc::new(RefCell::new(props)), + cached: RefCell::new(None), + bus: Broadcast::new(), + reactions, + reaction_subs: RefCell::new(Vec::new()), + machine_subs: RefCell::new(Vec::new()), + }); + let weak = Rc::downgrade(&inner); + let wake = machine.subscribe({ + let weak = weak.clone(); + move || { + if let Some(c) = weak.upgrade() { + c.wake(); + } + } + }); + let on_start = machine.on_start({ + let weak = weak.clone(); + move || { + let Some(c) = weak.upgrade() else { return }; + let subs: Vec = c + .reactions + .iter() + .map(|r| (r.setup)(&c.machine, Rc::downgrade(&c.props))) + .collect(); + c.reaction_subs.borrow_mut().extend(subs); + } + }); + let on_stop = machine.on_stop(move || { + if let Some(c) = weak.upgrade() { + for s in c.reaction_subs.borrow_mut().drain(..) { + s.unsubscribe(); + } + } + }); + inner + .machine_subs + .borrow_mut() + .extend([wake, on_start, on_stop]); + Self(inner) + } + + /// The memoized connect() output: the same `Rc` while machine and props are unchanged. + pub fn snapshot(&self) -> Rc { + if let Some(cached) = self.0.cached.borrow().as_ref() { + return cached.clone(); + } + let api = { + let inner = &self.0.machine.0; + let core = inner.core.borrow(); + let props = self.0.props.borrow(); + let snap = ConnectSnapshot { + view: View { core: &core, inner }, + props: &*props, + sender: self.0.machine.sender(), + }; + Rc::new((self.0.connect)(&snap)) + }; + *self.0.cached.borrow_mut() = Some(api.clone()); + api + } + + pub fn subscribe(&self, listener: impl Fn() + 'static) -> Subscription { + self.0.bus.add(Rc::new(listener)) + } + + /// Update the consumer props. Equal props change nothing. + pub fn set_props(&self, props: P) { + if *self.0.props.borrow() == props { + return; + } + *self.0.props.borrow_mut() = props; + self.0.wake(); + } + + /// Detach from the machine (only needed when discarding the connector on its own). + pub fn destroy(&self) { + for s in self.0.machine_subs.borrow_mut().drain(..) { + s.unsubscribe(); + } + for s in self.0.reaction_subs.borrow_mut().drain(..) { + s.unsubscribe(); + } + self.0.bus.clear(); + } +} + +impl ConnectorInner { + fn wake(&self) { + *self.cached.borrow_mut() = None; + self.bus.notify(); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..96e828c --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,66 @@ +//! `dunky-core` — the Rust implementation of Dunky's state machines, next to the TS one +//! (`@dunky.dev/state-machine`). +//! +//! Both implement the same behavior contract, `packages/core/SPEC.md`; what is specific +//! to this crate is `crates/core/SPEC.md`. The engine (flat states, run-to-completion, +//! guards, actions, effects, `after` timers, computed data, watchers, selections, +//! connector, composition, store) serves Rust programs and native bindings. +//! +//! Rust-specific shape: +//! - **Typed**: states, events and context are Rust types (`#[derive(State, Event, Context)]`). +//! - **Sans-IO timers**: the core owns no clock. `after` emits [`Command`]s; the host runs +//! them and calls [`Machine::fire_timer`]. +//! - **Change mask**: [`Machine::take_changes`] reports which fields changed, so a binding +//! can mirror only those into another runtime. + +// Lets the derive macros (which emit `::dunky_core::...`) work inside this crate too. +extern crate self as dunky_core; + +mod broadcast; +mod compose; +mod computed; +mod config; +mod connector; +mod machine; +mod params; +pub mod protocol; +mod selection; +mod store; +mod timers; +mod traits; +mod ts; + +pub mod testing; + +pub use broadcast::Subscription; +pub use compose::{Combined, Composition, Member}; +pub use computed::{ComputedKey, ComputedParams}; +pub use config::{ + Action, ActionFn, Branch, Cleanup, Config, ConfigBuilder, Delay, Effect, Guard, StateBuilder, + TransitionBuilder, +}; +pub use connector::{ConnectSnapshot, Connector, Reaction}; +pub use machine::{Changes, Machine, Sender}; +pub use params::{ActionParams, GuardParams, View}; +pub use selection::Selection; +pub use store::Store; +pub use timers::{Command, TimerId}; +pub use traits::{Context, EventEnum, Field, StateEnum, Types}; +#[cfg(feature = "serde")] +pub use traits::{DeserializeEvent, SerializeFields}; +pub use ts::TsType; + +#[cfg(feature = "derive")] +pub use dunky_macros::{Context, Event, State, TsType}; + +/// The `machine.init` marker name: what a boot effect or a data-reaction sees as its +/// event type (`ActionParams::event()` returns `None` for it). +pub const MACHINE_INIT: &str = "machine.init"; + +#[doc(hidden)] +pub mod __private { + #[cfg(feature = "serde")] + pub use serde; + + pub use crate::ts::{key as ts_key, literal as ts_literal, object as ts_object}; +} diff --git a/crates/core/src/machine.rs b/crates/core/src/machine.rs new file mode 100644 index 0000000..88d4a5f --- /dev/null +++ b/crates/core/src/machine.rs @@ -0,0 +1,831 @@ +//! The live service: run-to-completion queue, transition resolution, lifecycle. +//! +//! Borrow discipline (the Rust shape of the TS engine's re-entrancy rules): +//! - Rust actions, guards, effects, delays and computed definitions run while the core is +//! borrowed; they get params instead of the machine handle. +//! - Observers (subscribe, selections, lifecycle listeners, effect cleanups) run with NO +//! borrow held, so they may read the machine, send to it, or patch it. Sends made while +//! a flush is in progress are queued and run after the current item, never interleaved. + +use std::any::Any; +use std::cell::{Cell, OnceCell, Ref, RefCell}; +use std::collections::VecDeque; +use std::rc::{Rc, Weak}; + +use crate::broadcast::{Broadcast, Subscription}; +use crate::computed::{self, ComputedKey, ComputedRuntime}; +use crate::config::{ + Action, Cleanup, Config, Delay, Effect, Entry, Guard, Transition, WatchSource, missing, +}; +use crate::params::{ActionParams, GuardParams, View}; +use crate::selection::Selection; +use crate::timers::{Command, TimerId}; +use crate::traits::{Context, EventEnum, Field, StateEnum, Types}; + +/// Runaway guard for one flush. A real chain is a handful of items; thousands means a +/// feedback loop (a watcher writing what it watches, actions sending in a cycle). +const MAX_FLUSH: u32 = 10_000; + +pub(crate) enum Item { + Event(T::Event), + After { + state: T::State, + index: usize, + event: Option, + generation: u64, + }, + Watch { + index: usize, + }, +} + +struct TimerRecord { + id: TimerId, + state: T::State, + index: usize, + event: Option, + generation: u64, +} + +pub(crate) struct Core { + pub(crate) ctx: T::Context, + pub(crate) state: T::State, + pub(crate) running: bool, + /// Bumped on every state entry; a timer scheduled under another generation is stale. + entry_counter: u64, + state_cleanups: Vec, + timers: Vec>, + next_timer_id: u32, + commands: Vec, + pub(crate) computed: ComputedRuntime, + /// Machine-wide change counter; fields and the state are stamped with it when they change. + pub(crate) tick: u64, + /// Per field, the tick it last changed at; grown on first write, so a machine pays + /// only for the fields it writes. + pub(crate) field_changed_at: Vec, + pub(crate) state_changed_at: u64, + /// Effective changes made inside the current borrow; drained into notifications. + pending_notifies: u32, + /// What those changes touched: field bits, plus `STATE_BIT` for a state change. + pending_changes: u64, + /// Changes since the last `take_changes()`, for bindings. + changed_fields: u64, + changed_state: bool, +} + +/// A change bit no context field uses (fields take bits 0..64, all of them only with a +/// 64-field context, where a field selection may then also wake on a state change). +const STATE_BIT: u64 = 1 << 63; + +impl Core { + /// The notifications owed since the last take, and what they changed. + fn take_owed(&mut self) -> (u32, u64) { + ( + std::mem::take(&mut self.pending_notifies), + std::mem::take(&mut self.pending_changes), + ) + } + + /// Apply `patch`; return the mask of the fields that actually changed. + pub(crate) fn patch(&mut self, patch: ::Patch) -> u64 { + let mask = self.ctx.apply(patch); + self.mark_fields(mask); + mask + } + + /// Stamp the fields in `mask` as changed now and owe one notification. + fn mark_fields(&mut self, mask: u64) { + if mask == 0 { + return; + } + self.stamp_fields(mask); + self.changed_fields |= mask; + self.pending_notifies += 1; + self.pending_changes |= mask; + } + + /// Stamp the fields in `mask` as changed now. + fn stamp_fields(&mut self, mask: u64) { + self.tick += 1; + let top = 64 - mask.leading_zeros() as usize; + if self.field_changed_at.len() < top { + self.field_changed_at.resize(top, 0); + } + let mut bits = mask; + while bits != 0 { + let i = bits.trailing_zeros() as usize; + self.field_changed_at[i] = self.tick; + bits &= bits - 1; + } + } + + fn set_state(&mut self, next: T::State) { + self.state = next; + self.tick += 1; + self.state_changed_at = self.tick; + self.changed_state = true; + self.pending_notifies += 1; + self.pending_changes |= STATE_BIT; + } +} + +pub(crate) struct Inner { + pub(crate) config: Config, + pub(crate) core: RefCell>, + pub(crate) queue: RefCell>>, + flushing: Cell, + // Created on first use: a machine nobody subscribes to pays nothing. + bus: OnceCell>, + /// Per watch: the change stamp a computed watch last saw (unused by field watches). + watch_stamps: RefCell>, + lifecycle: OnceCell>, + stop_listeners: OnceCell>, + pub(crate) weak_self: Weak>, +} + +/// A live machine service. Built stopped; [`Machine::start`] boots effects and watchers, +/// [`Machine::stop`] runs their cleanups. Cloning shares the same service. +pub struct Machine(pub(crate) Rc>); + +impl Clone for Machine { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +/// A handle that sends events to a machine without keeping it alive. +pub struct Sender(pub(crate) Weak>); + +impl Clone for Sender { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Sender { + /// Send `event` if the machine still exists. Returns whether it did. + pub fn send(&self, event: T::Event) -> bool { + match self.0.upgrade() { + Some(inner) => { + inner.queue.borrow_mut().push_back(Item::Event(event)); + inner.flush(); + true + } + None => false, + } + } +} + +/// What changed since the last [`Machine::take_changes`]: a bit per context field, plus the state. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Changes { + pub state: bool, + pub fields: u64, +} + +impl Changes { + pub fn any(self) -> bool { + self.state || self.fields != 0 + } +} + +impl Machine { + /// Build a stopped service from `config`, with its own copy of the seed context. + pub fn new(config: &Config) -> Self { + assert!( + ::FIELDS.len() <= 64, + "[machine] a context supports at most 64 fields" + ); + let core = Core { + ctx: config.0.context.clone(), + state: config.0.initial, + running: false, + entry_counter: 0, + state_cleanups: Vec::new(), + timers: Vec::new(), + next_timer_id: 1, + commands: Vec::new(), + computed: ComputedRuntime::new(config.0.computed.len()), + tick: 0, + field_changed_at: Vec::new(), + state_changed_at: 0, + pending_notifies: 0, + pending_changes: 0, + changed_fields: 0, + changed_state: false, + }; + Self(Rc::new_cyclic(|weak| Inner { + config: config.clone(), + core: RefCell::new(core), + queue: RefCell::new(VecDeque::new()), + flushing: Cell::new(false), + bus: OnceCell::new(), + watch_stamps: RefCell::new(vec![0; config.0.watch.len()]), + lifecycle: OnceCell::new(), + stop_listeners: OnceCell::new(), + weak_self: weak.clone(), + })) + } + + /// Like [`Machine::new`], but seeds the context from `context` instead of the config's. + pub fn with_context(config: &Config, context: T::Context) -> Self { + let m = Self::new(config); + m.0.core.borrow_mut().ctx = context; + m + } + + pub fn config(&self) -> &Config { + &self.0.config + } + + pub fn state(&self) -> T::State { + self.0.core.borrow().state + } + + /// Borrow the live context. Do not hold the guard across `send`. + pub fn context(&self) -> Ref<'_, T::Context> { + Ref::map(self.0.core.borrow(), |c| &c.ctx) + } + + pub fn computed(&self, key: ComputedKey) -> Rc { + let core = self.0.core.borrow(); + computed::downcast(computed::get(&core, &self.0, key.id()), &self.0, key.id()) + } + + /// The type-erased value of computed `id` (for bindings that know the type elsewhere). + pub fn computed_any(&self, id: usize) -> Rc { + let core = self.0.core.borrow(); + computed::get(&core, &self.0, id) + } + + /// A version stamp for computed `id`: it changes exactly when the value changes. + pub fn computed_version(&self, id: usize) -> u64 { + let core = self.0.core.borrow(); + computed::changed_at(&core, &self.0, id) + } + + pub fn matches(&self, state: T::State) -> bool { + self.state() == state + } + + pub fn has_tag(&self, tag: &str) -> bool { + let state = self.state(); + self.0.config.0.states[state.index()].tags.contains(&tag) + } + + pub fn is_running(&self) -> bool { + self.0.core.borrow().running + } + + /// Dispatch an event. It runs to completion before the next queued item. + pub fn send(&self, event: T::Event) { + let inner = &*self.0; + if inner.flushing.get() || !inner.queue.borrow().is_empty() { + inner.queue.borrow_mut().push_back(Item::Event(event)); + inner.flush(); + return; + } + // Idle: run it now, then whatever it queued — no trip through the queue. + inner.flushing.set(true); + let reset = FlushReset(&inner.flushing, false); + inner.process(Item::Event(event)); + drop(reset); + if !inner.queue.borrow().is_empty() { + inner.flush(); + } + } + + /// Shallow-merge `patch` into the context (bridges use this to refresh prop-seeded + /// context). Observers are notified only if a field actually changed. + pub fn set_context(&self, patch: ::Patch) { + self.0.with_flush(|inner| { + let fields = inner.core.borrow_mut().patch(patch); + if fields != 0 { + inner.detect_watched(&inner.core.borrow(), fields); + } + inner.drain_notifies(); + }); + } + + /// Coarse subscription: fires on any later change (state or context), never on subscribe. + pub fn subscribe(&self, listener: impl Fn() + 'static) -> Subscription { + self.0.bus().add(Rc::new(listener)) + } + + /// A value-deduped view of anything the machine exposes. + pub fn select( + &self, + selector: impl Fn(&View<'_, T>) -> V + 'static, + ) -> Selection { + Selection::new(self.clone(), Rc::new(selector)) + } + + /// A value-deduped view of one context field (TS: `select.context(key)`). It wakes + /// only on writes to that field. + pub fn select_field(&self, field: Field) -> Selection { + Selection::waking( + self.clone(), + Rc::new(move |v: &View<'_, T>| field.get(v.context()).clone()), + field.bit(), + ) + } + + pub fn select_state(&self) -> Selection { + Selection::waking( + self.clone(), + Rc::new(|v: &View<'_, T>| v.state()), + STATE_BIT, + ) + } + + pub fn select_computed(&self, key: ComputedKey) -> Selection> { + self.select(move |v| v.computed(key)) + } + + /// Boot the watchers and the CURRENT state's effects. No-op if already running. + pub fn start(&self) { + let inner = &*self.0; + let started = inner.with_flush(|inner| { + let state = { + let mut core = inner.core.borrow_mut(); + if core.running { + return false; + } + core.running = true; + core.state + }; + inner.start_watchers(); + inner.start_effects(state, None); + true + }); + if started { + if let Some(listeners) = inner.lifecycle.get() { + inner.with_flush(|_| listeners.notify()); + } + } + } + + /// Run every active effect's cleanup and dispose the watchers. Keeps state and context. + pub fn stop(&self) { + let inner = &*self.0; + let stopped = inner.with_flush(|inner| { + { + let mut core = inner.core.borrow_mut(); + if !core.running { + return false; + } + core.running = false; + } + // Watchers need no teardown: they detect only while running, and a run + // still queued is dropped when it comes up. + inner.stop_effects(); + true + }); + if stopped { + if let Some(listeners) = inner.stop_listeners.get() { + inner.with_flush(|_| listeners.notify()); + } + } + } + + /// Fires on every `start()` — immediately if already running. + pub fn on_start(&self, listener: impl Fn() + 'static) -> Subscription { + let listener: Rc = Rc::new(listener); + let sub = self + .0 + .lifecycle + .get_or_init(Broadcast::new) + .add(listener.clone()); + if self.is_running() { + listener(); + } + sub + } + + /// Fires on every `stop()`. + pub fn on_stop(&self, listener: impl Fn() + 'static) -> Subscription { + self.0 + .stop_listeners + .get_or_init(Broadcast::new) + .add(Rc::new(listener)) + } + + /// The host clock: timer `id` (from a [`Command::StartTimer`]) came due. + pub fn fire_timer(&self, id: TimerId) { + let item = { + let mut core = self.0.core.borrow_mut(); + match core.timers.iter().position(|t| t.id == id) { + Some(pos) => { + let t = core.timers.swap_remove(pos); + Item::After { + state: t.state, + index: t.index, + event: t.event, + generation: t.generation, + } + } + None => return, // cancelled or already fired + } + }; + self.0.queue.borrow_mut().push_back(item); + self.0.flush(); + } + + /// Take the timer commands emitted since the last call. A host runs them. + pub fn take_commands(&self) -> Vec { + std::mem::take(&mut self.0.core.borrow_mut().commands) + } + + pub fn has_commands(&self) -> bool { + !self.0.core.borrow().commands.is_empty() + } + + /// Take what changed since the last call (for bindings that mirror state elsewhere). + pub fn take_changes(&self) -> Changes { + let mut core = self.0.core.borrow_mut(); + let changes = Changes { + state: core.changed_state, + fields: core.changed_fields, + }; + core.changed_state = false; + core.changed_fields = 0; + changes + } + + pub fn sender(&self) -> Sender { + Sender(Rc::downgrade(&self.0)) + } + + /// Number of coarse subscribers (diagnostics). + pub fn subscriber_count(&self) -> usize { + self.0.bus.get().map_or(0, |bus| bus.len()) + } +} + +impl Inner { + pub(crate) fn bus(&self) -> &Rc { + self.bus.get_or_init(Broadcast::new) + } + + /// Run `f` as part of a flush: re-entrant sends queue instead of nesting, and the queue + /// drains after `f` unless an outer flush owns it. + fn with_flush(&self, f: impl FnOnce(&Self) -> R) -> R { + let outer = self.flushing.replace(true); + let reset = FlushReset(&self.flushing, outer); + let result = f(self); + drop(reset); + if !outer { + self.flush(); + } + result + } + + pub(crate) fn flush(&self) { + if self.flushing.get() { + return; + } + self.flushing.set(true); + let _reset = FlushReset(&self.flushing, false); + let mut ticks = 0u32; + loop { + let item = self.queue.borrow_mut().pop_front(); + let Some(item) = item else { break }; + ticks += 1; + if ticks > MAX_FLUSH { + self.queue.borrow_mut().clear(); + let msg = format!( + "[machine] one flush exceeded {MAX_FLUSH} steps — feedback loop \ + (e.g. a watcher writing the field it watches, or actions sending in a cycle)" + ); + if cfg!(debug_assertions) { + panic!("{msg}"); + } + } + self.process(item); + } + } + + fn process(&self, item: Item) { + match item { + Item::Event(event) => { + let kind = ::kind_index(event.kind()); + let (state, running) = { + let core = self.core.borrow(); + (core.state, core.running) + }; + let Some(entry) = self.config.lookup(state, kind) else { + return; + }; + // Guards cannot write, so the state and the lifecycle read here hold + // until `apply`. + if let Some(t) = self.resolve(entry, Some(&event)) { + self.apply(t, Some(&event), state, running); + } + } + Item::After { + state, + index, + event, + generation, + } => { + { + let core = self.core.borrow(); + if !core.running || core.entry_counter != generation { + return; + } + } + // Same entry generation: the machine is still in `state`, running. + let entry = &self.config.0.states[state.index()].after[index].1; + if let Some(t) = self.resolve(entry, event.as_ref()) { + self.apply(t, event.as_ref(), state, true); + } + } + Item::Watch { index } => { + if self.core.borrow().running { + self.run_actions(&self.config.0.watch[index].actions, None); + } + } + } + } + + /// The first candidate whose guard passes. + fn resolve<'c>( + &self, + entry: &'c Entry, + event: Option<&T::Event>, + ) -> Option<&'c Transition> { + // An unguarded first candidate always wins: no guard to set up. + if let Some(first) = entry.first() + && first.guard.is_none() + { + return Some(first); + } + let core = self.core.borrow(); + let params = GuardParams { + core: &core, + inner: self, + event, + }; + entry + .iter() + .find(|t| t.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) + } + + fn apply(&self, t: &Transition, event: Option<&T::Event>, cur: T::State, running: bool) { + let next = t.target.unwrap_or(cur); + let leaving = next != cur; + let states = &self.config.0.states; + if leaving { + if running { + self.stop_effects(); + } + self.run_actions(&states[cur.index()].exit, event); + } + self.run_actions(&t.actions, event); + if leaving { + self.core.borrow_mut().set_state(next); + self.detect_watched(&self.core.borrow(), 0); + self.drain_notifies(); + self.run_actions(&states[next.index()].entry, event); + if self.core.borrow().running { + self.start_effects(next, event); + } + } + } + + pub(crate) fn run_actions(&self, actions: &[Action], event: Option<&T::Event>) { + for action in actions { + self.run_action(action, event); + } + } + + fn run_action(&self, action: &Action, event: Option<&T::Event>) { + match action { + Action::Fn(f) => self.call_action(f, event), + Action::Named(n) => match &n.imp { + Some(f) => self.call_action(f, event), + None => missing("action", n.name), + }, + Action::OneOf(branches) => { + let picked = { + let core = self.core.borrow(); + let params = GuardParams { + core: &core, + inner: self, + event, + }; + branches + .iter() + .find(|b| b.guard.as_ref().is_none_or(|g| eval_guard(g, ¶ms))) + }; + if let Some(branch) = picked { + self.run_actions(&branch.actions, event); + } + } + } + } + + fn call_action(&self, f: &crate::config::ActionFn, event: Option<&T::Event>) { + // The notifications owed are taken in the action's own borrow: one borrow, not two. + let (owed, changes) = { + let mut core = self.core.borrow_mut(); + f(&mut ActionParams { + core: &mut core, + inner: self, + event, + }); + core.take_owed() + }; + self.notify(owed, changes); + } + + /// Deliver the notifications owed by the changes made in the last borrow — once per + /// effective change, with no borrow held so observers can read and send. + pub(crate) fn drain_notifies(&self) { + let (n, changes) = self.core.borrow_mut().take_owed(); + self.notify(n, changes); + } + + /// Notify the subscribers `n` times (once per effective change), with no borrow held; + /// `changes` is what those changes touched. + fn notify(&self, n: u32, changes: u64) { + if let Some(bus) = self.bus.get() { + for _ in 0..n { + bus.notify_changed(changes); + } + } + } + + fn start_effects(&self, state: T::State, event: Option<&T::Event>) { + self.schedule_after(state, event); + let node = &self.config.0.states[state.index()]; + for effect in &node.effects { + let cleanup = match effect { + Effect::Fn(f) => self.call_effect(f, event), + Effect::Named(n) => match &n.imp { + Some(f) => self.call_effect(f, event), + None => { + missing("effect", n.name); + continue; + } + }, + }; + if let Some(cleanup) = cleanup { + self.core.borrow_mut().state_cleanups.push(cleanup); + } + self.drain_notifies(); + } + } + + /// Enter `state`'s timed transitions: a new entry generation, one timer per `after`. + fn schedule_after(&self, state: T::State, event: Option<&T::Event>) { + let node = &self.config.0.states[state.index()]; + let generation = { + let mut core = self.core.borrow_mut(); + core.entry_counter += 1; + core.entry_counter + }; + for (index, (delay, _)) in node.after.iter().enumerate() { + let ms = match delay { + Delay::Ms(ms) => *ms, + Delay::Named(n) => match &n.imp { + Some(f) => f(&GuardParams { + core: &self.core.borrow(), + inner: self, + event, + }), + None => { + missing("delay", n.name); + 0 + } + }, + }; + let mut core = self.core.borrow_mut(); + let id = core.next_timer_id; + core.next_timer_id = core.next_timer_id.wrapping_add(1).max(1); + core.timers.push(TimerRecord { + id, + state, + index, + event: event.cloned(), + generation, + }); + core.commands.push(Command::StartTimer { id, ms }); + } + } + + fn call_effect( + &self, + f: &crate::config::EffectFn, + event: Option<&T::Event>, + ) -> Option { + let mut core = self.core.borrow_mut(); + let mut params = ActionParams { + core: &mut core, + inner: self, + event, + }; + f(&mut params) + } + + /// Cancel the state's timers and run its effect cleanups. A failing cleanup must not + /// leak the others: finish the pass, then resume the first panic. + fn stop_effects(&self) { + let mut cleanups = { + let mut core = self.core.borrow_mut(); + let core = &mut *core; + if core.timers.is_empty() && core.state_cleanups.is_empty() { + return; + } + for t in core.timers.drain(..) { + core.commands.push(Command::CancelTimer { id: t.id }); + } + std::mem::take(&mut core.state_cleanups) + }; + let mut first_panic = None; + for cleanup in cleanups.drain(..) { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(cleanup)); + if let Err(payload) = result { + first_panic.get_or_insert(payload); + } + } + // Give the emptied list back, so the next state reuses its buffer. + if let Ok(mut core) = self.core.try_borrow_mut() + && core.state_cleanups.is_empty() + { + core.state_cleanups = cleanups; + } + if let Some(payload) = first_panic { + std::panic::resume_unwind(payload); + } + } + + /// Seed each computed watch with its value's change stamp. A field watch needs no + /// seed: a write's change mask names the fields it changed. + fn start_watchers(&self) { + let core = self.core.borrow(); + let mut stamps = Vec::with_capacity(self.config.0.watch.len()); + for watch in &self.config.0.watch { + stamps.push(match watch.source { + WatchSource::Computed(id) => computed::changed_at(&core, self, id), + WatchSource::Field(_) => 0, + }); + } + *self.watch_stamps.borrow_mut() = stamps; + } + + /// Watchers observe a change as it happens, like the TS selections they mirror: each + /// write of a watched field, and each change of a watched computed value, queues that + /// watch's run right away, in order with the sends around it (its actions are still + /// deferred). `fields` is the write's change mask; a state switch passes 0, since + /// only a computed value can observe it. + pub(crate) fn detect_watched(&self, core: &Core, fields: u64) { + if !core.running || self.config.0.watch.is_empty() { + return; + } + for (index, watch) in self.config.0.watch.iter().enumerate() { + let changed = match watch.source { + WatchSource::Field(field) => fields & (1u64 << field) != 0, + WatchSource::Computed(id) => { + // The stamp moves exactly when the value changes (by `eq`). + let now = computed::changed_at(core, self, id); + let mut stamps = self.watch_stamps.borrow_mut(); + let changed = stamps[index] != now; + stamps[index] = now; + changed + } + }; + if changed { + self.queue.borrow_mut().push_back(Item::Watch { index }); + } + } + } +} + +pub(crate) fn eval_guard(guard: &Guard, params: &GuardParams<'_, T>) -> bool { + match guard { + Guard::Fn(f) => f(params), + Guard::Named(n) => match n + .imp + .as_ref() + .or_else(|| params.inner.config.0.guards.get(n.name)) + { + Some(f) => f(params), + None => { + missing("guard", n.name); + false + } + }, + Guard::And(gs) => gs.iter().all(|g| eval_guard(g, params)), + Guard::Or(gs) => gs.iter().any(|g| eval_guard(g, params)), + Guard::Not(g) => !eval_guard(g, params), + } +} + +struct FlushReset<'a>(&'a Cell, bool); + +impl Drop for FlushReset<'_> { + fn drop(&mut self) { + self.0.set(self.1); + } +} diff --git a/crates/core/src/params.rs b/crates/core/src/params.rs new file mode 100644 index 0000000..5ed39c7 --- /dev/null +++ b/crates/core/src/params.rs @@ -0,0 +1,110 @@ +use std::rc::Rc; + +use crate::computed::{self, ComputedKey}; +use crate::config::Guard; +use crate::machine::{Core, Inner, Item, Sender}; +use crate::traits::{StateEnum, Types}; + +/// Everything an action (and an effect) can read and do. +pub struct ActionParams<'a, T: Types> { + pub(crate) core: &'a mut Core, + pub(crate) inner: &'a Inner, + pub(crate) event: Option<&'a T::Event>, +} + +impl<'a, T: Types> ActionParams<'a, T> { + pub fn context(&self) -> &T::Context { + &self.core.ctx + } + /// The event being processed. `None` is the `machine.init` marker: a boot effect on + /// `start()`, or a data-reaction (a data change is not a domain event). + pub fn event(&self) -> Option<&'a T::Event> { + self.event + } + pub fn state(&self) -> T::State { + self.core.state + } + /// Shallow-merge `patch` into the context. Observers are notified only if a field + /// actually changed. + pub fn set_context(&mut self, patch: ::Patch) { + let fields = self.core.patch(patch); + if fields != 0 { + self.inner.detect_watched(self.core, fields); + } + } + /// Dispatch another event. It is queued and runs after the current one completes. + pub fn send(&mut self, event: T::Event) { + self.inner.queue.borrow_mut().push_back(Item::Event(event)); + } + pub fn computed(&self, key: ComputedKey) -> Rc { + computed::downcast( + computed::get(self.core, self.inner, key.id()), + self.inner, + key.id(), + ) + } + /// A handle that can send events later (e.g. from a store subscription in an effect). + pub fn sender(&self) -> Sender { + Sender(self.inner.weak_self.clone()) + } +} + +/// Everything a guard (and a named delay) can read. +pub struct GuardParams<'a, T: Types> { + pub(crate) core: &'a Core, + pub(crate) inner: &'a Inner, + pub(crate) event: Option<&'a T::Event>, +} + +impl<'a, T: Types> GuardParams<'a, T> { + pub fn context(&self) -> &'a T::Context { + &self.core.ctx + } + pub fn event(&self) -> Option<&'a T::Event> { + self.event + } + pub fn state(&self) -> T::State { + self.core.state + } + pub fn computed(&self, key: ComputedKey) -> Rc { + computed::downcast( + computed::get(self.core, self.inner, key.id()), + self.inner, + key.id(), + ) + } + /// Evaluate another guard against these same params (for hand-written combinators). + pub fn check(&self, guard: &Guard) -> bool { + crate::machine::eval_guard(guard, self) + } +} + +/// A read-only view of the machine, handed to selectors. +pub struct View<'a, T: Types> { + pub(crate) core: &'a Core, + pub(crate) inner: &'a Inner, +} + +impl<'a, T: Types> View<'a, T> { + pub fn state(&self) -> T::State { + self.core.state + } + pub fn context(&self) -> &'a T::Context { + &self.core.ctx + } + pub fn computed(&self, key: ComputedKey) -> Rc { + computed::downcast( + computed::get(self.core, self.inner, key.id()), + self.inner, + key.id(), + ) + } + pub fn matches(&self, state: T::State) -> bool { + self.core.state == state + } + pub fn has_tag(&self, tag: &str) -> bool { + self.inner.config.0.states[self.core.state.index()] + .tags + .contains(&tag) + } +} diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs new file mode 100644 index 0000000..f86ec17 --- /dev/null +++ b/crates/core/src/protocol.rs @@ -0,0 +1,63 @@ +//! The handle protocol a binding speaks (React Native over JSI): one change mask per +//! call, and timer commands as flat numbers. The layout lives here, once. + +use crate::machine::Machine; +use crate::timers::Command; +use crate::traits::Types; + +/// Change-mask bit: the state changed. +pub const STATE_BIT: u32 = 1 << 0; +/// Change-mask bit: timer commands are waiting. +pub const COMMANDS_BIT: u32 = 1 << 1; +/// Context field `i` maps to bit `FIELD_SHIFT + i`. Fields from index [`HIGH_FIELD`] up +/// share the top bit: when it is set, re-read all of them. +pub const FIELD_SHIFT: u32 = 2; +pub const HIGH_FIELD: usize = (32 - FIELD_SHIFT as usize) - 1; +/// Timer command opcodes in [`encode_commands`]. +pub const OP_START: u32 = 1; +pub const OP_CANCEL: u32 = 2; + +/// Take what changed since the last call and pack it into the change mask. +pub fn take_change_mask(machine: &Machine) -> u32 { + let changes = machine.take_changes(); + let mut mask = if changes.state { STATE_BIT } else { 0 }; + if machine.has_commands() { + mask |= COMMANDS_BIT; + } + let mut fields = changes.fields; + while fields != 0 { + let i = fields.trailing_zeros() as usize; + mask |= 1 << (FIELD_SHIFT as usize + i.min(HIGH_FIELD)); + fields &= fields - 1; + } + mask +} + +/// Timer commands as flat `[op, id, ms]` triples (`ms` is 0 for a cancel). +pub fn encode_commands(commands: &[Command]) -> Vec { + let mut out = Vec::with_capacity(commands.len() * 3); + for c in commands { + match *c { + Command::StartTimer { id, ms } => out.extend([OP_START, id, ms]), + Command::CancelTimer { id } => out.extend([OP_CANCEL, id, 0]), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commands_encode_as_op_id_ms_triples() { + let commands = [ + Command::StartTimer { id: 7, ms: 250 }, + Command::CancelTimer { id: 7 }, + ]; + assert_eq!( + encode_commands(&commands), + [OP_START, 7, 250, OP_CANCEL, 7, 0] + ); + } +} diff --git a/crates/core/src/selection.rs b/crates/core/src/selection.rs new file mode 100644 index 0000000..f319a85 --- /dev/null +++ b/crates/core/src/selection.rs @@ -0,0 +1,117 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crate::broadcast::{ALL, Subscription}; +use crate::machine::Machine; +use crate::params::View; +use crate::traits::Types; + +type Selector = Rc) -> V>; + +/// A narrowed, value-deduped view of a machine. +pub struct Selection { + machine: Machine, + selector: Selector, + /// The change bits that can change the value; other notifications skip it. + wake: u64, +} + +impl Clone for Selection { + fn clone(&self) -> Self { + Self { + machine: self.machine.clone(), + selector: self.selector.clone(), + wake: self.wake, + } + } +} + +impl Selection { + pub(crate) fn new(machine: Machine, selector: Selector) -> Self { + Self::waking(machine, selector, ALL) + } + + /// A selection whose value can change only on the change bits in `wake`. + pub(crate) fn waking(machine: Machine, selector: Selector, wake: u64) -> Self { + Self { + machine, + selector, + wake, + } + } + + /// The current selected value, evaluated on read. + pub fn value(&self) -> V { + let inner = &self.machine.0; + let core = inner.core.borrow(); + (self.selector)(&View { core: &core, inner }) + } + + /// Fire `listener(value)` only when the selected value changes (`==`). Never fires on + /// subscribe. + pub fn subscribe(&self, listener: impl Fn(&V) + 'static) -> Subscription + where + V: PartialEq, + { + self.subscribe_with(listener, |a: &V, b: &V| a == b) + } + + /// Like [`Selection::subscribe`] with a custom equality. + pub fn subscribe_with( + &self, + listener: impl Fn(&V) + 'static, + equals: impl Fn(&V, &V) -> bool + 'static, + ) -> Subscription { + let inner = &self.machine.0; + // Seed at subscribe. If the machine is mid-action (core borrowed), seed on the + // first wake instead. + let seed = inner + .core + .try_borrow() + .ok() + .map(|core| (self.selector)(&View { core: &core, inner })); + // `Rc` so the listener runs with no borrow of `prev` held: it may patch the machine, + // which notifies (and re-enters this wake) before the listener returns. + let prev: RefCell>> = RefCell::new(seed.map(Rc::new)); + let selector = self.selector.clone(); + let weak = Rc::downgrade(inner); + // Unseeded, it must take its first wake whatever changed, or it would seed on the + // change it should report. + let wake = if prev.borrow().is_some() { + self.wake + } else { + ALL + }; + inner.bus().add_waking( + wake, + Rc::new(move || { + let Some(inner) = weak.upgrade() else { return }; + let next = { + let core = inner.core.borrow(); + selector(&View { + core: &core, + inner: &inner, + }) + }; + let seeded = match prev.borrow().as_deref() { + Some(old) if equals(old, &next) => return, + Some(_) => true, + None => false, + }; + let current = { + let mut slot = prev.borrow_mut(); + // Nobody else holds the last value (no listener of this wake still runs): + // overwrite it in place, so a steady stream of changes allocates nothing. + match slot.as_mut().and_then(Rc::get_mut) { + Some(old) => *old = next, + None => *slot = Some(Rc::new(next)), + } + slot.clone().expect("value just set") + }; + if seeded { + listener(¤t); + } + }), + ) + } +} diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs new file mode 100644 index 0000000..665e37e --- /dev/null +++ b/crates/core/src/store.rs @@ -0,0 +1,66 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crate::broadcast::{Broadcast, Subscription}; +use crate::traits::Context; + +/// A tiny reactive cell for state shared between machine instances ("only one tooltip +/// open at a time"). Updates shallow-merge and notify only on an actual change. +pub struct Store { + state: Rc>>, + bus: Rc, +} + +impl Clone for Store { + fn clone(&self) -> Self { + Self { + state: self.state.clone(), + bus: self.bus.clone(), + } + } +} + +impl Store { + pub fn new(initial: S) -> Self { + Self { + state: Rc::new(RefCell::new(Rc::new(initial))), + bus: Broadcast::new(), + } + } + + /// The current value. Each effective update yields a fresh `Rc`, so identity change + /// is the change signal. + pub fn get(&self) -> Rc { + self.state.borrow().clone() + } + + pub fn set(&self, patch: S::Patch) { + let changed = { + let mut state = self.state.borrow_mut(); + let mut next = (**state).clone(); + if next.apply(patch) == 0 { + false + } else { + *state = Rc::new(next); + true + } + }; + if changed { + self.bus.notify(); + } + } + + pub fn update(&self, f: impl FnOnce(&S) -> S::Patch) { + let patch = f(&self.get()); + self.set(patch); + } + + /// Fires on every later change (not on subscribe). + pub fn subscribe(&self, listener: impl Fn(&Rc) + 'static) -> Subscription { + let state = self.state.clone(); + self.bus.add(Rc::new(move || { + let value = state.borrow().clone(); + listener(&value); + })) + } +} diff --git a/crates/core/src/testing.rs b/crates/core/src/testing.rs new file mode 100644 index 0000000..0c3c20f --- /dev/null +++ b/crates/core/src/testing.rs @@ -0,0 +1,68 @@ +//! Test helpers: a virtual clock that plays the host for `after` timers. + +use crate::machine::Machine; +use crate::timers::{Command, TimerId}; +use crate::traits::Types; + +/// A deterministic host clock for one machine: it runs the machine's timer commands and +/// fires due timers as virtual time advances (like fake timers in the TS tests). +pub struct Clock { + machine: Machine, + now: u64, + seq: u64, + pending: Vec<(u64, u64, TimerId)>, +} + +impl Clock { + pub fn new(machine: &Machine) -> Self { + Self { + machine: machine.clone(), + now: 0, + seq: 0, + pending: Vec::new(), + } + } + + pub fn now(&self) -> u64 { + self.now + } + + /// Timers currently scheduled (as of the last sync). + pub fn pending(&self) -> usize { + self.pending.len() + } + + /// Apply the commands the machine emitted since the last sync. + pub fn sync(&mut self) { + for cmd in self.machine.take_commands() { + match cmd { + Command::StartTimer { id, ms } => { + self.seq += 1; + self.pending.push((self.now + u64::from(ms), self.seq, id)); + } + Command::CancelTimer { id } => self.pending.retain(|(_, _, t)| *t != id), + } + } + } + + /// Advance virtual time by `ms`, firing every timer that comes due, in order. + pub fn advance(&mut self, ms: u64) { + self.sync(); + let target = self.now + ms; + loop { + let next = self + .pending + .iter() + .enumerate() + .filter(|(_, (due, _, _))| *due <= target) + .min_by_key(|(_, (due, seq, _))| (*due, *seq)) + .map(|(i, _)| i); + let Some(i) = next else { break }; + let (due, _, id) = self.pending.remove(i); + self.now = due; + self.machine.fire_timer(id); + self.sync(); + } + self.now = target; + } +} diff --git a/crates/core/src/timers.rs b/crates/core/src/timers.rs new file mode 100644 index 0000000..a4d6380 --- /dev/null +++ b/crates/core/src/timers.rs @@ -0,0 +1,11 @@ +/// Identifies one scheduled `after` timer. +pub type TimerId = u32; + +/// What the sans-IO core asks its host to do. The core owns no clock: a host (a +/// native event loop, a binding, a test clock) runs these and calls +/// `Machine::fire_timer(id)` when a timer comes due. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Command { + StartTimer { id: TimerId, ms: u32 }, + CancelTimer { id: TimerId }, +} diff --git a/crates/core/src/traits.rs b/crates/core/src/traits.rs new file mode 100644 index 0000000..278d0ea --- /dev/null +++ b/crates/core/src/traits.rs @@ -0,0 +1,142 @@ +use core::cell::Cell; +use core::fmt::Debug; + +/// A machine's finite set of states — a fieldless enum. Derive it with `#[derive(State)]`. +pub trait StateEnum: Copy + Eq + Debug + 'static { + /// Names in variant order. These are the names the JS side sees. + const NAMES: &'static [&'static str]; + fn index(self) -> usize; + fn from_index(index: usize) -> Self; + fn name(self) -> &'static str { + Self::NAMES[self.index()] + } +} + +/// A machine's events. Derive it with `#[derive(Event)]`, which also generates the +/// fieldless `Kind` enum that transitions are keyed by. +pub trait EventEnum: Clone + Debug + 'static { + /// One variant per event type, without payload. + type Kind: Copy + Eq + Debug + 'static; + /// Event type names in variant order (`"query.set"` …). JS sends `{ type: }`. + const KIND_NAMES: &'static [&'static str]; + fn kind(&self) -> Self::Kind; + fn kind_index(kind: Self::Kind) -> usize; + fn kind_from_index(index: usize) -> Self::Kind; + /// Build a payload-less event from its kind. `None` for variants that carry data. + fn from_kind(kind: Self::Kind) -> Option; + fn type_name(&self) -> &'static str { + Self::KIND_NAMES[Self::kind_index(self.kind())] + } +} + +/// The machine context: one struct, patched in place. Derive it with `#[derive(Context)]`, +/// which generates the `Patch` builder, the field constants, and the tracked reader. +pub trait Context: Clone + 'static { + /// A partial update: every field optional (TS `Partial`). + type Patch: Default; + /// Read access that records which fields a computed value reads. + type Reader<'a>: Copy + where + Self: 'a; + /// Field names in declaration order, camelCased for JS. + const FIELDS: &'static [&'static str]; + /// Shallow-merge `patch`; return the bit mask of the fields that actually changed. + fn apply(&mut self, patch: Self::Patch) -> u64; + fn reader<'a>(&'a self, deps: &'a Cell) -> Self::Reader<'a>; +} + +/// Per-field serialization, for bindings that mirror context fields into another runtime. +/// Opt in with `#[context(serialize)]`. +#[cfg(feature = "serde")] +pub trait SerializeFields: Context { + fn serialize_field( + &self, + index: usize, + serializer: S, + ) -> Result; +} + +/// Rebuild an event from its kind index and its payload fields — the fast path for +/// bindings, which know the kind already. Opt in with `#[event(deserialize)]`. +#[cfg(feature = "serde")] +pub trait DeserializeEvent: EventEnum { + fn deserialize_payload<'de, D: serde::Deserializer<'de>>( + kind: usize, + deserializer: D, + ) -> Result; +} + +/// The unit context, for machines that keep no data. +impl Context for () { + type Patch = (); + type Reader<'a> = (); + const FIELDS: &'static [&'static str] = &[]; + fn apply(&mut self, _patch: ()) -> u64 { + 0 + } + fn reader<'a>(&'a self, _deps: &'a Cell) {} +} + +#[cfg(feature = "serde")] +impl SerializeFields for () { + fn serialize_field( + &self, + _index: usize, + serializer: S, + ) -> Result { + serializer.serialize_unit() + } +} + +/// A typed handle to one context field of type `V` (generated as `Context::FIELD_NAME` +/// consts): its index in the change mask, and how to read it. +pub struct Field { + index: usize, + get: fn(&C) -> &V, +} + +impl Field { + #[doc(hidden)] + pub const fn new(index: usize, get: fn(&C) -> &V) -> Self { + Self { index, get } + } + pub const fn index(self) -> usize { + self.index + } + pub const fn bit(self) -> u64 { + 1 << self.index + } + /// The field's value in `context`. + pub fn get(self, context: &C) -> &V { + (self.get)(context) + } +} + +impl Clone for Field { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Field {} +impl Debug for Field { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "Field({})", self.index) + } +} + +/// The type bundle of one machine — the Rust counterpart of the TS generics +/// ``. Implement it on a marker struct: +/// +/// ```ignore +/// pub struct Palette; +/// impl Types for Palette { +/// type State = PaletteState; +/// type Event = PaletteEvent; +/// type Context = PaletteCtx; +/// } +/// ``` +pub trait Types: 'static { + type State: StateEnum; + type Event: EventEnum; + type Context: Context; +} diff --git a/crates/core/src/ts.rs b/crates/core/src/ts.rs new file mode 100644 index 0000000..b80131f --- /dev/null +++ b/crates/core/src/ts.rs @@ -0,0 +1,168 @@ +//! TypeScript types of the values a machine shows JS, so a binding can generate a Rust +//! machine's TS types from its Rust ones. The mapping follows how `dunky-wasm` moves +//! values through serde: `None` is `null`, sequences are arrays, maps are `Map`s, and +//! every number is a `number`. + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +/// The TypeScript type of a value as JS sees it. `#[derive(TsType)]` implements it for +/// your own data types; `#[derive(State, Event, Context)]` implement it for the machine's. +pub trait TsType { + /// The type, written inline: `string`, `{ id: string; hint?: string }`, `number[]`. + fn ts() -> String; +} + +macro_rules! literal { + ($ts:literal: $($ty:ty),*) => { + $(impl TsType for $ty { + fn ts() -> String { + $ts.into() + } + })* + }; +} + +literal!("number": u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64); +literal!("string": String, str, char); +literal!("boolean": bool); +literal!("null": ()); + +macro_rules! same_as_inner { + ($($ty:ident),*) => { + $(impl TsType for $ty { + fn ts() -> String { + T::ts() + } + })* + }; +} + +same_as_inner!(Box, Rc, Arc); + +impl TsType for &T { + fn ts() -> String { + T::ts() + } +} + +impl TsType for Option { + fn ts() -> String { + format!("{} | null", T::ts()) + } +} + +macro_rules! sequence { + ($($ty:ident),*) => { + $(impl TsType for $ty { + fn ts() -> String { + array(&T::ts()) + } + })* + }; +} + +sequence!(Vec, VecDeque, HashSet, BTreeSet); + +impl TsType for [T] { + fn ts() -> String { + array(&T::ts()) + } +} + +impl TsType for [T; N] { + fn ts() -> String { + array(&T::ts()) + } +} + +impl TsType for HashMap { + fn ts() -> String { + format!("Map<{}, {}>", K::ts(), V::ts()) + } +} + +impl TsType for BTreeMap { + fn ts() -> String { + format!("Map<{}, {}>", K::ts(), V::ts()) + } +} + +macro_rules! tuple { + ($($name:ident),+) => { + impl<$($name: TsType),+> TsType for ($($name,)+) { + fn ts() -> String { + let items: &[String] = &[$($name::ts()),+]; + format!("[{}]", items.join(", ")) + } + } + }; +} + +tuple!(A); +tuple!(A, B); +tuple!(A, B, C); +tuple!(A, B, C, D); + +/// `T[]`, or `Array` when `T` is a union or an object. +fn array(item: &str) -> String { + if item.contains(' ') { + format!("Array<{item}>") + } else { + format!("{item}[]") + } +} + +/// An object key: as is when it is an identifier, else quoted. +#[doc(hidden)] +pub fn key(name: &str) -> String { + let mut chars = name.chars(); + let identifier = chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$'); + if identifier { + name.to_owned() + } else { + literal(name) + } +} + +/// A string literal type: `'name'`. +#[doc(hidden)] +pub fn literal(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) +} + +/// An object type from its members (`key: type` or `key?: type`). +#[doc(hidden)] +pub fn object(members: &[String]) -> String { + if members.is_empty() { + "{}".into() + } else { + format!("{{ {} }}", members.join("; ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_values_the_way_they_reach_js() { + assert_eq!(>::ts(), "string | null"); + assert_eq!(>::ts(), "number[]"); + assert_eq!(>>::ts(), "Array"); + assert_eq!(>::ts(), "Map"); + assert_eq!(<(u8, String)>::ts(), "[number, string]"); + } + + #[test] + fn quotes_keys_and_literals_only_when_needed() { + assert_eq!(key("activeId"), "activeId"); + assert_eq!(key("query.set"), "'query.set'"); + assert_eq!(literal("it's"), "'it\\'s'"); + assert_eq!(object(&[]), "{}"); + } +} diff --git a/crates/core/tests/act.rs b/crates/core/tests/act.rs new file mode 100644 index 0000000..a8f26b5 --- /dev/null +++ b/crates/core/tests/act.rs @@ -0,0 +1,126 @@ +//! `act`: write-sugar for the most common action, a context patch computed from the +//! params. It slots in wherever an action does. +//! +//! Not ported: +//! - "infers Context from the slot — no per-call generics, even with computed": a +//! TypeScript inference check. A Rust patch is a typed builder (`Ctx::patch().field(v)`), +//! so a wrong value type is a compile error by construction. +//! +//! Port note: TS `act(a, b)` applies several patches; Rust's `act` takes one, so the +//! multi-patch form is consecutive `act`s. + +mod common; + +use common::{Log, build}; +use dunky_core::{Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Focus, + Set { value: i32 }, + Inc, + Bump, + Go, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + focused: bool, + value: i32, + n: i32, + label: String, + hit: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn writes_a_static_patch() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Focus, |t| t.act(|_| Ctx::patch().focused(true))) + })); + m.send(Ev::Focus); + assert!(m.context().focused); +} + +#[test] +fn writes_a_patch_derived_from_the_event() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Set, |t| { + t.act(|p| match p.event() { + Some(Ev::Set { value }) => Ctx::patch().value(*value), + _ => Ctx::patch(), + }) + }) + })); + m.send(Ev::Set { value: 42 }); + assert_eq!(m.context().value, 42); +} + +#[test] +fn reads_the_context() { + let m = build( + Config::::builder( + S::A, + Ctx { + n: 1, + ..Ctx::default() + }, + ) + .state(S::A, |s| { + s.on(EvKind::Inc, |t| { + t.act(|p| Ctx::patch().n(p.context().n + 1)) + }) + }), + ); + m.send(Ev::Inc); + m.send(Ev::Inc); + assert_eq!(m.context().n, 3); +} + +#[test] +fn a_later_patch_sees_earlier_writes() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Bump, |t| { + t.act(|_| Ctx::patch().n(5)) + .act(|p| Ctx::patch().label(format!("n={}", p.context().n))) + }) + })); + m.send(Ev::Bump); + assert_eq!(m.context().n, 5); + assert_eq!(m.context().label, "n=5"); +} + +#[test] +fn composes_with_a_target_and_other_actions_in_order() { + let log: Log = Log::default(); + let record = |phase: &'static str| { + let log = log.clone(); + move |p: &mut dunky_core::ActionParams<'_, M>| { + log.push(format!("{phase}: hit={}", p.context().hit)); + } + }; + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.target(S::B) + .run(record("before")) + .act(|_| Ctx::patch().hit(true)) + .run(record("after")) + }) + })); + m.send(Ev::Go); + assert_eq!(m.state(), S::B); + assert_eq!(log.entries(), ["before: hit=false", "after: hit=true"]); +} diff --git a/crates/core/tests/actions.rs b/crates/core/tests/actions.rs new file mode 100644 index 0000000..5106941 --- /dev/null +++ b/crates/core/tests/actions.rs @@ -0,0 +1,391 @@ +//! Actions: inline and named, run in order; `one_of` picks one branch by guard; entry and +//! exit run around the state switch; every machine owns its context. +//! +//! Not ported: +//! - "action params include `computed` (empty when none configured)": JS params carry a +//! `computed` object; Rust reads computed values by typed key (see computed.rs). +//! - "the context object identity is permanent across writes": JS object identity. A Rust +//! machine owns its context and every read borrows the live value, so no reference can +//! be held across a write. + +mod common; + +use common::{CounterEvent, Log, build, counter}; +use dunky_core::{ + Action, Branch, Config, Context, Event, Guard, GuardParams, Machine, State, Types, +}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, + C, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Go, + ToB, + Auto, + Mark, + Stay, + Set { value: String }, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + n: i32, + kind: char, + mobile: bool, + admin: bool, + last: String, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +fn when(f: impl Fn(&GuardParams<'_, M>) -> bool + 'static) -> Guard { + Guard::when(f) +} + +#[test] +fn runs_an_inline_action_that_updates_the_context() { + let m = build(&mut counter()); + m.send(CounterEvent::Inc); + m.send(CounterEvent::Inc); + assert_eq!(m.context().n, 2); +} + +#[test] +fn runs_multiple_actions_in_order() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.run(log.action("a")) + .run(log.action("b")) + .run(log.action("c")) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["a", "b", "c"]); +} + +#[test] +fn an_action_can_read_the_event_payload() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Set, |t| { + t.act(|p| match p.event() { + Some(Ev::Set { value }) => Ctx::patch().last(value.clone()), + _ => Ctx::patch(), + }) + }) + })); + m.send(Ev::Set { value: "hi".into() }); + assert_eq!(m.context().last, "hi"); +} + +#[test] +fn an_action_can_queue_an_event_via_send() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.on(EvKind::ToB, |t| t.target(S::B).run(|p| p.send(Ev::Mark))) + }) + .state(S::B, |s| { + s.on(EvKind::Mark, |t| t.run(log.action("marked"))) + }), + ); + m.send(Ev::ToB); + assert_eq!(m.state(), S::B); + assert_eq!(log.entries(), ["marked"]); +} + +#[test] +fn resolves_actions_by_name() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .action("inc", |p| { + let n = p.context().n; + p.set_context(Ctx::patch().n(n + 1)); + }) + .state(S::A, |s| { + s.on(EvKind::Go, |t| t.action("inc").action("inc")) + }), + ); + m.send(Ev::Go); + assert_eq!(m.context().n, 2); +} + +#[test] +fn named_and_inline_actions_run_in_order() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .action("first", log.action("first")) + .action("last", log.action("last")) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.action("first").run(log.action("inline")).action("last") + }) + }), + ); + m.send(Ev::Go); + assert_eq!(log.entries(), ["first", "inline", "last"]); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no action \"missing\"")] +fn panics_in_debug_when_an_action_name_is_not_registered() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.on(EvKind::Go, |t| t.action("missing"))), + ); + m.send(Ev::Go); +} + +#[test] +fn one_of_runs_the_first_branch_whose_guard_passes() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + kind: 'b', + ..Ctx::default() + }, + ) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([ + Branch::when( + when(|p| p.context().kind == 'a'), + [Action::run(log.action("a"))], + ), + Branch::when( + when(|p| p.context().kind == 'b'), + [Action::run(log.action("b"))], + ), + Branch::otherwise([Action::run(log.action("fallback"))]), + ])) + }) + }), + ); + m.send(Ev::Go); + assert_eq!(log.entries(), ["b"]); +} + +#[test] +fn one_of_falls_back_to_the_guardless_branch() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([ + Branch::when( + when(|p| p.context().n > 0), + [Action::run(log.action("positive"))], + ), + Branch::otherwise([Action::run(log.action("fallback"))]), + ])) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["fallback"]); +} + +#[test] +fn one_of_runs_nothing_when_no_branch_matches_and_there_is_no_fallback() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([Branch::when( + when(|p| p.context().n > 0), + [Action::run(log.action("x"))], + )])) + }) + })); + m.send(Ev::Go); + assert!(log.is_empty()); +} + +#[test] +fn one_of_composes_with_unconditional_actions_in_order() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + mobile: true, + ..Ctx::default() + }, + ) + .guard("isMobile", |p| p.context().mobile) + .action("lockScroll", log.action("lockScroll")) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.run(log.action("always-before")) + .push(Action::one_of([Branch::when( + "isMobile", + [Action::named("lockScroll")], + )])) + .run(log.action("always-after")) + }) + }), + ); + m.send(Ev::Go); + assert_eq!( + log.entries(), + ["always-before", "lockScroll", "always-after"] + ); +} + +#[test] +fn one_of_branch_guards_accept_registered_names() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + admin: true, + ..Ctx::default() + }, + ) + .guard("isAdmin", |p| p.context().admin) + .action("adminPath", log.action("admin")) + .action("userPath", log.action("user")) + .state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.push(Action::one_of([ + Branch::when("isAdmin", [Action::named("adminPath")]), + Branch::otherwise([Action::named("userPath")]), + ])) + }) + }), + ); + m.send(Ev::Go); + assert_eq!(log.entries(), ["admin"]); +} + +#[test] +fn exit_runs_before_the_transition_actions_and_entry_after() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.exit_run(log.action("exit:a")).on(EvKind::ToB, |t| { + t.target(S::B).run(log.action("action:toB")) + }) + }) + .state(S::B, |s| s.entry_run(log.action("entry:b"))), + ); + m.send(Ev::ToB); + assert_eq!(log.entries(), ["exit:a", "action:toB", "entry:b"]); + assert_eq!(m.state(), S::B); +} + +#[test] +fn the_initial_state_entry_does_not_run_at_construction() { + let log = Log::default(); + let _m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.entry_run(log.action("entry:a"))), + ); + assert!(log.is_empty()); +} + +#[test] +fn a_targetless_transition_skips_entry_and_exit() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.entry_run(log.action("entry:a")) + .exit_run(log.action("exit:a")) + .on(EvKind::Go, |t| t.act(|p| Ctx::patch().n(p.context().n + 1))) + })); + m.send(Ev::Go); + assert!(log.is_empty()); + assert_eq!(m.context().n, 1); +} + +#[test] +fn a_transition_targeting_the_current_state_also_skips_entry_and_exit() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.entry_run(log.action("entry:a")) + .exit_run(log.action("exit:a")) + .on(EvKind::Stay, |t| t.target(S::A).run(log.action("action"))) + })); + m.send(Ev::Stay); + assert_eq!(log.entries(), ["action"]); +} + +#[test] +fn entry_and_exit_run_named_actions_and_one_of() { + let log = Log::default(); + let m = build( + Config::::builder( + S::A, + Ctx { + mobile: true, + ..Ctx::default() + }, + ) + .guard("isMobile", |p| p.context().mobile) + .action("logExit", log.action("exit:a")) + .action("mobileSetup", log.action("mobile")) + .action("desktopSetup", log.action("desktop")) + .state(S::A, |s| { + s.exit("logExit").on(EvKind::ToB, |t| t.target(S::B)) + }) + .state(S::B, |s| { + s.entry(Action::one_of([ + Branch::when("isMobile", [Action::named("mobileSetup")]), + Branch::otherwise([Action::named("desktopSetup")]), + ])) + }), + ); + m.send(Ev::ToB); + assert_eq!(log.entries(), ["exit:a", "mobile"]); +} + +#[test] +fn an_entry_action_can_queue_the_next_transition() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.on(EvKind::ToB, |t| t.target(S::B))) + .state(S::B, |s| { + s.entry_run(|p| p.send(Ev::Auto)) + .entry_run(log.action("entry:b")) + .on(EvKind::Auto, |t| t.target(S::C)) + }) + .state(S::C, |s| s.entry_run(log.action("entry:c"))), + ); + m.send(Ev::ToB); + assert_eq!(log.entries(), ["entry:b", "entry:c"]); + assert_eq!(m.state(), S::C); +} + +#[test] +fn a_machine_never_mutates_the_config_seed_context() { + let config = counter().build(); + let m = Machine::new(&config); + m.send(CounterEvent::Inc); + assert_eq!(m.context().n, 1); + assert_eq!(config.context().n, 0); +} + +#[test] +fn machines_built_from_one_config_are_isolated() { + let config = counter().build(); + let a = Machine::new(&config); + let b = Machine::new(&config); + a.send(CounterEvent::Inc); + assert_eq!(a.context().n, 1); + assert_eq!(b.context().n, 0); +} diff --git a/crates/core/tests/after.rs b/crates/core/tests/after.rs new file mode 100644 index 0000000..70264b5 --- /dev/null +++ b/crates/core/tests/after.rs @@ -0,0 +1,288 @@ +//! `after`: timed transitions while a state is active. Time is driven by the sans-IO test +//! `Clock` (the counterpart of vitest fake timers): it runs the machine's timer commands +//! and fires due timers as virtual time advances. +//! +//! Rust-only (a sans-IO host can fire a timer at any moment, which JS timers cannot): the +//! last three tests pin the SPEC's stale-timer and no-interleaving rules. + +mod common; + +use common::{Log, build}; +use dunky_core::testing::Clock; +use dunky_core::{Command, Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, + C, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Leave, + Back, + Chain, + Show, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + ms: u32, + n: i32, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn fires_a_fixed_delay_transition_after_the_delay() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.after(200, |t| t.target(S::B))), + ); + m.start(); + let mut clock = Clock::new(&m); + clock.advance(199); + assert_eq!(m.state(), S::A); + clock.advance(1); + assert_eq!(m.state(), S::B); +} + +#[test] +fn resolves_a_named_delay_that_reads_the_context() { + let m = build( + Config::::builder( + S::A, + Ctx { + ms: 500, + ..Ctx::default() + }, + ) + .delay("openDelay", |p| p.context().ms) + .state(S::A, |s| s.after("openDelay", |t| t.target(S::B))), + ); + m.start(); + let mut clock = Clock::new(&m); + clock.advance(499); + assert_eq!(m.state(), S::A); + clock.advance(1); + assert_eq!(m.state(), S::B); +} + +#[test] +fn leaving_the_state_first_cancels_its_timer() { + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.after(200, |t| t.target(S::B)) + .on(EvKind::Leave, |t| t.target(S::C)) + })); + m.start(); + let mut clock = Clock::new(&m); + m.send(Ev::Leave); + assert_eq!(m.state(), S::C); + clock.advance(500); + assert_eq!(m.state(), S::C); +} + +#[test] +fn timers_run_only_while_started_and_stop_cancels_a_pending_one() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.after(100, |t| t.target(S::B))), + ); + let mut clock = Clock::new(&m); + clock.advance(500); // never started: nothing scheduled + assert_eq!(m.state(), S::A); + + m.start(); + m.stop(); + clock.advance(500); + assert_eq!(m.state(), S::A); +} + +#[test] +fn an_after_handler_falls_through_to_the_first_passing_candidate() { + let m = build( + Config::::builder( + S::A, + Ctx { + n: 8, + ..Ctx::default() + }, + ) + .state(S::A, |s| { + s.after(100, |t| t.guard_fn(|p| p.context().n >= 10).target(S::B)) + .after_or(|t| t.target(S::C)) + }), + ); + m.start(); + Clock::new(&m).advance(100); + assert_eq!(m.state(), S::C); // n=8 < 10: the fallback +} + +#[test] +fn a_timed_transition_runs_its_actions_on_the_way() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(50, |t| t.target(S::B).run(log.action("tick"))) + }) + .state(S::B, |s| s.entry_run(log.action("entered-b"))), + ); + m.start(); + Clock::new(&m).advance(50); + assert_eq!(log.entries(), ["tick", "entered-b"]); + assert_eq!(m.state(), S::B); +} + +#[test] +fn a_state_entered_later_schedules_its_timer_on_entry() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.on(EvKind::Show, |t| t.target(S::B))) + .state(S::B, |s| s.after(300, |t| t.target(S::A))), + ); + m.start(); + let mut clock = Clock::new(&m); + m.send(Ev::Show); + assert_eq!(m.state(), S::B); + clock.advance(300); + assert_eq!(m.state(), S::A); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no delay \"missing\"")] +fn panics_in_debug_when_a_delay_name_is_not_registered() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| s.after("missing", |t| t.target(S::B))), + ); + m.start(); +} + +#[test] +fn events_sent_by_a_timed_transition_drain_in_the_same_run() { + let log = Log::default(); + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(10, |t| t.target(S::B).run(|p| p.send(Ev::Chain))) + }) + .state(S::B, |s| { + s.on(EvKind::Chain, |t| t.target(S::C).run(log.action("chained"))) + }) + .state(S::C, |s| s.entry_run(log.action("entered-c"))), + ); + m.start(); + Clock::new(&m).advance(10); + assert_eq!(m.state(), S::C); + assert_eq!(log.entries(), ["chained", "entered-c"]); +} + +#[test] +fn re_entering_a_timed_state_restarts_its_timer() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(100, |t| t.target(S::C)) + .on(EvKind::Leave, |t| t.target(S::B)) + }) + .state(S::B, |s| s.on(EvKind::Back, |t| t.target(S::A))), + ); + m.start(); + let mut clock = Clock::new(&m); + clock.advance(60); + m.send(Ev::Leave); + assert_eq!(m.state(), S::B); + clock.advance(50); // past the first entry's deadline, but that timer is gone + assert_eq!(m.state(), S::B); + m.send(Ev::Back); // a fresh 100ms timer + clock.advance(60); + assert_eq!(m.state(), S::A); + clock.advance(40); + assert_eq!(m.state(), S::C); +} + +#[test] +fn a_cancelled_timer_fired_anyway_is_ignored() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(100, |t| t.target(S::C)) + .on(EvKind::Leave, |t| t.target(S::B)) + }) + .state(S::B, |s| s.on(EvKind::Back, |t| t.target(S::A))), + ); + m.start(); + let [Command::StartTimer { id, .. }] = m.take_commands()[..] else { + panic!("expected one StartTimer") + }; + m.send(Ev::Leave); // emits the cancel; this host fires the timer anyway + m.fire_timer(id); + assert_eq!(m.state(), S::B); + m.send(Ev::Back); // a re-entry schedules a new timer; the old id stays dead + m.fire_timer(id); + assert_eq!(m.state(), S::A); +} + +#[test] +fn a_timer_fired_mid_transition_waits_for_it_to_complete() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.after(10, |t| t.target(S::C).run(log.action("timer"))) + .on(EvKind::Chain, |t| { + t.run(log.action("chain:start")) + .act(|p| Ctx::patch().n(p.context().n + 1)) + .run(log.action("chain:end")) + }) + })); + m.start(); + let [Command::StartTimer { id, .. }] = m.take_commands()[..] else { + panic!("expected one StartTimer") + }; + let _ = m.subscribe({ + let m = m.clone(); + move || m.fire_timer(id) // the host fires while `chain` is in flight + }); + m.send(Ev::Chain); + assert_eq!(log.entries(), ["chain:start", "chain:end", "timer"]); + assert_eq!(m.state(), S::C); +} + +#[test] +fn a_timer_queued_behind_an_exit_and_re_entry_of_its_state_is_stale() { + let m = build( + Config::::builder(S::A, Ctx::default()) + .state(S::A, |s| { + s.after(100, |t| t.target(S::C)) + .on(EvKind::Leave, |t| t.target(S::B)) + .on(EvKind::Chain, |t| { + t.run(|p| { + p.send(Ev::Leave); + p.send(Ev::Back); + }) + .act(|p| Ctx::patch().n(p.context().n + 1)) + }) + }) + .state(S::B, |s| s.on(EvKind::Back, |t| t.target(S::A))), + ); + m.start(); + let [Command::StartTimer { id, .. }] = m.take_commands()[..] else { + panic!("expected one StartTimer") + }; + // Fired at the `chain` patch, the timer queues behind `leave` and `back`. By the time + // it runs, `a` was exited and re-entered: the timer belongs to the previous entry. + let _ = m.subscribe({ + let m = m.clone(); + move || m.fire_timer(id) + }); + m.send(Ev::Chain); + assert_eq!(m.state(), S::A); +} diff --git a/crates/core/tests/bare_fn_entry.rs b/crates/core/tests/bare_fn_entry.rs new file mode 100644 index 0000000..8ad70c8 --- /dev/null +++ b/crates/core/tests/bare_fn_entry.rs @@ -0,0 +1,76 @@ +//! An action-only candidate (no target, no guard), the Rust form of the TS bare-fn entry, +//! is a guardless fallback in a fallthrough list. +//! +//! Not ported. Rust has no bare-fn shorthand: an action-only candidate is built like any +//! other transition, so these collapse into tests elsewhere: +//! - "a standalone bare fn runs as an action, no state change": transitions.rs +//! (a_targetless_transition_runs_its_actions_and_stays_in_the_state). +//! - "act(...) works bare in the entry slot": act.rs (writes_a_static_patch). +//! - "the bare fn sees the (narrowed) event": actions.rs (an_action_can_read_the_event_payload). +//! - "object form still works for targets alongside a bare-fn sibling event": there is one +//! form only; targets and action-only handlers are covered by transitions.rs. + +mod common; + +use common::{Log, build}; +use dunky_core::{Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Go, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + hit: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn an_action_only_candidate_after_failing_guards_is_the_fallback() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| t.guard_fn(|_| false).target(S::B)) + .on(EvKind::Go, |t| { + t.run(log.action("fallback")) + .act(|_| Ctx::patch().hit(true)) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["fallback"]); + assert!(m.context().hit); + assert_eq!(m.state(), S::A); // the fallback has no target +} + +#[test] +fn a_passing_guarded_candidate_before_an_action_only_one_wins() { + let log = Log::default(); + let m = build(Config::::builder(S::A, Ctx::default()).state(S::A, |s| { + s.on(EvKind::Go, |t| { + t.guard_fn(|_| true) + .target(S::B) + .run(log.action("transition")) + }) + .on(EvKind::Go, |t| { + t.run(log.action("fallback")) + .act(|_| Ctx::patch().hit(true)) + }) + })); + m.send(Ev::Go); + assert_eq!(log.entries(), ["transition"]); + assert!(!m.context().hit); + assert_eq!(m.state(), S::B); +} diff --git a/crates/core/tests/broadcast.rs b/crates/core/tests/broadcast.rs new file mode 100644 index 0000000..69c82cb --- /dev/null +++ b/crates/core/tests/broadcast.rs @@ -0,0 +1,111 @@ +//! The notify bus behind `Machine::subscribe` (and the connector's wake): who fires in +//! the pass where membership changes. The bus is private, so it is driven through the +//! machine. +//! +//! Not ported: +//! - "the remover detaches; removing twice is harmless": `Subscription::unsubscribe` +//! consumes the handle, so a second removal does not compile; detaching is pinned in +//! subscribe.rs. +//! +//! Port note: "clear() drops everyone at once" goes through `Connector::destroy`, the only +//! public path that clears a bus. + +mod common; + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use common::{Count, CounterEvent, Log, build, counter}; +use dunky_core::{Connector, Subscription}; + +/// A slot for a subscription made after the listener that will remove it. +type Slot = Rc>>; + +fn unsubscribe(slot: &Slot) { + if let Some(sub) = slot.borrow_mut().take() { + sub.unsubscribe(); + } +} + +#[test] +fn a_change_wakes_every_listener_in_subscription_order() { + let m = build(&mut counter()); + let log = Log::default(); + let _ = m.subscribe(log.listener("a")); + let _ = m.subscribe(log.listener("b")); + assert!(log.is_empty()); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "b"]); +} + +#[test] +fn a_listener_removed_mid_pass_does_not_fire_in_that_pass() { + let m = build(&mut counter()); + let log = Log::default(); + let off_b = Slot::default(); + let _ = m.subscribe({ + let (log, off_b) = (log.clone(), off_b.clone()); + move || { + log.push("a"); + unsubscribe(&off_b); + } + }); + *off_b.borrow_mut() = Some(m.subscribe(log.listener("b"))); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a"]); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "a"]); +} + +#[test] +fn a_listener_added_mid_pass_waits_for_the_next_notify() { + let m = build(&mut counter()); + let log = Log::default(); + let added = Cell::new(false); + let _ = m.subscribe({ + let (m, log) = (m.clone(), log.clone()); + move || { + log.push("a"); + if !added.replace(true) { + let _ = m.subscribe(log.listener("late")); + } + } + }); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a"]); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "a", "late"]); +} + +#[test] +fn a_nested_notify_does_not_resurrect_a_listener_removed_in_the_outer_pass() { + let m = build(&mut counter()); + let log = Log::default(); + let off_b = Slot::default(); + let nested = Cell::new(false); + let _ = m.subscribe({ + let (m, log, off_b) = (m.clone(), log.clone(), off_b.clone()); + move || { + log.push("a"); + if !nested.replace(true) { + unsubscribe(&off_b); + m.set_context(Count::patch().n(99)); // notifies while the outer pass runs + } + } + }); + *off_b.borrow_mut() = Some(m.subscribe(log.listener("b"))); + m.send(CounterEvent::Inc); + assert_eq!(log.entries(), ["a", "a"]); // b fired in neither pass +} + +#[test] +fn clearing_drops_every_listener_at_once() { + let m = build(&mut counter()); + let c = Connector::new(&m, |snap| snap.view.context().n, 0, Vec::new()); + let log = Log::default(); + let _ = c.subscribe(log.listener("a")); + let _ = c.subscribe(log.listener("b")); + c.destroy(); + c.set_props(1); // a props change wakes the connector's own bus + assert!(log.is_empty()); +} diff --git a/crates/core/tests/common/mod.rs b/crates/core/tests/common/mod.rs new file mode 100644 index 0000000..545f013 --- /dev/null +++ b/crates/core/tests/common/mod.rs @@ -0,0 +1,181 @@ +//! Fixtures shared by several test files: recorders (the stand-ins for pushing into an +//! array and for `vi.fn()`) and the two small machines most behaviors are pinned on. +#![allow(dead_code)] + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use dunky_core::{ + ActionParams, Cleanup, Config, ConfigBuilder, Context, Event, Machine, State, Types, +}; + +/// Records entries in order. +pub struct Log(Rc>>); + +impl Clone for Log { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl Default for Log { + fn default() -> Self { + Self(Rc::default()) + } +} + +impl Log { + pub fn push(&self, entry: impl Into) { + self.0.borrow_mut().push(entry.into()); + } + pub fn entries(&self) -> Vec { + self.0.borrow().clone() + } + pub fn is_empty(&self) -> bool { + self.0.borrow().is_empty() + } + pub fn clear(&self) { + self.0.borrow_mut().clear(); + } +} + +impl Log { + /// An action that records `entry`. + pub fn action( + &self, + entry: &'static str, + ) -> impl Fn(&mut ActionParams<'_, T>) + 'static { + let log = self.clone(); + move |_| log.push(entry) + } + /// An effect cleanup that records `entry`. + fn cleanup(&self, entry: &'static str) -> Cleanup { + let log = self.clone(); + Box::new(move || log.push(entry)) + } + /// A plain listener that records `entry`. + pub fn listener(&self, entry: &'static str) -> impl Fn() + 'static { + let log = self.clone(); + move || log.push(entry) + } + /// An effect that records `start` when it starts and returns a cleanup recording + /// `cleanup`; either may be omitted. + pub fn effect( + &self, + start: Option<&'static str>, + cleanup: Option<&'static str>, + ) -> impl Fn(&mut ActionParams<'_, T>) -> Option + 'static { + let log = self.clone(); + move |_| { + if let Some(entry) = start { + log.push(entry); + } + cleanup.map(|entry| log.cleanup(entry)) + } + } +} + +/// Counts calls. +#[derive(Clone, Default)] +pub struct Calls(Rc>); + +impl Calls { + pub fn hit(&self) { + self.0.set(self.0.get() + 1); + } + pub fn count(&self) -> usize { + self.0.get() + } + pub fn listener(&self) -> impl Fn() + 'static { + let calls = self.clone(); + move || calls.hit() + } + pub fn action(&self) -> impl Fn(&mut ActionParams<'_, T>) + 'static { + let calls = self.clone(); + move |_| calls.hit() + } + /// An effect that counts its starts; its cleanup, if `cleanups` is given, counts there. + pub fn effect( + &self, + cleanups: Option<&Calls>, + ) -> impl Fn(&mut ActionParams<'_, T>) -> Option + 'static { + let calls = self.clone(); + let cleanups = cleanups.cloned(); + move |_| { + calls.hit(); + cleanups + .clone() + .map(|c| Box::new(move || c.hit()) as Cleanup) + } + } +} + +pub fn build(builder: &mut ConfigBuilder) -> Machine { + Machine::new(&builder.build()) +} + +/// Two states, no context: `a --toB--> b --toA--> a`. +pub struct Ab; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +pub enum AbState { + A, + B, +} + +#[derive(Clone, Debug, Event)] +pub enum AbEvent { + ToB, + ToA, +} + +impl Types for Ab { + type State = AbState; + type Event = AbEvent; + type Context = (); +} + +pub fn ab() -> ConfigBuilder { + let mut b = Config::::builder(AbState::A, ()); + b.state(AbState::A, |s| { + s.on(AbEventKind::ToB, |t| t.target(AbState::B)) + }) + .state(AbState::B, |s| { + s.on(AbEventKind::ToA, |t| t.target(AbState::A)) + }); + b +} + +/// One state counting `inc` events in `n`. +pub struct Counter; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +pub enum CounterState { + Idle, +} + +#[derive(Clone, Debug, Event)] +pub enum CounterEvent { + Inc, +} + +#[derive(Clone, Debug, PartialEq, Context)] +pub struct Count { + pub n: i32, +} + +impl Types for Counter { + type State = CounterState; + type Event = CounterEvent; + type Context = Count; +} + +pub fn counter() -> ConfigBuilder { + let mut b = Config::::builder(CounterState::Idle, Count { n: 0 }); + b.state(CounterState::Idle, |s| { + s.on(CounterEventKind::Inc, |t| { + t.act(|p| Count::patch().n(p.context().n + 1)) + }) + }); + b +} diff --git a/crates/core/tests/compose.rs b/crates/core/tests/compose.rs new file mode 100644 index 0000000..78ea8da --- /dev/null +++ b/crates/core/tests/compose.rs @@ -0,0 +1,292 @@ +//! Composition: several machines run as one unit with a shared start / stop, plus `sync` +//! (a cross-member reaction) and `combine` (a value-deduped derivation across members). +//! +//! Not ported: +//! - the "members are reachable by name" half of "start() starts every member": the JS +//! `members` map. A Rust composition keeps no names; callers keep their own handles. +//! - "re-entrant send from sync is queued, not run mid-drain": the damped-forward test runs +//! the same rule for 200 sends and asserts the same end state. + +mod common; + +use std::rc::Rc; + +use common::{AbState, Calls, CounterEvent, Log, ab, build, counter}; +use dunky_core::{Composition, Config, Event, Machine, Member, State, Subscription, Types}; + +struct Popup; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum PopupState { + Closed, + Open, +} + +#[derive(Clone, Debug, Event)] +enum PopupEvent { + Focus, + Escape, +} + +impl Types for Popup { + type State = PopupState; + type Event = PopupEvent; + type Context = (); +} + +struct Submenu; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum SubmenuState { + Hidden, + Shown, +} + +#[derive(Clone, Debug, Event)] +enum SubmenuEvent { + Open, + Close, +} + +impl Types for Submenu { + type State = SubmenuState; + type Event = SubmenuEvent; + type Context = (); +} + +/// A started composition of a popup (closed <-> open) and a submenu (hidden <-> shown). +fn menu() -> (Composition, Machine, Machine) { + let popup = build( + Config::::builder(PopupState::Closed, ()) + .state(PopupState::Closed, |s| { + s.on(PopupEventKind::Focus, |t| t.target(PopupState::Open)) + }) + .state(PopupState::Open, |s| { + s.on(PopupEventKind::Escape, |t| t.target(PopupState::Closed)) + }), + ); + let submenu = build( + Config::::builder(SubmenuState::Hidden, ()) + .state(SubmenuState::Hidden, |s| { + s.on(SubmenuEventKind::Open, |t| t.target(SubmenuState::Shown)) + }) + .state(SubmenuState::Shown, |s| { + s.on(SubmenuEventKind::Close, |t| t.target(SubmenuState::Hidden)) + }), + ); + let group = Composition::new().with(&popup).with(&submenu); + group.start(); + (group, popup, submenu) +} + +#[test] +fn start_starts_every_member_in_order() { + let log = Log::default(); + let a = build(ab().state(AbState::A, |s| s.effect_run(log.effect(Some("a"), None)))); + let b = build(ab().state(AbState::A, |s| s.effect_run(log.effect(Some("b"), None)))); + let group = Composition::new().with(&a).with(&b); + assert!(log.is_empty()); + group.start(); + assert_eq!(log.entries(), ["a", "b"]); +} + +#[test] +fn stop_stops_members_in_reverse_order() { + let log = Log::default(); + let a = build(ab().state(AbState::A, |s| { + s.effect_run(log.effect(None, Some("a:cleanup"))) + })); + let b = build(ab().state(AbState::A, |s| { + s.effect_run(log.effect(None, Some("b:cleanup"))) + })); + let group = Composition::new().with(&a).with(&b); + group.start(); + group.stop(); + assert_eq!(log.entries(), ["b:cleanup", "a:cleanup"]); +} + +#[test] +fn members_stay_independent() { + let (_group, popup, submenu) = menu(); + popup.send(PopupEvent::Focus); + submenu.send(SubmenuEvent::Open); + assert_eq!(popup.state(), PopupState::Open); + assert_eq!(submenu.state(), SubmenuState::Shown); +} + +#[test] +fn sync_reacts_to_any_member_change_but_not_on_setup() { + let (group, popup, submenu) = menu(); + popup.send(PopupEvent::Focus); + submenu.send(SubmenuEvent::Open); + // Rule: when the popup closes, close the submenu too. + let _ = group.sync({ + let (popup, submenu) = (popup.clone(), submenu.clone()); + move || { + if popup.matches(PopupState::Closed) { + submenu.send(SubmenuEvent::Close); + } + } + }); + assert_eq!(submenu.state(), SubmenuState::Shown); + popup.send(PopupEvent::Escape); + assert_eq!(submenu.state(), SubmenuState::Hidden); +} + +#[test] +fn a_sync_rule_is_disposed_on_stop() { + let (group, popup, _submenu) = menu(); + let calls = Calls::default(); + let _ = group.sync(calls.listener()); + popup.send(PopupEvent::Focus); + assert_eq!(calls.count(), 1); + group.stop(); + popup.start(); // the member still runs, the rule is gone + popup.send(PopupEvent::Escape); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_sync_rule_detaches_early_when_unsubscribed() { + let (group, popup, _submenu) = menu(); + let calls = Calls::default(); + let sub = group.sync(calls.listener()); + popup.send(PopupEvent::Focus); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + popup.send(PopupEvent::Escape); + assert_eq!(calls.count(), 1); +} + +#[test] +fn combine_derives_one_value_across_members() { + let (group, popup, submenu) = menu(); + let view = group.combine({ + let (popup, submenu) = (popup.clone(), submenu.clone()); + move || (popup.matches(PopupState::Open), submenu.state()) + }); + assert_eq!(view.value(), (false, SubmenuState::Hidden)); + popup.send(PopupEvent::Focus); + submenu.send(SubmenuEvent::Open); + assert_eq!(view.value(), (true, SubmenuState::Shown)); +} + +#[test] +fn a_combined_subscription_fires_only_when_the_value_changes() { + let (group, popup, submenu) = menu(); + let seen: Log = Log::default(); + let _ = group + .combine({ + let popup = popup.clone(); + move || popup.matches(PopupState::Open) + }) + .subscribe({ + let seen = seen.clone(); + move |open| seen.push(*open) + }); + submenu.send(SubmenuEvent::Open); // a member changed, the value did not: silent + assert!(seen.is_empty()); + popup.send(PopupEvent::Focus); + assert_eq!(seen.entries(), [true]); +} + +#[test] +fn a_combined_subscription_is_disposed_on_stop() { + let (group, popup, _submenu) = menu(); + let calls = Calls::default(); + let _ = group + .combine({ + let popup = popup.clone(); + move || popup.state() + }) + .subscribe({ + let calls = calls.clone(); + move |_| calls.hit() + }); + popup.send(PopupEvent::Focus); + assert_eq!(calls.count(), 1); + group.stop(); + popup.start(); + popup.send(PopupEvent::Escape); + assert_eq!(calls.count(), 1); +} + +/// A member that counts unsubscribes, to see whether a detach runs twice. +#[derive(Clone)] +struct CountingMember(Calls); + +impl Member for CountingMember { + fn start(&self) {} + fn stop(&self) {} + fn subscribe_any(&self, _listener: Rc) -> Subscription { + let unsubscribes = self.0.clone(); + Subscription::new(move || unsubscribes.hit()) + } +} + +#[test] +fn stop_does_not_re_run_a_detach_already_run_by_hand() { + let unsubscribes = Calls::default(); + let member = CountingMember(unsubscribes.clone()); + let group = Composition::new().with(&member).with(&member); + group.start(); + let sync = group.sync(|| {}); + let combined = group.combine(|| 0).subscribe(|_| {}); + sync.unsubscribe(); + combined.unsubscribe(); + assert_eq!(unsubscribes.count(), 4); // one per member per subscription + group.stop(); + assert_eq!(unsubscribes.count(), 4); +} + +#[test] +fn a_damped_cross_member_forward_converges_with_linear_sync_calls() { + // `sync` wakes on every member, including the one it sends to, so the rule must damp + // its own forward (send only while b is behind a). + let a = build(&mut counter()); + let b = build(&mut counter()); + let group = Composition::new().with(&a).with(&b); + group.start(); + let calls = Calls::default(); + let _ = group.sync({ + let (a, b, calls) = (a.clone(), b.clone(), calls.clone()); + move || { + calls.hit(); + if a.context().n > b.context().n { + b.send(CounterEvent::Inc); + } + } + }); + const OPS: usize = 200; + for _ in 0..OPS { + a.send(CounterEvent::Inc); + } + assert_eq!(a.context().n, OPS as i32); + assert_eq!(b.context().n, OPS as i32); + assert_eq!(calls.count(), OPS * 2); // one wake from a, one from b, per send +} + +#[test] +fn a_combined_listener_may_send_to_a_member() { + let (group, popup, submenu) = menu(); + let seen: Log<(bool, SubmenuState)> = Log::default(); + let _ = group + .combine({ + let (popup, submenu) = (popup.clone(), submenu.clone()); + move || (popup.matches(PopupState::Open), submenu.state()) + }) + .subscribe({ + let (submenu, seen) = (submenu.clone(), seen.clone()); + move |value| { + seen.push(*value); + if *value == (true, SubmenuState::Hidden) { + submenu.send(SubmenuEvent::Open); // the member notifies at once + } + } + }); + popup.send(PopupEvent::Focus); + assert_eq!( + seen.entries(), + [(true, SubmenuState::Hidden), (true, SubmenuState::Shown)] + ); +} diff --git a/crates/core/tests/computed.rs b/crates/core/tests/computed.rs new file mode 100644 index 0000000..4ebd79f --- /dev/null +++ b/crates/core/tests/computed.rs @@ -0,0 +1,383 @@ +//! Computed values: lazy, memoized derivations of context, state and other computed +//! values, recomputed only when an input they read has changed. +//! +//! Not ported: +//! - "no computed config → params.computed is an empty bag": JS params carry a `computed` +//! object; Rust reads computed values by typed key, so there is no such object. +//! - "derives a value from context, readable in an action", "a computed can derive from +//! another computed" and "reads the current state": each is the opening step of other +//! tests here (recomputes_after_the_context_it_reads_changes, +//! an_upstream_change_propagates_through_a_chain, and the two state-reader tests). +//! - "a computed selection fires on change and dedups (value-gated)": the same +//! `select_computed` contract as subscribe.rs (select_computed_selects_a_derived_value). + +mod common; + +use common::{Calls, Log, build}; +use dunky_core::{ + ComputedKey, Config, ConfigBuilder, Context, Event, Machine, State, StateEnum, Types, +}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + Idle, + Busy, + Done, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Read, + Add, + BumpN, + BumpOther, + Rename, + UseDiscount, + SetDiscount(i32), + SetNote, + Go, + Finish, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + items: Vec, + n: i32, + other: i32, + first: String, + last: String, + use_discount: bool, + price: i32, + discount: i32, + note: String, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +/// Any-state handlers write the context; `go` / `finish` walk idle -> busy -> done. +/// Tests add their computed values and a `read` handler. +fn builder(ctx: Ctx) -> ConfigBuilder { + let mut b = Config::::builder(S::Idle, ctx); + b.on(EvKind::Add, |t| { + t.act(|p| { + let mut items = p.context().items.clone(); + items.push(1); + Ctx::patch().items(items) + }) + }) + .on(EvKind::BumpN, |t| { + t.act(|p| Ctx::patch().n(p.context().n + 1)) + }) + .on(EvKind::BumpOther, |t| { + t.act(|p| Ctx::patch().other(p.context().other + 1)) + }) + .on(EvKind::Rename, |t| t.act(|_| Ctx::patch().first("Grace"))) + .on(EvKind::UseDiscount, |t| { + t.act(|_| Ctx::patch().use_discount(true)) + }) + .on(EvKind::SetDiscount, |t| { + t.act(|p| match p.event() { + Some(Ev::SetDiscount(discount)) => Ctx::patch().discount(*discount), + _ => Ctx::patch(), + }) + }) + .on(EvKind::SetNote, |t| t.act(|_| Ctx::patch().note("hi"))) + .state(S::Idle, |s| s.on(EvKind::Go, |t| t.target(S::Busy))) + .state(S::Busy, |s| s.on(EvKind::Finish, |t| t.target(S::Done))); + b +} + +fn names() -> Ctx { + Ctx { + first: "Ada".into(), + last: "Lovelace".into(), + ..Ctx::default() + } +} + +#[test] +fn recomputes_after_the_context_it_reads_changes() { + let seen: Log = Log::default(); + let mut b = builder(Ctx::default()); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.on(EvKind::Read, |t| { + let seen = seen.clone(); + t.run(move |p| seen.push(*p.computed(is_empty))) + }); + let m = build(&mut b); + m.send(Ev::Read); + m.send(Ev::Add); + m.send(Ev::Read); + assert_eq!(seen.entries(), [true, false]); +} + +#[test] +fn is_readable_in_a_guard() { + let mut b = builder(Ctx::default()); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.state(S::Idle, |s| { + s.on(EvKind::Finish, |t| { + t.guard_fn(move |p| !*p.computed(is_empty)).target(S::Done) + }) + }); + let m = build(&mut b); + m.send(Ev::Finish); // empty: blocked + assert_eq!(m.state(), S::Idle); + m.send(Ev::Add); + m.send(Ev::Finish); + assert_eq!(m.state(), S::Done); +} + +#[test] +fn is_readable_in_an_effect_at_start() { + let seen: Log = Log::default(); + let mut b = builder(Ctx { + items: vec![1, 2, 3], + ..Ctx::default() + }); + let count = b.computed("count", |p| p.context.items().len()); + b.state(S::Idle, |s| { + let seen = seen.clone(); + s.effect_run(move |p| { + seen.push(*p.computed(count)); + None + }) + }); + let m = build(&mut b); + m.start(); + assert_eq!(seen.entries(), [3]); +} + +#[test] +fn memoizes_across_writes_to_fields_it_does_not_read() { + let runs = Calls::default(); + let mut b = builder(Ctx { + n: 2, + ..Ctx::default() + }); + let double = b.computed("double", { + let runs = runs.clone(); + move |p| { + runs.hit(); + p.context.n() * 2 + } + }); + b.on(EvKind::Read, |t| { + t.run(move |p| { + p.computed(double); + }) + }); + let m = build(&mut b); + m.send(Ev::Read); + assert_eq!(runs.count(), 1); + m.send(Ev::Read); + assert_eq!(runs.count(), 1); + m.send(Ev::BumpOther); // `double` does not read `other` + m.send(Ev::Read); + assert_eq!(runs.count(), 1); +} + +#[test] +fn an_upstream_change_propagates_through_a_chain() { + let seen: Log = Log::default(); + let mut b = builder(names()); + let full_name = b.computed("fullName", |p| { + format!("{} {}", p.context.first(), p.context.last()) + }); + let greeting = b.computed("greeting", move |p| { + format!("Hi, {}", p.computed(full_name)) + }); + b.on(EvKind::Read, |t| { + let seen = seen.clone(); + t.run(move |p| seen.push((*p.computed(greeting)).clone())) + }); + let m = build(&mut b); + m.send(Ev::Read); + m.send(Ev::Rename); + m.send(Ev::Read); + assert_eq!(seen.entries(), ["Hi, Ada Lovelace", "Hi, Grace Lovelace"]); +} + +#[test] +fn a_computed_may_read_one_defined_after_it() { + // Keys are numbered in definition order, so a forward reference is a const key. + const FULL_NAME: ComputedKey = ComputedKey::new(1); + let mut b = builder(names()); + let greeting = b.computed("greeting", |p| format!("Hi, {}", p.computed(FULL_NAME))); + let full_name = b.computed("fullName", |p| { + format!("{} {}", p.context.first(), p.context.last()) + }); + assert_eq!(full_name.id(), FULL_NAME.id()); + let m = build(&mut b); + assert_eq!(*m.computed(greeting), "Hi, Ada Lovelace"); +} + +#[test] +fn a_chain_recomputes_only_when_its_inputs_change() { + let base_runs = Calls::default(); + let derived_runs = Calls::default(); + let mut b = builder(Ctx { + n: 1, + ..Ctx::default() + }); + let base = b.computed("base", { + let runs = base_runs.clone(); + move |p| { + runs.hit(); + p.context.n() * 10 + } + }); + let derived = b.computed("derived", { + let runs = derived_runs.clone(); + move |p| { + runs.hit(); + *p.computed(base) + 1 + } + }); + b.on(EvKind::Read, |t| { + t.run(move |p| { + p.computed(derived); + }) + }); + let m = build(&mut b); + let runs = || (base_runs.count(), derived_runs.count()); + m.send(Ev::Read); + assert_eq!(runs(), (1, 1)); + m.send(Ev::BumpOther); // neither reads `other` + m.send(Ev::Read); + assert_eq!(runs(), (1, 1)); + m.send(Ev::BumpN); // base reads n: the whole chain invalidates + m.send(Ev::Read); + assert_eq!(runs(), (2, 2)); +} + +#[test] +fn the_machine_exposes_the_current_value() { + let mut b = builder(Ctx::default()); + let count = b.computed("count", |p| p.context.items().len()); + let m = build(&mut b); + assert_eq!(*m.computed(count), 0); + m.send(Ev::Add); + assert_eq!(*m.computed(count), 1); +} + +#[test] +fn the_machine_exposes_chained_values() { + let mut b = builder(names()); + let full = b.computed("full", |p| { + format!("{} {}", p.context.first(), p.context.last()) + }); + let greet = b.computed("greet", move |p| format!("Hi, {}", p.computed(full))); + let m = build(&mut b); + assert_eq!(*m.computed(full), "Ada Lovelace"); + assert_eq!(*m.computed(greet), "Hi, Ada Lovelace"); +} + +/// `total` reads `discount` only while `use_discount` is on. +fn discounted() -> (Machine, ComputedKey, Calls) { + let runs = Calls::default(); + let mut b = builder(Ctx { + price: 100, + discount: 10, + ..Ctx::default() + }); + let total = b.computed("total", { + let runs = runs.clone(); + move |p| { + runs.hit(); + if *p.context.use_discount() { + p.context.price() - p.context.discount() + } else { + *p.context.price() + } + } + }); + (build(&mut b), total, runs) +} + +#[test] +fn a_field_read_only_on_an_untaken_branch_is_not_a_dependency() { + let (m, total, runs) = discounted(); + assert_eq!(*m.computed(total), 100); + assert_eq!(runs.count(), 1); + m.send(Ev::SetDiscount(50)); + assert_eq!(*m.computed(total), 100); + assert_eq!(runs.count(), 1); +} + +#[test] +fn flipping_the_branch_re_tracks_the_dependencies() { + let (m, total, runs) = discounted(); + assert_eq!(*m.computed(total), 100); + m.send(Ev::UseDiscount); + assert_eq!(*m.computed(total), 90); + assert_eq!(runs.count(), 2); + m.send(Ev::SetDiscount(30)); // read on the new branch: now a dependency + assert_eq!(*m.computed(total), 70); + assert_eq!(runs.count(), 3); +} + +/// `is_busy` and `label` read only the state. +fn state_reader() -> (Machine, ComputedKey, ComputedKey, Calls) { + let runs = Calls::default(); + let mut b = builder(Ctx::default()); + let is_busy = b.computed("isBusy", |p| p.state() == S::Busy); + let label = b.computed("label", { + let runs = runs.clone(); + move |p| { + runs.hit(); + format!("state={}", p.state().name()) + } + }); + let m = build(&mut b); + m.start(); + (m, is_busy, label, runs) +} + +#[test] +fn a_transition_invalidates_a_state_reading_computed() { + let (m, is_busy, label, _) = state_reader(); + assert!(!*m.computed(is_busy)); + m.send(Ev::Go); + assert!(*m.computed(is_busy)); + assert_eq!(*m.computed(label), "state=busy"); + m.send(Ev::Finish); + assert!(!*m.computed(is_busy)); + assert_eq!(*m.computed(label), "state=done"); +} + +#[test] +fn a_context_write_does_not_recompute_a_state_only_computed() { + let (m, _, label, runs) = state_reader(); + assert_eq!(*m.computed(label), "state=idle"); + assert_eq!(runs.count(), 1); + m.send(Ev::SetNote); + assert_eq!(*m.computed(label), "state=idle"); + assert_eq!(runs.count(), 1); + m.send(Ev::Go); + assert_eq!(*m.computed(label), "state=busy"); + assert_eq!(runs.count(), 2); +} + +// Rust-only: a recompute reuses the value's memory when nothing else holds it, so a +// value a reader still holds must keep what it was. +#[test] +fn a_value_still_held_keeps_its_contents_after_a_recompute() { + let mut b = builder(Ctx::default()); + let double = b.computed("double", |p| p.context.n() * 2); + let m = Machine::new(&b.build()); + m.start(); + let held = m.computed(double); + m.send(Ev::BumpN); + assert_eq!(*held, 0); + assert_eq!(*m.computed(double), 2); + drop(held); + m.send(Ev::BumpN); + assert_eq!(*m.computed(double), 4); +} diff --git a/crates/core/tests/connector.rs b/crates/core/tests/connector.rs new file mode 100644 index 0000000..8529110 --- /dev/null +++ b/crates/core/tests/connector.rs @@ -0,0 +1,262 @@ +//! Connector: a memoized view surface over a machine and props, its coarse subscription, +//! and reactions (machine change -> consumer callback) wired on start, torn down on stop. +//! +//! Not ported: +//! - "forwards select for per-field (canvas/Lit) consumption": Rust's `Connector` does not +//! forward `select`; per-field selections come from the machine (`Machine::select`, +//! pinned in subscribe.rs). + +mod common; + +use std::cell::RefCell; +use std::rc::Rc; + +use common::{Ab, AbEvent, AbState, Calls, Log, ab, build}; +use dunky_core::{ + Config, Connector, Context, Event, Machine, Reaction, State, Subscription, Types, +}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Inc, + ToB, +} + +#[derive(Clone, Debug, Context)] +struct Ctx { + count: i32, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[derive(PartialEq)] +struct Props { + label: String, +} + +struct Api { + state: S, + count: i32, + label: String, +} + +/// `inc` works in any state; `toB` moves a -> b. The connector is shared so listeners can +/// read it. +fn setup(label: &str) -> (Machine, Rc>) { + let m = build( + Config::::builder(S::A, Ctx { count: 0 }) + .on(EvKind::Inc, |t| { + t.act(|p| Ctx::patch().count(p.context().count + 1)) + }) + .state(S::A, |s| s.on(EvKind::ToB, |t| t.target(S::B))), + ); + let c = Connector::new( + &m, + |snap| Api { + state: snap.view.state(), + count: snap.view.context().count, + label: snap.props.label.clone(), + }, + Props { + label: label.into(), + }, + Vec::new(), + ); + (m, Rc::new(c)) +} + +#[test] +fn the_snapshot_reflects_the_connect_output() { + let (_m, c) = setup("hi"); + let snap = c.snapshot(); + assert_eq!(snap.state, S::A); + assert_eq!(snap.count, 0); + assert_eq!(snap.label, "hi"); +} + +#[test] +fn the_snapshot_is_the_same_rc_while_nothing_changes() { + let (_m, c) = setup("hi"); + assert!(Rc::ptr_eq(&c.snapshot(), &c.snapshot())); +} + +#[test] +fn the_snapshot_changes_after_a_context_change() { + let (m, c) = setup("hi"); + let before = c.snapshot(); + m.send(Ev::Inc); + let after = c.snapshot(); + assert!(!Rc::ptr_eq(&before, &after)); + assert_eq!(after.count, 1); +} + +#[test] +fn the_snapshot_reads_the_live_state() { + let (m, c) = setup("hi"); + m.send(Ev::ToB); + assert_eq!(c.snapshot().state, S::B); +} + +#[test] +fn subscribe_fires_on_any_change_but_not_on_subscribe() { + let (m, c) = setup("hi"); + let calls = Calls::default(); + let sub = c.subscribe(calls.listener()); + assert_eq!(calls.count(), 0); + m.send(Ev::Inc); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + m.send(Ev::Inc); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_listener_unsubscribed_mid_notify_does_not_fire_in_that_pass() { + let (m, c) = setup("hi"); + let log = Log::default(); + let off_b: Rc>> = Rc::default(); + let _ = c.subscribe({ + let (log, off_b) = (log.clone(), off_b.clone()); + move || { + log.push("a"); + if let Some(sub) = off_b.borrow_mut().take() { + sub.unsubscribe(); + } + } + }); + *off_b.borrow_mut() = Some(c.subscribe(log.listener("b"))); + m.send(Ev::Inc); + assert_eq!(log.entries(), ["a"]); +} + +#[test] +fn new_props_rebuild_the_snapshot_and_wake_subscribers() { + let (_m, c) = setup("one"); + let calls = Calls::default(); + let _ = c.subscribe(calls.listener()); + assert_eq!(c.snapshot().label, "one"); + c.set_props(Props { + label: "two".into(), + }); + assert_eq!(c.snapshot().label, "two"); + assert_eq!(calls.count(), 1); +} + +#[test] +fn drives_a_use_sync_external_store_loop_without_churn() { + let (m, c) = setup("hi"); + let renders = Calls::default(); + let read = Rc::new({ + let (c, renders) = (c.clone(), renders.clone()); + move || { + renders.hit(); + c.snapshot() + } + }); + let snap = Rc::new(RefCell::new(read())); + let _ = c.subscribe({ + let (read, snap) = (read.clone(), snap.clone()); + move || { + let next = read(); + assert!( + !Rc::ptr_eq(&next, &snap.borrow()), + "a notify carries a new snapshot" + ); + *snap.borrow_mut() = next; + } + }); + let before = renders.count(); + m.send(Ev::Inc); // one change: one notify, one re-read + assert_eq!(renders.count(), before + 1); +} + +/// Props carrying a callback; equal when they carry the same one. +struct ReactionProps { + on_b: Rc, +} + +impl PartialEq for ReactionProps { + fn eq(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.on_b, &other.on_b) + } +} + +fn recording(log: &Log) -> ReactionProps { + let log = log.clone(); + ReactionProps { + on_b: Rc::new(move |in_b| log.push(in_b)), + } +} + +/// A connector whose reaction calls `props.on_b(in_b)` when "in b" changes. +fn connect(m: &Machine, props: ReactionProps) -> Connector { + let reaction = Reaction::new( + |v| v.matches(AbState::B), + |in_b: &bool, props: &ReactionProps| (props.on_b)(*in_b), + ); + Connector::new(m, |snap| snap.view.state(), props, vec![reaction]) +} + +#[test] +fn a_reaction_fires_on_a_change_once_the_machine_starts() { + let seen = Log::default(); + let m = build(&mut ab()); + let _c = connect(&m, recording(&seen)); + m.start(); + assert!(seen.is_empty()); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [true]); +} + +#[test] +fn reactions_stay_inert_until_the_machine_starts() { + let seen = Log::default(); + let m = build(&mut ab()); + let _c = connect(&m, recording(&seen)); + m.send(AbEvent::ToB); + assert!(seen.is_empty()); +} + +#[test] +fn stop_tears_reactions_down_and_a_restart_re_establishes_them() { + let seen = Log::default(); + let m = build(&mut ab()); + let _c = connect(&m, recording(&seen)); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [true]); + m.stop(); + m.send(AbEvent::ToA); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [true]); + m.start(); + seen.clear(); + m.send(AbEvent::ToA); + m.send(AbEvent::ToB); + assert_eq!(seen.entries(), [false, true]); +} + +#[test] +fn a_reaction_reads_the_latest_props() { + let first = Log::default(); + let second = Log::default(); + let m = build(&mut ab()); + let c = connect(&m, recording(&first)); + m.start(); + c.set_props(recording(&second)); + m.send(AbEvent::ToB); + assert!(first.is_empty()); + assert_eq!(second.entries(), [true]); +} diff --git a/crates/core/tests/derive.rs b/crates/core/tests/derive.rs new file mode 100644 index 0000000..753c90c --- /dev/null +++ b/crates/core/tests/derive.rs @@ -0,0 +1,88 @@ +//! Rust-only: the derive macros name events exactly like serde, so the JS side can send +//! `{ type, ... }` objects that both decoders (serde and `DeserializeEvent`) accept. + +use dunky_core::{Context, DeserializeEvent, Event, EventEnum, State, TsType}; +use serde::{Deserialize, Serialize}; + +#[derive(Event, Clone, Debug, PartialEq, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum Picker { + QuerySet { + query: String, + }, + #[serde(rename_all = "camelCase")] + ItemPick { + item_id: u32, + }, + #[serde(rename = "picker.close")] + Close, +} + +#[test] +fn kind_names_follow_serde_renames() { + assert_eq!( + Picker::KIND_NAMES, + ["query_set", "item_pick", "picker.close"] + ); + let parsed: Picker = + serde_json::from_value(serde_json::json!({ "type": "query_set", "query": "a" })).unwrap(); + assert_eq!(parsed, Picker::QuerySet { query: "a".into() }); +} + +#[test] +fn payload_decoding_follows_the_variant_field_renames() { + let kind = Picker::kind_index(PickerKind::ItemPick); + let event = Picker::deserialize_payload( + kind, + serde_json::json!({ "type": "item_pick", "itemId": 3 }), + ); + assert_eq!(event.unwrap(), Picker::ItemPick { item_id: 3 }); +} + +// Only their TS types are read. +#[allow(dead_code)] +#[derive(TsType, Serialize, Clone, PartialEq, Debug)] +struct Item { + id: String, + #[serde(skip_serializing_if = "Option::is_none")] + hint: Option, + note: Option, +} + +#[allow(dead_code)] +#[derive(TsType, Serialize, Clone, Copy, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +enum Direction { + Up, + Down, +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +enum Mode { + Idle, + HalfOpen, +} + +#[derive(Context, Clone, PartialEq, Debug)] +#[context(serialize)] +struct Ctx { + items: Vec, + active_index: u32, + direction: Option, +} + +#[test] +fn ts_types_follow_the_js_names_and_values() { + assert_eq!(Mode::ts(), "'idle' | 'halfOpen'"); + assert_eq!( + Picker::ts(), + "{ type: 'query_set'; query: string } | { type: 'item_pick'; itemId: number } \ + | { type: 'picker.close' }" + ); + assert_eq!( + Ctx::ts(), + "{ items: Array<{ id: string; hint?: string; note: string | null }>; \ + activeIndex: number; direction: 'up' | 'down' | null }" + ); +} diff --git a/crates/core/tests/effects.rs b/crates/core/tests/effects.rs new file mode 100644 index 0000000..892f211 --- /dev/null +++ b/crates/core/tests/effects.rs @@ -0,0 +1,257 @@ +//! Effects: started when their state is entered (and, on start, for the current state), +//! cleaned up when it is left or the machine stops. +//! +//! Not ported: +//! - "does NOT start effects at construction; starts them on start()": the same test as +//! lifecycle.rs (is_built_stopped_and_start_boots_the_effects). +//! - "stop() runs the active effect cleanups": covered by lifecycle.rs +//! (stop_runs_the_cleanups_and_is_idempotent). +//! - "cleanup of the initial effect runs on the first transition out": covered by +//! lifecycle.rs (is_built_stopped_and_start_boots_the_effects and +//! while_running_a_transition_cleans_up_the_old_effects_then_starts_the_new). +//! - "is restartable — start after stop re-boots the initial effect": covered by +//! lifecycle.rs (a_restart_boots_the_state_the_machine_is_in). +//! - "effect closures read LIVE context across later writes": effect params are borrowed +//! for the call, so no closure can keep them past it (the borrow checker rejects it); +//! there is no captured context reference that could go stale. + +mod common; + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use common::{Ab, AbEvent, AbState, Count, Counter, CounterState, Log, ab, build}; +use dunky_core::{ + ActionParams, Cleanup, Config, Context, Event, EventEnum, MACHINE_INIT, State, Types, +}; + +#[test] +fn runs_the_effect_on_enter_and_its_cleanup_on_exit() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(Some("start:b"), Some("cleanup:b"))) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(log.entries(), ["start:b"]); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["start:b", "cleanup:b"]); +} + +#[test] +fn the_cleanup_runs_before_exit_actions() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(None, Some("cleanup"))) + .exit_run(log.action("exit-action")) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["cleanup", "exit-action"]); +} + +#[test] +fn an_effect_starts_after_entry_actions() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.entry_run(log.action("entry-action")) + .effect_run(log.effect(Some("start"), None)) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(log.entries(), ["entry-action", "start"]); +} + +#[test] +fn an_effect_may_return_no_cleanup() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(Some("fire-and-forget"), None)) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["fire-and-forget"]); + assert_eq!(m.state(), AbState::A); +} + +#[test] +fn resolves_an_effect_by_name() { + let log = Log::default(); + let mut b = ab(); + b.effect( + "watch", + log.effect(Some("watch:start"), Some("watch:cleanup")), + ) + .state(AbState::B, |s| s.effect("watch")); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["watch:start", "watch:cleanup"]); +} + +#[test] +fn every_effect_of_the_state_is_cleaned_up_on_exit() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(log.effect(None, Some("c1"))) + .effect_run(log.effect(None, Some("c2"))) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(log.entries(), ["c1", "c2"]); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no effect \"missing\"")] +fn panics_in_debug_when_an_effect_name_is_not_registered() { + let mut b = ab(); + b.state(AbState::B, |s| s.effect("missing")); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); +} + +#[test] +fn a_panicking_cleanup_still_runs_the_others_and_clears_the_pass() { + let log = Log::default(); + let panicking = { + let log = log.clone(); + move |_: &mut ActionParams<'_, Ab>| { + let log = log.clone(); + let cleanup: Cleanup = Box::new(move || { + log.push("c1"); + panic!("boom"); + }); + Some(cleanup) + } + }; + let mut b = ab(); + b.state(AbState::B, |s| { + s.effect_run(panicking) + .effect_run(log.effect(None, Some("c2"))) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + let payload = catch_unwind(AssertUnwindSafe(|| m.send(AbEvent::ToA))).unwrap_err(); + assert_eq!(payload.downcast_ref::<&str>(), Some(&"boom")); + assert_eq!(log.entries(), ["c1", "c2"]); + m.stop(); // the failed pass already ran every cleanup: nothing runs twice + assert_eq!(log.entries(), ["c1", "c2"]); +} + +struct Labeled; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum LabeledState { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum LabeledEvent { + ToB, + Mark, +} + +#[derive(Clone, Debug, Context)] +struct Label { + label: String, +} + +impl Types for Labeled { + type State = LabeledState; + type Event = LabeledEvent; + type Context = Label; +} + +#[test] +fn an_effect_can_read_the_context_and_queue_events() { + let log = Log::default(); + let m = build( + Config::::builder( + LabeledState::A, + Label { + label: "hello".into(), + }, + ) + .state(LabeledState::A, |s| { + s.on(LabeledEventKind::ToB, |t| t.target(LabeledState::B)) + }) + .state(LabeledState::B, |s| { + let effect_log = log.clone(); + s.effect_run(move |p| { + effect_log.push(p.context().label.clone()); + p.send(LabeledEvent::Mark); + None + }) + .on(LabeledEventKind::Mark, |t| t.run(log.action("marked"))) + }), + ); + m.start(); + m.send(LabeledEvent::ToB); + assert_eq!(log.entries(), ["hello", "marked"]); +} + +#[test] +fn a_boot_effect_sees_the_machine_init_marker() { + let seen: Log<&'static str> = Log::default(); + let mut b = ab(); + b.state(AbState::A, |s| { + let seen = seen.clone(); + s.effect_run(move |p| { + seen.push(p.event().map_or(MACHINE_INIT, |e| e.type_name())); + None + }) + }); + let m = build(&mut b); + m.start(); + assert_eq!(seen.entries(), ["machine.init"]); + assert_eq!(MACHINE_INIT, "machine.init"); +} + +#[test] +fn start_runs_the_initial_effects_but_not_the_initial_entry() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::A, |s| { + s.entry_run(log.action("entry")) + .effect_run(log.effect(Some("effect"), None)) + }); + let m = build(&mut b); + m.start(); + assert_eq!(log.entries(), ["effect"]); +} + +#[test] +fn an_effect_can_read_the_context_at_start() { + let seen: Log = Log::default(); + let m = build( + Config::::builder(CounterState::Idle, Count { n: 7 }).state( + CounterState::Idle, + |s| { + let seen = seen.clone(); + s.effect_run(move |p| { + seen.push(p.context().n); + None + }) + }, + ), + ); + m.start(); + assert_eq!(seen.entries(), [7]); +} diff --git a/crates/core/tests/guards.rs b/crates/core/tests/guards.rs new file mode 100644 index 0000000..05ed8ab --- /dev/null +++ b/crates/core/tests/guards.rs @@ -0,0 +1,229 @@ +//! Guards gate transitions: inline predicates, named ones from the builder's registry, +//! and the and / or / not combinators. +//! +//! Not ported: +//! - "guard params include `computed` (empty when no computed configured)": JS params +//! carry a `computed` object; Rust reads computed values by typed key, so there is no +//! such object to inspect (a guard reading a computed value is pinned in computed.rs). + +mod common; + +use common::{Calls, build}; +use dunky_core::{Config, ConfigBuilder, Context, Event, Guard, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + Idle, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Go, + Add { by: i32 }, + Push { force: bool }, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + allow: bool, + n: i32, + a: bool, + b: bool, + locked: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +/// Every named guard the tests reference. +fn registry(b: &mut ConfigBuilder) -> &mut ConfigBuilder { + b.guard("isAllowed", |p| p.context().allow) + .guard("never", |_| false) + .guard("isA", |p| p.context().a) + .guard("isB", |p| p.context().b) + .guard("isLocked", |p| p.context().locked) + .guard("isTwo", |p| p.context().n == 2) + .guard("isThree", |p| p.context().n == 3) + .guard("isOdd", |p| p.context().n % 2 == 1) +} + +/// Whether `go` is taken under `guard`, against `ctx` and the registry. +fn go_taken(ctx: Ctx, guard: Guard) -> bool { + let ran = Calls::default(); + let mut b = Config::::builder(S::Idle, ctx); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Go, |t| t.guard_expr(guard).run(ran.action())) + }); + build(&mut b).send(Ev::Go); + ran.count() == 1 +} + +#[test] +fn an_inline_guard_gates_the_transition() { + let m = build( + Config::::builder( + S::Idle, + Ctx { + allow: true, + ..Ctx::default() + }, + ) + .state(S::Idle, |s| { + s.on(EvKind::Go, |t| { + t.guard_fn(|p| p.context().allow) + .act(|p| Ctx::patch().n(p.context().n + 1).allow(false)) + }) + }), + ); + m.send(Ev::Go); // allowed: runs, then disallows itself + assert_eq!(m.context().n, 1); + m.send(Ev::Go); // blocked + assert_eq!(m.context().n, 1); +} + +#[test] +fn a_guard_can_read_the_event_payload() { + let m = build( + Config::::builder(S::Idle, Ctx::default()).state(S::Idle, |s| { + s.on(EvKind::Add, |t| { + t.guard_fn(|p| matches!(p.event(), Some(Ev::Add { by }) if *by > 0)) + .act(|p| { + let Some(Ev::Add { by }) = p.event() else { + unreachable!() + }; + Ctx::patch().n(p.context().n + by) + }) + }) + }), + ); + m.send(Ev::Add { by: 5 }); + assert_eq!(m.context().n, 5); + m.send(Ev::Add { by: -3 }); // only positive additions pass + assert_eq!(m.context().n, 5); +} + +#[test] +fn resolves_a_guard_by_name() { + let mut b = Config::::builder( + S::Idle, + Ctx { + allow: true, + ..Ctx::default() + }, + ); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Go, |t| { + t.guard("isAllowed") + .act(|p| Ctx::patch().n(p.context().n + 1).allow(false)) + }) + }); + let m = build(&mut b); + m.send(Ev::Go); + assert_eq!(m.context().n, 1); + m.send(Ev::Go); + assert_eq!(m.context().n, 1); +} + +#[test] +fn named_and_inline_guards_coexist_in_a_fallthrough_list() { + let mut b = Config::::builder(S::Idle, Ctx::default()); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Go, |t| t.guard("never").act(|_| Ctx::patch().n(99))) + .on(EvKind::Go, |t| { + t.guard_fn(|p| p.context().n < 3) + .act(|p| Ctx::patch().n(p.context().n + 1)) + }) + }); + let m = build(&mut b); + m.send(Ev::Go); // `never` fails, the inline guard passes + assert_eq!(m.context().n, 1); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "no guard \"missing\"")] +fn panics_in_debug_when_a_guard_name_is_not_registered() { + let m = build( + Config::::builder(S::Idle, Ctx::default()) + .state(S::Idle, |s| s.on(EvKind::Go, |t| t.guard("missing"))), + ); + m.send(Ev::Go); +} + +#[test] +fn and_passes_when_every_guard_passes() { + let ctx = Ctx { + a: true, + b: true, + ..Ctx::default() + }; + assert!(go_taken(ctx, Guard::and(["isA".into(), "isB".into()]))); +} + +#[test] +fn and_blocks_when_one_guard_fails() { + let ctx = Ctx { + a: true, + b: false, + ..Ctx::default() + }; + assert!(!go_taken(ctx, Guard::and(["isA".into(), "isB".into()]))); +} + +#[test] +fn or_passes_when_any_guard_passes_and_not_negates() { + let ran = Calls::default(); + let mut b = Config::::builder( + S::Idle, + Ctx { + locked: true, + ..Ctx::default() + }, + ); + registry(&mut b).state(S::Idle, |s| { + s.on(EvKind::Push, |t| { + t.guard_expr(Guard::or([ + Guard::::when(|p| matches!(p.event(), Some(Ev::Push { force: true }))), + Guard::not("isLocked".into()), + ])) + .run(ran.action()) + }) + }); + let m = build(&mut b); + m.send(Ev::Push { force: false }); // locked and not forced + assert_eq!(ran.count(), 0); + m.send(Ev::Push { force: true }); + assert_eq!(ran.count(), 1); +} + +#[test] +fn combinators_accept_inline_guards() { + let ctx = Ctx { + n: 5, + ..Ctx::default() + }; + let guard = Guard::and([ + Guard::::when(|p| p.context().n > 0), + Guard::not(Guard::::when(|p| p.context().n > 100)), + ]); + assert!(go_taken(ctx, guard)); +} + +#[test] +fn combinators_nest() { + let ctx = Ctx { + n: 2, + ..Ctx::default() + }; + // or(isTwo, isThree) = true; and(isTwo, isOdd) = false; not(false) = true + let guard = Guard::and([ + Guard::or(["isTwo".into(), "isThree".into()]), + Guard::not(Guard::and(["isTwo".into(), "isOdd".into()])), + ]); + assert!(go_taken(ctx, guard)); +} diff --git a/crates/core/tests/lifecycle.rs b/crates/core/tests/lifecycle.rs new file mode 100644 index 0000000..286c879 --- /dev/null +++ b/crates/core/tests/lifecycle.rs @@ -0,0 +1,116 @@ +//! Lifecycle: a machine is built stopped; start boots the current state's effects and +//! stop runs their cleanups, both idempotent; events still work while stopped. + +mod common; + +use common::{Ab, AbEvent, AbEventKind, AbState, Calls, Log, ab, build}; +use dunky_core::Config; + +#[test] +fn is_built_stopped_and_start_boots_the_effects() { + let fx = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(fx.effect(None))); + let m = build(&mut b); + assert_eq!(fx.count(), 0); + m.start(); + assert_eq!(fx.count(), 1); +} + +#[test] +fn start_is_idempotent() { + let fx = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(fx.effect(None))); + let m = build(&mut b); + m.start(); + m.start(); + assert_eq!(fx.count(), 1); +} + +#[test] +fn stop_runs_the_cleanups_and_is_idempotent() { + let fx = Calls::default(); + let cleanup = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(fx.effect(Some(&cleanup)))); + let m = build(&mut b); + m.start(); + m.stop(); + assert_eq!(cleanup.count(), 1); + m.stop(); + assert_eq!(cleanup.count(), 1); +} + +#[test] +fn events_work_while_stopped_but_effects_do_not_run() { + let log = Log::default(); + let m = build( + Config::::builder(AbState::A, ()) + .state(AbState::A, |s| { + s.exit_run(log.action("exit-action")) + .effect_run(log.effect(None, Some("effect-cleanup"))) + .on(AbEventKind::ToB, |t| { + t.target(AbState::B).run(log.action("transition-action")) + }) + }) + .state(AbState::B, |s| { + s.entry_run(log.action("entry-action")) + .effect_run(log.effect(Some("effect-start"), None)) + }), + ); + m.send(AbEvent::ToB); // never started + assert_eq!(m.state(), AbState::B); + assert_eq!( + log.entries(), + ["exit-action", "transition-action", "entry-action"] + ); +} + +#[test] +fn start_boots_the_current_state_after_moving_while_stopped() { + let a_fx = Calls::default(); + let b_fx = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(a_fx.effect(None))) + .state(AbState::B, |s| s.effect_run(b_fx.effect(None))); + let m = build(&mut b); + m.send(AbEvent::ToB); + m.start(); + assert_eq!(a_fx.count(), 0); + assert_eq!(b_fx.count(), 1); +} + +#[test] +fn a_restart_boots_the_state_the_machine_is_in() { + let a_fx = Calls::default(); + let b_fx = Calls::default(); + let b_cleanup = Calls::default(); + let mut b = ab(); + b.state(AbState::A, |s| s.effect_run(a_fx.effect(None))) + .state(AbState::B, |s| s.effect_run(b_fx.effect(Some(&b_cleanup)))); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + m.stop(); + assert_eq!(b_cleanup.count(), 1); + m.start(); // still in b + assert_eq!(a_fx.count(), 1); + assert_eq!(b_fx.count(), 2); +} + +#[test] +fn while_running_a_transition_cleans_up_the_old_effects_then_starts_the_new() { + let log = Log::default(); + let mut b = ab(); + b.state(AbState::A, |s| { + s.effect_run(log.effect(None, Some("a:cleanup"))) + }) + .state(AbState::B, |s| { + s.effect_run(log.effect(Some("b:start"), None)) + }); + let m = build(&mut b); + m.start(); + m.send(AbEvent::ToB); + assert_eq!(log.entries(), ["a:cleanup", "b:start"]); +} diff --git a/crates/core/tests/setup.rs b/crates/core/tests/setup.rs new file mode 100644 index 0000000..e50a929 --- /dev/null +++ b/crates/core/tests/setup.rs @@ -0,0 +1,89 @@ +//! The builder's registries (`guard`, `action`, `effect`, `delay`): the runtime half of +//! TS `setup`. Named references resolve when the config is built. +//! +//! Not ported (TypeScript type-level cases with no Rust equivalent): +//! - "infer path: setup.infer().createMachine(literal) builds a valid config, types +//! inferred": a Rust config is always typed by its `Types` bundle. +//! - "checks names at compile time (the @ts-expect-error blocks are the test)": Rust names +//! are strings resolved at build; a missing one fails when it runs (the "panics in +//! debug" tests in guards.rs, actions.rs, effects.rs and after.rs). +//! - "valid names + numeric delays coexist, and inline fns still work": a compile-time +//! check; its runtime content (named and fixed delays, named and inline guards) is +//! covered below and in guards.rs. + +mod common; + +use common::{Calls, Log, build}; +use dunky_core::testing::Clock; +use dunky_core::{Config, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + Closed, + Open, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Open, + Close, +} + +#[derive(Clone, Debug, Context)] +struct Ctx { + id: String, + open_ms: u32, + open: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +#[test] +fn named_implementations_resolve_at_runtime() { + let set_id: Log = Log::default(); + let track = Calls::default(); + let m = build( + Config::::builder( + S::Closed, + Ctx { + id: "a".into(), + open_ms: 5, + open: false, + }, + ) + .guard("isOpen", |p| p.context().open) + .action("setId", { + let set_id = set_id.clone(); + move |p| set_id.push(p.context().id.clone()) + }) + .effect("track", track.effect(None)) + .delay("openDelay", |p| p.context().open_ms) + .state(S::Closed, |s| s.on(EvKind::Open, |t| t.target(S::Open))) + .state(S::Open, |s| { + s.entry("setId") + .effect("track") + .after("openDelay", |t| t.target(S::Closed)) + .after(200, |t| t.target(S::Closed)) + .on(EvKind::Close, |t| t.target(S::Closed).guard("isOpen")) + }), + ); + m.start(); + assert_eq!(m.state(), S::Closed); + m.send(Ev::Open); + assert_eq!(m.state(), S::Open); + assert_eq!(set_id.entries(), ["a"]); + assert_eq!(track.count(), 1); + m.send(Ev::Close); // `isOpen` reads open = false + assert_eq!(m.state(), S::Open); + let mut clock = Clock::new(&m); + clock.advance(4); // `openDelay` reads open_ms = 5 + assert_eq!(m.state(), S::Open); + clock.advance(1); + assert_eq!(m.state(), S::Closed); +} diff --git a/crates/core/tests/store.rs b/crates/core/tests/store.rs new file mode 100644 index 0000000..4de1eed --- /dev/null +++ b/crates/core/tests/store.rs @@ -0,0 +1,90 @@ +//! Store: a tiny reactive cell for state shared between machine instances. Updates +//! shallow-merge and notify only on an actual change. +//! +//! Not ported: +//! - "builder adds named methods alongside the base, with store access": a JS builder +//! API; in Rust, domain methods are ordinary functions or impls over a `Store`. + +mod common; + +use std::cell::RefCell; +use std::rc::Rc; + +use common::{Calls, Count, Log}; +use dunky_core::{Context, Store, Subscription}; + +#[derive(Clone, Debug, PartialEq, Context)] +struct Pair { + a: i32, + b: i32, +} + +#[test] +fn set_and_update_shallow_merge() { + let store = Store::new(Pair { a: 1, b: 2 }); + assert_eq!(*store.get(), Pair { a: 1, b: 2 }); + store.set(Pair::patch().a(9)); + assert_eq!(*store.get(), Pair { a: 9, b: 2 }); + store.update(|s| Pair::patch().b(s.b + 1)); + assert_eq!(*store.get(), Pair { a: 9, b: 3 }); +} + +#[test] +fn subscribe_fires_on_a_change_but_not_on_subscribe() { + let store = Store::new(Count { n: 0 }); + let calls = Calls::default(); + let sub = store.subscribe({ + let calls = calls.clone(); + move |_| calls.hit() + }); + assert_eq!(calls.count(), 0); + store.set(Count::patch().n(1)); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + store.set(Count::patch().n(2)); + assert_eq!(calls.count(), 1); +} + +#[test] +fn an_effective_set_yields_a_fresh_value() { + let store = Store::new(Count { n: 0 }); + let before = store.get(); + store.set(Count::patch().n(1)); + assert!(!Rc::ptr_eq(&before, &store.get())); +} + +#[test] +fn a_listener_unsubscribed_mid_notify_does_not_fire_in_that_pass() { + let store = Store::new(Count { n: 0 }); + let log: Log = Log::default(); + let off_b: Rc>> = Rc::default(); + let _ = store.subscribe({ + let (log, off_b) = (log.clone(), off_b.clone()); + move |_| { + log.push("a"); + if let Some(sub) = off_b.borrow_mut().take() { + sub.unsubscribe(); + } + } + }); + *off_b.borrow_mut() = Some(store.subscribe({ + let log = log.clone(); + move |_| log.push("b") + })); + store.set(Count::patch().n(1)); + assert_eq!(log.entries(), ["a"]); +} + +#[test] +fn a_set_that_changes_nothing_is_silent() { + let store = Store::new(Count { n: 5 }); + let calls = Calls::default(); + let _ = store.subscribe({ + let calls = calls.clone(); + move |_| calls.hit() + }); + store.set(Count::patch().n(5)); + assert_eq!(calls.count(), 0); + store.set(Count::patch().n(6)); + assert_eq!(calls.count(), 1); +} diff --git a/crates/core/tests/subscribe.rs b/crates/core/tests/subscribe.rs new file mode 100644 index 0000000..129c64e --- /dev/null +++ b/crates/core/tests/subscribe.rs @@ -0,0 +1,291 @@ +//! Observation: the coarse `subscribe` (wakes on any change) and value-deduped +//! selections (`select`, `select_computed`, `select_state`). Neither fires on subscribe. +//! +//! Not ported: +//! - "the select facade is a stable identity across accesses": a JS facade object; Rust's +//! `select` is a method. +//! - "without equals, a fresh-object selector fires every change (Object.is)": identity +//! semantics. Rust selections compare with `PartialEq`, which the value-dedup test pins. +//! - "select(fn) function form still works alongside the scope methods": a JS facade +//! shape; in Rust `select` and `select_field` are plain methods. +//! - ".value reads the current selected value": the first of two such TS tests; the +//! second ("on demand") covers it. +//! - "does NOT fire on subscribe; fires on a context change", "multiple subscribers all +//! fire" and the "reentrancy" group (subscribing or unsubscribing during a notify, +//! nested notify): the same bus behind the same `Machine::subscribe`, pinned in +//! broadcast.rs. + +mod common; + +use common::{ + AbEvent, AbState, Calls, Count, Counter, CounterEvent, CounterEventKind, CounterState, Log, ab, + build, counter, +}; +use dunky_core::{Config, Context, Event, State, Types}; + +/// A machine whose events write `x`, `y` and `other`. +struct Plane; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum PlaneState { + Idle, +} + +#[derive(Clone, Debug, Event)] +enum PlaneEvent { + MoveX, + BumpOther, + Both, +} + +#[derive(Clone, Debug, Default, Context)] +struct Pos { + x: i32, + y: i32, + other: i32, +} + +impl Types for Plane { + type State = PlaneState; + type Event = PlaneEvent; + type Context = Pos; +} + +fn plane() -> dunky_core::Machine { + build( + Config::::builder(PlaneState::Idle, Pos::default()).state(PlaneState::Idle, |s| { + s.on(PlaneEventKind::MoveX, |t| { + t.act(|p| Pos::patch().x(p.context().x + 1)) + }) + .on(PlaneEventKind::BumpOther, |t| { + t.act(|p| Pos::patch().other(p.context().other + 1)) + }) + .on(PlaneEventKind::Both, |t| t.act(|_| Pos::patch().x(1).y(1))) + }), + ) +} + +#[test] +fn subscribe_fires_on_a_state_change() { + let m = build(&mut ab()); + let calls = Calls::default(); + let _ = m.subscribe(calls.listener()); + m.send(AbEvent::ToB); + assert_eq!(calls.count(), 1); +} + +#[test] +fn unsubscribe_stops_notifications() { + let m = build(&mut counter()); + let calls = Calls::default(); + let sub = m.subscribe(calls.listener()); + m.send(CounterEvent::Inc); + assert_eq!(calls.count(), 1); + sub.unsubscribe(); + m.send(CounterEvent::Inc); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_multi_field_patch_notifies_once() { + let m = plane(); + let calls = Calls::default(); + let _ = m.subscribe(calls.listener()); + m.send(PlaneEvent::Both); + assert_eq!(calls.count(), 1); +} + +#[test] +fn a_patch_that_changes_nothing_is_silent() { + let m = build( + Config::::builder(CounterState::Idle, Count { n: 5 }) + .state(CounterState::Idle, |s| { + s.on(CounterEventKind::Inc, |t| t.act(|_| Count::patch().n(5))) + }), + ); + let calls = Calls::default(); + let _ = m.subscribe(calls.listener()); + m.send(CounterEvent::Inc); + assert_eq!(calls.count(), 0); +} + +#[test] +fn a_selection_fires_with_the_new_value_but_not_on_subscribe() { + let m = build(&mut counter()); + let seen: Log = Log::default(); + let _ = m.select(|v| v.context().n).subscribe({ + let seen = seen.clone(); + move |n| seen.push(*n) + }); + assert!(seen.is_empty()); + m.send(CounterEvent::Inc); + assert_eq!(seen.entries(), [1]); +} + +#[test] +fn a_selection_is_silent_while_the_selected_value_is_unchanged() { + let m = build(&mut counter()); + let seen: Log = Log::default(); + let _ = m.select(|v| v.context().n == 0).subscribe({ + let seen = seen.clone(); + move |is_zero| seen.push(*is_zero) + }); + m.send(CounterEvent::Inc); // true -> false: fires + assert_eq!(seen.entries(), [false]); + m.send(CounterEvent::Inc); // false -> false: silent + assert_eq!(seen.entries(), [false]); +} + +#[test] +fn unsubscribing_a_selection_stops_it() { + let m = build(&mut counter()); + let seen: Log = Log::default(); + let sub = m.select(|v| v.context().n).subscribe({ + let seen = seen.clone(); + move |n| seen.push(*n) + }); + m.send(CounterEvent::Inc); + sub.unsubscribe(); + m.send(CounterEvent::Inc); + assert_eq!(seen.entries(), [1]); +} + +/// A selected value with no `PartialEq`, so only a supplied equality can dedup it. +struct Point { + x: i32, + y: i32, +} + +#[test] +fn a_supplied_equality_decides_what_counts_as_a_change() { + let m = plane(); + let seen: Log<(i32, i32)> = Log::default(); + let _ = m + .select(|v| Point { + x: v.context().x, + y: v.context().y, + }) + .subscribe_with( + { + let seen = seen.clone(); + move |p| seen.push((p.x, p.y)) + }, + |a, b| a.x == b.x && a.y == b.y, + ); + m.send(PlaneEvent::BumpOther); // {x, y} unchanged by the equality: silent + assert!(seen.is_empty()); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [(1, 0)]); +} + +#[test] +fn value_reads_the_current_selection_on_demand() { + let m = build(&mut counter()); + let n = m.select(|v| v.context().n); + assert_eq!(n.value(), 0); + m.send(CounterEvent::Inc); + assert_eq!(n.value(), 1); + m.send(CounterEvent::Inc); + assert_eq!(n.value(), 2); +} + +#[test] +fn select_computed_selects_a_derived_value() { + let mut b = counter(); + let is_zero = b.computed("isZero", |p| *p.context.n() == 0); + let m = build(&mut b); + let selection = m.select_computed(is_zero); + assert!(*selection.value()); + let seen: Log = Log::default(); + let _ = selection.subscribe({ + let seen = seen.clone(); + move |v| seen.push(**v) + }); + m.send(CounterEvent::Inc); // true -> false: fires + m.send(CounterEvent::Inc); // false -> false: silent + assert_eq!(seen.entries(), [false]); +} + +#[test] +fn select_state_fires_on_transitions() { + let m = build(&mut ab()); + let state = m.select_state(); + assert_eq!(state.value(), AbState::A); + let seen: Log = Log::default(); + let _ = state.subscribe({ + let seen = seen.clone(); + move |s| seen.push(*s) + }); + m.send(AbEvent::ToB); + m.send(AbEvent::ToA); + assert_eq!(seen.entries(), [AbState::B, AbState::A]); +} + +#[test] +fn a_selection_listener_may_patch_the_machine_it_observes() { + let m = plane(); + let seen: Log = Log::default(); + let _ = m.select(|v| v.context().x).subscribe({ + let (m, seen) = (m.clone(), seen.clone()); + move |x| { + seen.push(*x); + if *x == 1 { + m.set_context(Pos::patch().x(5)); // notifies again before this call returns + } + } + }); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [1, 5]); +} + +#[test] +fn select_field_selects_one_field_with_its_type() { + let m = plane(); + let x = m.select_field(Pos::X); + let xv: i32 = x.value(); + assert_eq!(xv, 0); + let seen: Log = Log::default(); + let _ = x.subscribe({ + let seen = seen.clone(); + move |x| seen.push(*x) + }); + m.send(PlaneEvent::BumpOther); // changed `other`, not `x`: silent + assert!(seen.entries().is_empty()); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [1]); +} + +// Rust-only: a selection made inside an action cannot read the machine yet, so it seeds +// on its first wake. A field selection must still report the next change of its field, +// even when that first wake came from another field. +#[test] +fn a_field_selection_made_mid_action_reports_the_next_change() { + type Slot = std::rc::Rc>>>; + let handle: Slot = Slot::default(); + let seen: Log = Log::default(); + let m = build( + Config::::builder(PlaneState::Idle, Pos::default()).state(PlaneState::Idle, |s| { + let (handle, seen) = (handle.clone(), seen.clone()); + s.on(PlaneEventKind::MoveX, |t| { + t.act(|p| Pos::patch().x(p.context().x + 1)) + }) + .on(PlaneEventKind::BumpOther, move |t| { + t.run(move |p| { + if p.context().other == 0 { + // The core is borrowed by this action: the selection seeds later. + let m = handle.borrow().clone().expect("machine"); + std::mem::forget(m.select_field(Pos::X).subscribe({ + let seen = seen.clone(); + move |x| seen.push(*x) + })); + } + p.set_context(Pos::patch().other(p.context().other + 1)); + }) + }) + }), + ); + *handle.borrow_mut() = Some(m.clone()); + m.send(PlaneEvent::BumpOther); + m.send(PlaneEvent::MoveX); + assert_eq!(seen.entries(), [1]); +} diff --git a/crates/core/tests/transitions.rs b/crates/core/tests/transitions.rs new file mode 100644 index 0000000..a5afd78 --- /dev/null +++ b/crates/core/tests/transitions.rs @@ -0,0 +1,180 @@ +//! Transitions: per-state and any-state handlers, guard fallthrough, targetless +//! self-transitions, and the queued (run-to-completion) send. +//! +//! Port note: the TS "events from outside also serialize" case sends from an action, +//! which duplicates the re-entrant-send case; here it sends from an observer instead, +//! which is what its title describes. + +mod common; + +use common::{Calls, Count, Counter, CounterEvent, CounterEventKind, CounterState, Log, build}; +use dunky_core::{Config, ConfigBuilder, Event, State, Types}; + +struct Door; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum DoorState { + Closed, + Open, + Gone, +} + +#[derive(Clone, Debug, Event)] +enum DoorEvent { + Open, + Close, + AutoClose, + Kill, +} + +impl Types for Door { + type State = DoorState; + type Event = DoorEvent; + type Context = (); +} + +/// closed --open--> open --close--> closed +fn door() -> ConfigBuilder { + let mut b = Config::::builder(DoorState::Closed, ()); + b.state(DoorState::Closed, |s| { + s.on(DoorEventKind::Open, |t| t.target(DoorState::Open)) + }) + .state(DoorState::Open, |s| { + s.on(DoorEventKind::Close, |t| t.target(DoorState::Closed)) + }); + b +} + +#[test] +fn moves_between_states_on_a_matching_event() { + let m = build(&mut door()); + assert_eq!(m.state(), DoorState::Closed); + m.send(DoorEvent::Open); + assert_eq!(m.state(), DoorState::Open); + m.send(DoorEvent::Close); + assert_eq!(m.state(), DoorState::Closed); +} + +#[test] +fn ignores_an_event_the_current_state_does_not_handle() { + let m = build(&mut door()); + m.send(DoorEvent::Close); + assert_eq!(m.state(), DoorState::Closed); +} + +#[test] +fn any_state_handlers_work_from_every_state_and_a_state_handler_takes_precedence() { + let log = Log::default(); + let mut b = door(); + b.on(DoorEventKind::Kill, |t| t.target(DoorState::Gone)) + .state(DoorState::Closed, |s| { + s.on(DoorEventKind::Kill, |t| { + t.run(log.action("closed handled kill")) + }) + }); + let m = build(&mut b); + m.send(DoorEvent::Kill); // closed has its own handler: it wins + assert_eq!(m.state(), DoorState::Closed); + assert_eq!(log.entries(), ["closed handled kill"]); + m.send(DoorEvent::Open); + m.send(DoorEvent::Kill); // open has none: the any-state handler applies + assert_eq!(m.state(), DoorState::Gone); +} + +#[test] +fn guard_fallthrough_takes_the_first_candidate_whose_guard_passes() { + let m = build( + Config::::builder(CounterState::Idle, Count { n: 5 }).state( + CounterState::Idle, + |s| { + s.on(CounterEventKind::Inc, |t| { + t.guard_fn(|p| p.context().n > 10) + .act(|_| Count::patch().n(999)) + }) + .on(CounterEventKind::Inc, |t| { + t.guard_fn(|p| p.context().n > 0) + .act(|_| Count::patch().n(1)) + }) + .on(CounterEventKind::Inc, |t| t.act(|_| Count::patch().n(-1))) + }, + ), + ); + m.send(CounterEvent::Inc); // n=5: first guard fails, second passes + assert_eq!(m.context().n, 1); +} + +#[test] +fn a_targetless_transition_runs_its_actions_and_stays_in_the_state() { + let ran = Calls::default(); + let m = build( + Config::::builder(CounterState::Idle, Count { n: 0 }).state( + CounterState::Idle, + |s| { + s.on(CounterEventKind::Inc, |t| { + t.run(ran.action()) + .act(|p| Count::patch().n(p.context().n + 1)) + }) + }, + ), + ); + m.send(CounterEvent::Inc); + m.send(CounterEvent::Inc); + assert_eq!(ran.count(), 2); + assert_eq!(m.context().n, 2); + assert_eq!(m.state(), CounterState::Idle); +} + +#[test] +fn a_send_from_an_action_runs_after_the_current_transition_completes() { + let log = Log::default(); + let m = build( + Config::::builder(DoorState::Closed, ()) + .state(DoorState::Closed, |s| { + s.on(DoorEventKind::Open, |t| { + let log = log.clone(); + t.target(DoorState::Open).run(move |p| { + log.push("announce"); + p.send(DoorEvent::AutoClose); + }) + }) + }) + .state(DoorState::Open, |s| { + s.on(DoorEventKind::AutoClose, |t| { + t.target(DoorState::Closed).run(log.action("closed")) + }) + }), + ); + m.send(DoorEvent::Open); + // autoClose was processed against `open` (the new state), so it matched. + assert_eq!(log.entries(), ["announce", "closed"]); + assert_eq!(m.state(), DoorState::Closed); +} + +#[test] +fn a_send_from_an_observer_mid_transition_waits_for_the_transition_to_complete() { + let log = Log::default(); + let m = build( + Config::::builder(DoorState::Closed, ()) + .state(DoorState::Closed, |s| { + s.on(DoorEventKind::Open, |t| t.target(DoorState::Open)) + }) + .state(DoorState::Open, |s| { + s.entry_run(log.action("entry:open")) + .on(DoorEventKind::AutoClose, |t| { + t.target(DoorState::Closed).run(log.action("auto-closed")) + }) + }), + ); + let _ = m.subscribe({ + let m = m.clone(); + move || { + if m.state() == DoorState::Open { + m.send(DoorEvent::AutoClose); + } + } + }); + m.send(DoorEvent::Open); + // The observer fired at the switch to `open`, before its entry actions. + assert_eq!(log.entries(), ["entry:open", "auto-closed"]); + assert_eq!(m.state(), DoorState::Closed); +} diff --git a/crates/core/tests/watch.rs b/crates/core/tests/watch.rs new file mode 100644 index 0000000..57a090f --- /dev/null +++ b/crates/core/tests/watch.rs @@ -0,0 +1,329 @@ +//! `watch`: data-reactions on a context field or a computed value. Their actions run, +//! deferred onto the queue, when the value changes, in any state, only while running. +//! +//! Port note: the TS "watches a computed field" case only checks that an unchanged +//! computed value stays silent; this port also checks that a changed one fires. +//! +//! Like the TS engine, a write is observed as it happens: the last four tests pin that a +//! watcher run is queued at the write itself, in order with the sends around it. + +mod common; + +use common::{Log, build}; +use dunky_core::{Action, Config, ConfigBuilder, Context, Event, State, Types}; + +struct M; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, State)] +enum S { + A, + B, +} + +#[derive(Clone, Debug, Event)] +enum Ev { + Bump, + BumpOther, + BumpA, + BumpB, + Add, + Clear, + Toggle, + SetN(i32), + Go, + Ping, + ToB, +} + +#[derive(Clone, Debug, Default, Context)] +struct Ctx { + n: i32, + other: i32, + a: i32, + b: i32, + items: Vec, + flag: bool, +} + +impl Types for M { + type State = S; + type Event = Ev; + type Context = Ctx; +} + +/// Any-state handlers write the context; `toB` moves a -> b. Tests add their watchers. +fn builder(ctx: Ctx) -> ConfigBuilder { + let mut b = Config::::builder(S::A, ctx); + b.on(EvKind::Bump, |t| { + t.act(|p| Ctx::patch().n(p.context().n + 1)) + }) + .on(EvKind::BumpOther, |t| { + t.act(|p| Ctx::patch().other(p.context().other + 1)) + }) + .on(EvKind::BumpA, |t| { + t.act(|p| Ctx::patch().a(p.context().a + 1)) + }) + .on(EvKind::BumpB, |t| { + t.act(|p| Ctx::patch().b(p.context().b + 1)) + }) + .on(EvKind::Add, |t| { + t.act(|p| { + let mut items = p.context().items.clone(); + items.push(1); + Ctx::patch().items(items) + }) + }) + .on(EvKind::Clear, |t| t.act(|_| Ctx::patch().items(Vec::new()))) + .on(EvKind::Toggle, |t| { + t.act(|p| Ctx::patch().flag(!p.context().flag)) + }) + .on(EvKind::SetN, |t| { + t.act(|p| match p.event() { + Some(Ev::SetN(n)) => Ctx::patch().n(*n), + _ => Ctx::patch(), + }) + }) + .state(S::A, |s| s.on(EvKind::ToB, |t| t.target(S::B))); + b +} + +/// A watcher action that records `n`. +fn record_n(seen: &Log) -> Action { + let seen = seen.clone(); + Action::::run(move |p| seen.push(p.context().n)) +} + +#[test] +fn runs_when_a_watched_field_changes_but_not_on_start() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.start(); + assert!(seen.is_empty()); + m.send(Ev::Bump); + m.send(Ev::Bump); + assert_eq!(seen.entries(), [1, 2]); +} + +#[test] +fn ignores_writes_to_other_fields() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [Action::::run(log.action("fired"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::BumpOther); + assert!(log.is_empty()); + m.send(Ev::Bump); + assert_eq!(log.entries(), ["fired"]); +} + +#[test] +fn watches_a_computed_value() { + let seen: Log = Log::default(); + let mut b = builder(Ctx { + items: vec![1], + ..Ctx::default() + }); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.watch_computed( + is_empty, + [Action::::run({ + let seen = seen.clone(); + move |p| seen.push(*p.computed(is_empty)) + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::Add); // items changed, isEmpty stayed false: silent + assert!(seen.is_empty()); + m.send(Ev::Clear); + assert_eq!(seen.entries(), [true]); +} + +#[test] +fn reacts_in_any_state_and_can_send() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Ping, |t| t.run(log.action("ping"))) + .watch(Ctx::FLAG, [Action::::run(|p| p.send(Ev::Ping))]); + let m = build(&mut b); + m.start(); + m.send(Ev::Toggle); // in a + m.send(Ev::ToB); + m.send(Ev::Toggle); // in b + assert_eq!(log.entries(), ["ping", "ping"]); +} + +#[test] +fn does_not_run_while_stopped_and_stop_disposes_it() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.send(Ev::Bump); // never started + assert!(seen.is_empty()); + m.start(); + m.send(Ev::Bump); + assert_eq!(seen.entries(), [2]); + m.stop(); + m.send(Ev::Bump); + assert_eq!(seen.entries(), [2]); +} + +#[test] +fn a_run_still_pending_at_stop_is_dropped() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.start(); + let _ = m.subscribe({ + let m = m.clone(); + move || m.stop() // the write already queued the run; it must not run now + }); + m.send(Ev::Bump); + assert!(seen.is_empty()); +} + +#[test] +fn runs_after_the_whole_transition_settles() { + let seen: Log<(i32, i32)> = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Go, |t| { + t.act(|_| Ctx::patch().a(1)).act(|_| Ctx::patch().b(1)) + }) + .watch( + Ctx::A, + [Action::::run({ + let seen = seen.clone(); + move |p| seen.push((p.context().a, p.context().b)) + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + // One run, after the full action list: never mid-transition with b still 0. + assert_eq!(seen.entries(), [(1, 1)]); +} + +#[test] +fn a_watcher_writing_its_own_field_converges() { + let mut b = builder(Ctx::default()); + b.watch( + Ctx::N, + [Action::::run(|p| { + if p.context().n > 10 { + p.set_context(Ctx::patch().n(10)); + } + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::SetN(50)); + assert_eq!(m.context().n, 10); +} + +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "feedback loop")] +fn panics_in_debug_on_a_watcher_feedback_loop() { + let mut b = builder(Ctx::default()); + b.watch( + Ctx::N, + [Action::::act(|p| Ctx::patch().n(p.context().n + 1))], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::Bump); +} + +#[test] +fn watched_fields_react_independently() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.watch(Ctx::A, [Action::::run(log.action("a"))]) + .watch(Ctx::B, [Action::::run(log.action("b"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::BumpA); + m.send(Ev::BumpB); + m.send(Ev::BumpA); + assert_eq!(log.entries(), ["a", "b", "a"]); +} + +#[test] +fn a_watcher_runs_before_an_event_sent_later_by_the_same_action() { + let log = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Go, |t| { + t.run(|p| { + p.set_context(Ctx::patch().n(1)); + p.send(Ev::Ping); + }) + }) + .on(EvKind::Ping, |t| t.run(log.action("ping"))) + .watch(Ctx::N, [Action::::run(log.action("watch"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + assert_eq!(log.entries(), ["watch", "ping"]); +} + +#[test] +fn a_field_written_away_and_back_in_one_action_runs_the_watcher_per_write() { + let seen = Log::default(); + let mut b = builder(Ctx::default()); + b.on(EvKind::Go, |t| { + t.run(|p| { + p.set_context(Ctx::patch().n(1)); + p.set_context(Ctx::patch().n(0)); + }) + }) + .watch(Ctx::N, [record_n(&seen)]); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + assert_eq!(seen.entries(), [0, 0]); // both runs come after the action +} + +#[test] +fn a_computed_watcher_runs_before_an_event_sent_later_by_the_same_action() { + let log = Log::default(); + let mut b = builder(Ctx { + items: vec![1], + ..Ctx::default() + }); + let is_empty = b.computed("isEmpty", |p| p.context.items().is_empty()); + b.on(EvKind::Go, |t| { + t.run(|p| { + p.set_context(Ctx::patch().items(Vec::new())); + p.send(Ev::Ping); + }) + }) + .on(EvKind::Ping, |t| t.run(log.action("ping"))) + .watch_computed(is_empty, [Action::::run(log.action("watch"))]); + let m = build(&mut b); + m.start(); + m.send(Ev::Go); + assert_eq!(log.entries(), ["watch", "ping"]); +} + +#[test] +fn a_computed_watcher_reading_the_state_runs_on_a_transition() { + let seen: Log = Log::default(); + let mut b = builder(Ctx::default()); + let in_b = b.computed("inB", |p| p.state() == S::B); + b.watch_computed( + in_b, + [Action::::run({ + let seen = seen.clone(); + move |p| seen.push(*p.computed(in_b)) + })], + ); + let m = build(&mut b); + m.start(); + m.send(Ev::ToB); + assert_eq!(seen.entries(), [true]); +} diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml new file mode 100644 index 0000000..3e0c26c --- /dev/null +++ b/crates/macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dunky-macros" +description = "Derive macros for dunky-core: State, Event, Context, TsType." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs new file mode 100644 index 0000000..3d34b79 --- /dev/null +++ b/crates/macros/src/lib.rs @@ -0,0 +1,549 @@ +//! Derive macros for `dunky-core`. Use them through the re-exports in `dunky_core`. + +mod ts; + +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use syn::{Attribute, Data, DeriveInput, Fields, Ident, LitStr, parse_macro_input}; + +/// `#[derive(State)]` on a fieldless enum: state names default to the camelCased variant +/// (`HalfOpen` → `"halfOpen"`); override with `#[state(name = "...")]`. +#[proc_macro_derive(State, attributes(state))] +pub fn derive_state(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_state(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +/// `#[derive(Event)]` on an enum: generates `Kind` (one fieldless variant per event) +/// and implements `EventEnum`. Type names default to the camelCased variant; a +/// `#[event(name = "...")]` or `#[serde(rename = "...")]` attribute overrides it. +/// Add `#[event(deserialize)]` on the enum to implement `DeserializeEvent`: the event is +/// rebuilt from its kind + payload fields, without serde's buffered tagged-enum path. +#[proc_macro_derive(Event, attributes(event))] +pub fn derive_event(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_event(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +/// `#[derive(Context)]` on a struct with named fields: generates the `Patch` builder, +/// `Reader` (tracked reads for computed values), one `Field` const per field +/// (`active_index` → `ACTIVE_INDEX`) and `::patch()`. Field names for JS are +/// camelCased; override with `#[context(name = "...")]`. Add `#[context(serialize)]` on the +/// struct to implement `SerializeFields` (every field must be `serde::Serialize`). +#[proc_macro_derive(Context, attributes(context))] +pub fn derive_context(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + expand_context(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +/// `#[derive(TsType)]` on a data type a machine shows JS (a context field, an event +/// payload, a computed value): its TypeScript type, from the serde attributes it crosses +/// with. Supports structs with named fields, newtypes, unit enums (string unions), and +/// internally tagged enums (`#[serde(tag = "...")]`). +#[proc_macro_derive(TsType)] +pub fn derive_ts(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + ts::expand(input) + .unwrap_or_else(|e| e.to_compile_error()) + .into() +} + +fn camel(ident: &Ident) -> String { + let s = ident.to_string(); + let s = s.strip_prefix("r#").unwrap_or(&s); + if s.contains('_') { + let mut out = String::new(); + let mut upper = false; + for (i, ch) in s.chars().enumerate() { + if ch == '_' { + upper = i > 0; + continue; + } + if upper { + out.extend(ch.to_uppercase()); + upper = false; + } else { + out.push(ch); + } + } + out + } else { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_lowercase().chain(chars).collect(), + None => String::new(), + } + } +} + +/// Reads `#[(name = "...")]` (or `#[serde(rename = "...")]` when `serde_too`). +fn name_attr(attrs: &[Attribute], attr: &str, serde_too: bool) -> syn::Result> { + let mut found = None; + for a in attrs { + let is_own = a.path().is_ident(attr); + let is_serde = serde_too && a.path().is_ident("serde"); + if !is_own && !is_serde { + continue; + } + let key = if is_own { "name" } else { "rename" }; + a.parse_nested_meta(|meta| { + if meta.path.is_ident(key) { + let value: LitStr = meta.value()?.parse()?; + if is_own || found.is_none() { + found = Some(value.value()); + } + } else if meta.input.peek(syn::Token![=]) { + // Skip other `key = value` items (e.g. serde's). + let _: syn::Expr = meta.value()?.parse()?; + } else if meta.input.peek(syn::token::Paren) { + let _content; + syn::parenthesized!(_content in meta.input); + } + Ok(()) + })?; + } + Ok(found) +} + +/// serde's `rename_all` rules, applied to a (PascalCase) variant name exactly like serde does. +pub(crate) fn serde_case(variant: &str, rule: &str) -> Option { + let snake = || { + let mut out = String::new(); + for (i, ch) in variant.char_indices() { + if i > 0 && ch.is_uppercase() { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } + out + }; + Some(match rule { + "lowercase" => variant.to_ascii_lowercase(), + "UPPERCASE" => variant.to_ascii_uppercase(), + "PascalCase" => variant.to_owned(), + "camelCase" => { + let mut chars = variant.chars(); + chars + .next() + .map(|c| c.to_ascii_lowercase().to_string() + chars.as_str()) + .unwrap_or_default() + } + "snake_case" => snake(), + "SCREAMING_SNAKE_CASE" => snake().to_ascii_uppercase(), + "kebab-case" => snake().replace('_', "-"), + "SCREAMING-KEBAB-CASE" => snake().replace('_', "-").to_ascii_uppercase(), + _ => return None, + }) +} + +/// Reads `#[serde( = "...")]`. +fn serde_str(attrs: &[Attribute], key: &str) -> syn::Result> { + let mut found = None; + for a in attrs { + if !a.path().is_ident("serde") { + continue; + } + a.parse_nested_meta(|meta| { + if meta.path.is_ident(key) { + let value: LitStr = meta.value()?.parse()?; + found = Some(value.value()); + } else if meta.input.peek(syn::Token![=]) { + let _: syn::Expr = meta.value()?.parse()?; + } else if meta.input.peek(syn::token::Paren) { + let _content; + syn::parenthesized!(_content in meta.input); + } + Ok(()) + })?; + } + Ok(found) +} + +fn has_flag(attrs: &[Attribute], attr: &str, flag: &str) -> syn::Result { + let mut on = false; + for a in attrs { + if !a.path().is_ident(attr) { + continue; + } + a.parse_nested_meta(|meta| { + if meta.path.is_ident(flag) { + on = true; + } else if meta.input.peek(syn::Token![=]) { + let _: syn::Expr = meta.value()?.parse()?; + } + Ok(()) + })?; + } + Ok(on) +} + +fn expand_state(input: DeriveInput) -> syn::Result { + let name = &input.ident; + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "#[derive(State)] needs an enum", + )); + }; + let mut names = Vec::new(); + let mut variants = Vec::new(); + for v in &data.variants { + if !matches!(v.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + v, + "state variants cannot carry data", + )); + } + names.push(name_attr(&v.attrs, "state", false)?.unwrap_or_else(|| camel(&v.ident))); + variants.push(&v.ident); + } + let indices: Vec = (0..variants.len()).collect(); + let literals: Vec = names + .iter() + .map(|n| quote!(::dunky_core::__private::ts_literal(#n))) + .collect(); + let ts_impl = ts::impl_ts(&input, ts::union(&literals)); + let (impl_g, ty_g, where_g) = input.generics.split_for_impl(); + Ok(quote! { + #ts_impl + + impl #impl_g ::dunky_core::StateEnum for #name #ty_g #where_g { + const NAMES: &'static [&'static str] = &[#(#names),*]; + fn index(self) -> usize { + match self { #(#name::#variants => #indices,)* } + } + fn from_index(index: usize) -> Self { + match index { + #(#indices => #name::#variants,)* + _ => panic!("[machine] no state at index {index}"), + } + } + } + }) +} + +fn expand_event(input: DeriveInput) -> syn::Result { + let name = &input.ident; + let vis = &input.vis; + let Data::Enum(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "#[derive(Event)] needs an enum", + )); + }; + let kind = format_ident!("{}Kind", name); + let deserialize = has_flag(&input.attrs, "event", "deserialize")?; + let rename_all_fields = serde_str(&input.attrs, "rename_all_fields")?; + let rename_all = serde_str(&input.attrs, "rename_all")?; + let mut names = Vec::new(); + let mut variants = Vec::new(); + let mut patterns = Vec::new(); + let mut builders = Vec::new(); + let mut payload_arms = Vec::new(); + for (index, v) in data.variants.iter().enumerate() { + let ident = &v.ident; + payload_arms.push(match &v.fields { + Fields::Unit => quote!(#index => ::core::result::Result::Ok(#name::#ident),), + Fields::Named(fields) => { + let field_idents: Vec<&Ident> = + fields.named.iter().map(|f| f.ident.as_ref().expect("named field")).collect(); + let field_tys: Vec<&syn::Type> = fields.named.iter().map(|f| &f.ty).collect(); + let field_attrs: Vec> = fields + .named + .iter() + .map(|f| f.attrs.iter().filter(|a| a.path().is_ident("serde")).collect()) + .collect(); + // The variant's own rename_all wins over the enum's rename_all_fields. + let rename = serde_str(&v.attrs, "rename_all")? + .or_else(|| rename_all_fields.clone()) + .map(|r| quote!(#[serde(rename_all = #r)])) + .unwrap_or_default(); + quote!(#index => { + #[derive(::dunky_core::__private::serde::Deserialize)] + #[serde(crate = "::dunky_core::__private::serde")] + #rename + struct __Payload { #( #(#field_attrs)* #field_idents: #field_tys, )* } + let p = <__Payload as ::dunky_core::__private::serde::Deserialize>::deserialize(deserializer)?; + ::core::result::Result::Ok(#name::#ident { #(#field_idents: p.#field_idents,)* }) + }) + } + Fields::Unnamed(_) => quote!(#index => ::core::result::Result::Err( + <__D::Error as ::dunky_core::__private::serde::de::Error>::custom( + "tuple event variants carry no field names; use named fields" + ) + ),), + }); + } + for v in &data.variants { + let ident = &v.ident; + // Same name serde uses: explicit rename > the enum's rename_all > camelCase. + let kind_name = match name_attr(&v.attrs, "event", true)? { + Some(explicit) => explicit, + None => match &rename_all { + Some(rule) => serde_case(&ident.to_string(), rule).ok_or_else(|| { + syn::Error::new_spanned(ident, format!("unknown rename_all rule \"{rule}\"")) + })?, + None => camel(ident), + }, + }; + names.push(kind_name); + variants.push(ident.clone()); + patterns.push(match &v.fields { + Fields::Unit => quote!(#name::#ident), + Fields::Named(_) => quote!(#name::#ident { .. }), + Fields::Unnamed(_) => quote!(#name::#ident(..)), + }); + builders.push(match &v.fields { + Fields::Unit => quote!(::core::option::Option::Some(#name::#ident)), + _ => quote!(::core::option::Option::None), + }); + } + let indices: Vec = (0..variants.len()).collect(); + let ts_impl = if deserialize { + let mut types = Vec::new(); + for (v, kind_name) in data.variants.iter().zip(&names) { + let fields_rule = + serde_str(&v.attrs, "rename_all")?.or_else(|| rename_all_fields.clone()); + let mut members = vec![ts::tag_member("type", kind_name)]; + match &v.fields { + Fields::Unit => {} + Fields::Named(fields) => members.extend(ts::members( + &fields.named, + fields_rule.as_deref(), + ts::Direction::FromJs, + )?), + // JS cannot send it: no field names to read. + Fields::Unnamed(_) => continue, + } + types.push(ts::object(&members)); + } + ts::impl_ts(&input, ts::union(&types)) + } else { + quote! {} + }; + let deserialize_impl = if deserialize { + quote! { + impl ::dunky_core::DeserializeEvent for #name { + fn deserialize_payload<'de, __D: ::dunky_core::__private::serde::Deserializer<'de>>( + kind: usize, + deserializer: __D, + ) -> ::core::result::Result { + #[allow(unused_variables)] + let deserializer = deserializer; + match kind { + #(#payload_arms)* + _ => ::core::result::Result::Err( + <__D::Error as ::dunky_core::__private::serde::de::Error>::custom("no such event type") + ), + } + } + } + } + } else { + quote! {} + }; + Ok(quote! { + /// The event types of + #[doc = concat!("[`", stringify!(#name), "`]")] + /// without their payloads. + #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] + #vis enum #kind { #(#variants),* } + + impl ::dunky_core::EventEnum for #name { + type Kind = #kind; + const KIND_NAMES: &'static [&'static str] = &[#(#names),*]; + fn kind(&self) -> #kind { + match self { #(#patterns => #kind::#variants,)* } + } + fn kind_index(kind: #kind) -> usize { + match kind { #(#kind::#variants => #indices,)* } + } + fn kind_from_index(index: usize) -> #kind { + match index { + #(#indices => #kind::#variants,)* + _ => panic!("[machine] no event type at index {index}"), + } + } + fn from_kind(kind: #kind) -> ::core::option::Option { + match kind { #(#kind::#variants => #builders,)* } + } + } + + #deserialize_impl + + #ts_impl + }) +} + +fn expand_context(input: DeriveInput) -> syn::Result { + let name = &input.ident; + let vis = &input.vis; + let Data::Struct(data) = &input.data else { + return Err(syn::Error::new_spanned( + &input.ident, + "#[derive(Context)] needs a struct", + )); + }; + let fields: Vec<_> = match &data.fields { + Fields::Named(f) => f.named.iter().collect(), + Fields::Unit => Vec::new(), + Fields::Unnamed(f) => { + return Err(syn::Error::new_spanned( + f, + "#[derive(Context)] needs named fields", + )); + } + }; + if fields.len() > 64 { + return Err(syn::Error::new_spanned( + &input.ident, + "a context supports at most 64 fields", + )); + } + let serialize = has_flag(&input.attrs, "context", "serialize")?; + let patch = format_ident!("{}Patch", name); + let reader = format_ident!("{}Reader", name); + + let idents: Vec<&Ident> = fields + .iter() + .map(|f| f.ident.as_ref().expect("named field")) + .collect(); + let tys: Vec<&syn::Type> = fields.iter().map(|f| &f.ty).collect(); + let mut js_names = Vec::new(); + for f in &fields { + let ident = f.ident.as_ref().expect("named field"); + js_names.push(name_attr(&f.attrs, "context", false)?.unwrap_or_else(|| camel(ident))); + } + let consts: Vec = idents + .iter() + .map(|i| { + let s = i.to_string(); + Ident::new( + &s.trim_start_matches("r#").to_uppercase(), + Span::call_site(), + ) + }) + .collect(); + let indices: Vec = (0..idents.len()).collect(); + let bits: Vec = indices.iter().map(|i| 1u64 << i).collect(); + + let ts_impl = if serialize { + let members: Vec = js_names + .iter() + .zip(&tys) + .map(|(js, ty)| { + quote! { + ::std::format!( + "{}: {}", + ::dunky_core::__private::ts_key(#js), + <#ty as ::dunky_core::TsType>::ts(), + ) + } + }) + .collect(); + ts::impl_ts(&input, ts::object(&members)) + } else { + quote! {} + }; + let serialize_impl = if serialize { + quote! { + impl ::dunky_core::SerializeFields for #name { + fn serialize_field<__S: ::dunky_core::__private::serde::Serializer>( + &self, + index: usize, + serializer: __S, + ) -> ::core::result::Result<__S::Ok, __S::Error> { + match index { + #(#indices => ::dunky_core::__private::serde::Serialize::serialize(&self.#idents, serializer),)* + _ => serializer.serialize_unit(), + } + } + } + } + } else { + quote! {} + }; + + Ok(quote! { + /// A partial update of + #[doc = concat!("[`", stringify!(#name), "`]")] + /// — set only the fields you change. + #[derive(Default)] + #[allow(dead_code)] + #vis struct #patch { + #(#idents: ::core::option::Option<#tys>,)* + } + + #[allow(dead_code)] + impl #patch { + #( + pub fn #idents(mut self, value: impl ::core::convert::Into<#tys>) -> Self { + self.#idents = ::core::option::Option::Some(value.into()); + self + } + )* + } + + /// Tracked read access for computed values. + #[derive(Clone, Copy)] + #[allow(dead_code)] + #vis struct #reader<'a> { + ctx: &'a #name, + deps: &'a ::core::cell::Cell, + } + + #[allow(dead_code)] + impl<'a> #reader<'a> { + #( + pub fn #idents(&self) -> &'a #tys { + self.deps.set(self.deps.get() | #bits); + &self.ctx.#idents + } + )* + } + + #[allow(dead_code)] + impl #name { + #(pub const #consts: ::dunky_core::Field<#name, #tys> = + ::dunky_core::Field::new(#indices, |c: &#name| &c.#idents);)* + + /// Start a patch: `Self::patch().field(value)`. + pub fn patch() -> #patch { + ::core::default::Default::default() + } + } + + impl ::dunky_core::Context for #name { + type Patch = #patch; + type Reader<'a> = #reader<'a>; + const FIELDS: &'static [&'static str] = &[#(#js_names),*]; + #[allow(unused_mut, unused_variables)] + fn apply(&mut self, patch: #patch) -> u64 { + let mut changed = 0u64; + #( + if let ::core::option::Option::Some(value) = patch.#idents { + if self.#idents != value { + self.#idents = value; + changed |= #bits; + } + } + )* + changed + } + fn reader<'a>(&'a self, deps: &'a ::core::cell::Cell) -> #reader<'a> { + #reader { ctx: self, deps } + } + } + + #serialize_impl + + #ts_impl + }) +} diff --git a/crates/macros/src/ts.rs b/crates/macros/src/ts.rs new file mode 100644 index 0000000..72acf94 --- /dev/null +++ b/crates/macros/src/ts.rs @@ -0,0 +1,298 @@ +//! TypeScript types for the derives: `#[derive(TsType)]` on data types, and the +//! `TsType` impls `State`, `Event` and `Context` emit. Names follow the serde attributes +//! the value crosses with, so the TS type matches the JS value. + +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + Attribute, Data, DeriveInput, Expr, ExprLit, Field, Fields, GenericArgument, Ident, Lit, + PathArguments, Type, +}; + +use crate::serde_case; + +/// The serde attributes that shape a value's JS form. +#[derive(Default)] +pub(crate) struct Serde { + pub(crate) rename: Option, + pub(crate) rename_all: Option, + pub(crate) rename_all_fields: Option, + pub(crate) tag: Option, + skip: bool, + skip_serializing: bool, + skip_deserializing: bool, + skip_serializing_if: Option, + default: bool, + transparent: bool, +} + +pub(crate) fn serde_attrs(attrs: &[Attribute]) -> syn::Result { + let mut out = Serde::default(); + for a in attrs.iter().filter(|a| a.path().is_ident("serde")) { + a.parse_nested_meta(|meta| { + let name = meta + .path + .get_ident() + .map(Ident::to_string) + .unwrap_or_default(); + let value = if meta.input.peek(syn::Token![=]) { + match meta.value()?.parse::()? { + Expr::Lit(ExprLit { + lit: Lit::Str(s), .. + }) => Some(s.value()), + _ => None, + } + } else if meta.input.peek(syn::token::Paren) { + if matches!(name.as_str(), "rename" | "rename_all") { + return Err( + meta.error("TsType: split serialize/deserialize names are not supported") + ); + } + let _content; + syn::parenthesized!(_content in meta.input); + None + } else { + None + }; + match name.as_str() { + "rename" => out.rename = value, + "rename_all" => out.rename_all = value, + "rename_all_fields" => out.rename_all_fields = value, + "tag" => out.tag = value, + "skip" => out.skip = true, + "skip_serializing" => out.skip_serializing = true, + "skip_deserializing" => out.skip_deserializing = true, + "skip_serializing_if" => out.skip_serializing_if = value, + "default" => out.default = true, + "transparent" => out.transparent = true, + "flatten" | "untagged" | "content" => { + return Err(meta.error(format!("TsType: `{name}` is not supported"))); + } + _ => {} + } + Ok(()) + })?; + } + Ok(out) +} + +/// Which way a value crosses: shown to JS (serialized) or sent from JS (deserialized). +#[derive(Clone, Copy)] +pub(crate) enum Direction { + ToJs, + FromJs, +} + +/// serde's `rename_all` rules for a (snake_case) field name, like serde applies them. +fn field_case(field: &str, rule: &str) -> Option { + let pascal = || { + field + .split('_') + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() + }) + .collect::() + }; + Some(match rule { + "lowercase" | "snake_case" => field.to_owned(), + "UPPERCASE" | "SCREAMING_SNAKE_CASE" => field.to_ascii_uppercase(), + "PascalCase" => pascal(), + "camelCase" => { + let pascal = pascal(); + let mut chars = pascal.chars(); + chars + .next() + .map(|c| c.to_ascii_lowercase().to_string() + chars.as_str()) + .unwrap_or_default() + } + "kebab-case" => field.replace('_', "-"), + "SCREAMING-KEBAB-CASE" => field.replace('_', "-").to_ascii_uppercase(), + _ => return None, + }) +} + +fn unknown_rule(span: &impl quote::ToTokens, rule: &str) -> syn::Error { + syn::Error::new_spanned(span, format!("unknown rename_all rule \"{rule}\"")) +} + +/// The inner type of `Option`, read from the syntax. +fn option_inner(ty: &Type) -> Option<&Type> { + let Type::Path(path) = ty else { return None }; + let last = path.path.segments.last()?; + if last.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(args) = &last.arguments else { + return None; + }; + match args.args.first()? { + GenericArgument::Type(inner) => Some(inner), + _ => None, + } +} + +/// The TS members of named fields (`key: type` or `key?: type`), as expressions that +/// build them. `rename_all` is the rule the fields' container applies. +pub(crate) fn members<'a>( + fields: impl IntoIterator, + rename_all: Option<&str>, + direction: Direction, +) -> syn::Result> { + let mut out = Vec::new(); + for f in fields { + let attrs = serde_attrs(&f.attrs)?; + let skipped = attrs.skip + || match direction { + Direction::ToJs => attrs.skip_serializing, + Direction::FromJs => attrs.skip_deserializing, + }; + if skipped { + continue; + } + let ident = f.ident.as_ref().expect("named field").to_string(); + let ident = ident.strip_prefix("r#").unwrap_or(&ident); + let name = match (&attrs.rename, rename_all) { + (Some(name), _) => name.clone(), + (None, Some(rule)) => field_case(ident, rule).ok_or_else(|| unknown_rule(f, rule))?, + (None, None) => ident.to_owned(), + }; + let none_skipped = attrs.skip_serializing_if.as_deref() == Some("Option::is_none"); + let (optional, ty) = match direction { + // `None` never crosses as `null` when serde skips it. + Direction::ToJs => match option_inner(&f.ty) { + Some(inner) if none_skipped => (true, inner), + _ => (attrs.skip_serializing_if.is_some(), &f.ty), + }, + // serde reads a missing `Option` field as `None`. + Direction::FromJs => (attrs.default || option_inner(&f.ty).is_some(), &f.ty), + }; + let mark = if optional { "?" } else { "" }; + out.push(quote! { + ::std::format!( + "{}{}: {}", + ::dunky_core::__private::ts_key(#name), + #mark, + <#ty as ::dunky_core::TsType>::ts(), + ) + }); + } + Ok(out) +} + +/// An object type from member expressions. +pub(crate) fn object(members: &[TokenStream2]) -> TokenStream2 { + quote!(::dunky_core::__private::ts_object(&[#(#members),*])) +} + +/// The member that names a variant of a tagged union: `type: 'open'`. +pub(crate) fn tag_member(tag: &str, name: &str) -> TokenStream2 { + quote! { + ::std::format!( + "{}: {}", + ::dunky_core::__private::ts_key(#tag), + ::dunky_core::__private::ts_literal(#name), + ) + } +} + +/// Join type expressions into a union (`never` when there are none). +pub(crate) fn union(types: &[TokenStream2]) -> TokenStream2 { + if types.is_empty() { + return quote!(::std::string::String::from("never")); + } + quote!([#(#types),*].join(" | ")) +} + +/// `impl TsType` with the body `ts`, bounding every type parameter by `TsType`. +pub(crate) fn impl_ts(input: &DeriveInput, ts: TokenStream2) -> TokenStream2 { + let name = &input.ident; + let mut generics = input.generics.clone(); + for param in generics.type_params_mut() { + param.bounds.push(syn::parse_quote!(::dunky_core::TsType)); + } + let (impl_g, ty_g, where_g) = generics.split_for_impl(); + quote! { + impl #impl_g ::dunky_core::TsType for #name #ty_g #where_g { + fn ts() -> ::std::string::String { + #ts + } + } + } +} + +/// `#[derive(TsType)]`: the value as JS reads it (serialized with serde). +pub(crate) fn expand(input: DeriveInput) -> syn::Result { + let container = serde_attrs(&input.attrs)?; + let ts = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) if !container.transparent => object(&members( + &fields.named, + container.rename_all.as_deref(), + Direction::ToJs, + )?), + Fields::Named(fields) if fields.named.len() == 1 => { + let ty = &fields.named[0].ty; + quote!(<#ty as ::dunky_core::TsType>::ts()) + } + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => { + let ty = &fields.unnamed[0].ty; + quote!(<#ty as ::dunky_core::TsType>::ts()) + } + Fields::Unit => quote!(::std::string::String::from("null")), + _ => { + return Err(syn::Error::new_spanned( + &input.ident, + "TsType: use named fields, or one field (a newtype)", + )); + } + }, + Data::Enum(data) => { + let mut variants = Vec::new(); + for v in &data.variants { + let attrs = serde_attrs(&v.attrs)?; + if attrs.skip || attrs.skip_serializing { + continue; + } + let ident = v.ident.to_string(); + let name = match (&attrs.rename, &container.rename_all) { + (Some(name), _) => name.clone(), + (None, Some(rule)) => { + serde_case(&ident, rule).ok_or_else(|| unknown_rule(&v.ident, rule))? + } + (None, None) => ident, + }; + let fields_rule = attrs + .rename_all + .as_deref() + .or(container.rename_all_fields.as_deref()); + variants.push(match (&container.tag, &v.fields) { + (None, Fields::Unit) => quote!(::dunky_core::__private::ts_literal(#name)), + (Some(tag), Fields::Unit) => object(&[tag_member(tag, &name)]), + (Some(tag), Fields::Named(fields)) => { + let mut all = vec![tag_member(tag, &name)]; + all.extend(members(&fields.named, fields_rule, Direction::ToJs)?); + object(&all) + } + _ => { + return Err(syn::Error::new_spanned( + v, + "TsType: an enum with data needs #[serde(tag = \"...\")] and named fields", + )); + } + }); + } + union(&variants) + } + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "TsType: unions are not supported", + )); + } + }; + Ok(impl_ts(&input, ts)) +} diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml new file mode 100644 index 0000000..b451e7e --- /dev/null +++ b/crates/sandbox/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "dunky-sandbox" +description = "The sandbox machines written in Rust (the command palette, a dialog), exported to wasm for every sandbox through fromWasm." +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# The JS classes (export_machine!), on for the wasm build (pnpm build:wasm) only, so +# crates reusing the machines (crates/uniffi) never link the JS exports. +wasm = ["dep:dunky-wasm", "dep:wasm-bindgen"] + +[dependencies] +dunky-core = { workspace = true } +dunky-wasm = { workspace = true, optional = true } +serde = { workspace = true } +wasm-bindgen = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[[example]] +name = "typescript" +required-features = ["wasm"] diff --git a/crates/sandbox/examples/typescript.rs b/crates/sandbox/examples/typescript.rs new file mode 100644 index 0000000..303b90b --- /dev/null +++ b/crates/sandbox/examples/typescript.rs @@ -0,0 +1,6 @@ +//! Prints the TS types of the exported classes; `pnpm build:wasm` appends them to the +//! module's `.d.ts`, where `fromWasm` reads them. + +fn main() { + print!("{}", dunky_sandbox::typescript()); +} diff --git a/crates/sandbox/src/dialog.rs b/crates/sandbox/src/dialog.rs new file mode 100644 index 0000000..0431418 --- /dev/null +++ b/crates/sandbox/src/dialog.rs @@ -0,0 +1,72 @@ +//! A dialog with an exit phase: `closing` lasts `exit_ms` (a named, context-driven delay) +//! before `closed`, so a renderer can animate out. Shows `after` timers running through +//! the host's clock. + +use dunky_core::{Config, Context, Event, State, Types}; +use serde::Deserialize; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum DialogState { + Closed, + Open, + Closing, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum DialogEvent { + Open, + Close, + Toggle, +} + +#[derive(Context, Clone, Debug, PartialEq)] +#[context(serialize)] +pub struct DialogCtx { + /// How long `closing` lasts. Seeded from props. + pub exit_ms: u32, + /// How many times the dialog opened (demo data for the change mask). + pub open_count: u32, +} + +pub struct Dialog; + +impl Types for Dialog { + type State = DialogState; + type Event = DialogEvent; + type Context = DialogCtx; +} + +pub fn config(exit_ms: u32) -> Config { + use DialogEventKind as E; + use DialogState::{Closed, Closing, Open}; + let mut b = Config::::builder( + Closed, + DialogCtx { + exit_ms, + open_count: 0, + }, + ); + b.delay("exitDelay", |g| g.context().exit_ms); + b.action("countOpen", |p| { + let n = p.context().open_count + 1; + p.set_context(DialogCtx::patch().open_count(n)); + }); + b.state(Closed, |s| { + s.on(E::Open, |t| t.target(Open).action("countOpen")) + .on(E::Toggle, |t| t.target(Open).action("countOpen")) + }); + b.state(Open, |s| { + s.tag("visible") + .on(E::Close, |t| t.target(Closing)) + .on(E::Toggle, |t| t.target(Closing)) + }); + b.state(Closing, |s| { + s.tag("visible") + .after("exitDelay", |t| t.target(Closed)) + .on(E::Open, |t| t.target(Open).action("countOpen")) + .on(E::Toggle, |t| t.target(Open).action("countOpen")) + }); + b.build() +} diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs new file mode 100644 index 0000000..d5d24f5 --- /dev/null +++ b/crates/sandbox/src/lib.rs @@ -0,0 +1,63 @@ +//! The sandbox machines, written in Rust against `dunky-core` — the Rust twins of the TS +//! machines in `sandbox/shared/src`, following the same spec: +//! +//! - [`palette`] — the command palette, a faithful port of `sandbox/shared/src/machine.ts` +//! (computed filtering, logical navigation). +//! - [`dialog`] — open / closing / closed with a context-driven exit delay (`after`). +//! +//! `crates/uniffi` runs the palette on React Native. With the `wasm` feature, both are JS +//! classes too (`pnpm build:wasm` builds them to `crates/sandbox/pkg`): every sandbox +//! runs the palette on them with `?machine=rust` (`--machine=rust` in the terminal). + +pub mod dialog; +pub mod palette; + +#[cfg(all(feature = "wasm", not(target_arch = "wasm32")))] +pub use exports::typescript; + +#[cfg(feature = "wasm")] +mod exports { + use dunky_core::{Config, Machine}; + use wasm_bindgen::prelude::*; + + use crate::{dialog, palette}; + + // One config per machine type, shared by every instance (the shape a real app has). + thread_local! { + static PALETTE: Config = palette::config(Vec::new()); + static DIALOG: Config = dialog::config(0); + } + + dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // Malformed commands throw: the host maps result indices onto its own array. + dunky_wasm::from_js::>(commands) + .map(|commands| PALETTE.with(|c| Machine::with_context(c, palette::context(commands)))) + } + computed { + palette::RESULTS => Vec, + palette::ACTIVE_ID => Option, + } + // Only the JS side's `results` mapping reads it: not part of the machine's types. + internal { + palette::RESULT_INDICES => Vec, + } + } + + dunky_wasm::export_machine! { + /// A dialog whose `closing` phase lasts `exitMs`. + pub struct DialogMachine(dialog::Dialog); + new(exit_ms: u32) { + DIALOG.with(|c| Machine::with_context(c, dialog::DialogCtx { exit_ms, open_count: 0 })) + } + computed {} + } + + /// The classes' TS types (`examples/typescript.rs`, run by `pnpm build:wasm`). + #[cfg(not(target_arch = "wasm32"))] + pub fn typescript() -> String { + PALETTE.with(PaletteMachine::typescript) + &DIALOG.with(DialogMachine::typescript) + } +} diff --git a/crates/sandbox/src/palette.rs b/crates/sandbox/src/palette.rs new file mode 100644 index 0000000..cb6a8e5 --- /dev/null +++ b/crates/sandbox/src/palette.rs @@ -0,0 +1,333 @@ +//! The command palette — a faithful port of `sandbox/shared/src/machine.ts`. +//! +//! Two states (`closed` / `open`); all the interesting logic (filter, navigate with +//! wraparound, clamp) lives in context + computed, driven by logical events. A renderer +//! never computes an index: it sends `move` / `query.set` and reads `results` / `activeId`. + +use dunky_core::{ComputedKey, Config, Context, Event, State, TsType, Types}; +use serde::{Deserialize, Serialize}; + +#[derive(TsType, Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Command { + pub id: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub group: Option, +} + +/// Stamped on every execute; the nonce makes each execution a distinct value. +#[derive(TsType, Clone, Debug, PartialEq, Serialize)] +pub struct Executed { + pub id: String, + pub nonce: u32, +} + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +pub enum PaletteState { + Closed, + Open, +} + +#[derive(TsType, Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MoveTo { + Up, + Down, + First, + Last, +} + +#[derive(Event, Clone, Debug, Deserialize)] +#[event(deserialize)] +#[serde(tag = "type")] +pub enum PaletteEvent { + #[serde(rename = "open")] + Open, + #[serde(rename = "close")] + Close, + #[serde(rename = "query.set")] + QuerySet { query: String }, + #[serde(rename = "move")] + Move { to: MoveTo }, + #[serde(rename = "highlight")] + Highlight { index: i64 }, + #[serde(rename = "execute")] + Execute, +} + +#[derive(Context, Clone, Debug, PartialEq)] +#[context(serialize)] +pub struct PaletteCtx { + /// Every command, in source order. Filtering is derived, never stored. + pub commands: Vec, + pub query: String, + /// Highlighted row, as an index into the FILTERED list. + pub active_index: u32, + pub last_executed: Option, +} + +pub struct Palette; + +impl Types for Palette { + type State = PaletteState; + type Event = PaletteEvent; + type Context = PaletteCtx; +} + +/// The filtered list — what every renderer reads. +pub const RESULTS: ComputedKey> = ComputedKey::new(0); +/// The highlighted command's id, clamped to the current results. +pub const ACTIVE_ID: ComputedKey> = ComputedKey::new(1); +/// The filtered list as indices into `commands` — lets a JS host keep its own command +/// objects and only receive numbers across the boundary. +pub const RESULT_INDICES: ComputedKey> = ComputedKey::new(2); + +/// Subsequence match on the lowercased label ("gpr" matches "Go to Pull Requests"). +pub fn matches(query: &str, label: &str) -> bool { + let q = query.trim().to_lowercase(); + q.is_empty() || is_subsequence(&q, &label.to_lowercase()) +} + +pub fn filter_commands(commands: &[Command], query: &str) -> Vec { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return commands.to_vec(); + } + commands + .iter() + .filter(|c| is_subsequence(&q, &c.label.to_lowercase())) + .cloned() + .collect() +} + +pub fn filter_indices(commands: &[Command], query: &str) -> Vec { + let q = query.trim().to_lowercase(); + commands + .iter() + .enumerate() + .filter(|(_, c)| q.is_empty() || is_subsequence(&q, &c.label.to_lowercase())) + .map(|(i, _)| i as u32) + .collect() +} + +fn is_subsequence(needle: &str, haystack: &str) -> bool { + let mut needle = needle.chars().peekable(); + for ch in haystack.chars() { + match needle.peek() { + Some(n) if *n == ch => { + needle.next(); + } + Some(_) => {} + None => break, + } + } + needle.peek().is_none() +} + +/// A fresh palette context over `commands` (pair with a shared config via +/// `Machine::with_context`). +pub fn context(commands: Vec) -> PaletteCtx { + PaletteCtx { + commands, + query: String::new(), + active_index: 0, + last_executed: None, + } +} + +pub fn config(commands: Vec) -> Config { + use PaletteEventKind as E; + use PaletteState::{Closed, Open}; + let mut b = Config::::builder(Closed, context(commands)); + + let results = b.computed("results", |c| { + filter_commands(c.context.commands(), c.context.query()) + }); + let active_id = b.computed("activeId", move |c| { + let results = c.computed(results); + let last = results.len().checked_sub(1)?; + let i = (*c.context.active_index() as usize).min(last); + Some(results[i].id.clone()) + }); + let indices = b.computed("resultIndices", |c| { + filter_indices(c.context.commands(), c.context.query()) + }); + assert_eq!( + (results.id(), active_id.id(), indices.id()), + (RESULTS.id(), ACTIVE_ID.id(), RESULT_INDICES.id()) + ); + + // Set the query and reset the highlight to the top of the new result set. + b.action("setQuery", |p| { + if let Some(PaletteEvent::QuerySet { query }) = p.event() { + p.set_context(PaletteCtx::patch().query(query.clone()).active_index(0u32)); + } + }); + // Move within the FILTERED list, wrapping at both ends. + b.action("move", |p| { + let Some(PaletteEvent::Move { to }) = p.event() else { + return; + }; + let count = p.computed(RESULTS).len() as u32; + if count == 0 { + return; + } + let cur = p.context().active_index; + let next = match to { + MoveTo::First => 0, + MoveTo::Last => count - 1, + MoveTo::Down => (cur + 1) % count, + MoveTo::Up => (cur + count - 1) % count, + }; + p.set_context(PaletteCtx::patch().active_index(next)); + }); + // Jump straight to a row (pointer hover / click). + b.action("highlight", |p| { + let Some(PaletteEvent::Highlight { index }) = p.event() else { + return; + }; + let count = p.computed(RESULTS).len() as i64; + if count == 0 { + return; + } + let i = (*index).clamp(0, count - 1) as u32; + p.set_context(PaletteCtx::patch().active_index(i)); + }); + // Stamp the highlighted command as executed; no-op when nothing matches. + b.action("execute", |p| { + let Some(id) = (*p.computed(ACTIVE_ID)).clone() else { + return; + }; + let nonce = p.context().last_executed.as_ref().map_or(0, |e| e.nonce) + 1; + p.set_context(PaletteCtx::patch().last_executed(Executed { id, nonce })); + }); + // Each open starts fresh. + b.action("reset", |p| { + p.set_context(PaletteCtx::patch().query("").active_index(0u32)) + }); + + b.state(Closed, |s| { + s.on(E::Open, |t| t.target(Open).action("reset")) + }); + b.state(Open, |s| { + s.on(E::Close, |t| t.target(Closed)) + .on(E::QuerySet, |t| t.action("setQuery")) + .on(E::Move, |t| t.action("move")) + .on(E::Highlight, |t| t.action("highlight")) + // Stamp the selection, then close. The connector's reaction observes the stamp. + .on(E::Execute, |t| t.target(Closed).action("execute")) + }); + b.build() +} + +/// The demo command set from `sandbox/shared/src/commands.ts`. +pub fn demo_commands() -> Vec { + let c = |id: &str, label: &str, hint: Option<&str>, group: &str| Command { + id: id.into(), + label: label.into(), + hint: hint.map(Into::into), + group: Some(group.into()), + }; + vec![ + c("home", "Go to Dashboard", Some("g h"), "Navigation"), + c("issues", "Go to Issues", Some("g i"), "Navigation"), + c("prs", "Go to Pull Requests", Some("g p"), "Navigation"), + c("settings", "Open Settings", Some(","), "Navigation"), + c("new-issue", "Create New Issue", Some("c i"), "Actions"), + c("new-pr", "Open a Pull Request", Some("c p"), "Actions"), + c("invite", "Invite Teammate", None, "Actions"), + c("theme-light", "Theme: Light", None, "Preferences"), + c("theme-dark", "Theme: Dark", None, "Preferences"), + c("theme-system", "Theme: System", None, "Preferences"), + c("logout", "Log Out", None, "Account"), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use dunky_core::Machine; + + fn open_palette() -> Machine { + let m = Machine::new(&config(demo_commands())); + m.start(); + m.send(PaletteEvent::Open); + m + } + + #[test] + fn filters_by_subsequence_and_resets_highlight() { + let m = open_palette(); + m.send(PaletteEvent::Move { to: MoveTo::Down }); + assert_eq!(m.context().active_index, 1); + m.send(PaletteEvent::QuerySet { + query: "gpr".into(), + }); + assert_eq!(m.context().active_index, 0); + let results = m.computed(RESULTS); + assert_eq!( + results.iter().map(|c| c.id.as_str()).collect::>(), + ["prs"] + ); + assert_eq!(*m.computed(RESULT_INDICES), vec![2]); + } + + #[test] + fn navigation_wraps_and_execute_stamps_then_closes() { + let m = open_palette(); + m.send(PaletteEvent::Move { to: MoveTo::Up }); + assert_eq!(*m.computed(ACTIVE_ID), Some("logout".to_string())); + m.send(PaletteEvent::Execute); + assert_eq!(m.state(), PaletteState::Closed); + assert_eq!( + m.context().last_executed, + Some(Executed { + id: "logout".into(), + nonce: 1 + }) + ); + } + + #[test] + fn payload_events_rebuild_from_kind_and_fields_only() { + use dunky_core::{DeserializeEvent, EventEnum}; + let decode = |kind: PaletteEventKind, value: serde_json::Value| { + PaletteEvent::deserialize_payload(PaletteEvent::kind_index(kind), value) + }; + let set = decode( + PaletteEventKind::QuerySet, + serde_json::json!({ "type": "query.set", "query": "gpr" }), + ); + assert!(matches!(set, Ok(PaletteEvent::QuerySet { ref query }) if query == "gpr")); + let moved = decode(PaletteEventKind::Move, serde_json::json!({ "to": "last" })); + assert!(matches!(moved, Ok(PaletteEvent::Move { to: MoveTo::Last }))); + assert!(matches!( + decode(PaletteEventKind::Open, serde_json::json!({})), + Ok(PaletteEvent::Open) + )); + assert!( + decode( + PaletteEventKind::Highlight, + serde_json::json!({ "index": "x" }) + ) + .is_err() + ); + } + + #[test] + fn computed_results_are_memoized_until_an_input_changes() { + let m = open_palette(); + let a = m.computed(RESULTS); + let b = m.computed(RESULTS); + assert!(std::rc::Rc::ptr_eq(&a, &b)); + let v1 = m.computed_version(RESULTS.id()); + m.send(PaletteEvent::Move { to: MoveTo::Down }); // active_index only + assert_eq!(m.computed_version(RESULTS.id()), v1); + m.send(PaletteEvent::QuerySet { + query: "theme".into(), + }); + assert_ne!(m.computed_version(RESULTS.id()), v1); + } +} diff --git a/crates/uniffi/.gitignore b/crates/uniffi/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/crates/uniffi/.gitignore @@ -0,0 +1 @@ +/target diff --git a/crates/uniffi/Cargo.lock b/crates/uniffi/Cargo.lock new file mode 100644 index 0000000..ab90c1c --- /dev/null +++ b/crates/uniffi/Cargo.lock @@ -0,0 +1,721 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "dunky-core" +version = "0.1.0" +dependencies = [ + "dunky-macros", + "serde", +] + +[[package]] +name = "dunky-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunky-sandbox" +version = "0.1.0" +dependencies = [ + "dunky-core", + "serde", +] + +[[package]] +name = "dunky-uniffi" +version = "0.1.0" +dependencies = [ + "dunky-core", + "dunky-sandbox", + "serde_json", + "uniffi", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "smawk", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uniffi" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eefd5468602930da46b1f49d3448c6dfc2e81295f93120f23f8174fd70267f" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a0c9b375d32e1365cdb2bdd7cb495eecf6fac851ddbad077412b4ee1888514" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec017b112701681f6fbbe5d92014b5c468eb0b177a94389de03ceec40665095" +dependencies = [ + "anyhow", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4641669b48fefbc5e80ff08c5004d9c7617fb91232131a6734ab6712779cb04c" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb8617ee814de22caf7417bf514715ba0b3f46bd9d5a5d794413fd8282cb737" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58d5b94fc92803d21b2928bd15c6f06e57609b95caf98ea561c99cda1b6d2a25" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "032739b3ec725576914c15899dedaf080163ced86b6934566c20ec2b20ce90ca" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0a1d0a0252ce1af9e8ce78ba67ac0d8937fb2bedaf10cbddd43d3614d06ec6" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/uniffi/Cargo.toml b/crates/uniffi/Cargo.toml new file mode 100644 index 0000000..befe3a7 --- /dev/null +++ b/crates/uniffi/Cargo.toml @@ -0,0 +1,24 @@ +# React Native binding of the engine, built by uniffi-bindgen-react-native. +# Standalone on purpose: the root workspace excludes it, so its mobile-only +# toolchain can never break the other crates' builds. +[package] +name = "dunky-uniffi" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/dunky-dev/state-machine" +publish = false + +[lib] +name = "dunky_uniffi" +# staticlib: what ubrn links into the iOS xcframework and the Android turbo-module. +# cdylib: what ubrn's `jsi2` flavour loads instead, keeping that path open. +crate-type = ["cdylib", "staticlib"] + +[dependencies] +dunky-core = { path = "../core" } +dunky-sandbox = { path = "../sandbox" } +serde_json = "1" +# Must stay on the uniffi minor that uniffi-bindgen-react-native is built with +# (ubrn 0.31.0-6 -> uniffi 0.31): the generated C++/TS check the contract version. +uniffi = "=0.31" diff --git a/crates/uniffi/src/lib.rs b/crates/uniffi/src/lib.rs new file mode 100644 index 0000000..70c2203 --- /dev/null +++ b/crates/uniffi/src/lib.rs @@ -0,0 +1,355 @@ +//! React Native binding of the engine, exported over JSI by +//! uniffi-bindgen-react-native: the sandbox palette written in Rust. +//! +//! Events go in by kind index; a u32 change mask comes out, and timers come out as +//! flat commands (`dunky_core::protocol`). Values cross as JSON strings instead of JS +//! values. It still needs a TS facade over this handle (like `fromWasm` for wasm). + +use std::mem::ManuallyDrop; +use std::sync::Arc; +use std::thread::{self, ThreadId}; + +use dunky_core::{ + Config, Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, Types, +}; +use dunky_sandbox::palette::{self, Palette, PaletteEvent}; + +uniffi::setup_scaffolding!(); + +// The change mask and the command encoding: `dunky_core::protocol`. +use dunky_core::protocol::{FIELD_SHIFT, HIGH_FIELD, encode_commands, take_change_mask}; + +/// Bad input from JS, thrown as an `Error` with this message. +#[derive(Debug, uniffi::Error)] +#[uniffi(flat_error)] +pub enum MachineError { + Invalid(String), +} + +impl std::fmt::Display for MachineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self::Invalid(message) = self; + f.write_str(message) + } +} + +impl std::error::Error for MachineError {} + +/// Confines a `!Send` value to the thread that created it. uniffi objects must be +/// `Send + Sync`, but the engine is single-threaded (`Rc`/`RefCell`). JSI calls arrive +/// on the JS thread, so a call from any other thread is a bug: it panics instead of +/// racing. +struct ThreadBound { + owner: ThreadId, + value: ManuallyDrop, +} + +// SAFETY: `value` is only reachable through `get`, which panics off the owner thread, +// and `drop` never drops it on another thread. +unsafe impl Send for ThreadBound {} +unsafe impl Sync for ThreadBound {} + +impl ThreadBound { + fn new(value: T) -> Self { + Self { + owner: thread::current().id(), + value: ManuallyDrop::new(value), + } + } + + fn get(&self) -> &T { + assert!( + thread::current().id() == self.owner, + "[machine] called off the thread that created it" + ); + &self.value + } +} + +impl Drop for ThreadBound { + fn drop(&mut self) { + // Off the owner thread (a GC finalizer, say) the value leaks: freeing the `Rc` + // graph there would race the owner. + if thread::current().id() == self.owner { + // SAFETY: dropped once, here; `value` is never read again. + unsafe { ManuallyDrop::drop(&mut self.value) } + } + } +} + +/// The event type name at `kind`, or the error for an unknown kind. +fn event_name(kind: u32) -> Result<&'static str, MachineError> { + ::KIND_NAMES + .get(kind as usize) + .copied() + .ok_or_else(|| MachineError::Invalid(format!("[machine] no event type #{kind}"))) +} + +/// Appends field `index` as JSON. `None` is `null`, matching the TS machines. +fn write_field(context: &C, index: usize, out: &mut Vec) { + let start = out.len(); + let mut serializer = serde_json::Serializer::new(&mut *out); + if context.serialize_field(index, &mut serializer).is_err() { + out.truncate(start); + out.extend_from_slice(b"null"); + } +} + +/// Every field, in order, as a JSON array. +fn fields_json(context: &C) -> String { + let mut out = vec![b'[']; + for i in 0..C::FIELDS.len() { + if i > 0 { + out.push(b','); + } + write_field(context, i, &mut out); + } + out.push(b']'); + String::from_utf8(out).expect("serde_json writes UTF-8") +} + +/// The static description the TS adapter reads once per machine type. +fn meta_json(machine: &Machine) -> String { + let config = machine.config(); + let states = ::NAMES; + let events = ::KIND_NAMES; + let unit_events: Vec = (0..events.len()) + .map(|i| { + let kind = ::kind_from_index(i); + ::from_kind(kind).is_some() + }) + .collect(); + let tags: Vec<&[&str]> = (0..states.len()) + .map(|i| config.tags(::from_index(i))) + .collect(); + serde_json::json!({ + "states": states, + "events": events, + "unitEvents": unit_events, + "fields": ::FIELDS, + "computed": config.computed_names().collect::>(), + "tags": tags, + "fieldShift": FIELD_SHIFT, + "highField": HIGH_FIELD, + }) + .to_string() +} + +thread_local! { + // One config shared by every palette, as in the sandbox wasm. Palettes never leave + // the JS thread, so a thread-local is enough. + static PALETTE: Config = palette::config(Vec::new()); +} + +/// The sandbox command palette (`dunky_sandbox::palette`), behind the handle protocol. +#[derive(uniffi::Object)] +pub struct PaletteMachine { + machine: ThreadBound>, +} + +#[uniffi::export] +impl PaletteMachine { + /// `commands_json`: the commands, a JSON array of `{ id, label, hint?, group? }`. + #[uniffi::constructor] + pub fn new(commands_json: String) -> Result, MachineError> { + let commands = serde_json::from_str(&commands_json) + .map_err(|e| MachineError::Invalid(format!("[machine] bad commands: {e}")))?; + let machine = + PALETTE.with(|config| Machine::with_context(config, palette::context(commands))); + Ok(Arc::new(Self { + machine: ThreadBound::new(machine), + })) + } + + /// Payload-less event by kind index. Returns the change mask. + pub fn send_kind(&self, kind: u32) -> Result { + let name = event_name::(kind)?; + let event = PaletteEvent::from_kind(PaletteEvent::kind_from_index(kind as usize)) + .ok_or_else(|| { + MachineError::Invalid(format!( + "[machine] event \"{name}\" carries data; use sendEvent" + )) + })?; + let machine = self.machine.get(); + machine.send(event); + Ok(take_change_mask(machine)) + } + + /// An event with a payload, by kind index; `json` holds its fields. Returns the + /// change mask. + pub fn send_payload_json(&self, kind: u32, json: String) -> Result { + let name = event_name::(kind)?; + let bad = |e: serde_json::Error| { + MachineError::Invalid(format!("[machine] bad \"{name}\" event: {e}")) + }; + let payload: serde_json::Value = serde_json::from_str(&json).map_err(bad)?; + let event = PaletteEvent::deserialize_payload(kind as usize, payload).map_err(bad)?; + let machine = self.machine.get(); + machine.send(event); + Ok(take_change_mask(machine)) + } + + pub fn state_index(&self) -> u32 { + self.machine.get().state().index() as u32 + } + + /// Context field `index` as JSON. + pub fn field_json(&self, index: u32) -> String { + let mut out = Vec::new(); + write_field(&*self.machine.get().context(), index as usize, &mut out); + String::from_utf8(out).expect("serde_json writes UTF-8") + } + + /// Every context field, in order, as a JSON array (the adapter's initial mirror). + pub fn fields_json(&self) -> String { + fields_json(&*self.machine.get().context()) + } + + /// Computed `index` as JSON (`results`, `activeId`, `resultIndices`); `null` for + /// any other index. + pub fn computed_json(&self, index: u32) -> String { + let machine = self.machine.get(); + let id = index as usize; + let json = if id == palette::RESULTS.id() { + serde_json::to_string(&*machine.computed(palette::RESULTS)) + } else if id == palette::ACTIVE_ID.id() { + serde_json::to_string(&*machine.computed(palette::ACTIVE_ID)) + } else if id == palette::RESULT_INDICES.id() { + serde_json::to_string(&*machine.computed(palette::RESULT_INDICES)) + } else { + return "null".to_string(); + }; + json.unwrap_or_else(|_| "null".to_string()) + } + + /// Changes exactly when computed `index` changes. + pub fn computed_version(&self, index: u32) -> f64 { + self.machine.get().computed_version(index as usize) as f64 + } + + pub fn start(&self) -> u32 { + let machine = self.machine.get(); + machine.start(); + take_change_mask(machine) + } + + pub fn stop(&self) -> u32 { + let machine = self.machine.get(); + machine.stop(); + take_change_mask(machine) + } + + pub fn fire_timer(&self, id: u32) -> u32 { + let machine = self.machine.get(); + machine.fire_timer(id); + take_change_mask(machine) + } + + /// Timer commands since the last call: `[op, id, ms, …]`, op 1 = start, 2 = cancel. + pub fn take_commands(&self) -> Vec { + encode_commands(&self.machine.get().take_commands()) + } + + /// Names, payload-less events, tags and the mask layout, as JSON. + pub fn meta_json(&self) -> String { + meta_json(self.machine.get()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dunky_sandbox::palette::{PaletteCtx, PaletteEventKind}; + use serde_json::{Value, json}; + use std::panic::{AssertUnwindSafe, catch_unwind}; + use std::sync::atomic::{AtomicBool, Ordering}; + + fn kind(kind: PaletteEventKind) -> u32 { + PaletteEvent::kind_index(kind) as u32 + } + + fn palette() -> Arc { + PaletteMachine::new(serde_json::to_string(&palette::demo_commands()).unwrap()).unwrap() + } + + #[test] + fn thread_bound_refuses_other_threads_and_leaks_there() { + struct Flag(Arc); + impl Drop for Flag { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let dropped = Arc::new(AtomicBool::new(false)); + let bound = ThreadBound::new(Flag(dropped.clone())); + let refused = thread::spawn(move || { + let refused = catch_unwind(AssertUnwindSafe(|| { + bound.get(); + })) + .is_err(); + drop(bound); + refused + }) + .join() + .unwrap(); + assert!(refused); + assert!(!dropped.load(Ordering::SeqCst)); + + drop(ThreadBound::new(Flag(dropped.clone()))); + assert!(dropped.load(Ordering::SeqCst)); + } + + #[test] + fn values_cross_as_json() { + let m = palette(); + m.start(); + m.send_kind(kind(PaletteEventKind::Open)).unwrap(); + m.send_payload_json( + kind(PaletteEventKind::QuerySet), + r#"{"type":"query.set","query":"gpr"}"#.into(), + ) + .unwrap(); + + assert_eq!(m.field_json(PaletteCtx::QUERY.index() as u32), r#""gpr""#); + assert_eq!( + m.field_json(PaletteCtx::LAST_EXECUTED.index() as u32), + "null" + ); + assert_eq!(m.computed_json(palette::RESULT_INDICES.id() as u32), "[2]"); + assert_eq!(m.computed_json(palette::ACTIVE_ID.id() as u32), r#""prs""#); + let fields: Value = serde_json::from_str(&m.fields_json()).unwrap(); + assert_eq!(fields.as_array().unwrap().len(), PaletteCtx::FIELDS.len()); + assert_eq!(fields[PaletteCtx::QUERY.index()], "gpr"); + } + + #[test] + fn bad_input_throws_instead_of_panicking() { + assert!(PaletteMachine::new("{".into()).is_err()); + let m = palette(); + assert!(m.send_kind(99).is_err()); + assert!(m.send_kind(kind(PaletteEventKind::QuerySet)).is_err()); + assert!( + m.send_payload_json(kind(PaletteEventKind::Highlight), r#"{"index":"x"}"#.into()) + .is_err() + ); + } + + #[test] + fn meta_describes_the_palette() { + let meta: Value = serde_json::from_str(&palette().meta_json()).unwrap(); + assert_eq!( + meta, + json!({ + "states": ["closed", "open"], + "events": ["open", "close", "query.set", "move", "highlight", "execute"], + "unitEvents": [true, true, false, false, false, true], + "fields": ["commands", "query", "activeIndex", "lastExecuted"], + "computed": ["results", "activeId", "resultIndices"], + "tags": [[], []], + "fieldShift": 2, + "highField": 29, + }) + ); + } +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml new file mode 100644 index 0000000..11ee9a4 --- /dev/null +++ b/crates/wasm/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dunky-wasm" +description = "Run dunky-core machines from JavaScript: export a Rust machine to a JS class with one macro." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +dunky-core = { workspace = true } +wasm-bindgen = { workspace = true } +js-sys = { workspace = true } +serde = { workspace = true } +serde-wasm-bindgen = { workspace = true } diff --git a/crates/wasm/README.md b/crates/wasm/README.md new file mode 100644 index 0000000..7a2b752 --- /dev/null +++ b/crates/wasm/README.md @@ -0,0 +1,61 @@ +# dunky-wasm + +Run [`dunky-core`](../core) machines from JavaScript. Export a machine written in Rust +with one macro; wrap an instance with `fromWasm` from `@dunky.dev/state-machine-wasm`, and +the connector, `useMachine` and every target consume it like a machine written in TS. + +```rust +use dunky_core::{Config, Machine}; +use wasm_bindgen::prelude::*; + +thread_local!(static CONFIG: Config = palette::config(Vec::new())); + +dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // A Result body throws its error from the JS constructor. + dunky_wasm::from_js(commands) + .map(|c| CONFIG.with(|config| Machine::with_context(config, palette::context(c)))) + } + computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } + // Optional: served too, but left out of the machine's TS types. + internal { palette::RESULT_INDICES => Vec } +} +``` + +Build it, add its TS types, then wrap it: + +```bash +cargo build --target wasm32-unknown-unknown --release +wasm-bindgen --target web --weak-refs --reference-types --out-dir pkg target/wasm32-unknown-unknown/release/my_machines.wasm +# Natively, PaletteMachine::typescript(&config) returns the class's TS types, generated +# from the Rust types: print them from a small binary or example, onto the .d.ts. +cargo run --example typescript >> pkg/my_machines.d.ts +``` + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import init, { PaletteMachine } from './pkg/my_machines.js' + +await init() +// Machine<'closed' | 'open', { commands: ...; query: string; ... }, ...>: from Rust. +const palette = fromWasm(new PaletteMachine(commands)) +``` + +The data types the machine shows JS derive `TsType` (`#[derive(TsType)]`), next to +their serde derives. + +The crate using the macro also depends on `wasm-bindgen` (the version this crate pins), +and its event type derives `#[event(deserialize)]`. + +What the protocol guarantees: [`SPEC.md`](./SPEC.md). + +## Why two engines, not Rust everywhere + +We tried one engine: the TS package as a thin layer over the Rust engine, compiled to wasm. +It worked, but it was not the win it looked like. Part of the TS side still had to mirror +the Rust engine — the context, the computed cache and the transition steps stay in JS, +next to the user code — and every event still paid the JS↔wasm bridge, so it gave up a +bit of speed. So we maintain both engines against one spec: each one runs natively, and +wasm is for machines written in Rust. diff --git a/crates/wasm/SPEC.md b/crates/wasm/SPEC.md new file mode 100644 index 0000000..ab36bf4 --- /dev/null +++ b/crates/wasm/SPEC.md @@ -0,0 +1,125 @@ +# SPEC - `dunky-wasm` + +## Overview + +`dunky-wasm` runs machines written in Rust ([`dunky-core`](../core/SPEC.md)) from +JavaScript. `export_machine!` turns a machine type into a JS class, in the author's own +wasm module; [`@dunky.dev/state-machine-wasm`](../../packages/wasm) wraps an instance +with `fromWasm` in the `Machine` interface, so the connector and every target consume +it next to the machines of the TS engine. + +``` + Rust machine (dunky-core types) + | + | export_machine! (your crate, your wasm module) + v + exported class + | the protocol + | events in .... by kind number + the JS event + | notify ....... once per change, synchronously + | timers ....... on the host clock + v + fromWasm: the JS host + the Machine facade (@dunky.dev/state-machine-wasm) + | + v + connect() / useMachine / every target +``` + +## Intent + +Write a machine once in Rust and use it in a Rust program, on React Native, and in JS, +with the same behavior. In JS the machine still runs entirely in Rust; JS keeps a mirror +of its context and fans each change out to the subscribers. + +## Scope & boundaries + +**In scope.** The protocol; the export macro; the conversion helpers between JS values +and Rust values; the TS types of an exported class. + +**Out of scope — invariants, not preferences.** + +- **No facade.** The `Machine` interface, the names (states, event types, fields) and the + subscribers live in `@dunky.dev/state-machine-wasm`. +- **No TS machines.** Machines written in TypeScript run on the TS engine + (`@dunky.dev/state-machine`), never on this binding. +- **No platform.** Only the JS host's own functions are called: no DOM, no `window`. +- **No strong JS references.** Rust holds no JS object of a machine (see below). +- **Dunky-specific.** This is Dunky's JS binding, not a general Rust/JS bridge (that is + `wasm-bindgen`, which it builds on). + +## The protocol + +### The facade stays in JS + +- Rust calls the host only during a call into the machine, so JS keeps the **facade** + whose call is in progress itself (saved and restored around each call, as calls + nest), and every host function runs for it. No reference to the facade crosses: a + call carries only numbers (and the JS event). Rust holds no JS object of a machine, so + no reference cycle through wasm keeps it alive: only JS references do. + +### The host + +- One JS host object serves every machine of a module. Its functions take numbers and + return status codes, so each call crosses the boundary once, with nothing to box or + free. They never throw: each catches what a subscriber throws, keeps the first error + on the facade, and returns a failure status. +- `notify(state, changedLo, changedHi)`: once per effective change, in order, + synchronously, with the current state and the context fields changed since the + previous notify (the low and high 32 bits of the mask). The binding hears the changes + through the engine's own subscription, so the engine needs no hook for it. +- `startTimer(id, ms)` returns the host's timeout; `cancelTimer(timeout)` cancels it. A + due timer calls back into the machine with its id. + +### Calls + +- Each call into a machine runs to completion and returns a status: 0, 1 when a + subscriber failed during this call (the facade holds the error), 3 when the binding + failed (a malformed event, a second attach: its message comes from `takeFailure`). +- A subscriber's failure does not stop the step: the call finishes, then the facade + throws the error. +- The **outermost** call also runs the timer commands. A **nested** call (a subscriber + calling back in) reports only a failure it caused; the outer call reports it too. +- After a failure the machine stays usable. + +### Timers + +- The engine's timer commands run on the host clock. A pending timer is held by the + host's closure, so it keeps its machine alive, like a JS timer holds `this`. + +### Values + +- Values convert with serde: `None` becomes `null`. A derived value is serialized only + when its version changed, and the same JS value is served until then, so its identity + marks a change. + +## The export macro + +- The macro takes the class name, the machine type, a constructor body, and the derived + values the class serves, each with its key and its type; an optional `internal` block + lists derived values it serves but leaves out of its types. A constructor body that + returns an error throws it from the JS constructor; bad arguments read as + `[machine] bad argument: ...`. +- The class implements the protocol: attach to a host (once), dispatch by kind, start, + stop, report whether it runs, report the state, read a context field, read a derived + value, take a due timer, and report the names (states, event types, fields, derived + values, tags per state) the facade maps the numbers onto. +- The class works on its own too: `start()`, `send(kind, event)`. Once attached, call it + through its `fromWasm` facade only: the host functions run for the facade whose call + is in progress. +- An event kind without data is built from the number alone; any other kind is read from + the JS event's fields. The event type opts in to that with `#[event(deserialize)]`. +- The crate using the macro depends on `wasm-bindgen` at the version this crate pins. +- **Types.** Natively, the class reports its TS types (`typescript(&config)`), generated + from the machine's Rust types: the states, the context, the events, and the derived + values outside `internal`. The build appends them to the module's `.d.ts` as a + `__types` member of the class, which exists only in the types; `fromWasm` infers the + machine's types from it. A Rust type change changes the TS types on the next build. +- Freeing the class frees the machine: the engine subscription that feeds `notify` is + undone when the class drops. + +## Edge cases that carry design meaning + +- Attaching a machine to a host twice is a binding failure. +- An unknown event kind is an error; the facade filters unknown event types before. +- A malformed payload is an error that names the event type. +- Commands emitted before a machine was attached run when it attaches. diff --git a/crates/wasm/src/bridge.rs b/crates/wasm/src/bridge.rs new file mode 100644 index 0000000..f849271 --- /dev/null +++ b/crates/wasm/src/bridge.rs @@ -0,0 +1,188 @@ +//! What every machine exported to JS (`export_machine!`) shares: the JS host it calls +//! back into, the failure flag of a call, and the host clock that runs its timers. +//! +//! The host is one JS object per module. Its functions take numbers and return status +//! codes, so a call crosses the boundary once, with nothing to box or free. They run for +//! the machine whose call is in progress, which JS keeps track of itself: no reference to +//! the facade crosses, and Rust holds no JS object of a machine, so no JS <-> wasm +//! reference cycle keeps a machine alive. +//! +//! JS owns the errors: a host function catches what a subscriber throws, keeps it, and +//! returns a failure status. Rust finishes the call and reports the status back. + +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use dunky_core::{Changes, Command, Machine, Types}; +use wasm_bindgen::prelude::*; + +/// A call finished. +pub const OK: u32 = 0; +/// User code failed during the call; the JS side holds the error. +pub const FAILED: u32 = 1; +/// The binding failed (a malformed event, a second attach): read `takeFailure()`. +pub const ENGINE_FAILED: u32 = 3; + +#[wasm_bindgen] +extern "C" { + /// The JS host object. + #[derive(Clone)] + pub type HostObject; + + /// Once per effective change. Returns 0, or 1 when an observer threw. + #[wasm_bindgen(method)] + fn notify(this: &HostObject, state: u32, lo: u32, hi: u32) -> u32; + + /// Returns the host's timeout. + #[wasm_bindgen(method, js_name = startTimer)] + fn start_timer(this: &HostObject, id: u32, ms: u32) -> JsValue; + + #[wasm_bindgen(method, js_name = cancelTimer)] + fn cancel_timer(this: &HostObject, timeout: &JsValue); +} + +/// One machine's line to the JS host. +pub(crate) struct Link { + pub(crate) host: HostObject, + /// A host function failed during the current call. + failed: Cell, +} + +impl Link { + pub(crate) fn new(host: HostObject) -> Self { + Self { + host, + failed: Cell::new(false), + } + } + + pub(crate) fn fail(&self) { + self.failed.set(true); + } + + pub(crate) fn failed(&self) -> bool { + self.failed.get() + } + + pub(crate) fn notify(&self, state: u32, changes: Changes) { + let (lo, hi) = (changes.fields as u32, (changes.fields >> 32) as u32); + if self.host.notify(state, lo, hi) != OK { + self.fail(); + } + } +} + +/// A machine exported to JS: runs each call to completion, then the timer commands, and +/// reports a status. +pub struct Bridge { + machine: Machine, + link: OnceCell>, + /// Pending timers: the engine's timer id, and the host's timeout. + timers: RefCell>, + /// Nesting of calls (JS code calls back in); the outermost one runs the timers. + depth: Cell, + /// The binding's own failure, until JS takes it. + failure: RefCell>, +} + +impl Bridge { + pub fn new(machine: Machine) -> Self { + Self { + machine, + link: OnceCell::new(), + timers: RefCell::default(), + depth: Cell::new(0), + failure: RefCell::new(None), + } + } + + pub fn machine(&self) -> &Machine { + &self.machine + } + + /// Connect to the JS host. Returns false when already connected. + pub(crate) fn connect(&self, link: Rc) -> bool { + self.link.set(link).is_ok() + } + + /// Run `f` on the machine and report a status. The outermost call also runs the + /// timer commands. `FAILED` means user code failed during this very call: a nested + /// call (JS calling back in) does not report a failure from before it. + pub fn call(&self, f: impl FnOnce(&Machine)) -> u32 { + let depth = self.depth.get(); + let link = self.link.get(); + let failed_before = link.is_some_and(|l| l.failed()); + self.depth.set(depth + 1); + f(&self.machine); + self.depth.set(depth); + let failed = link.is_some_and(|l| l.failed()); + if depth > 0 { + return if failed && !failed_before { FAILED } else { OK }; + } + match link { + Some(link) => { + self.run_timers(link); + if link.failed.replace(false) { + FAILED + } else { + OK + } + } + None => OK, + } + } + + /// Keep the binding's `message` for `take_failure`. + pub fn fail(&self, message: String) -> u32 { + *self.failure.borrow_mut() = Some(message); + ENGINE_FAILED + } + + pub fn take_failure(&self) -> Option { + self.failure.borrow_mut().take() + } + + pub fn start(&self) -> u32 { + self.call(|m| m.start()) + } + + pub fn stop(&self) -> u32 { + self.call(|m| m.stop()) + } + + /// The host clock: timer `id` came due. + pub fn fire(&self, id: u32) -> u32 { + { + let mut timers = self.timers.borrow_mut(); + if let Some(pos) = timers.iter().position(|(t, _)| *t == id) { + timers.swap_remove(pos); + } + } + self.call(|m| m.fire_timer(id)) + } + + /// The core emits timer commands; the host runs them on its clock (`setTimeout`). + pub(crate) fn run_timers(&self, link: &Link) { + if !self.machine.has_commands() { + return; + } + for command in self.machine.take_commands() { + match command { + Command::StartTimer { id, ms } => { + let timeout = link.host.start_timer(id, ms); + self.timers.borrow_mut().push((id, timeout)); + } + Command::CancelTimer { id } => { + let timeout = { + let mut timers = self.timers.borrow_mut(); + let pos = timers.iter().position(|(t, _)| *t == id); + pos.map(|pos| timers.swap_remove(pos).1) + }; + if let Some(timeout) = timeout { + link.host.cancel_timer(&timeout); + } + } + } + } + } +} diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs new file mode 100644 index 0000000..0e1802c --- /dev/null +++ b/crates/wasm/src/lib.rs @@ -0,0 +1,382 @@ +//! `dunky-wasm` — run `dunky-core` machines from JavaScript. +//! +//! [`export_machine!`] turns a machine written in Rust into a JS class, in your own wasm +//! module. `@dunky.dev/state-machine-wasm` wraps an instance with `fromWasm`, so the +//! connector and every target — react, solid, native, opentui — consume it as a +//! `Machine`, next to the machines of the TS engine. +//! +//! A class speaks one protocol with that package's JS host: events in by kind number, +//! a `notify(state, changedLo, changedHi)` per effective change (synchronously, +//! like the TS subscribers), timers on the host clock (`startTimer` / `cancelTimer`, then +//! `fire(id)`), and a status code back from every call (see [`bridge`]). +//! +//! The crate using the macro must also depend on `wasm-bindgen`. +//! +//! A class also knows its TS types (`typescript`, natively): they come from the machine's +//! Rust types (`TsType`), and `pnpm build:wasm` adds them to the module's `.d.ts`, where +//! `fromWasm` reads them. + +use std::cell::RefCell; +use std::rc::Rc; + +use dunky_core::{ + ComputedKey, Context, DeserializeEvent, EventEnum, Machine, SerializeFields, StateEnum, + Subscription, Types, +}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; + +pub mod bridge; + +use bridge::Link; +pub use bridge::{Bridge, ENGINE_FAILED, FAILED, HostObject, OK}; + +#[doc(hidden)] +pub mod __private { + pub use dunky_core::{ComputedKey, Config, TsType}; + + use dunky_core::__private::{ts_key, ts_object}; + use dunky_core::{Context, Types}; + + /// The declaration that gives exported class `class` its machine's types: a + /// `__types` member, only in the types, that `fromWasm` reads. `computed` holds the + /// computed values of the types by id, with their TS types. + pub fn class_types( + class: &str, + config: &Config, + computed: &[(usize, String)], + ) -> String + where + T::State: TsType, + T::Event: TsType, + T::Context: TsType, + { + let names: Vec<&str> = config.computed_names().collect(); + let computed: Vec = computed + .iter() + .map(|(id, ts)| format!("{}: {ts}", ts_key(names[*id]))) + .collect(); + // The facade builds the context from the fields: none gives an empty object. + let empty = || String::from("Record"); + let context = if ::FIELDS.is_empty() { + empty() + } else { + ::ts() + }; + let computed = if computed.is_empty() { + empty() + } else { + ts_object(&computed) + }; + format!( + "\nexport interface {class} {{\n /** Generated from the Rust machine's types; `fromWasm` reads them. */\n readonly __types?: {{\n state: {};\n context: {};\n event: {};\n computed: {};\n }};\n}}\n", + ::ts(), + context, + ::ts(), + computed, + ) + } +} + +/// What an [`export_machine!`] constructor body may evaluate to: a machine, or a +/// `Result` whose error is thrown from the JS constructor. +pub trait IntoMachine { + fn into_machine(self) -> Result, JsError>; +} + +impl IntoMachine for Machine { + fn into_machine(self) -> Result, JsError> { + Ok(self) + } +} + +impl IntoMachine for Result, JsError> { + fn into_machine(self) -> Result, JsError> { + self + } +} + +/// Deserialize a JS value (constructor arguments, props). +pub fn from_js(value: JsValue) -> Result { + serde_wasm_bindgen::from_value(value).map_err(|e| { + let error: js_sys::Error = JsValue::from(e).unchecked_into(); + JsError::new(&format!( + "[machine] bad argument: {}", + String::from(error.message()) + )) + }) +} + +// `None` becomes `null`, matching the TS machines (`lastExecuted: null`, `activeId: null`). +fn serializer() -> serde_wasm_bindgen::Serializer { + serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true) +} + +/// Serialize any value for JS (plain objects, arrays, `null` for `None`). +pub fn to_js(value: &V) -> JsValue { + value.serialize(&serializer()).unwrap_or(JsValue::UNDEFINED) +} + +/// The implementation behind every class [`export_machine!`] generates. +pub struct Exported { + bridge: Bridge, + /// Per computed value: the version and the JS value last served. + computed: RefCell>>, + /// The engine subscription that tells JS each change (set on attach). It holds the + /// machine, so it is undone on drop: the class is freed, the cycle goes with it. + changes: RefCell>, +} + +impl Drop for Exported { + fn drop(&mut self) { + if let Some(sub) = self.changes.get_mut().take() { + sub.unsubscribe(); + } + } +} + +impl Exported +where + T::Event: DeserializeEvent, + T::Context: SerializeFields, +{ + pub fn new(machine: Machine) -> Self { + let count = machine.config().computed_names().count(); + Self { + bridge: Bridge::new(machine), + computed: RefCell::new(vec![None; count]), + changes: RefCell::new(None), + } + } + + pub fn machine(&self) -> &Machine { + self.bridge.machine() + } + + /// Connect to the JS host. Returns a status; a second attach is an engine failure. + /// From then on, call the machine through its `fromWasm` facade: the host functions + /// run for the facade whose call is in progress. + pub fn attach(&self, host: HostObject) -> u32 { + let link = Rc::new(Link::new(host)); + if !self.bridge.connect(link.clone()) { + return self.bridge.fail("[machine] already attached".into()); + } + // Once per effective change, in order, with what changed since the last one. + let machine = self.machine().clone(); + let sub = self.machine().subscribe(move || { + let changes = machine.take_changes(); + link.notify(machine.state().index() as u32, changes); + }); + *self.changes.borrow_mut() = Some(sub); + // Run what the machine did before it had a host (e.g. timers of a start in Rust). + self.bridge.call(|_| {}) + } + + /// Send event `kind`; `event` (the JS event object) supplies the payload, if any. + pub fn send(&self, kind: u32, event: JsValue) -> u32 { + let names = ::KIND_NAMES; + let Some(name) = names.get(kind as usize) else { + return self.bridge.fail(format!("[machine] no event type #{kind}")); + }; + let k = ::kind_from_index(kind as usize); + let event = match ::from_kind(k) { + Some(event) => event, + None => { + let deserializer = serde_wasm_bindgen::Deserializer::from(event); + match ::deserialize_payload( + kind as usize, + deserializer, + ) { + Ok(event) => event, + Err(e) => { + return self + .bridge + .fail(format!("[machine] bad \"{name}\" event: {e}")); + } + } + } + }; + self.bridge.call(|m| m.send(event)) + } + + pub fn start(&self) -> u32 { + self.bridge.start() + } + + pub fn stop(&self) -> u32 { + self.bridge.stop() + } + + pub fn fire(&self, id: u32) -> u32 { + self.bridge.fire(id) + } + + /// The engine's failure behind status 3. + pub fn take_failure(&self) -> Option { + self.bridge.take_failure() + } + + pub fn running(&self) -> bool { + self.machine().is_running() + } + + pub fn state(&self) -> u32 { + self.machine().state().index() as u32 + } + + pub fn field(&self, index: u32) -> JsValue { + self.machine() + .context() + .serialize_field(index as usize, &serializer()) + .unwrap_or(JsValue::UNDEFINED) + } + + /// The value of `key`, serialized only when it changed since the last read. + pub fn computed(&self, key: ComputedKey) -> JsValue { + let version = self.machine().computed_version(key.id()); + if let Some((seen, value)) = &self.computed.borrow()[key.id()] + && *seen == version + { + return value.clone(); + } + let value = to_js(&*self.machine().computed(key)); + self.computed.borrow_mut()[key.id()] = Some((version, value.clone())); + value + } + + /// The names `fromWasm` maps the numbers onto, once per machine type. + pub fn meta(&self) -> JsValue { + #[derive(Serialize)] + struct Meta<'a> { + states: &'a [&'a str], + events: &'a [&'a str], + fields: &'a [&'a str], + computed: Vec<&'a str>, + tags: Vec<&'a [&'a str]>, + } + let config = self.machine().config(); + let states = ::NAMES; + to_js(&Meta { + states, + events: ::KIND_NAMES, + fields: ::FIELDS, + computed: config.computed_names().collect(), + tags: (0..states.len()) + .map(|i| config.tags(::from_index(i))) + .collect(), + }) + } +} + +/// Export a machine type as a JS class. Wrap an instance with `fromWasm` from +/// `@dunky.dev/state-machine`. +/// +/// ```ignore +/// dunky_wasm::export_machine! { +/// /// The command palette. +/// pub struct PaletteMachine(palette::Palette); +/// new(commands: JsValue) { +/// // A `Result` body throws its error from the JS constructor. +/// dunky_wasm::from_js(commands).map(|c| Machine::new(&palette::config(c))) +/// } +/// computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } +/// // Optional: served too, but left out of the machine's TS types. +/// internal { palette::RESULT_INDICES => Vec } +/// } +/// ``` +/// +/// Natively, the class also has `typescript(&config)`: its TS types, from the machine's +/// Rust types, for `pnpm build:wasm` to add to the module's `.d.ts`. +#[macro_export] +macro_rules! export_machine { + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident($types:ty); + new($($arg:ident : $argty:ty),* $(,)?) $make:block + computed { $($key:expr => $cty:ty),* $(,)? } + $(internal { $($ikey:expr => $ity:ty),* $(,)? })? + ) => { + $(#[$meta])* + #[::wasm_bindgen::prelude::wasm_bindgen] + $vis struct $name { + inner: $crate::Exported<$types>, + } + + #[::wasm_bindgen::prelude::wasm_bindgen] + impl $name { + /// Throws when the constructor body returns an error (bad arguments). + #[wasm_bindgen(constructor)] + #[allow(unused_braces)] // `$make` is the caller's block, passed as an argument + pub fn new($($arg: $argty),*) -> ::core::result::Result<$name, ::wasm_bindgen::JsError> { + let machine = $crate::IntoMachine::<$types>::into_machine($make)?; + ::core::result::Result::Ok($name { inner: $crate::Exported::new(machine) }) + } + /// Connect to the JS host (`fromWasm` does). Calls return a status: 0, 1 when + /// JS code failed (the host holds the error), 3 when the engine failed. + pub fn attach(&self, host: $crate::HostObject) -> u32 { + self.inner.attach(host) + } + pub fn send(&self, kind: u32, event: ::wasm_bindgen::JsValue) -> u32 { + self.inner.send(kind, event) + } + pub fn start(&self) -> u32 { + self.inner.start() + } + pub fn stop(&self) -> u32 { + self.inner.stop() + } + pub fn fire(&self, id: u32) -> u32 { + self.inner.fire(id) + } + #[wasm_bindgen(js_name = takeFailure)] + pub fn take_failure(&self) -> ::core::option::Option<::std::string::String> { + self.inner.take_failure() + } + pub fn running(&self) -> bool { + self.inner.running() + } + pub fn state(&self) -> u32 { + self.inner.state() + } + pub fn field(&self, index: u32) -> ::wasm_bindgen::JsValue { + self.inner.field(index) + } + #[allow(unused_variables)] + pub fn computed(&self, id: u32) -> ::wasm_bindgen::JsValue { + $( + let key: $crate::__private::ComputedKey<$cty> = $key; + if id as usize == key.id() { + return self.inner.computed(key); + } + )* + $($( + let key: $crate::__private::ComputedKey<$ity> = $ikey; + if id as usize == key.id() { + return self.inner.computed(key); + } + )*)? + ::wasm_bindgen::JsValue::UNDEFINED + } + pub fn meta(&self) -> ::wasm_bindgen::JsValue { + self.inner.meta() + } + } + + #[cfg(not(target_arch = "wasm32"))] + impl $name { + /// This class's TS types, from the machine's Rust types. + pub fn typescript(config: &$crate::__private::Config<$types>) -> ::std::string::String { + $crate::__private::class_types::<$types>( + ::core::stringify!($name), + config, + &[$({ + let key: $crate::__private::ComputedKey<$cty> = $key; + (key.id(), <$cty as $crate::__private::TsType>::ts()) + }),*], + ) + } + } + }; +} diff --git a/package.json b/package.json index d835914..d62b270 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "scripts": { "test": "vitest", "test:ci": "vitest run", - "benchmark": "pnpm -C benchmark benchmark", - "benchmark:demo": "pnpm -C benchmark/demo dev", + "benchmark": "pnpm -C benchmark/ts benchmark", + "benchmark:demo": "pnpm -C benchmark/ts/demo dev", "website:dev": "pnpm -C website dev", "website:prod": "pnpm -C website build", "build": "tsdown", + "build:wasm": "./scripts/build-wasm.sh", "typecheck": "tsc -b tsconfig.build.json", "lint": "oxlint .", "format": "oxfmt .", diff --git a/packages/core/SPEC.md b/packages/core/SPEC.md index 23c9821..e978577 100644 --- a/packages/core/SPEC.md +++ b/packages/core/SPEC.md @@ -70,6 +70,11 @@ connect. +-- createStore .. state shared between machine instances ``` +**Two engines.** This contract has two implementations: this package +(TypeScript) and [`crates/core`](../../crates/core) (Rust). A behavior +change here lands in both, with their tests, in the same PR (see +[AGENTS.md](../../AGENTS.md#two-engines-one-spec)). + ## Intent Author a state **once**, as a plain state diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6be01f8..4454374 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { machine } from './machine' +export { machine, toMachine, type MachineSource } from './machine' export { MACHINE_INIT } from './constants' export { setup } from './setup' export { and, or, not } from './guards' @@ -7,6 +7,10 @@ export { connector } from './connector' export { makeReaction } from './reaction' export { compose, type Composition } from './compose' export { createStore, type Store, type Listener, type SetStateAction } from './store' +// Building blocks for machines that run outside this engine (e.g. Rust machines through +// @dunky.dev/state-machine-wasm), so their subscriptions behave exactly like this engine's. +export { makeBroadcast, type Broadcast } from './broadcast' +export { makeSelection } from './selection' export type { Machine, diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 0d97796..3cb1dc5 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -323,3 +323,29 @@ export function machine< ): Machine { return new MachineClass(config) as unknown as Machine } + +/** + * What a target can build a service from: a config (run by this engine), or a ready + * machine — e.g. a Rust machine wrapped with `fromWasm` from + * `@dunky.dev/state-machine-wasm`. + */ +export type MachineSource< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +> = TransitionConfig | Machine + +/** Build a stopped service from a config; a ready machine is returned as is. */ +export function toMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +>( + source: MachineSource, +): Machine { + return typeof (source as Machine).send === 'function' + ? (source as Machine) + : machine(source as TransitionConfig) +} diff --git a/packages/react/src/use-machine.ts b/packages/react/src/use-machine.ts index 541d6f8..74b11af 100644 --- a/packages/react/src/use-machine.ts +++ b/packages/react/src/use-machine.ts @@ -1,5 +1,11 @@ import { useEffect, useMemo, useSyncExternalStore } from 'react' -import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' +import { + connector, + toMachine, + type Connect, + type Machine, + type MachineSource, +} from '@dunky.dev/state-machine' /** * A substrate-specific effect: a setup/teardown function plus the prop names it depends on. @@ -19,9 +25,14 @@ export type ComponentEffect = [ ] /** - * The generic React bridge. Builds the machine once from the first render's props, + * The generic React bridge. Builds the machine once from the first render's props — + * from a config, or as a ready machine (e.g. a Rust machine via wasm) — * keeps props fresh via setProps, runs substrate effects, and drives React via * useSyncExternalStore over the connector's snapshot. + * + * A ready machine must come fresh from `createConfig` (one per component instance): + * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine + * between components is not supported. */ export function useMachine< State extends string, @@ -31,14 +42,14 @@ export function useMachine< Api, Computed = Record, >( - createConfig: (props: Props) => TransitionConfig, + createConfig: (props: Props) => MachineSource, connect: Connect, - effects: ComponentEffect>, Props>[], + effects: ComponentEffect, Props>[], props: Props, -): { api: Api; machine: ReturnType> } { +): { api: Api; machine: Machine } { const { service, connection } = useMemo( () => { - const service = machine(createConfig(props)) + const service = toMachine(createConfig(props)) const connection = connector(service, connect, props) return { service, connection } }, diff --git a/packages/react/tests/use-machine.test.tsx b/packages/react/tests/use-machine.test.tsx index 996aa71..48b2cc3 100644 --- a/packages/react/tests/use-machine.test.tsx +++ b/packages/react/tests/use-machine.test.tsx @@ -117,6 +117,25 @@ describe('useMachine — lifecycle', () => { }) }) +describe('useMachine — machine source', () => { + it('accepts a ready machine instead of a config and runs that very instance', () => { + // The shape a Rust machine takes (built outside the engine, e.g. via wasm). + const ready = machine(createConfig()({})) + const sink: { api?: ToggleApi; machine?: ToggleMachine } = {} + function Comp(p: ToggleProps) { + const result = useMachine(() => ready, connect, noEffects, p) + sink.api = result.api + sink.machine = result.machine + return null + } + render() + expect(sink.machine).toBe(ready) + act(() => sink.api!.toggle()) + expect(ready.state).toBe('open') + expect(sink.api!.open).toBe(true) + }) +}) + describe('useMachine — build once', () => { it('builds the machine ONCE: state survives prop changes (no rebuild)', () => { const { sink, Comp } = harness({ label: 'a' }) diff --git a/packages/solid/src/use-machine.ts b/packages/solid/src/use-machine.ts index 844f393..052cafb 100644 --- a/packages/solid/src/use-machine.ts +++ b/packages/solid/src/use-machine.ts @@ -1,5 +1,11 @@ import { createEffect, createStore, onCleanup, onSettled, reconcile } from 'solid-js' -import { connector, machine, type Connect, type TransitionConfig } from '@dunky.dev/state-machine' +import { + connector, + toMachine, + type Connect, + type Machine, + type MachineSource, +} from '@dunky.dev/state-machine' /** * One substrate-specific effect: a setup/teardown function plus the prop names @@ -13,10 +19,15 @@ export type ComponentEffect = [ ] /** - * The generic Solid bridge: builds the machine + connector once, mirrors the + * The generic Solid bridge: builds the machine (from a config, or a ready machine such + * as a Rust machine via wasm) + connector once, mirrors the * connector's snapshot into a fine-grained store, runs the lifecycle and the * component's effects. Returns the connect() api (reactive store proxy) and * the running machine. + * + * A ready machine must come fresh from `createConfig` (one per component instance): + * the hook owns its lifecycle, exactly like a config-built one. Sharing one machine + * between components is not supported. */ export function useMachine< State extends string, @@ -26,14 +37,14 @@ export function useMachine< Api extends object, Computed = Record, >( - createConfig: (props: Props) => TransitionConfig, + createConfig: (props: Props) => MachineSource, connect: Connect, - effects: ComponentEffect>, Props>[], + effects: ComponentEffect, Props>[], props: Props, -): { api: Api; machine: ReturnType> } { +): { api: Api; machine: Machine } { // Seed with a plain copy, never the live props proxy: setProps value-dedups, // and a held proxy would compare equal to its own fresh spread and never wake. - const service = machine(createConfig(props)) + const service = toMachine(createConfig(props)) const connection = connector(service, connect, { ...props }) // Fine-grained mirror of the snapshot: reading `api.x` subscribes to that diff --git a/packages/solid/tests/use-machine.test.tsx b/packages/solid/tests/use-machine.test.tsx index 47f5481..f4b449a 100644 --- a/packages/solid/tests/use-machine.test.tsx +++ b/packages/solid/tests/use-machine.test.tsx @@ -68,6 +68,25 @@ const noEffects: ComponentEffect[] = [] afterEach(() => vi.clearAllMocks()) +describe('useMachine — machine source', () => { + it('accepts a ready machine instead of a config and runs that very instance', () => { + // The shape a Rust machine takes (built outside the engine, e.g. via wasm). + const ready = machine(createConfig()({})) + let captured: { api: ToggleApi; machine: ToggleMachine } | undefined + function Comp() { + const props: ToggleProps = {} + captured = useMachine(() => ready, connect, noEffects, props) + return null + } + render(() => ) + expect(captured!.machine).toBe(ready) + captured!.api.toggle() + flush() + expect(ready.state).toBe('open') + expect(captured!.api.open).toBe(true) + }) +}) + describe('useMachine — lifecycle', () => { it('returns { api, machine }: api is the connect() output, machine is the running service', () => { let captured: { api: ToggleApi; machine: ToggleMachine } | undefined diff --git a/packages/wasm/LICENSE b/packages/wasm/LICENSE new file mode 100644 index 0000000..08a9692 --- /dev/null +++ b/packages/wasm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ivan Banov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/wasm/README.md b/packages/wasm/README.md new file mode 100644 index 0000000..34d1ad1 --- /dev/null +++ b/packages/wasm/README.md @@ -0,0 +1,50 @@ +# @dunky.dev/state-machine-wasm + +Use a Dunky machine written in **Rust** from TypeScript. `fromWasm` wraps a class +exported with `dunky_wasm::export_machine!` (see [`crates/wasm`](../../crates/wasm)) in +the same `Machine` interface the TS engine returns, so `connect()`, `useMachine` and +every target consume it unchanged, next to your TS machines. + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import init, { PaletteMachine } from './pkg/my_machines.js' // your wasm-bindgen output + +await init() +// Machine<'closed' | 'open', …>: the types come from the Rust types. +const palette = fromWasm(new PaletteMachine(commands)) +``` + +- **The machine runs in Rust**: its graph, user code, timers and computed values. The + facade keeps a JS mirror of the context and re-reads only the fields each change + names. +- **Typed from Rust**: the build generates each class's types from the machine's Rust + types; `fromWasm` infers `Machine` from them. For a + class built without them, name them: `fromWasm(…)`. +- **`computed` option**: map a raw computed value once per change — e.g. indices that + crossed the boundary, back onto your own objects: + + ```ts + fromWasm(new PaletteMachine(commands), { + computed: { + results: { + from: 'resultIndices', + map: raw => Array.from(raw as number[], i => commands[i]!), + }, + }, + }) + ``` + +- **In a target**: `useMachine(props => fromWasm(new PaletteMachine(props.commands)), connect, effects, props)`. + +In JS a TS machine is a little faster (its context already lives in JS); a Rust machine +is for reuse: the same machine runs in Rust programs and on React Native. The protocol: +[`crates/wasm/SPEC.md`](../../crates/wasm/SPEC.md). + +## Why two engines, not Rust everywhere + +We tried one engine: the TS package as a thin layer over the Rust engine, compiled to wasm. +It worked, but it was not the win it looked like. Part of the TS side still had to mirror +the Rust engine — the context, the computed cache and the transition steps stay in JS, +next to the user code — and every event still paid the JS↔wasm bridge, so it gave up a +bit of speed. So we maintain both engines against one spec: each one runs natively, and +wasm is for machines written in Rust. diff --git a/packages/wasm/package.json b/packages/wasm/package.json new file mode 100644 index 0000000..052208b --- /dev/null +++ b/packages/wasm/package.json @@ -0,0 +1,40 @@ +{ + "name": "@dunky.dev/state-machine-wasm", + "version": "0.0.0", + "description": "Run Dunky machines written in Rust from TypeScript: fromWasm wraps a class exported with dunky_wasm::export_machine! in the Machine interface.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/state-machine.git", + "directory": "packages/wasm" + }, + "files": [ + "dist", + "src" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "workspace:*" + } +} diff --git a/packages/wasm/src/from-wasm.ts b/packages/wasm/src/from-wasm.ts new file mode 100644 index 0000000..9135f7f --- /dev/null +++ b/packages/wasm/src/from-wasm.ts @@ -0,0 +1,345 @@ +import { + makeBroadcast, + makeSelection, + type Broadcast, + type Machine, + type Select, + type Selection, +} from '@dunky.dev/state-machine' + +/** + * An instance of a class exported with `dunky_wasm::export_machine!`. Calls that run + * machine code return a status (see `OK`); the host functions run for the facade whose + * call is in progress (`calling`). + */ +export interface WasmMachine { + /** Connect to the host. Returns a status. */ + attach: (host: object) => number + send: (kind: number, event: unknown) => number + start: () => number + stop: () => number + running: () => boolean + state: () => number + field: (index: number) => unknown + computed: (id: number) => unknown + /** The host clock: timer `id` came due. */ + fire: (id: number) => number + /** The binding's failure behind `ENGINE_FAILED`. */ + takeFailure: () => string | undefined + meta: () => WasmMachineMeta +} + +/** The names a Rust machine type reports, in its numbering. */ +export interface WasmMachineMeta { + states: string[] + events: string[] + fields: string[] + computed: string[] + /** Per state: its tags. */ + tags: string[][] +} + +/** + * A Rust machine's types. `pnpm build:wasm` generates them from the machine's Rust types + * onto its exported class, as a `__types` member that exists only in the types. + */ +export interface MachineTypes { + state: string + context: object + event: { type: string } + computed: object +} + +/** The types generated onto an exported class; loose ones when it has none. */ +export type TypesOf = Handle extends { readonly __types?: infer Types extends MachineTypes } + ? Types + : MachineTypes + +/** How to produce one computed value on the JS side. */ +export type ComputedMapping = + | ((raw: unknown) => Value) + | { + /** Read this Rust computed value instead (e.g. indices instead of objects). */ + from: string + map: (raw: unknown) => Value + } + +export interface FromWasmOptions { + /** + * Map a raw computed value — e.g. indices that crossed the boundary, back onto the + * host's own objects. Runs only when the value changed. + */ + computed?: { [K in keyof Computed]?: ComputedMapping } +} + +// The statuses of a call (crates/wasm/src/bridge.rs). JS owns the errors: a host +// function catches what a subscriber throws and keeps it on the machine. +const OK = 0 +const FAILED = 1 +const ENGINE_FAILED = 3 +const NONE = Symbol('none') +const UNREAD = {} + +// The facade whose call is in progress: every host function runs for it. Calls nest +// (a subscriber calls another machine), so each call saves and restores it. Keeping it +// here means no reference to the facade crosses into wasm. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let calling: RustMachine = null! + +interface Shape { + stateNames: string[] + tags: Array> + kinds: Map + fields: string[] + computedIndex: Map +} + +// The shape is per machine TYPE: read it once per exported class, not per instance. +const shapes = new WeakMap() + +function shapeOf(handle: WasmMachine): Shape { + const type = Object.getPrototypeOf(handle) as object + let shape = shapes.get(type) + if (!shape) { + const meta = handle.meta() + shape = { + stateNames: meta.states, + tags: meta.tags.map(tags => new Set(tags)), + kinds: new Map(meta.events.map((name, i) => [name, i])), + fields: meta.fields, + computedIndex: new Map(meta.computed.map((name, i) => [name, i])), + } + shapes.set(type, shape) + } + return shape +} + +type Timeout = ReturnType + +/** The JS functions a Rust machine calls back into, for the facade of the call in + * progress. They never throw: an error is kept on the facade, and a status returned. */ +const HOST = { + notify: (state: number, lo: number, hi: number): number => { + const m = calling + try { + m.current = state + if (lo | hi) m.refresh(lo, hi) + m.broadcast.notify() + return OK + } catch (error) { + return m.fail(error) + } + }, + // The pending timer holds the machine, like a JS timer holds `this`. + startTimer: (id: number, ms: number): Timeout => { + const m = calling + return setTimeout(() => m.fire(id), ms) + }, + cancelTimer: (timeout: Timeout): void => clearTimeout(timeout), +} + +/** + * The `Machine` interface over a Rust machine. Rust runs the machine: its graph, user + * code, timers and computed values. The facade maps names to numbers, keeps a JS mirror + * of the context (re-reading only the fields each change names), and fans each change + * out to the subscribers. + */ +class RustMachine< + State extends string, + Context extends object, + Event extends { type: string }, + Computed, +> implements Machine { + shape: Shape + handle: WasmMachine + ctx: Context + computed = {} as Computed + /** The current state's index, moved by every notify. */ + current: number + broadcast: Broadcast = makeBroadcast() + // Lazily created — a machine with no connector pays nothing. + startListeners: Set<() => void> | null = null + stopListeners: Set<() => void> | null = null + selectFacade: Select | null = null + /** The first error a subscriber threw in the current call. */ + error: unknown = NONE + /** Nesting of calls: JS code calls back in from inside one. */ + depth = 0 + /** Started and not stopped (mirrors Rust, so JS needs no call to ask). */ + isRunning: boolean + + constructor( + shape: Shape, + handle: WasmMachine, + mappings: Record | undefined>, + ) { + this.shape = shape + this.handle = handle + const ctx: Record = {} + for (let i = 0; i < shape.fields.length; i++) ctx[shape.fields[i]!] = handle.field(i) + this.ctx = ctx as Context + this.current = handle.state() + this.isRunning = handle.running() + for (const [name, own] of shape.computedIndex) { + const mapping = mappings[name] + const map = typeof mapping === 'function' ? mapping : mapping?.map + const source = typeof mapping === 'object' ? shape.computedIndex.get(mapping.from) : own + if (source === undefined) { + throw new Error(`[machine] no computed "${(mapping as { from: string }).from}"`) + } + // Rust serves the same JS value until it changes, so identity marks a change. + let raw: unknown = UNREAD + let value: unknown + Object.defineProperty(this.computed, name, { + enumerable: true, + get: map + ? () => { + const next = handle.computed(source) + if (next !== raw) { + raw = next + value = map(next) + } + return value + } + : () => handle.computed(source), + }) + } + this.run(() => handle.attach(HOST)) + } + + /** Run a call into Rust for this facade, and throw what failed in it. */ + run(call: () => number): void { + const outer = calling + calling = this + this.depth++ + const status = call() + this.depth-- + calling = outer + if (status !== OK) this.raise(status) + } + /** Keep the first error of the call. */ + fail(error: unknown): number { + if (this.error === NONE) this.error = error + return FAILED + } + /** Throw what failed: the outermost call takes the error, a nested one leaves it. */ + raise(status: number): never { + if (status === ENGINE_FAILED) throw new Error(this.handle.takeFailure()) + const error = this.error + if (this.depth === 0) this.error = NONE + throw error + } + /** A change named context fields `lo`/`hi` (the low and high 32 bits of the mask). */ + refresh(lo: number, hi: number): void { + const ctx = this.ctx as Record + const { fields } = this.shape + for (let bits = lo; bits !== 0; bits &= bits - 1) { + const i = 31 - Math.clz32(bits & -bits) + ctx[fields[i]!] = this.handle.field(i) + } + for (let bits = hi; bits !== 0; bits &= bits - 1) { + const i = 63 - Math.clz32(bits & -bits) + ctx[fields[i]!] = this.handle.field(i) + } + } + + send = (event: Event): void => { + const kind = this.shape.kinds.get(event.type) + if (kind === undefined) return // handled nowhere: a no-op + this.run(() => this.handle.send(kind, event)) + } + fire(id: number): void { + this.run(() => this.handle.fire(id)) + } + + get state(): State { + return this.shape.stateNames[this.current] as State + } + get context(): Context { + return this.ctx + } + hasTag(tag: string): boolean { + return this.shape.tags[this.current]!.has(tag) + } + matches(name: State): boolean { + return this.shape.stateNames[this.current] === name + } + + start = (): void => { + if (this.isRunning) return + this.isRunning = true + this.run(() => this.handle.start()) + if (this.startListeners) for (const fn of this.startListeners) fn() + } + stop = (): void => { + if (!this.isRunning) return + this.isRunning = false + this.run(() => this.handle.stop()) + if (this.stopListeners) for (const fn of this.stopListeners) fn() + } + onStart = (fn: () => void): (() => void) => { + ;(this.startListeners ??= new Set()).add(fn) + if (this.isRunning) fn() // already running — fire immediately so late registrants don't miss it + return () => this.startListeners?.delete(fn) + } + onStop = (fn: () => void): (() => void) => { + ;(this.stopListeners ??= new Set()).add(fn) + return () => this.stopListeners?.delete(fn) + } + + subscribe = (listener: () => void): (() => void) => this.broadcast.add(listener) + + private makeSelection(selector: () => Value): Selection { + return makeSelection(selector, onWake => this.broadcast.add(onWake)) + } + // Built on first access, then reused — the facade is stateless, so one instance serves all reads. + get select(): Select { + if (this.selectFacade) return this.selectFacade + const sel = ((selector: () => Value) => this.makeSelection(selector)) as Select< + State, + Context, + Computed + > + sel.context = (key: K) => this.makeSelection(() => this.ctx[key]) + sel.computed = (key: K) => + this.makeSelection(() => this.computed[key]) + sel.state = () => this.makeSelection(() => this.state) + return (this.selectFacade = sel) + } +} + +/** + * Wrap a Rust machine (an instance of a class exported with + * `dunky_wasm::export_machine!`) in the `Machine` interface, so the connector and every + * target consume it like a machine of the TS engine. Its types are the class's, + * generated from the machine's Rust types. + */ +export function fromWasm( + handle: Handle, + options?: FromWasmOptions['computed']>, +): Machine< + TypesOf['state'], + TypesOf['context'], + TypesOf['event'], + TypesOf['computed'] +> +/** For a class built without generated types: name them. */ +export function fromWasm< + State extends string, + Context extends object, + Event extends { type: string }, + Computed = Record, +>( + handle: WasmMachine, + options?: FromWasmOptions, +): Machine +export function fromWasm( + handle: WasmMachine, + options: FromWasmOptions> = {}, +): Machine> { + return new RustMachine>( + shapeOf(handle), + handle, + (options.computed ?? {}) as Record | undefined>, + ) +} diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts new file mode 100644 index 0000000..29286cf --- /dev/null +++ b/packages/wasm/src/index.ts @@ -0,0 +1,9 @@ +export { + fromWasm, + type ComputedMapping, + type FromWasmOptions, + type MachineTypes, + type TypesOf, + type WasmMachine, + type WasmMachineMeta, +} from './from-wasm' diff --git a/packages/wasm/tests/from-wasm.test.ts b/packages/wasm/tests/from-wasm.test.ts new file mode 100644 index 0000000..d34e41e --- /dev/null +++ b/packages/wasm/tests/from-wasm.test.ts @@ -0,0 +1,113 @@ +/** + * `fromWasm` against a scripted handle that plays the Rust side of the protocol: + * events in by kind, a notify per change naming the changed fields, computed values + * served by identity. The facade behavior both machine kinds share (subscribe, select, + * lifecycle, timers) is pinned by the TS-machine tests. + */ +import { describe, expect, it, vi } from 'vitest' +import { fromWasm, type WasmMachine, type WasmMachineMeta } from '../src' + +const FIELDS = Array.from({ length: 40 }, (_, i) => `f${i}`) + +function fake() { + let host: { notify: (state: number, lo: number, hi: number) => number } + // Rust calls the host only during a call into it: a change waits for the next event. + let pending: (() => void) | null = null + const rust = { + state: 0, + values: FIELDS.map(() => 0), + computed: [[2, 0], 'other'] as unknown[], + } + const reads = { field: 0 } + const handle = { + attach: (h: object) => { + host = h as typeof host + return 0 + }, + send: vi.fn((_kind: number, _event: unknown) => { + pending?.() + pending = null + return 0 + }), + start: () => 0, + stop: () => 0, + running: () => false, + state: () => rust.state, + computed: (id: number) => rust.computed[id], + fire: vi.fn(() => 0), + takeFailure: () => undefined, + field: (i: number) => { + reads.field++ + return rust.values[i] + }, + meta: (): WasmMachineMeta => ({ + states: ['idle', 'busy'], + events: ['go', 'set'], + fields: FIELDS, + computed: ['indices', 'other'], + tags: [[], ['working']], + }), + } satisfies WasmMachine + /** On the next event, Rust changes the state and the fields at `changed`. */ + const change = (state: number, changed: Record) => { + pending = () => { + rust.state = state + let lo = 0 + let hi = 0 + for (const [i, value] of Object.entries(changed)) { + rust.values[Number(i)] = value + if (Number(i) < 32) lo |= 1 << Number(i) + else hi |= 1 << (Number(i) - 32) + } + host.notify(state, lo >>> 0, hi >>> 0) + } + } + return { handle, rust, reads, change } +} + +type Ctx = Record + +describe('fromWasm', () => { + it('sends events by kind, with the event object for the payload; ignores unknown types', () => { + const { handle } = fake() + const m = fromWasm<'idle' | 'busy', Ctx, { type: string; v?: number }>(handle) + m.send({ type: 'go' }) + m.send({ type: 'set', v: 1 }) + m.send({ type: 'nope' }) + expect(handle.send.mock.calls.map(([kind, event]) => [kind, event])).toEqual([ + [0, { type: 'go' }], + [1, { type: 'set', v: 1 }], + ]) + }) + + it('moves the state on each notify and re-reads only the fields it names', () => { + const { handle, reads, change } = fake() + const m = fromWasm<'idle' | 'busy', Ctx, { type: 'go' }>(handle) + const context = m.context + const listener = vi.fn(() => [m.state, m.context.f1, m.context.f35]) + m.subscribe(listener) + reads.field = 0 + change(1, { 1: 7, 35: 9 }) + m.send({ type: 'go' }) + expect(listener).toHaveReturnedWith(['busy', 7, 9]) + expect(m.hasTag('working')).toBe(true) + expect(reads.field).toBe(2) + expect(m.context).toBe(context) + }) + + it('maps a computed value only when Rust serves a new one; `from` reads another value', () => { + const { handle, rust } = fake() + const own = ['x', 'y', 'z'] + const map = vi.fn((raw: unknown) => (raw as number[]).map(i => own[i]!)) + const m = fromWasm<'idle', Ctx, { type: 'go' }, { indices: string[]; other: string[] }>( + handle, + { computed: { indices: map, other: { from: 'indices', map } } }, + ) + expect(m.computed.indices).toEqual(['z', 'x']) + expect(m.computed.indices).toBe(m.computed.indices) + expect(map).toHaveBeenCalledTimes(1) + rust.computed[0] = [1] + expect(m.computed.indices).toEqual(['y']) + expect(m.computed.other).toEqual(['y']) + }) +}) diff --git a/packages/wasm/tsconfig.json b/packages/wasm/tsconfig.json new file mode 100644 index 0000000..3fd6989 --- /dev/null +++ b/packages/wasm/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "tests"], + "exclude": ["**/node_modules", "**/dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d1998c..a75981b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,14 +45,14 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@22.19.19)(jsdom@29.1.1)(vite@8.0.14(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) - benchmark: + benchmark/ts: dependencies: '@dunky.dev/react-state-machine': specifier: workspace:^ - version: link:../packages/react + version: link:../../packages/react '@dunky.dev/state-machine': specifier: workspace:^ - version: link:../packages/core + version: link:../../packages/core devDependencies: '@types/jsdom': specifier: ^21.1.7 @@ -94,11 +94,11 @@ importers: specifier: ^5.32.0 version: 5.32.1 - benchmark/demo: + benchmark/ts/demo: dependencies: '@dunky.dev/state-machine': specifier: workspace:^ - version: link:../../packages/core + version: link:../../../packages/core devDependencies: '@vitejs/plugin-react': specifier: ^6.0.2 @@ -232,6 +232,12 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.0.14(@types/node@24.13.2)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/wasm: + dependencies: + '@dunky.dev/state-machine': + specifier: workspace:* + version: link:../core + sandbox/native: dependencies: '@dunky.dev/native-state-machine': @@ -289,6 +295,9 @@ importers: '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../../packages/shared/utils + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm '@opentui/core': specifier: ^0.4.1 version: 0.4.1(typescript@7.0.2)(web-tree-sitter@0.25.10) @@ -323,6 +332,9 @@ importers: '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../../packages/shared/utils + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm '@sandbox/cmdk-core': specifier: workspace:^ version: link:../shared @@ -354,6 +366,9 @@ importers: '@dunky.dev/state-machine-bindings': specifier: workspace:^ version: link:../../packages/shared/bindings + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm sandbox/solid: dependencies: @@ -369,6 +384,9 @@ importers: '@dunky.dev/state-machine-utils': specifier: workspace:^ version: link:../../packages/shared/utils + '@dunky.dev/state-machine-wasm': + specifier: workspace:^ + version: link:../../packages/wasm '@sandbox/cmdk-core': specifier: workspace:^ version: link:../shared diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66765af..ec93898 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,8 @@ packages: - packages/**/* - sandbox/**/* - - benchmark - - benchmark/demo + - benchmark/ts + - benchmark/ts/demo - website allowBuilds: esbuild: true diff --git a/sandbox/README.md b/sandbox/README.md index 4f45dc5..0880db2 100644 --- a/sandbox/README.md +++ b/sandbox/README.md @@ -38,8 +38,25 @@ pnpm -C sandbox/solid dev pnpm -C sandbox/opentui dev # Native — needs Expo + an iOS/Android simulator or device. -pnpm -C sandbox/native start # then press i / a, or scan the QR +pnpm -C sandbox/native dev # then press i / a, or scan the QR ``` All four consume the workspace packages straight from their TypeScript `src/` (Vite alias / Bun workspace / Metro watch-folders) — no build step. + +## The same palette, written in Rust + +Both palettes run on the Rust engine. By default the sandboxes run the one written in +TypeScript (`shared/src/machine.ts`); each web and terminal sandbox can also run its +Rust twin (`crates/sandbox`, wrapped with `fromWasm` from `@dunky.dev/state-machine-wasm`) — same `connect()`, same markup, +only the machine's source language differs: + +```bash +pnpm build:wasm # once: Rust → wasm (see CONTRIBUTING.md) +pnpm -C sandbox/react dev # then open http://localhost:5173/?machine=rust +pnpm -C sandbox/solid dev # then open http://localhost:5173/?machine=rust +pnpm -C sandbox/opentui dev:rust # Bun runs the wasm +``` + +React Native has no WebAssembly (Hermes), so the native sandbox waits for the engine +over JSI. diff --git a/sandbox/opentui/package.json b/sandbox/opentui/package.json index d80f031..106d266 100644 --- a/sandbox/opentui/package.json +++ b/sandbox/opentui/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run src/index.tsx" + "dev": "bun run src/index.tsx", + "dev:rust": "bun run src/index.tsx --machine=rust" }, "dependencies": { "@dunky.dev/opentui-state-machine": "workspace:^", @@ -12,6 +13,7 @@ "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^", "@opentui/core": "^0.4.1", "@opentui/react": "^0.4.1", "@sandbox/cmdk-core": "workspace:^", diff --git a/sandbox/opentui/src/app.tsx b/sandbox/opentui/src/app.tsx index 1cf0a55..dfc0b36 100644 --- a/sandbox/opentui/src/app.tsx +++ b/sandbox/opentui/src/app.tsx @@ -1,8 +1,11 @@ import { useState } from 'react' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' + +export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -22,7 +25,8 @@ export function App() { NAMED ANSI color ('green'), which the terminal maps to its own palette. */} ⌘K Command Pallete - setLast(c.label)} /> + setLast(c.label)} /> + Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} One state machine drives this ⌘K palette. diff --git a/sandbox/opentui/src/command-palette.tsx b/sandbox/opentui/src/command-palette.tsx index f8ae000..8d215ed 100644 --- a/sandbox/opentui/src/command-palette.tsx +++ b/sandbox/opentui/src/command-palette.tsx @@ -7,9 +7,9 @@ import type { KeyEvent } from '@opentui/core' import { useMachine } from '@dunky.dev/react-state-machine' import { normalize } from '@dunky.dev/opentui-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -54,8 +54,12 @@ const paletteKeys = (machine: CommandPaletteMachine) => (key: KeyEvent) => { // the SAME shared machine, `connect` produces the SAME logical bindings — only // `normalize` (from the opentui package) and the JSX elements differ. The index // math, filtering, and selection all live in the shared machine, unchanged. -export function CommandPalette(props: CommandPaletteProps) { - const { api, machine } = useMachine(commandPaletteMachineConfig, connectCommandPalette, [], props) +// `source` is the TS config or the Rust machine (see index.tsx); nothing else differs. +export function CommandPalette({ + source, + ...props +}: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api, machine } = useMachine(source, connectCommandPalette, [], props) // Starts closed — press Ctrl+K to open. (⌘K can't be used in a terminal: // macOS/Ghostty don't forward Cmd to the program — Cmd is an app/OS modifier — diff --git a/sandbox/opentui/src/index.tsx b/sandbox/opentui/src/index.tsx index 45724b3..bc7f11d 100644 --- a/sandbox/opentui/src/index.tsx +++ b/sandbox/opentui/src/index.tsx @@ -1,6 +1,19 @@ import { createCliRenderer } from '@opentui/core' import { createRoot } from '@opentui/react' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Language } from './app' + +// `--machine=rust` runs the palette written in Rust (crates/sandbox → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = process.argv.includes('--machine=rust') ? 'rust' : 'ts' + +let source: CommandPaletteSource = commandPaletteMachineConfig +if (language === 'rust') { + // Imported only for the Rust machine, so the TS app loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust-node') + rust.loadRustNode() + source = props => rust.createRustPalette(props.commands) +} const renderer = await createCliRenderer() -createRoot(renderer).render() +createRoot(renderer).render() diff --git a/sandbox/react/package.json b/sandbox/react/package.json index ffa4d77..de98d4d 100644 --- a/sandbox/react/package.json +++ b/sandbox/react/package.json @@ -13,6 +13,7 @@ "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^", "@sandbox/cmdk-core": "workspace:^", "react": "^19.2.6", "react-dom": "^19.2.6" diff --git a/sandbox/react/src/app.tsx b/sandbox/react/src/app.tsx index 38b4005..3603605 100644 --- a/sandbox/react/src/app.tsx +++ b/sandbox/react/src/app.tsx @@ -1,8 +1,11 @@ import { useState } from 'react' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' + +export function App({ language, source }: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = useState('—') return ( @@ -10,6 +13,7 @@ export function App() {

⌘K Command Palette


{ setLast(c.label) @@ -25,6 +29,12 @@ export function App() {

Last selected: {last}

+
) } diff --git a/sandbox/react/src/command-palette.tsx b/sandbox/react/src/command-palette.tsx index 4b2746a..924a1d4 100644 --- a/sandbox/react/src/command-palette.tsx +++ b/sandbox/react/src/command-palette.tsx @@ -1,9 +1,9 @@ import { useEffect, useRef } from 'react' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/react-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -28,13 +28,12 @@ const cmdkShortcut: ComponentEffect // machine, `connect` produces logical bindings, and `normalize` turns them into // DOM props (onPress→onClick, role/aria-*, etc). The component is just markup; // the look lives in the stylesheet shared with the Solid app. -export function CommandPalette(props: CommandPaletteProps) { - const { api } = useMachine( - commandPaletteMachineConfig, - connectCommandPalette, - [cmdkShortcut], - props, - ) +// `source` is the TS config or the Rust machine (see main.tsx); nothing else differs. +export function CommandPalette({ + source, + ...props +}: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api } = useMachine(source, connectCommandPalette, [cmdkShortcut], props) const inputRef = useRef(null) // Focus the input whenever the palette opens (a renderer concern, not the diff --git a/sandbox/react/src/main.tsx b/sandbox/react/src/main.tsx index 4950347..d1c148f 100644 --- a/sandbox/react/src/main.tsx +++ b/sandbox/react/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Language } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -8,8 +9,24 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -createRoot(root).render( - - - , -) +// `?machine=rust` runs the palette written in Rust (crates/sandbox → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = + new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' + +async function boot(target: HTMLElement) { + let source: CommandPaletteSource = commandPaletteMachineConfig + if (language === 'rust') { + // Imported only for the Rust machine, so the TS page loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust') + await rust.loadRust() + source = props => rust.createRustPalette(props.commands) + } + createRoot(target).render( + + + , + ) +} + +void boot(root) diff --git a/sandbox/react/vite.config.ts b/sandbox/react/vite.config.ts index 26d9620..97bc9ea 100644 --- a/sandbox/react/vite.config.ts +++ b/sandbox/react/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ resolve: { alias: { '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), '@dunky.dev/react-state-machine': resolve(__dirname, '../../packages/react/src'), '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), diff --git a/sandbox/shared/package.json b/sandbox/shared/package.json index 425d0d2..fa6f570 100644 --- a/sandbox/shared/package.json +++ b/sandbox/shared/package.json @@ -6,10 +6,13 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./rust": "./src/rust.ts", + "./rust-node": "./src/rust-node.ts" }, "peerDependencies": { "@dunky.dev/state-machine": "workspace:^", - "@dunky.dev/state-machine-bindings": "workspace:^" + "@dunky.dev/state-machine-bindings": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^" } } diff --git a/sandbox/shared/src/index.ts b/sandbox/shared/src/index.ts index 2ae22e0..ea943d8 100644 --- a/sandbox/shared/src/index.ts +++ b/sandbox/shared/src/index.ts @@ -9,4 +9,5 @@ export type { CommandPaletteComputed, CommandPaletteState, CommandPaletteProps, + CommandPaletteSource, } from './types' diff --git a/sandbox/shared/src/rust-node.ts b/sandbox/shared/src/rust-node.ts new file mode 100644 index 0000000..6fc58b9 --- /dev/null +++ b/sandbox/shared/src/rust-node.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' +import { loadRustSync } from './rust' + +/** Load the sandbox wasm in Node or Bun from the built package. */ +export function loadRustNode(): void { + loadRustSync( + readFileSync(new URL('../../../crates/sandbox/pkg/dunky_sandbox_bg.wasm', import.meta.url)), + ) +} + +export * from './rust' diff --git a/sandbox/shared/src/rust.ts b/sandbox/shared/src/rust.ts new file mode 100644 index 0000000..0c55acb --- /dev/null +++ b/sandbox/shared/src/rust.ts @@ -0,0 +1,65 @@ +/** + * The sandbox machines written in Rust (crates/sandbox), wrapped with `fromWasm`. + * Build the wasm first: `pnpm build:wasm`. Load it once — `await loadRust()` in a + * browser, `loadRustSync(bytes)` (or `./rust-node`) in Node and Bun — then build + * machines with the factories below. Their types come from the Rust types: the build + * generates them onto each class, and `fromWasm` reads them. + */ +import { fromWasm, type TypesOf } from '@dunky.dev/state-machine-wasm' +import init, { + DialogMachine, + initSync, + PaletteMachine, +} from '../../../crates/sandbox/pkg/dunky_sandbox.js' +import type { CommandPaletteMachine } from './machine' +import type { + Command, + CommandPaletteComputed, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteState, +} from './types' + +/** Load the wasm in a browser (fetches `dunky_sandbox_bg.wasm` next to the glue). */ +export async function loadRust(input?: Parameters[0]): Promise { + await init(input) +} + +/** Load the wasm synchronously from its bytes (Node, Bun, tests, workers). */ +export function loadRustSync(bytes: BufferSource): void { + initSync({ module: bytes }) +} + +/** The command palette. `results` crosses the boundary as indices and maps back onto + * the caller's own command objects: Rust owns the ids, rich values stay in JS. */ +export function createRustPalette(commands: Command[]): CommandPaletteMachine { + return fromWasm(new PaletteMachine(commands), { + computed: { + results: { + from: 'resultIndices', + map: raw => Array.from(raw as ArrayLike, i => commands[i]!), + }, + }, + }) +} + +/** A dialog whose `closing` phase lasts `exitMs` (an `after` timer on the host clock). */ +export function createRustDialog(exitMs: number) { + return fromWasm(new DialogMachine(exitMs)) +} + +export type DialogState = TypesOf['state'] +export type DialogContext = TypesOf['context'] +export type DialogEvent = TypesOf['event'] + +// The Rust palette's generated types must be the TS palette's, exactly: `pnpm typecheck` +// fails here as soon as one side changes without the other. +type Same = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Expect = T +type RustPalette = TypesOf +export type RustPaletteMatchesTs = [ + Expect>, + Expect>, + Expect>, + Expect>, +] diff --git a/sandbox/shared/src/types.ts b/sandbox/shared/src/types.ts index 46828ed..d833165 100644 --- a/sandbox/shared/src/types.ts +++ b/sandbox/shared/src/types.ts @@ -1,3 +1,5 @@ +import type { MachineSource } from '@dunky.dev/state-machine' + // ============================================================================= // cmdk — shared types // @@ -59,3 +61,14 @@ export interface CommandPaletteProps { /** Fired whenever the palette opens or closes. */ onOpenChange?: (open: boolean) => void } + +/** Where a renderer gets its palette machine: the TS config, or a ready machine (e.g. the + * Rust palette via wasm). Both seed from the same props and speak the same events. */ +export type CommandPaletteSource = ( + props: CommandPaletteProps, +) => MachineSource< + CommandPaletteState, + CommandPaletteContext, + CommandPaletteEvent, + CommandPaletteComputed +> diff --git a/sandbox/shared/tests/rust.test.ts b/sandbox/shared/tests/rust.test.ts new file mode 100644 index 0000000..72c44e4 --- /dev/null +++ b/sandbox/shared/tests/rust.test.ts @@ -0,0 +1,94 @@ +/** + * The palette written in Rust (through wasm + `fromWasm`) must behave exactly like the + * TS palette for the same event sequence — state, context, computed, and the + * notifications. Needs the wasm build: `pnpm build:wasm`. + */ +import { beforeAll, describe, expect, it, vi } from 'vitest' +import { machine } from '@dunky.dev/state-machine' +import { commandPaletteMachineConfig, DEMO_COMMANDS, type CommandPaletteEvent } from '../src' +import { createRustDialog, createRustPalette, loadRustNode } from '../src/rust-node' + +beforeAll(() => loadRustNode()) + +const script: CommandPaletteEvent[] = [ + { type: 'open' }, + { type: 'move', to: 'down' }, + { type: 'move', to: 'down' }, + { type: 'query.set', query: 'go' }, + { type: 'move', to: 'up' }, + { type: 'highlight', index: 99 }, + { type: 'query.set', query: 'zzz' }, + { type: 'move', to: 'down' }, + { type: 'execute' }, + { type: 'query.set', query: 'theme' }, + { type: 'move', to: 'last' }, + { type: 'execute' }, + { type: 'open' }, + { type: 'move', to: 'first' }, + { type: 'execute' }, + { type: 'close' }, +] + +const snapshot = (m: ReturnType) => ({ + state: m.state, + query: m.context.query, + activeIndex: m.context.activeIndex, + lastExecuted: m.context.lastExecuted, + results: m.computed.results.map(c => c.id), + activeId: m.computed.activeId, +}) + +describe('Rust palette', () => { + it('matches the TS palette step by step, notification for notification', () => { + const ts = machine(commandPaletteMachineConfig({ commands: DEMO_COMMANDS })) + const rs = createRustPalette(DEMO_COMMANDS) + const seen = { ts: [] as unknown[], rs: [] as unknown[] } + ts.subscribe(() => seen.ts.push(snapshot(ts))) + rs.subscribe(() => seen.rs.push(snapshot(rs))) + ts.start() + rs.start() + for (const event of script) { + ts.send(event) + rs.send(event) + expect(snapshot(rs)).toEqual(snapshot(ts)) + } + expect(seen.rs).toEqual(seen.ts) + }) + + it('maps result indices back onto the caller’s own command objects', () => { + const rs = createRustPalette(DEMO_COMMANDS) + rs.send({ type: 'open' }) + expect(rs.computed.results[0]).toBe(DEMO_COMMANDS[0]) + }) + + it('throws on malformed props instead of guessing', () => { + expect(() => + createRustPalette([{ id: 1 } as unknown as (typeof DEMO_COMMANDS)[number]]), + ).toThrow('[machine] bad argument: invalid type') + }) +}) + +describe('Rust dialog', () => { + it('runs the `after` timer on the host clock, and cancels it when the state is left', () => { + vi.useFakeTimers() + try { + const d = createRustDialog(150) + d.start() + d.send({ type: 'open' }) + expect(d.hasTag('visible')).toBe(true) + d.send({ type: 'close' }) + vi.advanceTimersByTime(149) + expect(d.state).toBe('closing') + vi.advanceTimersByTime(1) + expect(d.state).toBe('closed') + expect(d.context.openCount).toBe(1) + + d.send({ type: 'open' }) + d.send({ type: 'close' }) + d.send({ type: 'open' }) // back to open before the exit delay ends + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/sandbox/solid/package.json b/sandbox/solid/package.json index e9ac5b6..43d6d6a 100644 --- a/sandbox/solid/package.json +++ b/sandbox/solid/package.json @@ -13,6 +13,7 @@ "@dunky.dev/state-machine": "workspace:^", "@dunky.dev/state-machine-bindings": "workspace:^", "@dunky.dev/state-machine-utils": "workspace:^", + "@dunky.dev/state-machine-wasm": "workspace:^", "@sandbox/cmdk-core": "workspace:^", "@solidjs/web": "^2.0.0-rc.1", "solid-js": "^2.0.0-rc.1" diff --git a/sandbox/solid/src/app.tsx b/sandbox/solid/src/app.tsx index 3d9110c..21c879a 100644 --- a/sandbox/solid/src/app.tsx +++ b/sandbox/solid/src/app.tsx @@ -1,8 +1,11 @@ import { createSignal } from 'solid-js' -import { DEMO_COMMANDS } from '@sandbox/cmdk-core' +import { DEMO_COMMANDS, type CommandPaletteSource } from '@sandbox/cmdk-core' import { CommandPalette } from './command-palette' -export function App() { +/** The language the palette machine is written in. Both run on the Rust engine. */ +export type Language = 'ts' | 'rust' + +export function App(props: { language: Language; source: CommandPaletteSource }) { const [last, setLast] = createSignal('—') return ( @@ -10,6 +13,7 @@ export function App() {

⌘K Command Palette


{ setLast(c.label) @@ -25,6 +29,12 @@ export function App() {

Last selected: {last()}

+

+ Machine written in {props.language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} + + Switch to {props.language === 'rust' ? 'TypeScript' : 'Rust'} + +

) } diff --git a/sandbox/solid/src/command-palette.tsx b/sandbox/solid/src/command-palette.tsx index 74a5b1b..12a815e 100644 --- a/sandbox/solid/src/command-palette.tsx +++ b/sandbox/solid/src/command-palette.tsx @@ -1,9 +1,9 @@ import { createEffect, For, Show } from 'solid-js' import { type ComponentEffect, normalize, useMachine } from '@dunky.dev/solid-state-machine' import { - commandPaletteMachineConfig, type CommandPaletteMachine, type CommandPaletteProps, + type CommandPaletteSource, connectCommandPalette, } from '@sandbox/cmdk-core' @@ -27,13 +27,10 @@ const cmdkShortcut: ComponentEffect // machine and `normalize` maps the logical bindings to DOM props. The // component is just markup; the look lives in the stylesheet shared with the // React app. -export function CommandPalette(props: CommandPaletteProps) { - const { api } = useMachine( - commandPaletteMachineConfig, - connectCommandPalette, - [cmdkShortcut], - props, - ) +// `source` is the TS config or the Rust machine (see main.tsx), read once: the machine +// is built once, like any initial-only prop. +export function CommandPalette(props: CommandPaletteProps & { source: CommandPaletteSource }) { + const { api } = useMachine(props.source, connectCommandPalette, [cmdkShortcut], props) let inputEl: HTMLInputElement | undefined diff --git a/sandbox/solid/src/main.tsx b/sandbox/solid/src/main.tsx index ed52a73..b346627 100644 --- a/sandbox/solid/src/main.tsx +++ b/sandbox/solid/src/main.tsx @@ -1,5 +1,6 @@ import { render } from '@solidjs/web' -import { App } from './app' +import { commandPaletteMachineConfig, type CommandPaletteSource } from '@sandbox/cmdk-core' +import { App, type Language } from './app' // The stylesheet both web sandboxes share. import '../../shared/src/styles.css' @@ -7,4 +8,20 @@ import '../../shared/src/styles.css' const root = document.getElementById('root') if (!root) throw new Error('missing #root') -render(() => , root) +// `?machine=rust` runs the palette written in Rust (crates/sandbox → wasm) instead +// of the one written in TS. Build the wasm first: `pnpm build:wasm`. +const language: Language = + new URLSearchParams(location.search).get('machine') === 'rust' ? 'rust' : 'ts' + +async function boot(target: HTMLElement) { + let source: CommandPaletteSource = commandPaletteMachineConfig + if (language === 'rust') { + // Imported only for the Rust machine, so the TS page loads no second wasm module. + const rust = await import('@sandbox/cmdk-core/rust') + await rust.loadRust() + source = props => rust.createRustPalette(props.commands) + } + render(() => , target) +} + +void boot(root) diff --git a/sandbox/solid/vite.config.ts b/sandbox/solid/vite.config.ts index eea5af4..23eb478 100644 --- a/sandbox/solid/vite.config.ts +++ b/sandbox/solid/vite.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ dedupe: ['solid-js', '@solidjs/web'], alias: { '@dunky.dev/state-machine': resolve(__dirname, '../../packages/core/src'), + '@dunky.dev/state-machine-wasm': resolve(__dirname, '../../packages/wasm/src'), '@dunky.dev/solid-state-machine': resolve(__dirname, '../../packages/solid/src'), '@dunky.dev/state-machine-utils': resolve(__dirname, '../../packages/shared/utils/src'), '@dunky.dev/state-machine-bindings': resolve(__dirname, '../../packages/shared/bindings/src'), diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 0000000..c20a725 --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Build the wasm of the sandbox machines written in Rust (crates/sandbox -> its pkg/), +# with their TS types: what `?machine=rust` and the sandbox's Rust tests load. +# Needs: rustup target wasm32-unknown-unknown, wasm-bindgen-cli matching the crate +# version (see Cargo.toml), and optionally wasm-opt (binaryen) for the size pass. +set -euo pipefail +cd "$(dirname "$0")/.." +# The features rustc enables by default for wasm32 (Rust >= 1.82); binaryen must accept them. +WASM_OPT_FLAGS=(-Oz --strip-debug --strip-producers + --enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int + --enable-sign-ext --enable-mutable-globals --enable-reference-types --enable-multivalue) + +# build [cargo args...] +build() { + local crate=$1 lib=$2 out=$3 + shift 3 + cargo build -p "$crate" --target wasm32-unknown-unknown --profile wasm "$@" + # --target web: one ES module for browsers (init) and Node/Bun/tests (initSync). + # --weak-refs: instances are freed by a FinalizationRegistry. + # --reference-types: JS values cross as externrefs, so passing one costs no JS call. + wasm-bindgen --target web --weak-refs --reference-types --out-dir "$out" "target/wasm32-unknown-unknown/wasm/$lib.wasm" + if command -v wasm-opt >/dev/null 2>&1; then + wasm-opt "${WASM_OPT_FLAGS[@]}" "$out/${lib}_bg.wasm" -o "$out/${lib}_bg.wasm" + else + echo "wasm-opt not found: skipping the size pass (brew install binaryen)" >&2 + fi + local raw gz + raw=$(wc -c < "$out/${lib}_bg.wasm" | tr -d ' ') + gz=$(gzip -9c "$out/${lib}_bg.wasm" | wc -c | tr -d ' ') + echo "$crate: ${raw} B raw, ${gz} B gzip" +} + +# types <.d.ts> [cargo args...] — a Rust machine's TS types come from its Rust +# types: the crate's `typescript` example prints them for each exported class, and they +# join the class wasm-bindgen declared, where fromWasm reads them. +types() { + local crate=$1 dts=$2 + shift 2 + cargo run -q -p "$crate" --example typescript "$@" >> "$dts" +} + +build dunky-sandbox dunky_sandbox crates/sandbox/pkg --features wasm +types dunky-sandbox crates/sandbox/pkg/dunky_sandbox.d.ts --features wasm diff --git a/tsconfig.build.json b/tsconfig.build.json index ba5a59a..116a41d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,6 +3,7 @@ "references": [ { "path": "./tsconfig.json" }, { "path": "./packages/core" }, + { "path": "./packages/wasm" }, { "path": "./packages/dom" }, { "path": "./packages/react" }, { "path": "./packages/solid" }, @@ -10,6 +11,7 @@ { "path": "./packages/opentui" }, { "path": "./packages/shared/utils" }, { "path": "./packages/shared/bindings" }, - { "path": "./benchmark" } + { "path": "./benchmark/ts" }, + { "path": "./sandbox/shared" } ] } diff --git a/tsconfig.json b/tsconfig.json index 8436124..0f11fdb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "strict": true, "paths": { "@dunky.dev/state-machine": ["./packages/core/src"], + "@dunky.dev/state-machine-wasm": ["./packages/wasm/src"], "@dunky.dev/react-state-machine": ["./packages/react/src"], "@dunky.dev/solid-state-machine": ["./packages/solid/src"], "@dunky.dev/native-state-machine": ["./packages/native/src"], diff --git a/tsdown.config.ts b/tsdown.config.ts index 937d196..51d9e8b 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ // choice. Keep in sync with the publish set in .changeset/config.json. workspace: [ 'packages/core', + 'packages/wasm', 'packages/dom', 'packages/react', 'packages/solid', diff --git a/vitest.config.ts b/vitest.config.ts index 117b2dc..5940b5e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ name: 'default', globals: false, environment: 'node', - include: ['packages/**/tests/**/*.test.{ts,tsx}'], + include: ['packages/**/tests/**/*.test.{ts,tsx}', 'sandbox/shared/tests/**/*.test.ts'], exclude: ['**/node_modules/**', '**/dist/**', 'packages/solid/**'], }, }, diff --git a/website/astro.config.ts b/website/astro.config.ts index 20f7ef6..e503409 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -133,6 +133,14 @@ export default defineConfig({ { label: 'OpenTUI', link: 'libs/opentui' }, ], }, + { + label: 'Rust', + items: [ + { label: 'Two engines', link: 'rust' }, + { label: 'Machines in Rust', link: 'rust/machines' }, + { label: 'Use in TypeScript', link: 'rust/typescript' }, + ], + }, { label: 'Reference', items: [ diff --git a/website/package.json b/website/package.json index 0882d10..3f2b368 100644 --- a/website/package.json +++ b/website/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "astro dev", "build": "astro build && pnpm build:benchmark-demo", - "build:benchmark-demo": "BASE_PATH=/state-machine/benchmark/demo/ OUT_DIR=../../website/dist/state-machine/benchmark/demo pnpm -C ../benchmark/demo build", + "build:benchmark-demo": "BASE_PATH=/state-machine/benchmark/demo/ OUT_DIR=../../../website/dist/state-machine/benchmark/demo pnpm -C ../benchmark/ts/demo build", "preview": "astro preview", "astro": "astro" }, diff --git a/website/src/content/docs/install.mdx b/website/src/content/docs/install.mdx index 85cef64..25b129d 100644 --- a/website/src/content/docs/install.mdx +++ b/website/src/content/docs/install.mdx @@ -25,3 +25,13 @@ toggle.start() toggle.send({ type: 'flip' }) toggle.state // 'on' ``` + +## In Rust + +The same machines exist in Rust, on a second engine that follows the same spec. See +[Two engines](/rust) and [Machines in Rust](/rust/machines): + +```toml +[dependencies] +dunky-core = { git = "https://github.com/dunky-dev/state-machine" } +``` diff --git a/website/src/content/docs/rust/index.mdx b/website/src/content/docs/rust/index.mdx new file mode 100644 index 0000000..f6e2924 --- /dev/null +++ b/website/src/content/docs/rust/index.mdx @@ -0,0 +1,64 @@ +--- +title: Two engines +description: Dunky's state machines exist twice, in TypeScript and in Rust, and both follow one spec. +--- + +Dunky's state machines are implemented **twice**: in TypeScript +(`@dunky.dev/state-machine`) and in Rust (`dunky-core`). Neither engine runs on the +other. Each is tuned for its own runtime, and both follow one behavior contract: +[`packages/core/SPEC.md`](https://github.com/dunky-dev/state-machine/blob/main/packages/core/SPEC.md). + +``` + packages/core/SPEC.md + (the one behavior contract) + / \ + v v +TypeScript engine Rust engine +@dunky.dev/state-machine dunky-core +JS and every target Rust programs, native bindings, + and JS through wasm +``` + +## Which one to use + +- **Building a JS app** (React, Solid, React Native, OpenTUI): write your machines in + **TypeScript**. That is the default, and the fastest path in JS: the context lives in + JS, so nothing crosses a boundary. +- **Building in Rust** (a native app, a game, a server): write them in **Rust**, with + `dunky-core`. The same states, events, guards, effects, timers and computed values. +- **Sharing one machine between Rust and JS**: write it in Rust, and use it from + TypeScript through wasm with [`fromWasm`](/rust/typescript). The machine still runs + on the Rust engine. + +## The crates + +| Crate | What it is | +| -------------- | ------------------------------------------------------------------------- | +| `dunky-core` | The Rust engine: machines, config builder, timers as commands, observers. | +| `dunky-macros` | `#[derive(State, Event, Context)]`, re-exported by `dunky-core`. | +| `dunky-wasm` | `export_machine!`: a Rust machine as a JS class, in your own wasm module. | +| `dunky-uniffi` | React Native over JSI (in progress). | + +The crates are not on crates.io yet. Use them from the repository: + +```toml +[dependencies] +dunky-core = { git = "https://github.com/dunky-dev/state-machine" } +``` + +## How the two stay in sync + +- A behavior change updates the spec, both engines and both test suites in the same + pull request. A change that lands in one engine only is not done. +- The Rust tests are twins of the TS tests, file by file: a new behavior test on one + side gets its twin on the other. +- What only one engine has stays out of the shared spec: TS-only typing (`setup()`), + and Rust-only authoring (derives, timers as commands). + +## Benchmarks + +Each engine has its own suite, on the same scenarios: +[`benchmark/ts`](https://github.com/dunky-dev/state-machine/tree/main/benchmark/ts) (the +TS engine against XState and Zag; see [Benchmark](/benchmark)) and +[`benchmark/rust`](https://github.com/dunky-dev/state-machine/tree/main/benchmark/rust) +(the Rust engine, natively: `cargo bench -p dunky-benchmark`). diff --git a/website/src/content/docs/rust/machines.mdx b/website/src/content/docs/rust/machines.mdx new file mode 100644 index 0000000..cfcda3b --- /dev/null +++ b/website/src/content/docs/rust/machines.mdx @@ -0,0 +1,121 @@ +--- +title: Machines in Rust +description: Write a Dunky machine in Rust with dunky-core. +--- + +`dunky-core` has the same machine model as the TS engine: flat states, +run-to-completion, guards, actions, effects, `after` timers, computed values, watchers +and subscriptions. The difference is the shape: states, events and context are Rust +types, and the config is built once with a builder. + +```toml +[dependencies] +dunky-core = { git = "https://github.com/dunky-dev/state-machine" } +``` + +## Types + +A machine names three types, and the derives generate the glue: + +```rust +use dunky_core::{Config, Context, Event, Machine, State, Types}; + +#[derive(State, Clone, Copy, PartialEq, Eq, Debug)] +enum DialogState { Closed, Open, Closing } + +#[derive(Event, Clone, Debug)] +enum DialogEvent { Open, Close, Toggle } + +#[derive(Context, Clone, Debug, PartialEq)] +struct DialogCtx { exit_ms: u32, open_count: u32 } + +struct Dialog; +impl Types for Dialog { + type State = DialogState; + type Event = DialogEvent; + type Context = DialogCtx; +} +``` + +- `State`: one name per variant (`Closing` → `"closing"`). +- `Event`: one kind per variant (`DialogEventKind::Open`, …), which keys the handlers. A + variant can carry a payload. +- `Context`: a patch builder (`DialogCtx::patch().open_count(1)`), one `Field` per + field, and tracked reads for computed values. + +## Config + +Build the config once; every machine of the type shares it. + +```rust +fn config(exit_ms: u32) -> Config { + use DialogEventKind as E; + use DialogState::{Closed, Closing, Open}; + let mut b = Config::::builder(Closed, DialogCtx { exit_ms, open_count: 0 }); + b.delay("exitDelay", |g| g.context().exit_ms); + b.action("countOpen", |p| { + let n = p.context().open_count + 1; + p.set_context(DialogCtx::patch().open_count(n)); + }); + b.state(Closed, |s| s.on(E::Open, |t| t.target(Open).action("countOpen"))); + b.state(Open, |s| s.tag("visible").on(E::Close, |t| t.target(Closing))); + b.state(Closing, |s| { + s.tag("visible") + .after("exitDelay", |t| t.target(Closed)) + .on(E::Open, |t| t.target(Open).action("countOpen")) + }); + b.build() +} +``` + +Guards, actions, effects and delays are inline closures or registered names +(`b.guard`, `b.action`, `b.effect`, `b.delay`); a name resolves when the config is +built. + +## Running it + +```rust +let dialog = Machine::new(&config(200)); +dialog.start(); +dialog.send(DialogEvent::Open); +assert_eq!(dialog.state(), DialogState::Open); +assert!(dialog.has_tag("visible")); +assert_eq!(dialog.context().open_count, 1); + +let _sub = dialog.subscribe(|| println!("changed")); +// One field, typed (TS: select.context('openCount')): wakes only when it changes. +let _count = dialog.select_field(DialogCtx::OPEN_COUNT).subscribe(|n| println!("{n}")); +``` + +## Timers are commands + +The engine owns no clock. An `after` timer is a **command** your host runs: take the +commands after each call, schedule them on your event loop, and report each due timer. + +```rust +for command in dialog.take_commands() { + // Command::StartTimer { id, ms }: call dialog.fire_timer(id) after ms. + // Command::CancelTimer { id }: drop it. +} +``` + +In tests, `dunky_core::testing::Clock` plays the host with virtual time: + +```rust +let mut clock = dunky_core::testing::Clock::new(&dialog); +dialog.send(DialogEvent::Close); // -> closing, schedules exitDelay +clock.sync(); +clock.advance(200); +assert_eq!(dialog.state(), DialogState::Closed); +``` + +## Computed values + +```rust +let label = b.computed("label", |c| format!("opened {} times", c.context.open_count())); +// later, on a machine: +let text = machine.computed(label); +``` + +A computed value is lazy and memoized: it reruns only when a field, a state or a computed +value it read has changed. diff --git a/website/src/content/docs/rust/typescript.mdx b/website/src/content/docs/rust/typescript.mdx new file mode 100644 index 0000000..6834880 --- /dev/null +++ b/website/src/content/docs/rust/typescript.mdx @@ -0,0 +1,103 @@ +--- +title: Use in TypeScript +description: Run a machine written in Rust from TypeScript, through wasm. +--- + +import Install from '../../../components/install.astro' + + + +A machine written in Rust can run in a JS app, next to your TS machines. It still runs +entirely on the Rust engine, inside its own wasm module; `fromWasm` puts it behind the +same `Machine` interface the TS engine returns, so `connect()`, `useMachine` and every +target take it unchanged. + +## 1. Export the machine + +In your Rust crate, depend on `dunky-core`, `dunky-wasm` and `wasm-bindgen`, and export +the machine type as a JS class: + +```rust +dunky_wasm::export_machine! { + /// The command palette. + pub struct PaletteMachine(palette::Palette); + new(commands: JsValue) { + // A Result body throws its error from the JS constructor. + dunky_wasm::from_js(commands).map(|c| Machine::with_context(&config(), palette::context(c))) + } + computed { palette::RESULTS => Vec, palette::ACTIVE_ID => Option } + // Optional: served too, but left out of the machine's TS types. + internal { palette::RESULT_INDICES => Vec } +} +``` + +The event type derives `#[event(deserialize)]` and the context `#[context(serialize)]`, +so values can cross. Data types your context, events or computed values hold derive +`TsType` next to their serde derives. + +## 2. Build it, with its types + +```bash +cargo build --target wasm32-unknown-unknown --release +wasm-bindgen --target web --weak-refs --reference-types --out-dir pkg \ + target/wasm32-unknown-unknown/release/my_machines.wasm +``` + +Then add the class's TS types: natively, `PaletteMachine::typescript(&config)` returns +them, generated from the Rust types. Print them from a small example binary onto the +`.d.ts`: + +```bash +cargo run --example typescript >> pkg/my_machines.d.ts +``` + +## 3. Use it + +```ts +import { fromWasm } from '@dunky.dev/state-machine-wasm' +import init, { PaletteMachine } from './pkg/my_machines.js' + +await init() +// Machine<'closed' | 'open', { commands: …; query: string; … }, …>: typed from Rust. +const palette = fromWasm(new PaletteMachine(commands)) +palette.start() +palette.send({ type: 'query.set', query: 'git' }) +``` + +In a target, return it from the first argument of `useMachine`, where a config would go: + +```tsx +useMachine(props => fromWasm(new PaletteMachine(props.commands)), connect, effects, props) +``` + +The machine must come fresh from that function (one per component): the hook owns its +lifecycle, as it does for a config. + +## Mapping computed values + +The `computed` option maps a raw value once per change. For example, results that +crossed the boundary as indices go back onto your own objects: + +```ts +fromWasm(new PaletteMachine(commands), { + computed: { + results: { from: 'resultIndices', map: raw => Array.from(raw as number[], i => commands[i]!) }, + }, +}) +``` + +## What it costs + +In JS, a machine written in TypeScript is a little faster: its context already lives in +JS, while a Rust machine refreshes a JS mirror of its context on each change. Write a +machine in Rust to **reuse** it: the same machine runs in Rust programs and on React +Native. + +## Why two engines, not Rust everywhere + +We tried one engine: the TS package as a thin layer over the Rust engine, compiled to wasm. +It worked, but it was not the win it looked like. Part of the TS side still had to mirror +the Rust engine — the context, the computed cache and the transition steps stay in JS, +next to the user code — and every event still paid the JS↔wasm bridge, so it gave up a +bit of speed. So we maintain both engines against one spec: each one runs natively, and +wasm is for machines written in Rust. diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index e14d333..69f8ee9 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -697,6 +697,52 @@ const shareOnX = `https://twitter.com/intent/tweet?text=${encodeURIComponent(sha + +
+
+

Two engines · one spec

+

+ Write it in TypeScript.
Or in Rust. +

+

+ The same machines exist twice: a TypeScript engine for JS and every + target, and a Rust engine for Rust programs and native bindings. Both + follow one behavior contract, with twin test suites, and neither runs + on the other, so each stays tuned for its own runtime. +

+
+
+

TypeScript · @dunky.dev/state-machine

+
{`const dialog = machine({
+  initial: 'closed',
+  context: { openCount: 0 },
+  states: {
+    closed: { on: { open: { target: 'open' } } },
+    open: { on: { close: { target: 'closed' } } },
+  },
+})`}
+
+
+

Rust · dunky-core

+
{`let mut b = Config::::builder(
+    Closed, DialogCtx { open_count: 0 });
+b.state(Closed, |s| s.on(E::Open,
+    |t| t.target(Open)));
+b.state(Open, |s| s.on(E::Close,
+    |t| t.target(Closed)));
+let dialog = Machine::new(&b.build());`}
+
+
+

+ A machine written in Rust also runs in a JS app through wasm, behind + the same Machine interface, so every target takes it. +

+ How the two engines work → +
+
+
@@ -1643,6 +1689,107 @@ const shareOnX = `https://twitter.com/intent/tweet?text=${encodeURIComponent(sha opacity: 0.7; } + /* ==================================================================== + TWO ENGINES SECTION + ==================================================================== */ + .engines-section { + background-color: var(--color-bg); + padding: 6rem 1.5rem; + border-top: 1px solid var(--color-border); + } + .engines-inner { + max-width: 64rem; + margin: 0 auto; + } + .engines-eyebrow { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--color-accent); + margin: 0 0 1.25rem; + } + .engines-heading { + font-size: 2.5rem; + font-weight: 800; + line-height: 1.08; + letter-spacing: -0.02em; + color: var(--color-fg); + margin: 0 0 1.25rem; + } + @media (min-width: 640px) { + .engines-heading { + font-size: 3rem; + } + } + .engines-heading-alt { + color: var(--color-accent); + } + .engines-sub { + font-size: 1.0625rem; + line-height: 1.75; + color: var(--color-muted); + max-width: 42rem; + margin: 0 0 2.5rem; + } + .engines-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + margin-bottom: 2rem; + } + @media (min-width: 768px) { + .engines-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .engine-card { + border: 1px solid var(--color-border); + border-top-width: 3px; + border-radius: 0.75rem; + padding: 1.25rem; + background: var(--color-panel); + min-width: 0; + } + .engine-card-ts { + border-top-color: var(--color-accent); + } + .engine-card-rust { + border-top-color: #22c5d3; + } + .engine-label { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--color-fg); + margin: 0 0 0.75rem; + } + .engine-code { + margin: 0; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.6; + color: var(--color-fg); + background: transparent; + } + .engines-note { + font-size: 0.95rem; + line-height: 1.7; + color: var(--color-muted); + margin: 0 0 1.5rem; + } + .engines-cta { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: none; + } + .engines-cta:hover { + opacity: 0.7; + } + /* ==================================================================== CONTRIBUTE SECTION ==================================================================== */

+ Machine written in {language === 'rust' ? 'Rust' : 'TypeScript'} ·{' '} + + Switch to {language === 'rust' ? 'TypeScript' : 'Rust'} + +