plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


6fd238d — Peter John 2026-11-05T18:41:07+05:30
Io as a built-in primitive with extern methods

Generated files not shown: plum-game/Cargo.lock

README.md CHANGED
@@ -1103,7 +1103,7 @@ fun main(io: Io) =
1103
1103
  io.printLn("hello")
1104
1104
  ```
1105
1105
 
1106
- Every call that leaves the program (printing, files, clock, randomness, network, drawing, input, sound) is a method of the `Io` capability in `plum-std/Io.plum`, the only place `extern fun` may be used; calling an extern anywhere else is a compile error, and `Io` can only be constructed inside std. An entry point (`main`, an `export fun`, a `test`) may take it as its first parameter (a `test` gets `io` implicitly), and code passes it down only to the functions that do I/O, so a function with no `Io` reachable cannot touch the host. That is all purity is: there is no function colouring and no `pure` keyword. `extern fun` declares a function backed by a host-provided wasm import (module `plum`, the function's name), with no Plum body. At the boundary `Int` is narrowed to a wasm `i32` (the rest of the language uses 64-bit `Int`), so extern parameters and results are 32-bit, and an extern can't return an error union. The `io` parameter is not part of the wasm ABI. `plum run`, `plum test` and `plum build` provide the standard host functions (printing, `crash`, clock and random, files, HTTP).
1106
+ Every call that leaves the program (printing, files, clock, randomness, network, drawing, input, sound) is a method of the built-in `Io` capability, declared once as `extern fun` methods in `primitive Io` (`plum-std/Io.plum`); an `Io` cannot be constructed in user code, so no host import is reachable without one. An entry point (`main`, an `export fun`, a `test`) may take it as its first parameter (a `test` gets `io` implicitly), and code passes it down only to the functions that do I/O, so a function with no `Io` reachable cannot touch the host. That is all purity is: there is no function colouring and no `pure` keyword. An `extern fun` method declares a function backed by a host-provided wasm import (module `plum`, the function's name), with no Plum body. At the boundary `Int` is narrowed to a wasm `i32` (the rest of the language uses 64-bit `Int`), so extern parameters and results are 32-bit, and an extern can't return an error union. The `io` parameter is not part of the wasm ABI. `plum run`, `plum test` and `plum build` provide the standard host functions (printing, `crash`, clock and random, files, HTTP).
1107
1107
 
1108
1108
  ```sh
1109
1109
  $ plum run plum-examples/basics.plum
docs/backlog.md CHANGED
@@ -21,12 +21,17 @@ Open work, in the order the user queued it, plus smaller items found during buil
21
21
  - **User's proposal:** make `Actor` a plain struct/record and let embedding give inheritance-like behaviour: a record that embeds another *first* is a subtype of it (`Walker` is an `Actor`, so it can sit in a `Slice[Actor]`), with `override fun` dispatched on the concrete kind. Today record embedding only copies (`a World is not a Stage`), so this means embedding becomes subtyping. That is the box-free trait layout (wasm-gc base struct + subtypes, `isSubtypedTrait`) applied to records, which would retire trait fields, `__get_`/`__set_` accessors and the boxed-trait path. Open: whether behaviour-only traits stay (Rust-style, no fields) or abstract methods on the base record cover them; what `==` means on a subtyped value (identity today); whether only first-position embedding subtypes (the rule today).
22
22
  - **Performance evidence** (BENCHMARK.md, 2026-11-03 benchmarks, M3 Pro headed): at 5.8k nodes the three styles are within about 3% (imperative trait Scene 1.70 ms, pure 1.71, functional 1.75, draw-dominated). Boxed trait field reads from outside the trait cost a dispatch over every actor kind (about 30-40 ns vs 10 ns), which made `stress_world` (20k nodes, y-sort) +92%; the box-free subtyped layout brought it back to closure speed (1.10 vs 1.09 ms). So the proposal keeps the fast path. Method dispatch is still a `ref.test` chain whose cost grows with the number of kinds. Not measured: a functional-style 20k-node world (no culling or sort fixture exists for it) and per-frame allocation/GC of `State` at that size. Add a `stress_world_state_view` fixture to the benchmark item (3) before deciding.
23
23
  6. **Deterministic games** — fixed timestep, input recording and replay, seeded `Random` in game state, state checksums; port the design from `../kam_macroquad` (`main.rs` tick accumulator, `game/replay.rs`). The `Io` capability makes recording and replaying host input straightforward. (Decision record §10.)
24
+ - **Design notes (2026-11-05):** determinism comes from what may enter `update`, not from the style. Rules for both styles: (1) fixed timestep — host accumulates wall time, calls `update` with a constant `dt`, `draw` may interpolate; (2) input is a per-tick `InputFrame` value passed in, never polled inside `update` (a replay is the list of frames); (3) `update` takes no `Io` — the compiler then proves it reads no clock/random/files and draws nothing; `draw(state, io)` keeps `Io` and can't change state; (4) a seeded `Random` lives in the state (each draw returns a new generator); seed from `io` once at startup, recorded with the replay; (5) no ordering by reference identity or host-seeded hashes (std Map's hash is deterministic; keep it so); (6) wasm floats are deterministic IEEE 754 (don't hash NaN bits); (7) hash the state after each tick, compare runs.
25
+ - **Imperative** (`update(world, input)` mutates): deterministic under the rules, but snapshots/rollback need a deep copy or serialization of the mutable graph, checksums need a graph walker that skips shared refs, and mutation order through aliases is part of the semantics (fragile).
26
+ - **Functional** (`update(state, input) -> State`): determinism is almost free — pure by construction (purity pass can check it since `update` has no `Io`); a snapshot is keeping the old value (persistent structures share memory), rollback/resimulation = old state + logged inputs, checksum = hash of a value. Cost: a new state per tick, which FBIP removes when the old value isn't shared. Argues for the functional shape when the game styles become one (see "One way to build a game").
24
27
  7. **Kids' games (ages 4-7)** (user 2026-11-04: LAST, after all the refactoring is done) — review plan `docs/plans/2026-11-04-kids-games.review.md` (written, NOT approved; re-present for a fresh human approve). Seven functional-style games in `plum-examples/games/functional/kids/`: Balloon Pop (tap), Bunny Hop, Letter Bubbles, Caterpillar, Color Piano, Star Rocket (gentle Orb Run), Hoppy Frog (platformer); nothing can be lost. Draft `balloon_pop.plum` exists (untracked) and is blocked by the `List.length` bug below. Re-check the plan against the post-refactor APIs (explicit generics, `Io` primitive, crate paths) before approving.
25
28
 
26
29
  ## Planned, not yet placed in the queue
27
30
 
28
31
  - **Language ideas to discuss later** (user 2026-11-05: "add them ideas to be discussed later"):
29
32
  - **Single inheritance** — DECIDED 2026-11-05, built in run C (T11).
33
+ - **One way to build a game** (user 2026-11-05, idea): collapse the three styles (std/game/imperative Scene, functional FunctionalScene/Ui, pure PureScene/SceneInput) into one; the game-entry shape (below) is chosen with it.
34
+ - **No top-level functions** (user 2026-11-05, thinking, not scheduled): every `fun` lives under a `primitive`, `enum` or `struct`; this also removes top-level `extern fun` (Io's host calls are already `extern` methods on `primitive Io`). The entry point is always on `primitive App` (`primitive App = fun main(io: Io)`). Games (user 2026-11-05, preferred): std `struct Game = abstract fun update(self, io: Io, dt: Float) -> Game; abstract fun draw(self, io: Io)`; the game's `World` embeds `Game` and overrides both; `App.main(io) = io.run(World(..))`. `io.run` hands the game ref to the host (rooted); each frame the host calls two codegen-generated exports that dispatch `game.update`/`game.draw` (normal override dispatch); `update` returns the next `Game` (imperative returns `self`, functional/pure a new value), the host keeps it. A missing `draw` is a compile error (abstract), no magic export names, "init" is just constructing `World` in `main`. Replaces the three free `export fun init/update/draw`. Considered: `App.init/update/draw` statics (untyped name contract). Open (user: "this is more OOP"): the functional alternative `io.run[S](state: S, update: fn(S, Io, Float) -> S, draw: fn(S, Io))`, e.g. `App.main(io) = io.run(World.start(io), World.update, World.draw)` — typed by the call, no base class, state-in/state-out (FBIP, deterministic sim); host stores state + two fn refs. Undecided (user 2026-11-05): pick when the three game styles (imperative, functional, pure scene) are collapsed into ONE way to build a game; the game-entry shape is chosen together with that. Trade-offs noted: embed `Game` uses the struct's single embed slot and makes `update` return the base `Game`; `io.run(state, update, draw)` keeps the concrete type and lets tests swap `update`. Open points: unqualified calls inside a type resolve to its own statics (so helpers stay short at call sites); loose helpers go in a `primitive Name =` namespace (exists today); `test` blocks stay top-level. Scale: ~109 top-level fns in std, ~350 in examples, ~70 in web-test fixtures.
30
35
  - **One way to set up a struct.** Drop `fun init(self)` in favour of field defaults + ordinary constructor functions. `init` is what forces the Boxed layout and the EmbedInit machinery in `ctors.rs`. Main user: `std/game/imperative/Scene.plum`.
31
36
  - **From Pony:** (1) a two-level ownership model (unique + mutable vs shared + immutable, like Pony's `iso`/`val` with `consume`) as the visible face of FBIP, replacing `mut` fields — take into the FBIP plan; (2) no ambient authority: no globals, a function without `Io` provably does no IO — what deterministic sim needs; (3) Pony partial functions (`?`, no error value) argue for `Option` + `?` as the common path and `Result` only when the error value matters. Not copied: union types, structural interfaces, six capabilities.
32
37
 
docs/plans/2026-11-05-compiler-cleanup-phase3b.progress.md ADDED
@@ -0,0 +1,11 @@
1
+ - start faa4d7e
2
+ wasm hashes before T1: .superlite/wasm-before-3b.txt (25 examples, all compile)
3
+ Ruling: skip Wave 0 - cost: none
4
+ Ruling: accept predicted 39 min - forced chain - cost: none
5
+ Ruling: T1 API names *Diag (loadAndMergeDiag, lower::perFileDiag, frontEndDiag); old String names are wrappers removed in T6 - cost: none
6
+ Ruling: T2 residual - inferExpr's Err(String) undefined-name errors are classified Resolution by a registry of exact registered messages (not keywords); full fix = inferExpr returns Diagnostic, moved to phase 4 (spans/node ids) - cost: one narrow string match remains until phase 4
7
+ Ruling: T4 touched plum-core/tests/core/trmc_test.rs (compile return type) - needed - cost: none
8
+ Ruling: final-review checkpoint-dropped files (plum-driver/, Cargo.toml deps, trmc_test, host_test, docs) - bookkeeping; all on disk, workspace member present, T6 full sweep green on the working tree - cost: none
9
+ Ruling: final-review registry accumulation - resetCheck replaces the whole CheckState with Default (clears resolution_msgs) per compile - cost: none
10
+ Ruling: final-review dropped-rewrite state - driver test aRewriteThatFailsItsRecheckIsDropped prepends a breaking pass and asserts the result equals the run without it; order is the PASSES const - cost: none
11
+ Ruling: final-review protocol - CLI stderr/exit tested by undefinedNameIsReportedAsFileLineColumnTypeError; LSP 0-based mapping by typeDiagnosticUsesTheDiagnosticPosition - cost: none
docs/plans/2026-11-05-io-primitive.md ADDED
@@ -0,0 +1,98 @@
1
+ # Io as a built-in primitive with extern methods
2
+
3
+ **Review plan:** docs/plans/2026-11-04-io-primitive-extern-methods.review.md
4
+ **Goal:** `Io` is a built-in primitive (like `Int`) whose host calls are declared once, as `extern fun name(self, ..)` methods in `primitive Io =` (std/Io.plum). The ~50 top-level externs and ~51 forwarding methods go; user code can't construct an `Io` or reach a host import without one, by construction, so the "needs io" scan, `isIoFile` and the "Io can only be constructed inside std" checks go. Host import names (`env.drawRect`, ...) and the host side are unchanged.
5
+ **Architecture:** plum-core (ast BUILTIN_PRIMITIVES, parser collectBody, tests_lower, purity), plum-checker, plum-wasm-codegen, plum-std/Io.plum (+ callers), plum-game web-test.
6
+ **Tech stack:** Rust 2021, Plum, wasm-gc.
7
+ **Detail:** contracts
8
+ **Target-min:** 30
9
+
10
+ ## Global constraints
11
+ - Work on `master` in the main checkout, default shared `target/`. Don't commit. Never run `plum format` on real files. No `sed -i -E`.
12
+ - No `cargo test` (hook): `cargo build --tests`; per-crate test binaries in `target/debug/deps` (`core-*`, `checker-*`, `cli-*`, `wasm_codegen-*`, `driver_tests-*`; newest by mtime `/bin/ls -t`). Plum sweep `cargo build -p plum-cli && target/debug/plum test plum-std plum-examples` (662).
13
+ - Rulings (coordinator, AI orchestrator): an `Io` value is an `i32` constant 0 in wasm (review plan's fallback; dropping the value entirely is a later optimisation). `raw*` host calls are ordinary `extern` methods on `Io`, not hidden (they need an `Io` anyway). Extern methods are allowed only in `primitive Io`. Top-level `extern fun` stays a language feature for now (removal is the user's call; ask after this run).
14
+ - T1-T2 add the new path next to today's `struct Io` path (both work); T3 switches std; T4 deletes the old path.
15
+
16
+ ## Task graph
17
+ ```mermaid
18
+ flowchart LR
19
+ T1[core + checker: primitive Io, extern methods]
20
+ T2[codegen: Io as i32 0, extern method imports]
21
+ T3[std Io.plum rewrite]
22
+ T4[delete old struct-Io paths]
23
+ T5[verify incl. browser games]
24
+ %% taskgraph
25
+ T1 --> T2
26
+ T2 --> T3
27
+ T3 --> T4
28
+ T4 --> T5
29
+ ```
30
+ Ruling: sequential [chain] - checker support, then codegen, then the std switch that deletes the old path - cost: one task at a time.
31
+
32
+ ## Package A: compiler
33
+ **Probe entry:** run=plum test plum-std plum-examples
34
+
35
+ ### T1 core and checker: primitive Io, extern methods · Complexity 4 · Est. context 45k
36
+ **Owns:** plum-core/src/ast.rs, plum-core/src/parser.rs, plum-core/src/tests_lower.rs, plum-core/src/purity.rs, plum-core/src/loader.rs, plum-checker/src/*.rs, plum-checker/tests/checker/io_primitive_tests.rs, plum-checker/tests/checker/main.rs, plum-core/tests/core/parser_test.rs
37
+ **Components:** 2
38
+ **LOC owned:** 120
39
+ **Tests:** fast
40
+ **Model:** sonnet
41
+ **Produces:** `primitive Io = extern fun f(self, ..)` parses and checks; `Io` is a built-in type (`BUILTIN_PRIMITIVES` has "Io"); `io.f(a)` types as the extern's return; extern methods elsewhere rejected
42
+ e.g. `primitive Io =\n extern fun printLn(self, s: Str)` + `fun main(io: Io) = io.printLn("hi")` -> checks; `struct R =\n extern fun f(self)` -> error "extern methods are only allowed on Io"; `primitive Int =\n extern fun g(self)` (in a test program) -> same error; user `Io()` -> error "Io has no constructor"
43
+ **Notes:** parser `collectBody` keeps extern fns in a primitive body. Tests' `io` binding, tests_lower's io param and purity's io detection recognise `primitive Io` as well as today's `struct Io` (old path stays until T3). An `Io` param/local/closure capture types as `TNamed("Io")` treated as built-in.
44
+ **Test code:** io_primitive_tests: the e.g. cases; a test block using `io` with `primitive Io` in scope; purity: fn taking Io not pure. All binaries + sweep green.
45
+
46
+ ### T2 codegen: Io value and extern-method imports · Complexity 4 · Est. context 45k
47
+ **Owns:** plum-wasm-codegen/src/*.rs, plum-wasm-codegen/tests/wasm_codegen/e2e_types/io_primitive.rs, plum-wasm-codegen/tests/wasm_codegen/e2e_types/mod.rs
48
+ **Components:** 1
49
+ **LOC owned:** 120
50
+ **Tests:** fast
51
+ **Model:** sonnet
52
+ **Consumes:** `primitive Io = extern fun f(self, ..)` parses and checks; `Io` is a built-in type (`BUILTIN_PRIMITIVES` has "Io"); `io.f(a)` types as the extern's return; extern methods elsewhere rejected (T1)
53
+ **Produces:** with `primitive Io`, an `Io` is wasm `i32` (constant 0), each extern method is imported as `env.<name>` with the extern ABI (Int narrowed to i32 as today), `io.f(a)` calls the import with the receiver dropped, entry wrappers pass `i32.const 0` (no `struct.new`)
54
+ e.g. a program with `primitive Io` declaring `extern fun printLn(self, s: Str)` and `fun main(io: Io) = io.printLn("hi")` -> wasm imports `env.printLn`, no Io struct type; running it with the test host prints "hi"
55
+ **Notes:** closures capturing `io` and a generic fn instantiated with an `Io` argument must compile. `Array`/other built-in special paths must not catch `Io`. Today's `struct Io` codegen path stays until T3.
56
+ **Test code:** e2e io_primitive: the e.g. case; a closure capturing io; a test block calling `io.printLn`; existing wasm_codegen + sweep green.
57
+
58
+ ## Package B: switch
59
+ **Probe entry:** run=plum test plum-std plum-examples
60
+
61
+ ### T3 rewrite std Io.plum on primitive Io · Complexity 3 · Est. context 40k
62
+ **Owns:** plum-std/**/*.plum, plum-examples/**/*.plum, plum-game/web-test/fixtures/*.plum, README.md
63
+ **Components:** 1
64
+ **LOC owned:** 150
65
+ **Tests:** fast
66
+ **Model:** sonnet
67
+ **Consumes:** with `primitive Io`, an `Io` is wasm `i32` (constant 0), each extern method is imported as `env.<name>` with the extern ABI (Int narrowed to i32 as today), `io.f(a)` calls the import with the receiver dropped, entry wrappers pass `i32.const 0` (no `struct.new`) (T2)
68
+ **Produces:** std/Io.plum is `primitive Io =` with one declaration per host call (plus real-work methods like `randomSeed`); every caller uses `io.x(..)`; wasm import lists unchanged
69
+ e.g. `grep -c '^ extern fun' plum-std/Io.plum` == the number of top-level externs today (50); `grep -c '^extern fun' plum-std/Io.plum` -> 0
70
+ **Notes:** before editing, record every example's and fixture's wasm import list (names + types, e.g. via `wasm-tools print` or wasmparser in a scratch script); after, they must be identical. Methods doing real work over a host call stay ordinary methods. Anything that called a top-level extern directly now calls the method.
71
+ **Test code:** import-list comparison identical; `plum test plum-std plum-examples` green; fixtures compile.
72
+
73
+ ### T4 delete the old struct-Io paths · Complexity 3 · Est. context 40k
74
+ **Owns:** plum-core/src/*.rs, plum-checker/src/*.rs, plum-wasm-codegen/src/*.rs, plum-lsp/src/*.rs, plum-checker/tests/checker/**/*.rs, plum-wasm-codegen/tests/wasm_codegen/**/*.rs, plum-core/tests/core/*.rs, plum-cli/tests/cli/*.rs
75
+ **Components:** 1
76
+ **LOC owned:** 80
77
+ **Tests:** fast
78
+ **Model:** sonnet
79
+ **Consumes:** std/Io.plum is `primitive Io =` with one declaration per host call (plus real-work methods like `randomSeed`); every caller uses `io.x(..)`; wasm import lists unchanged (T3)
80
+ **Produces:** no `struct Io`/`Item::Enum .. "Io"` code paths, no "needs io" scan, no `isIoFile`, no Io-construction checks
81
+ e.g. `grep -rn 'e.name == "Io"' plum-*/src` -> no hits; `grep -rn isIoFile plum-*/src` -> no hits
82
+ **Notes:** codegen's Io record type/struct.new entry path and class_type_idx lookups for Io go; tests that built a `struct Io` move to `primitive Io`. LSP index entry for Io stays working.
83
+ **Test code:** the greps empty; all binaries + sweep green.
84
+
85
+ ### T5 verify including browser games · Complexity 2 · Est. context 25k
86
+ **Owns:** docs/plans/2026-11-05-io-primitive.results.md
87
+ **Components:** 1
88
+ **LOC owned:** 30
89
+ **Tests:** slow
90
+ **Model:** sonnet
91
+ **Consumes:** no `struct Io`/`Item::Enum .. "Io"` code paths, no "needs io" scan, no `isIoFile`, no Io-construction checks (T4)
92
+ **Produces:** Io primitive results
93
+ **Notes:** workspace build (zero warnings), every binary, sweep, fixtures; then the browser host: `cargo run --release --manifest-path plum-game/web-test/Cargo.toml -- plum-game/web-test/fixtures/stress_scene.plum --measure 3 --headed --json` and one game from plum-examples/games per style (functional, imperative, pure) runs and draws (frame count > 0, no console errors). Io.plum line count before/after.
94
+ **Test code:** results doc; report red, don't fix.
95
+
96
+ ## Review Focus
97
+ - A: protocol — import names/signatures unchanged; extern ABI narrowing identical.
98
+ - B: state — closures and generics over `Io`; no built-in path treats `Io` as having a heap value.
docs/plans/2026-11-05-io-primitive.results.md ADDED
@@ -0,0 +1,74 @@
1
+ # Io primitive: T5 verification results (2026-11-05)
2
+
3
+ All green. Nothing was fixed or committed.
4
+
5
+ ## Build
6
+
7
+ `cargo build --workspace --tests`: finished, zero Rust warnings. The only warnings are the two pre-existing C `unused function` warnings from `tree-sitter-plum/src/scanner.c` (`set_raw`, `set_bytes`). `cargo build -p plum-cli` is also clean.
8
+
9
+ ## Test binaries
10
+
11
+ | Binary | Result |
12
+ |---|---|
13
+ | core | 393 passed |
14
+ | checker | 146 passed |
15
+ | cli (includes crash, game_web, host_stub, explain_layout, test_scope modules) | 16 passed |
16
+ | wasm_codegen | 212 passed |
17
+ | driver_tests | 3 passed |
18
+ | plum_lsp (lib) | 20 passed |
19
+ | plum_host (lib) | 3 passed |
20
+ | plum_driver (lib) | 0 tests |
21
+ | plum-game host_test (own target dir) | 18 passed |
22
+
23
+ Only the binaries built by this run were counted. A stale `crash_test-*` binary from 16:25 still sits in `target/debug/deps`. It fails `int_literal_overflow_error_has_position`, but `crash_test.rs` is now a module of the `cli` binary, where the same test passes. Ignore it.
24
+
25
+ `target/debug/plum test plum-std plum-examples`: 662 passed, 0 failed, 65/65 files.
26
+
27
+ `plum compile` on all 9 `plum-game/web-test/fixtures/*.plum` succeeded: interleave, rendertarget, stress_pure_scene, stress_scene, stress_state_view_deep, stress_state_view, stress_world, stress, text_sizes. The outputs were deleted.
28
+
29
+ ## Browser host
30
+
31
+ The headed browser ran fine here with `plumRenderer=gpu-webgpu`. Every run printed PASS with no console errors.
32
+
33
+ | Game | Frames in 3s | fps | GPU draw calls |
34
+ |---|---|---|---|
35
+ | stress_scene fixture | 360 | 120 | 1 |
36
+ | functional/game_state_view | 359 | 120 | 7 |
37
+ | imperative/game_scene | 360 | 120 | 7 |
38
+ | pure/game_pure_scene | 359 | 120 | 7 |
39
+
40
+ For stress_scene: avg 8.33 ms, p99 9.2 ms, and 464348 pixels changed over 1s.
41
+
42
+ ## Io.plum size
43
+
44
+ Before (79df289): 249 lines. After: 98 lines.
45
+
46
+ ## `git diff --stat 79df289`
47
+
48
+ 19 files changed, 336 insertions(+), 374 deletions(-). The stat covers tracked files only.
49
+
50
+ ```
51
+ .gitignore | 3 +
52
+ README.md | 2 +-
53
+ docs/backlog.md | 5 +
54
+ plum-checker/src/lib.rs | 80 ++----
55
+ .../tests/checker/features/io_capability.rs | 22 +-
56
+ plum-checker/tests/checker/main.rs | 1 +
57
+ plum-core/src/ast.rs | 11 +-
58
+ plum-core/src/drive.rs | 4 +-
59
+ plum-core/src/purity.rs | 3 +-
60
+ plum-core/src/tests_lower.rs | 2 +-
61
+ plum-core/tests/core/drive_test.rs | 54 ++--
62
+ plum-core/tests/core/parser_test.rs | 14 +
63
+ plum-core/tests/core/purity_test.rs | 23 +-
64
+ plum-game/Cargo.lock | 9 +
65
+ plum-std/Io.plum | 307 ++++++---------------
66
+ plum-wasm-codegen/src/lib.rs | 60 ++--
67
+ .../tests/wasm_codegen/codegen_tests.rs | 18 +-
68
+ .../tests/wasm_codegen/e2e_types/mod.rs | 1 +
69
+ .../tests/wasm_codegen/fusion_alloc_test.rs | 91 ++++++
70
+ ```
71
+
72
+ Untracked and not in the stat: `plum-checker/tests/checker/io_primitive_tests.rs`, `plum-wasm-codegen/tests/wasm_codegen/e2e_types/io_primitive.rs`, `docs/plans/2026-11-05-io-primitive.md`, `docs/plans/2026-11-05-compiler-cleanup-phase3b.progress.md`, `.claude/`.
73
+
74
+ Note: building plum-game updated `plum-game/Cargo.lock` (adds a `plum-driver` package entry). That was a side effect of this verification, not part of the Io change.
plum-checker/src/lib.rs CHANGED
@@ -1011,67 +1011,40 @@ fn checkSourceFull(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1011
1011
  (result, info)
1012
1012
  }
1013
1013
 
1014
- /// `file` is std's `Io.plum`, the only place host externs may be called.
1015
- fn isIoFile(file: &str) -> bool {
1016
- let p = std::path::Path::new(file);
1017
- p.file_name().and_then(|n| n.to_str()) == Some("Io.plum")
1018
- && p.parent().and_then(|d| d.file_name()).and_then(|n| n.to_str()) == Some("plum-std")
1019
- }
1020
-
1021
- /// `file` belongs to the std library tree (`plum-std/...`).
1022
- fn isStdFile(file: &str) -> bool {
1023
- std::path::Path::new(file)
1024
- .components()
1025
- .any(|c| c.as_os_str() == "plum-std")
1026
- }
1027
-
1028
- /// The `Io` capability rules: an `extern fun` is callable only inside std's `Io.plum`
1014
+ /// The `Io` capability rules: no top-level `extern fun`, and the primitive `Io` has no constructor.
1029
- /// and `Io()` is constructed only inside std.
1030
1015
  fn ioCapabilityErrors(source: &ast::Source) -> Vec<CheckError> {
1031
- let externs: BTreeSet<&str> = source
1032
- .items
1033
- .iter()
1034
- .filter_map(|i| match i {
1035
- ast::Item::Fn(f) if f.is_extern => Some(f.name.as_str()),
1036
- _ => None,
1037
- })
1038
- .collect();
1039
- let hasIo = source
1040
- .items
1041
- .iter()
1042
- .any(|i| matches!(i, ast::Item::Enum(e) if e.name == "Io"));
1043
- let checkExterns = !externs.is_empty();
1044
- if !checkExterns && !hasIo {
1045
- return Vec::new();
1046
- }
1047
1016
  let mut errors = Vec::new();
1048
- let mut scan = |file: &str, body: &mut ast::FnBody| {
1017
+ // Host calls are declared only as `extern fun` methods on `primitive Io`.
1049
- let io = isIoFile(file);
1018
+ for item in &source.items {
1050
- let std = isStdFile(file);
1019
+ if let ast::Item::Fn(f) = item {
1051
- plum_core::forms::mapExprs(body, &mut |e| match e {
1020
+ if f.is_extern && f.type_param.is_none() {
1052
- ast::Expr::FnCall(c) if checkExterns && !io && externs.contains(c.name.as_str()) => {
1053
1021
  errors.push(CheckError::at(
1054
- c.line,
1022
+ f.line,
1055
- c.column,
1023
+ f.column,
1056
- format!("{} needs io: call io.{}(..)", c.name, c.name),
1024
+ "host calls are declared as `extern fun` methods on `primitive Io`".to_string(),
1057
1025
  ));
1058
1026
  }
1027
+ }
1028
+ }
1029
+ if !source.hasPrimitiveIo() {
1030
+ return errors;
1031
+ }
1032
+ let mut scan = |body: &mut ast::FnBody| {
1033
+ plum_core::forms::mapExprs(body, &mut |e| match e {
1059
- ast::Expr::FnCall(c) if hasIo && !std && c.name == "Io" => {
1034
+ ast::Expr::FnCall(c) if c.name == ast::IO_TYPE => {
1060
- errors.push(CheckError::at(c.line, c.column, "Io can only be constructed inside std".to_string()));
1035
+ errors.push(CheckError::at(c.line, c.column, "Io has no constructor".to_string()));
1061
1036
  }
1062
- ast::Expr::ClassCall(c) if hasIo && !std && c.type_name == "Io" => {
1037
+ ast::Expr::ClassCall(c) if c.type_name == ast::IO_TYPE => {
1063
- errors.push(CheckError::at(0, 0, "Io can only be constructed inside std".to_string()));
1038
+ errors.push(CheckError::at(0, 0, "Io has no constructor".to_string()));
1064
1039
  }
1065
1040
  _ => {}
1066
1041
  });
1067
1042
  };
1068
1043
  for item in &source.items {
1069
1044
  match item {
1070
- ast::Item::Fn(f) if !f.is_extern => scan(&f.file, &mut f.body.clone()),
1045
+ ast::Item::Fn(f) if !f.is_extern => scan(&mut f.body.clone()),
1071
- ast::Item::Test(t) => {
1072
- scan(&t.file, &mut ast::FnBody::Block(t.body.clone()));
1046
+ ast::Item::Test(t) => scan(&mut ast::FnBody::Block(t.body.clone())),
1073
- }
1074
- ast::Item::Const(c) => scan("", &mut ast::FnBody::Expr(c.value.clone())),
1047
+ ast::Item::Const(c) => scan(&mut ast::FnBody::Expr(c.value.clone())),
1075
1048
  _ => {}
1076
1049
  }
1077
1050
  }
@@ -1702,7 +1675,7 @@ fn checkSourceInner(source: &ast::Source) -> (CheckResult<()>, TypedInfo) {
1702
1675
  let prevFile = (!t.file.is_empty()).then(|| setCurrentFile(&t.file));
1703
1676
  let mut env = global_env.clone();
1704
1677
  // A test may use `io` when std/Io is loaded (the test wrapper passes it).
1705
- if source.items.iter().any(|i| matches!(i, ast::Item::Enum(e) if e.name == "Io")) {
1678
+ if source.hasPrimitiveIo() {
1706
1679
  env.insert("io".to_string(), TypeScheme::mono(PlumType::TNamed("Io".to_string())));
1707
1680
  }
1708
1681
  let mut block_errors = checkBlock(
@@ -1974,14 +1947,11 @@ fn checkFnBody(f: &ast::Fn, global_env: &TypeEnv, ctx: &CheckCtx) -> Vec<CheckEr
1974
1947
  // report it plainly rather than trying to guess which side was "right".
1975
1948
  match (f.is_extern, &f.body) {
1976
1949
  (true, ast::FnBody::Extern) => {
1977
- if f.type_param.is_some() {
1950
+ if f.type_param.as_deref().is_some_and(|t| t != ast::IO_TYPE) {
1978
1951
  errors.push(CheckError::resolution(
1979
1952
  f.line,
1980
1953
  f.column,
1981
- format!(
1982
- "fn '{}': extern functions can't be methods (no receiver)",
1954
+ format!("fn '{}': extern methods are only allowed on Io", f.name),
1983
- f.name
1984
- ),
1985
1955
  ));
1986
1956
  }
1987
1957
  return errors;
plum-checker/tests/checker/features/io_capability.rs CHANGED
@@ -1,4 +1,4 @@
1
- //! The `Io` capability: host externs are callable only through `Io`.
1
+ //! The `Io` capability: the primitive `Io` has no constructor.
2
2
  use plum_checker::checkSource;
3
3
 
4
4
  fn run(src: &str) -> Result<(), String> {
@@ -8,17 +8,21 @@ fn run(src: &str) -> Result<(), String> {
8
8
  r
9
9
  }
10
10
 
11
- const EXTERN: &str = "extern fun printLn(s: Str)\n\nfun hello() =\n printLn(\"hi\")\n";
11
+ const IO: &str = "primitive Io =\n extern fun ping(self) -> Int\n\n";
12
12
 
13
13
  #[test]
14
- fn extern_outside_io_rejected() {
14
+ fn io_method_callable_with_an_io() {
15
- let e = run(EXTERN).unwrap_err();
16
- assert!(e.contains("printLn needs io: call io.printLn(..)"), "{e}");
15
+ run(&format!("{IO}fun f(io: Io) -> Int =\n io.ping()\n")).unwrap();
17
16
  }
18
17
 
19
18
  #[test]
20
- fn io_constructor_outside_std_rejected() {
19
+ fn io_constructor_rejected() {
21
- let src = "struct Io(host: Int) =\n fun ping(self) -> Int =\n 1\n\nfun f() -> Int =\n io := Io(host: 0)\n io.ping()\n";
20
+ let e = run(&format!("{IO}fun f() -> Int =\n io := Io()\n io.ping()\n")).unwrap_err();
22
- let e = run(src).unwrap_err();
23
- assert!(e.contains("Io can only be constructed inside std"), "{e}");
21
+ assert!(e.contains("Io has no constructor"), "{e}");
22
+ }
23
+
24
+ #[test]
25
+ fn top_level_extern_rejected() {
26
+ let e = run("extern fun printLn(s: Str)\n\nfun hello() =\n printLn(\"hi\")\n").unwrap_err();
27
+ assert!(e.contains("host calls are declared as `extern fun` methods on `primitive Io`"), "{e}");
24
28
  }
plum-checker/tests/checker/io_primitive_tests.rs ADDED
@@ -0,0 +1,39 @@
1
+ #![allow(non_snake_case)]
2
+ //! `primitive Io` with `extern fun f(self, ..)` host methods.
3
+ use plum_checker::checkSource;
4
+
5
+ fn run(src: &str) -> Result<(), String> {
6
+ let mut source = plum_test_support::parseRawSource(src);
7
+ plum_core::lower::frontEndDiag(&mut source).map_err(|e| format!("lower: {e:?}"))?;
8
+ checkSource(&source).map_err(|e| e.into_iter().map(|x| x.message).collect::<Vec<_>>().join(" | "))
9
+ }
10
+
11
+ const IO: &str = "primitive Io =\n extern fun printLn(self, s: Str)\n extern fun width(self) -> Float\n\n";
12
+
13
+ #[test]
14
+ fn ioExternMethodsCheckAndTypeAsTheirReturn() {
15
+ run(&format!("{IO}fun main(io: Io) =\n io.printLn(\"hi\")\n")).unwrap();
16
+ run(&format!("{IO}fun w(io: Io) -> Float = io.width()\n")).unwrap();
17
+ let e = run(&format!("{IO}fun w(io: Io) -> Int = io.width()\n")).unwrap_err();
18
+ assert!(!e.is_empty());
19
+ }
20
+
21
+ #[test]
22
+ fn ioIsUsableInClosuresAndTestBlocks() {
23
+ run(&format!("{IO}fun main(io: Io) =\n f := || io.printLn(\"x\")\n f()\n")).unwrap();
24
+ run(&format!("{IO}test \"uses io\"\n io.printLn(\"hi\")\n")).unwrap();
25
+ }
26
+
27
+ #[test]
28
+ fn externMethodsAreOnlyAllowedOnIo() {
29
+ let e = run("struct R(x: Int) =\n extern fun f(self)\n").unwrap_err();
30
+ assert!(e.contains("extern methods are only allowed on Io"), "{e}");
31
+ let e = run("primitive Int =\n extern fun g(self)\n").unwrap_err();
32
+ assert!(e.contains("extern methods are only allowed on Io"), "{e}");
33
+ }
34
+
35
+ #[test]
36
+ fn ioHasNoConstructor() {
37
+ let e = run(&format!("{IO}fun main() =\n io := Io()\n io.printLn(\"x\")\n")).unwrap_err();
38
+ assert!(e.contains("Io has no constructor"), "{e}");
39
+ }
plum-checker/tests/checker/main.rs CHANGED
@@ -3,6 +3,7 @@
3
3
  mod checker_tests;
4
4
  mod closure_hint_test;
5
5
  mod diagnostic_tests;
6
+ mod io_primitive_tests;
6
7
  mod explicit_generics_tests;
7
8
  mod features;
8
9
  mod fusion_pipeline_test;
plum-core/src/ast.rs CHANGED
@@ -32,7 +32,11 @@ pub struct PrimitiveDecl {
32
32
  }
33
33
 
34
34
  /// Compiler built-in types whose API a `primitive` block declares (std only).
35
- pub const BUILTIN_PRIMITIVES: [&str; 6] = ["Int", "Float", "Bool", "Str", "Byte", "Array"];
35
+ pub const BUILTIN_PRIMITIVES: [&str; 7] = ["Int", "Float", "Bool", "Str", "Byte", "Array", "Io"];
36
+
37
+ /// The host capability type. Its host calls are `extern fun f(self, ..)` methods of
38
+ /// `primitive Io =` (std/Io.plum); extern methods are allowed nowhere else.
39
+ pub const IO_TYPE: &str = "Io";
36
40
 
37
41
  /// Separator of an associated item's mangled global name: `Float.Pi`.
38
42
  pub const MEMBER_SEP: &str = ".";
@@ -45,6 +49,11 @@ pub enum TopLevelKind {
45
49
  }
46
50
 
47
51
  impl Source {
52
+ /// Whether the program declares the primitive `Io` (some `extern fun` method of it).
53
+ pub fn hasPrimitiveIo(&self) -> bool {
54
+ self.items.iter().any(|i| matches!(i, Item::Fn(f) if f.is_extern && f.type_param.as_deref() == Some(IO_TYPE)))
55
+ }
56
+
48
57
  /// Records `file` on the source and on every fn it declares.
49
58
  pub fn stampFile(&mut self, file: &str) {
50
59
  self.file = file.to_string();
plum-core/src/drive.rs CHANGED
@@ -1779,7 +1779,9 @@ impl Driver {
1779
1779
  _ => None,
1780
1780
  };
1781
1781
  let Some((p, v)) = cand else { continue };
1782
+ // the whole value is pure: a pure call under an impure one (`sumL(markL(io, build(n)))`)
1783
+ // must not move past a host call
1782
- let isProducer = self.calls(&v).iter().any(|c| self.pure.contains(&c.key));
1784
+ let isProducer = self.calls(&v).iter().any(|c| self.pure.contains(&c.key)) && self.argPure(&v);
1783
1785
  // a just-built value with pure fields moves to its use like a pure producer call,
1784
1786
  // so a consumer applied to it can pick its arm (`layers := Cons(..); draw(layers)`)
1785
1787
  let isBuilt = self.ctorType(&v).is_some() && self.argPure(&v);
plum-core/src/purity.rs CHANGED
@@ -168,7 +168,8 @@ pub fn readOnlyFns(src: &Source) -> HashSet<String> {
168
168
  }
169
169
  let mut info = analyse(f, &ctors);
170
170
  info.impure = if f.is_extern || matches!(f.body, FnBody::Extern) {
171
+ // `self` of a primitive `Io` extern method is the unit-like capability, not an object
171
- f.has_self
172
+ (f.has_self && f.type_param.as_deref() != Some(crate::ast::IO_TYPE))
172
173
  || !f.params.iter().all(|p| match &p.ty {
173
174
  ParamType::Type(t) => t.generics.is_empty() && values.contains(t.name.as_str()),
174
175
  _ => false,
plum-core/src/tests_lower.rs CHANGED
@@ -67,7 +67,7 @@ fn desugarTestsToFns(source: &crate::ast::Source) -> (crate::ast::Source, Vec<(S
67
67
  let mut test_idx = 0usize;
68
68
  let mut counter = 0usize;
69
69
  // Tests may use `io` when std/Io is loaded: the export wrapper passes the `Io`.
70
- let hasIo = source.items.iter().any(|it| matches!(it, crate::ast::Item::Enum(c) if c.name == "Io"));
70
+ let hasIo = source.hasPrimitiveIo();
71
71
  let ioParam = if hasIo {
72
72
  vec![crate::ast::Param {
73
73
  name: "io".to_string(),
plum-core/tests/core/drive_test.rs CHANGED
@@ -157,14 +157,15 @@ fn sharedResultUnchanged() {
157
157
  #[test]
158
158
  fn impure_producer_untouched() {
159
159
  let (s, _) = run(
160
+ "primitive Io =
160
- "extern fun tick(x: Int) -> Int
161
+ extern fun tick(self, x: Int) -> Int
161
- fun mapI(xs: Lst) -> Lst = match xs
162
+ fun mapI(io: Io, xs: Lst) -> Lst = match xs
162
163
  Nil => Nil
163
- Cons(h, t) => Cons(head: tick(h), tail: mapI(t))
164
+ Cons(h, t) => Cons(head: io.tick(h), tail: mapI(io, t))
164
165
  fun sumL(xs: Lst) -> Int = match xs
165
166
  Nil => 0
166
167
  Cons(h, t) => h + sumL(t)
167
- fun total(xs: Lst) -> Int = sumL(mapI(xs))
168
+ fun total(io: Io, xs: Lst) -> Int = sumL(mapI(io, xs))
168
169
  ",
169
170
  );
170
171
  assert!(fusedNames(&s).is_empty());
@@ -343,13 +344,14 @@ fn impureConsumerFusesWithPureProducer() {
343
344
  // Only the producer must be pure: the consumer's effects interleave with the producer's
344
345
  // (pure, unobservable) steps.
345
346
  let (s, notes) = run(&format!(
347
+ "primitive Io =
346
- "extern fun emit(x: Int)
348
+ extern fun emit(self, x: Int)
347
- {MAP_SUM}fun drawL(xs: Lst) -> Int = match xs
349
+ {MAP_SUM}fun drawL(io: Io, xs: Lst) -> Int = match xs
348
350
  Nil => 0
349
351
  Cons(h, t) =>
350
- emit(h)
352
+ io.emit(h)
351
- drawL(t)
353
+ drawL(io, t)
352
- fun go(xs: Lst) -> Int = drawL(mapL(xs))
354
+ fun go(io: Io, xs: Lst) -> Int = drawL(io, mapL(xs))
353
355
  "
354
356
  ));
355
357
  let f = getFn(&s, "drawL$mapL$0").unwrap_or_else(|| panic!("{notes:?}"));
@@ -361,14 +363,15 @@ fun go(xs: Lst) -> Int = drawL(mapL(xs))
361
363
  #[test]
362
364
  fn impureProducerIsExplained() {
363
365
  let (_, notes) = run(
366
+ "primitive Io =
364
- "extern fun tick(x: Int) -> Int
367
+ extern fun tick(self, x: Int) -> Int
365
- fun mapI(xs: Lst) -> Lst = match xs
368
+ fun mapI(io: Io, xs: Lst) -> Lst = match xs
366
369
  Nil => Nil
367
- Cons(h, t) => Cons(head: tick(h), tail: mapI(t))
370
+ Cons(h, t) => Cons(head: io.tick(h), tail: mapI(io, t))
368
371
  fun sumL(xs: Lst) -> Int = match xs
369
372
  Nil => 0
370
373
  Cons(h, t) => h + sumL(t)
371
- fun total(xs: Lst) -> Int = sumL(mapI(xs))
374
+ fun total(io: Io, xs: Lst) -> Int = sumL(mapI(io, xs))
372
375
  ",
373
376
  );
374
377
  assert!(notes.iter().any(|n| n.starts_with("impure producer") && n.contains("mapI")), "{notes:?}");
@@ -381,18 +384,19 @@ fn wrapperAndViewUnfoldToNoNodes() {
381
384
  "enum Node =
382
385
  | Leaf(v: Int)
383
386
  | Pair(l: Node, r: Node)
387
+ primitive Io =
384
- extern fun emit(x: Int)
388
+ extern fun emit(self, x: Int)
385
- fun drawN(n: Node, k: Int) = match n
389
+ fun drawN(io: Io, n: Node, k: Int) = match n
386
- Leaf(v) => emit(v + k)
390
+ Leaf(v) => io.emit(v + k)
387
391
  Pair(l, r) =>
388
- drawN(l, k)
392
+ drawN(io, l, k)
389
- drawN(r, k)
393
+ drawN(io, r, k)
390
394
  primitive Pic =
391
- fun draw(n: Node) =
395
+ fun draw(io: Io, n: Node) =
392
- drawN(n, 0)
396
+ drawN(io, n, 0)
393
397
  fun view(x: Int) -> Node = if x < 0 then Leaf(v: 0) else Pair(l: Leaf(v: x), r: Pair(l: Leaf(v: x + 1), r: Leaf(v: x + 2)))
394
- fun frame(x: Int) =
398
+ fun frame(io: Io, x: Int) =
395
- Pic.draw(view(x))
399
+ Pic.draw(io, view(x))
396
400
  ",
397
401
  );
398
402
  let name = fusedNames(&s).into_iter().find(|n| n.starts_with("drawN$view")).unwrap_or_else(|| panic!("{notes:?}"));
@@ -423,10 +427,8 @@ fun total(xs: Lst) -> Int = sumL(mapB(xs))
423
427
  // migration and the component rewrite. Shapes: a consumer with an extra `Io` argument whose
424
428
  // method shares its name with a mutating method of another type, and children built by
425
429
  // a generic `map`-like producer whose elements call same-named methods of several types.
426
- const IO_DRAW: &str = "extern fun hostClear(c: Int)
430
+ const IO_DRAW: &str = "primitive Io =
427
- struct Io =
428
- fun clear(self, c: Int) =
431
+ extern fun clear(self, c: Int)
429
- hostClear(c)
430
432
  struct Buf(mut n: Int) =
431
433
  fun clear(self) =
432
434
  self.n = 0
plum-core/tests/core/parser_test.rs CHANGED
@@ -950,3 +950,17 @@ fn singleLetterTypeArgument() {
950
950
  assert_eq!(t.generics.len(), 1);
951
951
  assert_eq!(t.generics[0].name, "P");
952
952
  }
953
+
954
+ #[test]
955
+ fn primitive_io_keeps_its_extern_methods() {
956
+ let s = parse("primitive Io =\n extern fun printLn(self, s: Str)\n extern fun width(self) -> Float\n");
957
+ let fns: Vec<&Fn> = s
958
+ .items
959
+ .iter()
960
+ .filter_map(|i| if let Item::Fn(f) = i { Some(f) } else { None })
961
+ .collect();
962
+ assert_eq!(fns.len(), 2);
963
+ assert!(fns.iter().all(|f| f.is_extern && f.has_self && f.type_param.as_deref() == Some("Io")));
964
+ assert!(matches!(fns[0].body, FnBody::Extern));
965
+ assert!(s.hasPrimitiveIo());
966
+ }
plum-core/tests/core/purity_test.rs CHANGED
@@ -15,11 +15,11 @@ fn set(xs: &[&str]) -> HashSet<String> {
15
15
  xs.iter().map(|s| s.to_string()).collect()
16
16
  }
17
17
 
18
- const P: &str = "struct Pt(x: Int)\nextern fun printLn(s: Str)\n";
18
+ const P: &str = "struct Pt(x: Int)\nprimitive Io =\n extern fun printLn(self, s: Str)\n";
19
19
 
20
20
  #[test]
21
21
  fn extern_impure_and_callers_of_pure() {
22
- let s = format!("{P}fun a() = printLn(\"x\")\nfun b() -> Int = 1\nfun c() -> Int = b()\n");
22
+ let s = format!("{P}fun a(io: Io) = io.printLn(\"x\")\nfun b() -> Int = 1\nfun c() -> Int = b()\n");
23
23
  assert_eq!(pure(&s), set(&["b", "c"]));
24
24
  }
25
25
 
@@ -45,20 +45,20 @@ fn recursive_pure_fn_is_pure() {
45
45
  fn mutual_recursion_pure_and_impure() {
46
46
  let s = format!(
47
47
  "{P}fun a(n: Int) -> Int = if n < 1 then 0 else b(n - 1)\nfun b(n: Int) -> Int = a(n - 1)\n\
48
- fun c(n: Int) -> Int = if n < 1 then 0 else d(n - 1)\nfun d(n: Int) -> Int =\n printLn(\"x\")\n c(n - 1)\n"
48
+ fun c(n: Int, io: Io) -> Int = if n < 1 then 0 else d(n - 1, io)\nfun d(n: Int, io: Io) -> Int =\n io.printLn(\"x\")\n c(n - 1, io)\n"
49
49
  );
50
50
  assert_eq!(pure(&s), set(&["a", "b"]));
51
51
  }
52
52
 
53
53
  #[test]
54
54
  fn calls_impure_is_impure_transitively() {
55
- let s = format!("{P}fun a() = printLn(\"x\")\nfun b() = a()\nfun c() = b()\nfun d() -> Int = 2\n");
55
+ let s = format!("{P}fun a(io: Io) = io.printLn(\"x\")\nfun b(io: Io) = a(io)\nfun c(io: Io) = b(io)\nfun d() -> Int = 2\n");
56
56
  assert_eq!(pure(&s), set(&["d"]));
57
57
  }
58
58
 
59
59
  #[test]
60
60
  fn closure_literal_body_counts() {
61
- let s = format!("{P}fun a() =\n f := |x: Int| printLn(\"x\")\n 1\nfun b() =\n f := |x: Int| x + 1\n 1\n");
61
+ let s = format!("{P}fun a(io: Io) =\n f := |x: Int| io.printLn(\"x\")\n 1\nfun b() =\n f := |x: Int| x + 1\n 1\n");
62
62
  assert_eq!(pure(&s), set(&["b"]));
63
63
  }
64
64
 
@@ -73,7 +73,7 @@ fn unknown_callee_impure() {
73
73
  assert!(pure("fun a() -> Int = nope()\n").is_empty());
74
74
  }
75
75
 
76
- const IO: &str = "struct Io =\n fun ping(self) = 1\nstruct World(io: Io, n: Int)\nstruct Pt2(x: Int)\n";
76
+ const IO: &str = "primitive Io =\n extern fun ping(self) -> Int\nstruct World(io: Io, n: Int)\nstruct Pt2(x: Int)\n";
77
77
 
78
78
  #[test]
79
79
  fn io_param_impure_view_pure() {
@@ -104,7 +104,7 @@ fn closure_capturing_io_impure() {
104
104
  #[test]
105
105
  fn virtual_call_through_base_is_impure() {
106
106
  let s = format!(
107
- "{P}struct Actor(w: Int) =\n fun go(self) -> Int = 1\n\nstruct Ship(Actor(w), t: Int) =\n override fun go(self) -> Int =\n printLn(\"x\")\n 2\n\nfun run(a: Actor) -> Int = a.go()\nfun leaf(s: Ship) -> Int = s.go()\nfun plain(a: Actor) -> Int = a.w\n"
107
+ "{P}struct Actor(w: Int) =\n fun go(self, io: Io) -> Int = 1\n\nstruct Ship(Actor(w), t: Int) =\n override fun go(self, io: Io) -> Int =\n io.printLn(\"x\")\n 2\n\nfun run(a: Actor, io: Io) -> Int = a.go(io)\nfun leaf(s: Ship, io: Io) -> Int = s.go(io)\nfun plain(a: Actor) -> Int = a.w\n"
108
108
  );
109
109
  let p = pure(&s);
110
110
  assert!(!p.contains("Actor.go"), "{p:?}");
@@ -118,3 +118,12 @@ fn nonvirtual_method_stays_pure() {
118
118
  let p = pure(&s);
119
119
  assert!(p.contains("Actor.go") && p.contains("run"), "{p:?}");
120
120
  }
121
+
122
+ #[test]
123
+ fn fn_taking_primitive_io_is_not_pure() {
124
+ let src = "primitive Io =\n extern fun mark(self, n: Int)\n\nfun touch(io: Io) =\n io.mark(1)\n\nfun plus(a: Int, b: Int) -> Int = a + b\n";
125
+ let source = plum_test_support::parseRawSource(src);
126
+ let pure = plum_core::purity::pureFns(&source);
127
+ assert!(!pure.contains("touch"), "{pure:?}");
128
+ assert!(pure.contains("plus"), "{pure:?}");
129
+ }
plum-std/Io.plum CHANGED
@@ -7,243 +7,92 @@ import std/game/Vec2
7
7
  import std/game/Color
8
8
 
9
9
  # The host capability. Every call that leaves the program (drawing, input, sound,
10
- # files, environment, clock, randomness, network) is a method of `Io`; the
10
+ # files, environment, clock, randomness, network) is a method of `Io`. `Io` is a
11
- # `extern fun` host imports are declared here and nowhere else. `Io` has no fields
11
+ # built-in primitive with no fields; the host calls are the `extern fun` methods
12
- # and is built only inside std (the host entry points pass the singleton), so a
12
+ # below and nowhere else. The host entry points pass the one `Io` value, so a
13
13
  # function with no `Io` reachable cannot touch the host: that is what "pure" means.
14
14
  #
15
15
  # Texture and sound handles are -1 on failure. File, texture and sound paths are
16
16
  # relative to the asset root. `Int` in an extern signature is narrowed to i32 at the
17
17
  # host boundary, so wall-clock time travels as `Float`.
18
18
 
19
- extern fun screenWidth() -> Float
20
- extern fun screenHeight() -> Float
21
- extern fun frameTime() -> Float
22
- extern fun clear(c: Color)
23
- extern fun drawRect(x: Float, y: Float, w: Float, h: Float, c: Color)
24
- extern fun drawCircle(x: Float, y: Float, r: Float, c: Color)
25
- extern fun drawLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, c: Color)
26
- extern fun drawText(s: Str, x: Float, y: Float, size: Float, c: Color)
27
- extern fun setCamera(x: Float, y: Float, scale: Float)
28
- extern fun resetCamera()
29
- # Loads any image the host decodes (PNG at least); -1 on failure.
30
- extern fun textureLoad(path: Str) -> Int
31
- # Size in pixels; 0 for a bad handle.
32
- extern fun textureWidth(t: Int) -> Int
33
- extern fun textureHeight(t: Int) -> Int
34
- # Linear (true) or nearest (false, the default) filtering.
35
- extern fun textureSetSmooth(t: Int, smooth: Bool)
36
- # A blank, transparent texture of w x h pixels that can be drawn into with
37
- # `renderTarget`; -1 if a side is below 1 or above 8192.
38
- extern fun textureCreate(w: Int, h: Int) -> Int
39
- # Sends the following draws (and `clear`) into texture `t` made by `textureCreate`,
40
- # or back to the screen with -1; any other handle is ignored. Each target has its
41
- # own camera: selecting a target resets its camera to its pixels, and returning to
42
- # the screen restores the screen's camera. The texture's contents persist between
43
- # frames; draw it like any texture once the screen is selected again.
44
- extern fun renderTarget(t: Int)
45
- # The blend mode of the following draws: a `Blend` constant (other values are
46
- # ignored). Every frame starts with `Blend.Normal`.
47
- extern fun blendMode(m: Int)
48
- # The texture at its natural size, top-left at (x, y).
49
- extern fun drawTexture(t: Int, x: Float, y: Float, tint: Color)
50
- # The source rect (sx, sy, sw, sh) in texture pixels drawn into the dest rect
51
- # (x, y, w, h), rotated `rotation` radians clockwise about the dest centre.
52
- extern fun drawTextureEx(t: Int, sx: Float, sy: Float, sw: Float, sh: Float, x: Float, y: Float, w: Float, h: Float, rotation: Float, flipX: Bool, tint: Color)
53
- extern fun fileExists(path: Str) -> Bool
54
- # The whole file; "" if it is missing.
55
- extern fun fileLoad(path: Str) -> Str
56
- # Loads a WAV or OGG file relative to the asset root; -1 on failure. The host
57
- # decodes every sound file in the asset root before `init` runs.
58
- extern fun soundLoad(path: Str) -> Int
59
- # A one-shot play; plays of the same sound may overlap. Volume is 0..1.
60
- extern fun soundPlay(s: Int, volume: Float)
61
- # Starts the sound looping. Call `soundStop` first to restart it cleanly.
62
- extern fun soundLoop(s: Int, volume: Float)
63
- # Stops every play and loop of the sound.
64
- extern fun soundStop(s: Int)
65
- # Changes the volume of a sound that is playing or looping.
66
- extern fun soundSetVolume(s: Int, volume: Float)
67
- extern fun isKeyDown(k: Int) -> Bool
68
- extern fun isKeyPressed(k: Int) -> Bool
69
- extern fun mousePos() -> Vec2
70
- extern fun isMouseDown(b: Int) -> Bool
71
- extern fun isMousePressed(b: Int) -> Bool
72
- extern fun mouseWheel() -> Float
73
- extern fun printLn(s: Str)
74
- extern fun rawReadFile(path: Str) -> Str
75
- extern fun rawWriteFile(path: Str, data: Str) -> Int
76
- extern fun rawExists(path: Str) -> Int
77
- extern fun rawMkdir(path: Str) -> Int
78
- extern fun rawRemove(path: Str) -> Int
79
- extern fun rawRemoveDir(path: Str) -> Int
80
- extern fun rawRename(old_path: Str, new_path: Str) -> Int
81
- extern fun rawTruncate(path: Str, len: Int) -> Int
82
- extern fun rawLink(existing_path: Str, new_path: Str) -> Int
83
- extern fun rawSymlink(target: Str, path: Str) -> Int
84
- extern fun rawReadlink(path: Str) -> Str
85
- extern fun rawRealpath(path: Str) -> Str
86
- extern fun rawChmod(path: Str, mode: Int) -> Int
87
- extern fun rawReadDir(path: Str) -> Str
88
- extern fun rawNowMillis() -> Float
89
- extern fun rawRandomHalf() -> Int
90
- extern fun rawHttpRequest(method: Str, url: Str, headers: Str, body: Str) -> Str
91
-
92
- struct Io =
93
-
94
- fun screenWidth(self) -> Float =
95
- screenWidth()
96
-
97
- fun screenHeight(self) -> Float =
98
- screenHeight()
99
-
100
- fun frameTime(self) -> Float =
101
- frameTime()
102
-
103
- fun clear(self, c: Color) =
104
- clear(c)
105
-
106
- fun drawRect(self, x: Float, y: Float, w: Float, h: Float, c: Color) =
107
- drawRect(x, y, w, h, c)
108
-
109
- fun drawCircle(self, x: Float, y: Float, r: Float, c: Color) =
110
- drawCircle(x, y, r, c)
111
-
112
- fun drawLine(self, x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, c: Color) =
113
- drawLine(x1, y1, x2, y2, thickness, c)
114
-
115
- fun drawText(self, s: Str, x: Float, y: Float, size: Float, c: Color) =
116
- drawText(s, x, y, size, c)
117
-
118
- fun setCamera(self, x: Float, y: Float, scale: Float) =
119
- setCamera(x, y, scale)
120
-
121
- fun resetCamera(self) =
122
- resetCamera()
123
-
124
- fun textureLoad(self, path: Str) -> Int =
125
- textureLoad(path)
126
-
127
- fun textureWidth(self, t: Int) -> Int =
128
- textureWidth(t)
129
-
130
- fun textureHeight(self, t: Int) -> Int =
131
- textureHeight(t)
132
-
133
- fun textureSetSmooth(self, t: Int, smooth: Bool) =
134
- textureSetSmooth(t, smooth)
135
-
136
- fun textureCreate(self, w: Int, h: Int) -> Int =
137
- textureCreate(w, h)
138
-
139
- fun renderTarget(self, t: Int) =
140
- renderTarget(t)
141
-
142
- fun blendMode(self, m: Int) =
143
- blendMode(m)
144
-
145
- fun drawTexture(self, t: Int, x: Float, y: Float, tint: Color) =
146
- drawTexture(t, x, y, tint)
147
-
148
- fun drawTextureEx(self, t: Int, sx: Float, sy: Float, sw: Float, sh: Float, x: Float, y: Float, w: Float, h: Float, rotation: Float, flipX: Bool, tint: Color) =
149
- drawTextureEx(t, sx, sy, sw, sh, x, y, w, h, rotation, flipX, tint)
150
-
151
- fun fileExists(self, path: Str) -> Bool =
152
- fileExists(path)
153
-
154
- fun fileLoad(self, path: Str) -> Str =
155
- fileLoad(path)
156
-
157
- fun soundLoad(self, path: Str) -> Int =
158
- soundLoad(path)
159
-
160
- fun soundPlay(self, s: Int, volume: Float) =
161
- soundPlay(s, volume)
162
-
163
- fun soundLoop(self, s: Int, volume: Float) =
164
- soundLoop(s, volume)
165
-
166
- fun soundStop(self, s: Int) =
167
- soundStop(s)
168
-
169
- fun soundSetVolume(self, s: Int, volume: Float) =
170
- soundSetVolume(s, volume)
171
-
172
- fun isKeyDown(self, k: Int) -> Bool =
173
- isKeyDown(k)
174
-
175
- fun isKeyPressed(self, k: Int) -> Bool =
176
- isKeyPressed(k)
177
-
178
- fun mousePos(self) -> Vec2 =
179
- mousePos()
180
-
181
- fun isMouseDown(self, b: Int) -> Bool =
182
- isMouseDown(b)
183
-
184
- fun isMousePressed(self, b: Int) -> Bool =
185
- isMousePressed(b)
186
-
187
- fun mouseWheel(self) -> Float =
188
- mouseWheel()
189
-
190
- fun printLn(self, s: Str) =
191
- printLn(s)
192
-
193
- fun rawReadFile(self, path: Str) -> Str =
194
- rawReadFile(path)
195
-
196
- fun rawWriteFile(self, path: Str, data: Str) -> Int =
197
- rawWriteFile(path, data)
198
-
199
- fun rawExists(self, path: Str) -> Int =
200
- rawExists(path)
201
-
202
- fun rawMkdir(self, path: Str) -> Int =
203
- rawMkdir(path)
204
-
205
- fun rawRemove(self, path: Str) -> Int =
206
- rawRemove(path)
207
-
208
- fun rawRemoveDir(self, path: Str) -> Int =
209
- rawRemoveDir(path)
210
-
211
- fun rawRename(self, old_path: Str, new_path: Str) -> Int =
212
- rawRename(old_path, new_path)
213
-
214
- fun rawTruncate(self, path: Str, len: Int) -> Int =
215
- rawTruncate(path, len)
216
-
217
- fun rawLink(self, existing_path: Str, new_path: Str) -> Int =
218
- rawLink(existing_path, new_path)
219
-
220
- fun rawSymlink(self, target: Str, path: Str) -> Int =
221
- rawSymlink(target, path)
222
-
223
- fun rawReadlink(self, path: Str) -> Str =
224
- rawReadlink(path)
225
-
226
- fun rawRealpath(self, path: Str) -> Str =
227
- rawRealpath(path)
228
-
229
- fun rawChmod(self, path: Str, mode: Int) -> Int =
230
- rawChmod(path, mode)
231
-
232
- fun rawReadDir(self, path: Str) -> Str =
233
- rawReadDir(path)
234
-
235
- fun rawNowMillis(self) -> Float =
236
- rawNowMillis()
237
-
238
- fun rawRandomHalf(self) -> Int =
239
- rawRandomHalf()
19
+ primitive Io =
20
+ extern fun screenWidth(self) -> Float
21
+ extern fun screenHeight(self) -> Float
22
+ extern fun frameTime(self) -> Float
23
+ extern fun clear(self, c: Color)
24
+ extern fun drawRect(self, x: Float, y: Float, w: Float, h: Float, c: Color)
25
+ extern fun drawCircle(self, x: Float, y: Float, r: Float, c: Color)
26
+ extern fun drawLine(self, x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, c: Color)
27
+ extern fun drawText(self, s: Str, x: Float, y: Float, size: Float, c: Color)
28
+ extern fun setCamera(self, x: Float, y: Float, scale: Float)
29
+ extern fun resetCamera(self)
30
+ # Loads any image the host decodes (PNG at least); -1 on failure.
31
+ extern fun textureLoad(self, path: Str) -> Int
32
+ # Size in pixels; 0 for a bad handle.
33
+ extern fun textureWidth(self, t: Int) -> Int
34
+ extern fun textureHeight(self, t: Int) -> Int
35
+ # Linear (true) or nearest (false, the default) filtering.
36
+ extern fun textureSetSmooth(self, t: Int, smooth: Bool)
37
+ # A blank, transparent texture of w x h pixels that can be drawn into with
38
+ # `renderTarget`; -1 if a side is below 1 or above 8192.
39
+ extern fun textureCreate(self, w: Int, h: Int) -> Int
40
+ # Sends the following draws (and `clear`) into texture `t` made by `textureCreate`,
41
+ # or back to the screen with -1; any other handle is ignored. Each target has its
42
+ # own camera: selecting a target resets its camera to its pixels, and returning to
43
+ # the screen restores the screen's camera. The texture's contents persist between
44
+ # frames; draw it like any texture once the screen is selected again.
45
+ extern fun renderTarget(self, t: Int)
46
+ # The blend mode of the following draws: a `Blend` constant (other values are
47
+ # ignored). Every frame starts with `Blend.Normal`.
48
+ extern fun blendMode(self, m: Int)
49
+ # The texture at its natural size, top-left at (x, y).
50
+ extern fun drawTexture(self, t: Int, x: Float, y: Float, tint: Color)
51
+ # The source rect (sx, sy, sw, sh) in texture pixels drawn into the dest rect
52
+ # (x, y, w, h), rotated `rotation` radians clockwise about the dest centre.
53
+ extern fun drawTextureEx(self, t: Int, sx: Float, sy: Float, sw: Float, sh: Float, x: Float, y: Float, w: Float, h: Float, rotation: Float, flipX: Bool, tint: Color)
54
+ extern fun fileExists(self, path: Str) -> Bool
55
+ # The whole file; "" if it is missing.
56
+ extern fun fileLoad(self, path: Str) -> Str
57
+ # Loads a WAV or OGG file relative to the asset root; -1 on failure. The host
58
+ # decodes every sound file in the asset root before `init` runs.
59
+ extern fun soundLoad(self, path: Str) -> Int
60
+ # A one-shot play; plays of the same sound may overlap. Volume is 0..1.
61
+ extern fun soundPlay(self, s: Int, volume: Float)
62
+ # Starts the sound looping. Call `soundStop` first to restart it cleanly.
63
+ extern fun soundLoop(self, s: Int, volume: Float)
64
+ # Stops every play and loop of the sound.
65
+ extern fun soundStop(self, s: Int)
66
+ # Changes the volume of a sound that is playing or looping.
67
+ extern fun soundSetVolume(self, s: Int, volume: Float)
68
+ extern fun isKeyDown(self, k: Int) -> Bool
69
+ extern fun isKeyPressed(self, k: Int) -> Bool
70
+ extern fun mousePos(self) -> Vec2
71
+ extern fun isMouseDown(self, b: Int) -> Bool
72
+ extern fun isMousePressed(self, b: Int) -> Bool
73
+ extern fun mouseWheel(self) -> Float
74
+ extern fun printLn(self, s: Str)
75
+ extern fun rawReadFile(self, path: Str) -> Str
76
+ extern fun rawWriteFile(self, path: Str, data: Str) -> Int
77
+ extern fun rawExists(self, path: Str) -> Int
78
+ extern fun rawMkdir(self, path: Str) -> Int
79
+ extern fun rawRemove(self, path: Str) -> Int
80
+ extern fun rawRemoveDir(self, path: Str) -> Int
81
+ extern fun rawRename(self, old_path: Str, new_path: Str) -> Int
82
+ extern fun rawTruncate(self, path: Str, len: Int) -> Int
83
+ extern fun rawLink(self, existing_path: Str, new_path: Str) -> Int
84
+ extern fun rawSymlink(self, target: Str, path: Str) -> Int
85
+ extern fun rawReadlink(self, path: Str) -> Str
86
+ extern fun rawRealpath(self, path: Str) -> Str
87
+ extern fun rawChmod(self, path: Str, mode: Int) -> Int
88
+ extern fun rawReadDir(self, path: Str) -> Str
89
+ extern fun rawNowMillis(self) -> Float
90
+ extern fun rawRandomHalf(self) -> Int
91
+ extern fun rawHttpRequest(self, method: Str, url: Str, headers: Str, body: Str) -> Str
240
92
 
241
93
  # A wall-clock 64-bit seed for `Random.seeded` (two host i32 halves; `Int` is
242
94
  # narrowed to i32 at the extern boundary).
243
95
  fun randomSeed(self) -> Int =
244
- hi := rawRandomHalf()
96
+ hi := self.rawRandomHalf()
245
- lo := rawRandomHalf() & 0xFFFFFFFF
97
+ lo := self.rawRandomHalf() & 0xFFFFFFFF
246
98
  return { hi << 32 } | lo
247
-
248
- fun rawHttpRequest(self, method: Str, url: Str, headers: Str, body: Str) -> Str =
249
- rawHttpRequest(method, url, headers, body)
plum-wasm-codegen/src/lib.rs CHANGED
@@ -771,17 +771,6 @@ fn errScratch(expr: &ast::Expr) -> String {
771
771
  format!("errtmp#{}", expr as *const ast::Expr as usize)
772
772
  }
773
773
 
774
- /// The wasm leaf zero for an "undefined" value slot.
775
- fn emitZeroLeaf(vt: ValType, body: &mut Vec<u8>) {
776
- pushSelfPlaceholder(vt, body);
777
- }
778
-
779
- fn emitZeros(ty: &PlumType, body: &mut Vec<u8>) {
780
- for vt in flatValtypes(ty, &gcTypes()) {
781
- emitZeroLeaf(vt, body);
782
- }
783
- }
784
-
785
774
  fn valueFieldsOf(ty: &PlumType) -> Option<Vec<(String, PlumType)>> {
786
775
  match ty {
787
776
  PlumType::TNamed(n) => withGen(|s| s.value_enums.get(n).cloned()),
@@ -1016,6 +1005,14 @@ fn plumTypeToGcValtype(t: &PlumType, registry: &GcTypeRegistry) -> ValType {
1016
1005
  PlumType::TFloat => ValType::F64,
1017
1006
  PlumType::TBool => ValType::I32,
1018
1007
  PlumType::TByte => ValType::I32,
1008
+ // The primitive `Io` (no struct of that name): an i32 constant 0.
1009
+ PlumType::TNamed(name)
1010
+ if name == ast::IO_TYPE
1011
+ && !registry.class_type_idx.contains_key(name)
1012
+ && !registry.enum_super_type_idx.contains_key(name) =>
1013
+ {
1014
+ ValType::I32
1015
+ }
1019
1016
  PlumType::TNamed(name) => match registry
1020
1017
  .class_type_idx
1021
1018
  .get(name)
@@ -1792,13 +1789,10 @@ fn entryTakesIo(f: &ast::Fn) -> bool {
1792
1789
  && matches!(&p.ty, ast::ParamType::Type(t) if t.name == "Io" && t.generics.is_empty()))
1793
1790
  }
1794
1791
 
1795
- /// Host-facing wrapper for an entry point: drops the leading `io: Io` param (building
1796
- /// the `Io` record and passing it) and optionally narrows `Int` to i32 (the `extern fun` ABI).
1792
+ /// Host-facing wrapper for an entry point: drops the leading `io: Io` param (passing the `Io` constant 0) and optionally narrows `Int` to i32 (the `extern fun` ABI).
1797
1793
  #[allow(clippy::too_many_arguments)]
1798
1794
  fn emitEntryWrapper(
1799
1795
  module: &mut WasmModule,
1800
- ctx: &CompileCtx,
1801
- enum_variants: &EnumVariants,
1802
1796
  name: &str,
1803
1797
  idx: u32,
1804
1798
  sig: &FuncSig,
@@ -1822,12 +1816,8 @@ fn emitEntryWrapper(
1822
1816
  b.push(0);
1823
1817
  }
1824
1818
  if io {
1825
- let io_idx = *ctx.gc_types.class_type_idx.get("Io").expect("Io record type");
1826
- let info = enum_variants.get("Io").expect("Io record info");
1819
+ // The primitive `Io` is the i32 constant 0.
1827
- for ty in plum_checker::variantFields(info, &PlumType::TNamed("Io".to_string())) {
1828
- emitZeros(&ty, &mut b);
1829
- }
1830
- Instruction::StructNew(io_idx).encode(&mut b);
1820
+ Instruction::I32Const(0).encode(&mut b);
1831
1821
  }
1832
1822
  for (i, vt) in hostParams.iter().enumerate() {
1833
1823
  Instruction::LocalGet(i as u32).encode(&mut b);
@@ -2110,25 +2100,32 @@ fn compileSourceInner(
2110
2100
  // Register every `extern fun` as a genuine wasm import BEFORE any function.
2111
2101
  let mut func_ids: HashMap<String, u32> = HashMap::new();
2112
2102
  let mut func_sigs: HashMap<String, FuncSig> = HashMap::new();
2103
+ // (key, import index, wrapper params, results, leading receiver params the host import lacks)
2113
- let mut extern_wrappers: Vec<(String, u32, Vec<ValType>, Vec<ValType>)> = Vec::new();
2104
+ let mut extern_wrappers: Vec<(String, u32, Vec<ValType>, Vec<ValType>, usize)> = Vec::new();
2114
2105
  for item in &source.items {
2115
2106
  if let ast::Item::Fn(f) = item {
2116
2107
  if !f.is_extern {
2117
2108
  continue;
2118
2109
  }
2110
+ // An extern METHOD (of the primitive `Io`) takes its receiver as the first
2111
+ // wrapper param; the host import has no receiver.
2112
+ let skip = usize::from(f.type_param.is_some() && f.has_self);
2113
+ let key = if f.type_param.is_some() { fnKey(f) } else { f.name.clone() };
2114
+ if f.type_param.is_none() {
2119
- known_fns.insert(f.name.clone());
2115
+ known_fns.insert(f.name.clone());
2116
+ }
2120
2117
  let sig = unitFuncSig(f, &Subst::new(), &eg, &gc_types);
2121
2118
  let param_types = sig.params.clone();
2122
2119
  let ret = sig.ret.clone();
2123
2120
  // `Int` in an extern signature is narrowed to wasm i32 at the host boundary.
2124
2121
  let narrow = |v: &ValType| if *v == ValType::I64 { ValType::I32 } else { *v };
2125
- let imp_params: Vec<ValType> = param_types.iter().map(narrow).collect();
2122
+ let imp_params: Vec<ValType> = param_types[skip..].iter().map(narrow).collect();
2126
2123
  let imp_results: Vec<ValType> = ret.iter().map(narrow).collect();
2127
2124
  let type_idx = module.addType(&imp_params, &imp_results);
2128
2125
  let func_idx = module.addImport("plum", &f.name, type_idx);
2129
- extern_wrappers.push((f.name.clone(), func_idx, param_types, ret));
2126
+ extern_wrappers.push((key.clone(), func_idx, param_types, ret, skip));
2130
2127
  func_sigs.insert(
2131
- f.name.clone(),
2128
+ key,
2132
2129
  FuncSig {
2133
2130
  param_defaults: Vec::new(),
2134
2131
  ..sig
@@ -2149,7 +2146,8 @@ fn compileSourceInner(
2149
2146
  }
2150
2147
 
2151
2148
  // Extern `Int` narrowing wrappers.
2152
- for (name, imp_idx, params, ret) in &extern_wrappers {
2149
+ for (name, imp_idx, params, ret, skip) in &extern_wrappers {
2150
+ let skip = *skip;
2153
2151
  let mut b: Vec<u8> = Vec::new();
2154
2152
  // With several results, the narrowed ones are parked in locals so each can be
2155
2153
  // widened individually.
@@ -2169,7 +2167,7 @@ fn compileSourceInner(
2169
2167
  } else {
2170
2168
  b.push(0);
2171
2169
  }
2172
- for (i, vt) in params.iter().enumerate() {
2170
+ for (i, vt) in params.iter().enumerate().skip(skip) {
2173
2171
  if *vt == ValType::I64 {
2174
2172
  Instruction::LocalGet(i as u32).encode(&mut b);
2175
2173
  Instruction::LocalGet(i as u32).encode(&mut b);
@@ -2181,7 +2179,7 @@ fn compileSourceInner(
2181
2179
  Instruction::End.encode(&mut b);
2182
2180
  }
2183
2181
  }
2184
- for (i, vt) in params.iter().enumerate() {
2182
+ for (i, vt) in params.iter().enumerate().skip(skip) {
2185
2183
  Instruction::LocalGet(i as u32).encode(&mut b);
2186
2184
  if *vt == ValType::I64 {
2187
2185
  Instruction::I32WrapI64.encode(&mut b);
@@ -2671,7 +2669,7 @@ fn compileSourceInner(
2671
2669
  continue;
2672
2670
  }
2673
2671
  let sig = ctx.func_sigs.get(&fnKey(f)).expect("entry fn has a signature");
2674
- let w = emitEntryWrapper(&mut module, &ctx, &ctx.enum_variants, &f.name, idx, sig, narrowInts, takesIo(f));
2672
+ let w = emitEntryWrapper(&mut module, &f.name, idx, sig, narrowInts, takesIo(f));
2675
2673
  module.addExport(&name, ExportKind::Func, w);
2676
2674
  }
2677
2675
 
@@ -2754,7 +2752,7 @@ fn compileSourceInner(
2754
2752
  if io {
2755
2753
  let f = source.items.iter().find_map(|it| match it { ast::Item::Fn(f) if &f.name == internal_name => Some(f), _ => None }).unwrap();
2756
2754
  let sig = ctx.func_sigs.get(&fnKey(f)).expect("test fn has a signature");
2757
- let w = emitEntryWrapper(&mut module, &ctx, &ctx.enum_variants, &f.name, idx, sig, false, true);
2755
+ let w = emitEntryWrapper(&mut module, &f.name, idx, sig, false, true);
2758
2756
  module.addExport(export_name, ExportKind::Func, w);
2759
2757
  } else {
2760
2758
  module.addExport(export_name, ExportKind::Func, idx);
plum-wasm-codegen/tests/wasm_codegen/codegen_tests.rs CHANGED
@@ -1443,12 +1443,8 @@ fun main() -> Int =
1443
1443
  assert_eq!(runMerged::<i64>("bool_try", src), 1);
1444
1444
  }
1445
1445
 
1446
- /// These tests declare their own `extern fun`s to exercise the host-boundary ABI,
1446
+ /// These tests declare their own `extern fun` methods on `primitive Io` to exercise the
1447
- /// and call them directly. The `Io` capability only lets std's `Io.plum` do that,
1448
- /// so stamp the program as that file (the ABI is what is under test, not the rule).
1449
- fn asIoFile(source: &mut plum_core::ast::Source) {
1450
- source.stampFile("plum-std/Io.plum");
1447
+ /// host-boundary ABI.
1451
- }
1452
1448
 
1453
1449
  // ---- extern `Int` narrowing (T1): i32 at the host boundary ----
1454
1450
 
@@ -1456,11 +1452,10 @@ fn asIoFile(source: &mut plum_core::ast::Source) {
1456
1452
  /// import that is `(i32) -> i32`; returns the module plus `main`'s outcome.
1457
1453
  fn externEcho(arg: &str) -> (wasmtime::Module, Result<i64, String>) {
1458
1454
  let src = format!(
1459
- "extern fun echo(x: Int) -> Int\n\nfun main() -> Int =\n echo({})\n",
1455
+ "primitive Io =\n extern fun echo(self, x: Int) -> Int\n\nfun main(io: Io) -> Int =\n io.echo({})\n",
1460
1456
  arg
1461
1457
  );
1462
- let mut source = parse(&src);
1458
+ let source = parse(&src);
1463
- asIoFile(&mut source);
1464
1459
  let bytes = compileSource(&source).expect("compile failed");
1465
1460
  wasmparser::validate(&bytes).expect("valid wasm");
1466
1461
  let engine = gcEngine();
@@ -1706,10 +1701,9 @@ fn value_nested_flatten() {
1706
1701
  #[test]
1707
1702
  fn extern_value_multi_value() {
1708
1703
  let src = format!(
1709
- "{VEC2}struct Color(r: Int, g: Int)\n\nextern fun mousePos() -> Vec2\nextern fun tint() -> Color\nextern fun move(p: Vec2, c: Color)\n\nfun main() -> Int =\n p := mousePos()\n c := tint()\n move(p, c)\n if p.x == 1.5 && c.g == 0 - 7\n return 1\n return 0\n"
1704
+ "{VEC2}struct Color(r: Int, g: Int)\n\nprimitive Io =\n extern fun mousePos(self) -> Vec2\n extern fun tint(self) -> Color\n extern fun move(self, p: Vec2, c: Color)\n\nfun main(io: Io) -> Int =\n p := io.mousePos()\n c := io.tint()\n io.move(p, c)\n if p.x == 1.5 && c.g == 0 - 7\n return 1\n return 0\n"
1710
1705
  );
1711
- let mut source = parse(&src);
1706
+ let source = parse(&src);
1712
- asIoFile(&mut source);
1713
1707
  let bytes = compileSource(&source).unwrap_or_else(|e| panic!("compile failed: {e}"));
1714
1708
  wasmparser::validate(&bytes).expect("valid wasm");
1715
1709
  let engine = gcEngine();
plum-wasm-codegen/tests/wasm_codegen/e2e_types/io_primitive.rs ADDED
@@ -0,0 +1,78 @@
1
+ //! `primitive Io` with `extern fun f(self, ..)`: an `Io` is the i32 constant 0 and each extern
2
+ //! method is a host import (`plum.<name>`) called without the receiver.
3
+ use plum_test_support::{engine, parseRawSource};
4
+ use plum_wasm_codegen::{compile, CompileOptions};
5
+
6
+ const IO: &str = "primitive Io =\n extern fun mark(self, n: Int)\n extern fun width(self) -> Float\n\n";
7
+
8
+ fn build(src: &str, tests: bool) -> (wasmtime::Engine, wasmtime::Module, Vec<String>) {
9
+ let mut source = parseRawSource(src);
10
+ plum_core::lower::frontEndDiag(&mut source).expect("front end");
11
+ let out = compile(&source, &CompileOptions { tests, ..CompileOptions::default() })
12
+ .unwrap_or_else(|e| panic!("compile failed: {}", e.iter().map(|d| d.to_string()).collect::<Vec<_>>().join("\n")));
13
+ wasmparser::validate(&out.wasm).expect("valid wasm");
14
+ let engine = engine();
15
+ let module = wasmtime::Module::new(&engine, &out.wasm).unwrap();
16
+ (engine, module, out.test_names)
17
+ }
18
+
19
+ fn instantiate(
20
+ engine: &wasmtime::Engine,
21
+ module: &wasmtime::Module,
22
+ ) -> (wasmtime::Store<Vec<i32>>, wasmtime::Instance) {
23
+ let mut store = wasmtime::Store::new(engine, Vec::new());
24
+ let mut linker = wasmtime::Linker::new(engine);
25
+ linker.func_wrap("plum", "mark", |mut c: wasmtime::Caller<'_, Vec<i32>>, n: i32| c.data_mut().push(n)).unwrap();
26
+ linker.func_wrap("plum", "width", || 640.0f64).unwrap();
27
+ linker.define_unknown_imports_as_default_values(&mut store, module).unwrap();
28
+ let instance = linker.instantiate(&mut store, module).unwrap();
29
+ (store, instance)
30
+ }
31
+
32
+ #[test]
33
+ fn extern_methods_are_imports_and_io_is_an_i32() {
34
+ let src = format!("{IO}fun main(io: Io) -> Float =\n io.mark(7)\n io.width()\n");
35
+ let (engine, module, _) = build(&src, false);
36
+ let imports: Vec<String> = module.imports().map(|i| format!("{}.{}", i.module(), i.name())).collect();
37
+ assert!(imports.contains(&"plum.mark".to_string()) && imports.contains(&"plum.width".to_string()), "{imports:?}");
38
+ let (mut store, instance) = instantiate(&engine, &module);
39
+ let main = instance.get_typed_func::<(), f64>(&mut store, "main").unwrap();
40
+ assert_eq!(main.call(&mut store, ()).unwrap(), 640.0);
41
+ assert_eq!(store.data(), &vec![7]);
42
+ }
43
+
44
+ #[test]
45
+ fn closures_and_generic_fns_take_io() {
46
+ let src = format!(
47
+ "{IO}fun twice(f: fn() -> Unit) =\n f()\n f()\n\nfun pass[T](x: T, io: Io) -> T =\n io.mark(1)\n x\n\nfun main(io: Io) -> Int =\n twice(|| io.mark(5))\n pass(3, io)\n"
48
+ );
49
+ let (engine, module, _) = build(&src, false);
50
+ let (mut store, instance) = instantiate(&engine, &module);
51
+ let main = instance.get_typed_func::<(), i64>(&mut store, "main").unwrap();
52
+ assert_eq!(main.call(&mut store, ()).unwrap(), 3);
53
+ assert_eq!(store.data(), &vec![5, 5, 1]);
54
+ }
55
+
56
+ #[test]
57
+ fn test_blocks_can_call_io_methods() {
58
+ let src = format!("{IO}test \"marks\"\n io.mark(9)\n");
59
+ let (engine, module, names) = build(&src, true);
60
+ assert_eq!(names, vec!["marks".to_string()]);
61
+ let (mut store, instance) = instantiate(&engine, &module);
62
+ let f = instance.get_func(&mut store, "test#marks").expect("export");
63
+ let mut results = [wasmtime::Val::I32(0)];
64
+ f.call(&mut store, &[], &mut results).unwrap();
65
+ assert_eq!(store.data(), &vec![9]);
66
+ }
67
+
68
+ #[test]
69
+ fn io_stored_in_a_generic_container_works() {
70
+ let src = format!(
71
+ "{IO}enum Opt[T] =\n | Nope\n | Yep(v: T)\n\nfun use(o: Opt[Io]) =\n match o\n Yep(x) => x.mark(4)\n Nope => 0\n\nfun main(io: Io) -> Int =\n use(Yep(v: io))\n use(Nope)\n 1\n"
72
+ );
73
+ let (engine, module, _) = build(&src, false);
74
+ let (mut store, instance) = instantiate(&engine, &module);
75
+ let main = instance.get_typed_func::<(), i64>(&mut store, "main").unwrap();
76
+ assert_eq!(main.call(&mut store, ()).unwrap(), 1);
77
+ assert_eq!(store.data(), &vec![4]);
78
+ }
plum-wasm-codegen/tests/wasm_codegen/e2e_types/mod.rs CHANGED
@@ -2,6 +2,7 @@
2
2
  mod closure_optional;
3
3
  mod closure_types;
4
4
  mod io_entry;
5
+ mod io_primitive;
5
6
  mod list_and_shadow;
6
7
  mod question;
7
8
  mod subtyping;
plum-wasm-codegen/tests/wasm_codegen/fusion_alloc_test.rs CHANGED
@@ -289,3 +289,94 @@ fn million_pipeline_timed() {
289
289
  tOn.as_millis(), tOff.as_millis(), tBuild.as_millis(), tSlice.as_millis()
290
290
  ));
291
291
  }
292
+
293
+ /// A self-contained program (no imports) parsed as is: `primitive Io` is only allowed in std files
294
+ /// through the loader, so this skips it.
295
+ fn loadRaw(src: &str) -> plum_core::ast::Source {
296
+ let mut source = plum_test_support::parseRawSource(src);
297
+ plum_core::lower::frontEndDiag(&mut source).expect("front end");
298
+ source
299
+ }
300
+
301
+ /// The host calls (`plum.mark`) a run of `main` makes, in order.
302
+ fn markTrace(wasm: &[u8]) -> Vec<i32> {
303
+ let engine = engine(false);
304
+ let module = wasmtime::Module::new(&engine, wasm).unwrap();
305
+ let mut store = wasmtime::Store::new(&engine, Vec::<i32>::new());
306
+ let mut linker = wasmtime::Linker::new(&engine);
307
+ linker
308
+ .func_wrap("plum", "mark", |mut c: wasmtime::Caller<'_, Vec<i32>>, n: i32| c.data_mut().push(n))
309
+ .unwrap();
310
+ linker.define_unknown_imports_as_default_values(&mut store, &module).unwrap();
311
+ let instance = linker.instantiate(&mut store, &module).unwrap();
312
+ let main = instance.get_typed_func::<(), i64>(&mut store, "main").unwrap();
313
+ main.call(&mut store, ()).unwrap();
314
+ store.into_data()
315
+ }
316
+
317
+ const IO_MARK: &str = "primitive Io =
318
+ extern fun mark(self, n: Int)
319
+
320
+ enum Lst =
321
+ | Nil
322
+ | Cons(head: Int, tail: Lst)
323
+
324
+ fun build(n: Int) -> Lst = if n == 0 then Nil else Cons(head: n, tail: build(n - 1))
325
+ fun mapL(xs: Lst) -> Lst = match xs
326
+ Nil => Nil
327
+ Cons(h, t) => Cons(head: h * 10, tail: mapL(t))
328
+ ";
329
+
330
+ /// Fusion keeps every `io.mark(..)` call, once each and in order: the host-call trace of a
331
+ /// fused run equals the unfused (`--no-fusion`) run. An `io` method is read-only, never
332
+ /// pure, so a pure producer fuses into an `Io` consumer but no `io` call is moved, dropped or
333
+ /// duplicated; an `io` call inside a producer keeps that producer unfused.
334
+ #[test]
335
+ fn fusion_keeps_io_calls_in_order() {
336
+ // Pure producer, consumer calls io per element (the producer's cells are fused away).
337
+ let consumer = format!(
338
+ "{IO_MARK}fun drawL(io: Io, xs: Lst) -> Int = match xs
339
+ Nil => 0
340
+ Cons(h, t) =>
341
+ io.mark(h)
342
+ io.mark(h + 1)
343
+ drawL(io, t)
344
+
345
+ fun main(io: Io) -> Int =
346
+ a := drawL(io, mapL(build(5)))
347
+ a + drawL(io, mapL(build(2)))
348
+ "
349
+ );
350
+ let src = loadRaw(&consumer);
351
+ let (fused, plain) = (compile(&src, true), compile(&src, false));
352
+ assert!(
353
+ functions(&fused).iter().any(|f| f.name.starts_with("drawL$mapL")),
354
+ "the pipeline should fuse (test is meaningful)"
355
+ );
356
+ let want: Vec<i32> = [5, 4, 3, 2, 1, 2, 1].iter().flat_map(|n| [n * 10, n * 10 + 1]).collect();
357
+ assert_eq!(markTrace(&plain), want);
358
+ assert_eq!(markTrace(&fused), want);
359
+
360
+ // io call in the producer: an impure producer stays unfused and still runs once per element.
361
+ let producer = format!(
362
+ "{IO_MARK}fun markL(io: Io, xs: Lst) -> Lst = match xs
363
+ Nil => Nil
364
+ Cons(h, t) =>
365
+ io.mark(h)
366
+ Cons(head: h, tail: markL(io, t))
367
+ fun sumL(xs: Lst) -> Int = match xs
368
+ Nil => 0
369
+ Cons(h, t) => h + sumL(t)
370
+
371
+ fun main(io: Io) -> Int =
372
+ a := sumL(markL(io, build(4)))
373
+ io.mark(99)
374
+ a + sumL(markL(io, build(2)))
375
+ "
376
+ );
377
+ let src = loadRaw(&producer);
378
+ let (fused, plain) = (compile(&src, true), compile(&src, false));
379
+ let want = vec![4, 3, 2, 1, 99, 2, 1];
380
+ assert_eq!(markTrace(&plain), want);
381
+ assert_eq!(markTrace(&fused), want);
382
+ }