plum

#treesitter#compiler#wasm

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

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


35af6cfPeter John 2026-07-23T17:55:25+05:30
feat: close closures/match/interpolation known gaps in codegen
.recall/.capture.json CHANGED
@@ -1 +1 @@
1
- {"505dc4d5-42ca-45dc-8df3-1f5e1b1c5bc6": 1886367, "78fe4b8b-6ba2-4a17-b415-0bf00e4a0f4d": 4168472, "61ed18b5-cab0-453c-9a65-dcb8cfc58361": 5702751, "4126b3b8-6d8e-4ca3-8898-fa4ab65a979c": 2683872}
1
+ {"505dc4d5-42ca-45dc-8df3-1f5e1b1c5bc6": 1886367, "78fe4b8b-6ba2-4a17-b415-0bf00e4a0f4d": 4168472, "61ed18b5-cab0-453c-9a65-dcb8cfc58361": 5702751, "4126b3b8-6d8e-4ca3-8898-fa4ab65a979c": 5858356}
.recall/history.md CHANGED
@@ -7749,3 +7749,657 @@ Now let's fix `else if`, which is a real bug — confirmed it's a single combine
7749
7749
  If it's still showing as type-color in your actual running Helix session, that's very likely a **stale cache** — Helix compiles and caches the query set once per running instance, and picks up `highlights.scm` changes only on a full restart, not just closing/reopening the buffer or `:reload`. Fully quit Helix and reopen it (not just the file) and it should show correctly now — let me know if it still doesn't after that, since I'd want to dig further rather than assume.
7750
7750
 
7751
7751
  Also fixed along the way: `tooling/tree-sitter-plum/Makefile`'s `make build`/`make corpus_test` called a bare `tree-sitter` binary that isn't installed globally on this machine — updated it to use `npx --yes tree-sitter-cli`, verified both targets now work and produce `grammars/plum.so`.
7752
+
7753
+ **You:** Ok great can you complete and finish the known gaps
7754
+ - `Bash: cd /Users/pyrossh/Code/plum grep -n -i "known gap" -A 40 README.md | head -80`
7755
+ - `Bash: cd /Users/pyrossh/Code/plum grep -n "^## Known gaps" README.md sed -n '/^## Known gaps/,/^## /p' README.md | head -150`
7756
+ - `Bash: cd /Users/pyrossh/Code/plum grep -n -i "known gaps" README.md wc -l README.md`
7757
+ - `Read: /Users/pyrossh/Code/plum/README.md`
7758
+
7759
+ **Claude:** This is a substantial compiler engineering task — let me first get a clear picture of the codegen internals so I can tackle each gap correctly rather than guessing.
7760
+ - `Agent: Survey codegen internals for known-gaps work`
7761
+
7762
+ **Claude:** That research turned up 7 fairly different problems — three are contained (Small/Medium), two are genuinely large and cross-cutting (real type-inference work, and scanner internals with real regression risk), and two are structurally invasive. Here's the shape of it:
7763
+
7764
+ | # | Gap | Difficulty |
7765
+ |---|-----|------------|
7766
+ | 6 | Named function as `fn(...)` value (`each(double)`) | Small–Medium — design already sketched in an old plan doc |
7767
+ | 2 | Multi-subject `match` | Medium |
7768
+ | 3 | Nested constructor patterns in `match` | Medium |
7769
+ | 4 | Multi-line closure body + closing `)` on the same line | Medium–Large — tree-sitter scanner surgery, regression-prone |
7770
+ | 1 | String interpolation codegen | Large — needs a new runtime string-concat/build subsystem that doesn't exist at all |
7771
+ | 5 | Closure-via-assignment `call_indirect` trap at Float/class types | Large — real fix lives in the type checker's non-unifying inference, not codegen |
7772
+ | 7 | Nested closures | Large — the whole closure-discovery pass is single-level; needs to become recursive with multi-level capture-chain analysis |
7773
+
7774
+ Given the size and risk profile here, I'd rather not silently plow through all seven in one unattended pass — a couple of these touch shared inference/scanner code where a mistake could quietly regress things that already work.
7775
+ - `AskUserQuestion`
7776
+
7777
+ **Claude:** Let's start. First, gap #6 — using a plain top-level function as a `fn(...)`-typed value (e.g. `each(double)`).
7778
+ - `Bash: cd /Users/pyrossh/Code/plum sed -n '1,50p' docs/superpowers/plans/2026-07-20-closures.md | head -5 grep -n "useNamedFunctionAsValue\|named function\|table_index`
7779
+ - `Bash: cd /Users/pyrossh/Code/plum grep -n "struct RawClosure\|struct ClosureInfo\|struct ClosureSigKey\|closures:\|closure_table\|elem_segment\|fn record_closure\|fn`
7780
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7781
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7782
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7783
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7784
+ - `Bash: cd /Users/pyrossh/Code/plum grep -n "STRING_BASE\|HEAP_BASE\|bump_global\s*=\|add_data_segment\|memory.min\|Memory {" plum-wasm-codegen/src/lib.rs | head -30`
7785
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7786
+ - `Bash: cd /Users/pyrossh/Code/plum grep -n "struct FuncSig" -A 5 plum-wasm-codegen/src/lib.rs grep -n "fn add_type\|fn add_function\|fn add_table_element\|fn add_globa`
7787
+
7788
+ **Claude:** Now let's implement. First, the discovery-pass changes in `ClosureWalker` to detect a bare reference to a top-level function name.
7789
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7790
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7791
+
7792
+ **Claude:** Need to import `HashSet`. Let's make all the changes now.
7793
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7794
+
7795
+ **Claude:** Now the `ClosureWalker` struct — add `locals` and `named_fn_refs` fields:
7796
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7797
+
7798
+ **Claude:** Now update the discovery-loop setup to seed `locals` and collect `named_fn_refs`:
7799
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7800
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7801
+
7802
+ **Claude:** Now update `walk_stmt`'s `Assign`/`For` arms to track locals, and `walk_expr`'s `Var` arm to detect named function references:
7803
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7804
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7805
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7806
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7807
+
7808
+ **Claude:** Now update `walk_expr`'s catch-all to split out `Var`:
7809
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7810
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7811
+ - `Bash: grep -n "let mut closures: HashMap<usize, ClosureInfo>\|let ctx = CompileCtx\|let mut state = ModuleState\|for f in &fns {$" /Users/pyrossh/Code/plum/plum-wasm-`
7812
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7813
+ - `Bash: grep -n "Instruction::End\|fn compile_fn_body" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -10`
7814
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7815
+ - `Bash: grep -n "fn finish\|functions.push\|fn add_function" -A 15 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -60`
7816
+ - `Bash: grep -n "Function::new\|CodeSection\|code.function" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -10`
7817
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7818
+ - `Bash: grep -n "Function::new\|encode_leb128_u32\|fn compile_fn_body\|Ok(body)\|-> Result<Vec<u8>, String>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | hea`
7819
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7820
+
7821
+ **Claude:** Now let's wire this into `CompileCtx`/`LocalCtx` and the module-building orchestration. First, the struct fields:
7822
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7823
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7824
+
7825
+ **Claude:** Now `LocalCtx`:
7826
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7827
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7828
+
7829
+ **Claude:** Now the two `LocalCtx` construction sites:
7830
+ - `Bash: grep -n "closure_call_types: &ctx.closure_call_types," /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7831
+ - `Bash: cd /Users/pyrossh/Code/plum sed -i '' 's/closure_call_types: &ctx.closure_call_types,/closure_call_types: \&ctx.closure_call_types,\n named_fn_values: \&ctx.nam`
7832
+ - `Bash: sed -n '1205,1216p;2298,2310p' /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7833
+
7834
+ **Claude:** Good, both aligned correctly. Now the main registration block in `compile_source`, and the `ctx`/`state` construction.
7835
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7836
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7837
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7838
+
7839
+ **Claude:** Now let's update `compile_expr`'s `Var` arm to use this table.
7840
+ - `Bash: grep -n "ast::Expr::Var(name) =>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7841
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7842
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7843
+ - `Bash: sed -n '735,750p;925,940p' /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7844
+ - `Bash: grep -n "fn collect_free_vars\|fn fv_collect_refs_expr\|fn fv_collect_refs_stmt" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7845
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7846
+ - `Bash: grep -n "^fn fv_collect_refs" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7847
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7848
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7849
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7850
+
7851
+ **Claude:** Now update `fv_collect_refs_block` and `fv_collect_refs_expr` signatures and all recursive calls with a sed-based mechanical pass, then fix the `Var` arm.
7852
+ - `Bash: cd /Users/pyrossh/Code/plum python3 - << 'EOF' import re path = "plum-wasm-codegen/src/lib.rs" src = open(path).read() start = src.index("fn fv_collect_refs_blo`
7853
+
7854
+ **Claude:** Now fix the `Var` arm inside `fv_collect_refs_expr` to skip top-level function names:
7855
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7856
+
7857
+ **Claude:** Mechanical threading worked. Now fix the `Var` arm itself:
7858
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7859
+
7860
+ **Claude:** Now update the call site of `collect_free_vars` in `record_closure`:
7861
+ - `Bash: grep -n "collect_free_vars(cl" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7862
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7863
+
7864
+ **Claude:** Now let's check for any other `CompileCtx { ... }` construction sites (tests might build one directly) that need the new `named_fn_values` field, then build.
7865
+ - `Bash: grep -rn "CompileCtx {" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/*.rs 2>/dev/null`
7866
+
7867
+ **Claude:** Good, only one construction site. Let's build.
7868
+ - `Bash: cargo build -p plum-wasm-codegen 2>&1 | tail -100`
7869
+
7870
+ **Claude:** Compiles cleanly. Now let's write a real test proving `each(double)` compiles and runs correctly.
7871
+ - `Bash: grep -n "fn run_main\|fn assert_valid\|fn compile_and_run\|^fn " /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs | head -20`
7872
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
7873
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
7874
+
7875
+ **Claude:** Now let's write the test for gap #6, using the exact `each(double)` scenario the README/plan docs anticipated.
7876
+ - `Bash: grep -n "^fn run_main" -A 15 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
7877
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
7878
+
7879
+ **Claude:** Let's insert our new test right after these closure tests.
7880
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
7881
+ - `Bash: grep -n "cb(5)\|cb(0)\|compile_closure_call\|fn check_fn_call\|ast::Expr::FnCall" /Users/pyrossh/Code/plum/plum-checker/src/lib.rs | head -10`
7882
+ - `Read: /Users/pyrossh/Code/plum/plum-checker/src/lib.rs`
7883
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests named_function -- --nocapture 2>&1 | tail -60`
7884
+
7885
+ **Claude:** All three pass on the first try. Let's run the full test suite to make sure nothing else regressed.
7886
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -80`
7887
+
7888
+ **Claude:** Gap #6 done, all tests green. Let's update the README before moving on.
7889
+ - `Read: /Users/pyrossh/Code/plum/README.md`
7890
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
7891
+
7892
+ **Claude:** Now let's tackle gap #2: multi-subject `match`. First let's look at the current match compilation machinery in detail.
7893
+ - `Bash: grep -n "fn compile_match\|fn compile_match_arms\|fn compile_variant_eq_arm\|fn compile_variant_constructor_arm\|fn compile_case_body\|fn match_covers_every_enu`
7894
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7895
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7896
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7897
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7898
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7899
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7900
+
7901
+ **Claude:** Let's implement. First, the `Collector`'s `match_scratch` type change and its `Match` walk logic:
7902
+ - `Bash: grep -n "match_scratch: HashMap<usize, PlumType>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7903
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7904
+
7905
+ **Claude:** Now the `walk_stmt`'s `Match` arm:
7906
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7907
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7908
+
7909
+ **Claude:** Now the two duplicated slot-allocation blocks:
7910
+ - `Bash: grep -n "for (ptr, ty) in collector.match_scratch.iter()" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7911
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7912
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7913
+
7914
+ **Claude:** Now let's rewrite the core match-compilation functions. Let's view the current state with fresh line numbers:
7915
+ - `Bash: grep -n "^fn match_covers_every_enum_variant\|^fn compile_match\|^fn compile_match_arms\|^fn compile_variant_eq_arm\|^fn compile_variant_constructor_arm\|^fn co`
7916
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7917
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7918
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7919
+ - `Bash: grep -n "compile_match_arms(" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7920
+
7921
+ **Claude:** No stale references. Let's build.
7922
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -150`
7923
+
7924
+ **Claude:** Compiles. Now let's fix the `monomorphize.rs` gap flagged in the research (only mangles the first subject/pattern).
7925
+ - `Read: /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs`
7926
+ - `Edit: /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs`
7927
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-checker 2>&1 | tail -60`
7928
+
7929
+ **Claude:** Now let's write tests for multi-subject match and run everything.
7930
+ - `Bash: grep -n "match_string_pattern_is_a_clear_error\|nested_constructor_pattern_is_a_clear_error" -A 30 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_test`
7931
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
7932
+
7933
+ **Claude:** Let's add multi-subject match tests near the closure tests at the end (with `run_main`), covering compile+runtime correctness.
7934
+ - `Bash: grep -n "fn named_function_used_as_a_value_alongside_a_closure_at_the_same_call_type_runs_correctly" -A 20 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/code`
7935
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
7936
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests multi_subject -- --nocapture 2>&1 | tail -60`
7937
+
7938
+ **Claude:** This is exactly the case in `libs/std/bool.plum`'s `and`/`or` (a full Bool×Bool cross-product with no wildcard). The existing exhaustiveness heuristic only understood single-subject matches — let's generalize it to a proper cross-product check.
7939
+ - `Bash: grep -n "^fn match_covers_every_enum_variant" -A 40 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -45`
7940
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7941
+
7942
+ **Claude:** Now update `compile_match` to use this generalized check regardless of subject count:
7943
+ - `Bash: grep -n "match_covers_every_enum_variant\|if let \[(subject_vt, _)\] = all_subjects" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7944
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7945
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7946
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -60`
7947
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests multi_subject -- --nocapture 2>&1 | tail -30`
7948
+
7949
+ **Claude:** All 4 pass. Let's run the full suite.
7950
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -100`
7951
+
7952
+ **Claude:** All green. Let's update the README and check whether `bool.plum` (which uses this exact `and`/`or` pattern) now actually compiles end-to-end.
7953
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
7954
+ - `Bash: cd /Users/pyrossh/Code/plum cat > /tmp/bool_compile_test.rs << 'EOF' use plum_checker::check_source; use plum_core::AstParser; use plum_wasm_codegen::compile_so`
7955
+ - `Bash: sed -i '' 's#libs/std/bool.plum#/Users/pyrossh/Code/plum/libs/std/bool.plum#' /tmp/bool_check/src/main.rs cd /tmp/bool_check && cargo run 2>&1 | tail -10`
7956
+ - `Bash: rm -rf /tmp/bool_check /tmp/bool_compile_test.rs`
7957
+
7958
+ **Claude:** Confirms our fix directly unblocks `and`/`or` (the remaining error is just `Ok`/`Err` living in a separate file — a cross-module concern, not related to this gap). Now let's tackle gap #3: nested constructor patterns inside `match`.
7959
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7960
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7961
+
7962
+ **Claude:** Given how the recursion works, I can keep this contained — reusing the existing top-level dispatch and adding a new recursive helper just for a constructor pattern's own fields. Let's implement.
7963
+ - `Bash: grep -n "struct Collector\|classcall_scratch: HashMap<usize, u32>,\|next_classcall_slot: u32,\|match_scratch: HashMap" /Users/pyrossh/Code/plum/plum-wasm-codege`
7964
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7965
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7966
+
7967
+ **Claude:** Now let's add the recursive binding/scratch-collection method and rewire the `Match` handling in `walk_stmt`:
7968
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7969
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7970
+
7971
+ **Claude:** Now update the `Match` handling in `walk_stmt` to call this instead of its old flat binding logic:
7972
+ - `Bash: grep -n "ast::Stmt::Match(m) =>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7973
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7974
+
7975
+ **Claude:** This is the one to replace.
7976
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7977
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7978
+ - `Bash: grep -n "next_closure_slot: 0," /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7979
+ - `Bash: cd /Users/pyrossh/Code/plum sed -i '' 's/next_closure_slot: 0,/next_closure_slot: 0,\n nested_class_scratch: HashMap::new(),\n next_nested_class_slot: 0,/' plum`
7980
+
7981
+ **Claude:** Now let's add `LocalCtx` fields and wire the slot-allocation logic (both places):
7982
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7983
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7984
+
7985
+ **Claude:** Now update the two slot-allocation blocks (in `compile_fn_body` and `compile_closure_body`):
7986
+ - `Bash: grep -n "let closure_scratch_base = idx;" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7987
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7988
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7989
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7990
+ - `Bash: grep -n "closure_scratch_base,\s*$\|closure_scratch: collector.closure_scratch,\s*$" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7991
+ - `Bash: cd /Users/pyrossh/Code/plum python3 - << 'EOF' path = "plum-wasm-codegen/src/lib.rs" src = open(path).read() old = " closure_scratch_base,\n closure_scratch: co`
7992
+
7993
+ **Claude:** Now let's build to catch any remaining field-mismatch errors.
7994
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -80`
7995
+
7996
+ **Claude:** Good, plumbing compiles — just unused until we wire it into the constructor-arm field loop. Now let's write `compile_fields`/`compile_field_pattern` and update `compile_variant_constructor_arm`.
7997
+ - `Bash: grep -n "^fn compile_variant_constructor_arm" -A 75 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -80`
7998
+ - `Bash: grep -n "^fn emit_store\|^fn emit_load" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
7999
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8000
+
8001
+ **Claude:** Now let's rewrite `compile_variant_constructor_arm`'s field loop and add the two new recursive helpers.
8002
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8003
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -150`
8004
+
8005
+ **Claude:** Now let's flip the existing "clear error" test into a positive test, and add a couple more covering binding + fallthrough.
8006
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8007
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8008
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests nested_constructor -- --nocapture 2>&1 | tail -60 cargo test -p plum-wasm-codeg`
8009
+ - `Bash: grep -n "fn collect_pattern\|collect_pattern(" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8010
+ - `Bash: grep -n "^fn compile_case_body" -A 40 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -45`
8011
+ - `Bash: grep -n "Stmt::Todo =>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8012
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8013
+
8014
+ **Claude:** Root cause found: the exhaustiveness heuristic clones `m.cases` to append a synthetic wildcard, which reallocates every nested `CasePattern::Class` node at a new address — breaking the pointer-identity-keyed scratch-slot lookup. Let's fix it by threading a boolean flag instead of cloning.
8015
+ - `Bash: grep -n "compile_match_arms_multi(rest, all_subjects, result_vt, body, ctx, state)\|fn compile_match_arms_multi\|fn compile_case_positions\|fn compile_variant_e`
8016
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8017
+
8018
+ **Claude:** Now let's rewrite this whole section with the threaded `exhaustive_fallback: bool` instead of cloning.
8019
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8020
+
8021
+ **Claude:** Now let's view and update `compile_match_arms_multi`'s body and signature:
8022
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8023
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8024
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8025
+
8026
+ **Claude:** Given the volume of mechanical threading needed across these four functions, let's do it with a careful scripted rewrite rather than dozens of manual edits.
8027
+ - `Bash: cd /Users/pyrossh/Code/plum python3 - << 'PYEOF' import re path = "plum-wasm-codegen/src/lib.rs" src = open(path).read() start = src.index("fn compile_case_posi`
8028
+
8029
+ **Claude:** Let's build and see what's left.
8030
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | grep -E "^error" -A 8 | head -200`
8031
+
8032
+ **Claude:** Just one over-eager replacement — `compile_case_body` doesn't need this param. Let's fix it.
8033
+ - `Bash: grep -n "compile_case_body(&case.body, result_vt, exhaustive_fallback, body, ctx, state)" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8034
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8035
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | grep -E "^error" -A 8 | head -200`
8036
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -30`
8037
+ - `Bash: grep -n "exhaustive_fallback" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | wc -l sed -n '1980,2050p' /Users/pyrossh/Code/plum/plum-wasm-codegen/src/l`
8038
+
8039
+ **Claude:** Threading correctly propagated into the nested closure too. Now let's run the tests.
8040
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests -- --nocapture 2>&1 | tail -100`
8041
+
8042
+ **Claude:** All 62 pass, including the two previously-failing nested-constructor tests. Let's run the full workspace suite.
8043
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -50`
8044
+
8045
+ **Claude:** Now let's also fix the monomorphize.rs side for gap #3 — recursing into nested constructor-pattern fields for generic-enum mangling.
8046
+ - `Read: /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs`
8047
+ - `Edit: /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs`
8048
+ - `Bash: grep -n "fn rewrite_stmt\|impl.*Monomorphizer\|enum_variant_mangling:\|enum_variants:" /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs | head -20`
8049
+ - `Read: /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs`
8050
+
8051
+ **Claude:** Let's add `mangle_pattern` right before `rewrite_stmt`:
8052
+ - `Edit: /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs`
8053
+
8054
+ **Claude:** Now let's also update the call site to pass `Option<&BTreeMap<...>>` instead of `Option<BTreeMap<...>>`:
8055
+ - `Bash: grep -n "self.mangle_pattern(pat, subject_ty, variant_mangling" /Users/pyrossh/Code/plum/plum-checker/src/monomorphize.rs`
8056
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-checker 2>&1 | tail -60`
8057
+
8058
+ **Claude:** Now let's write a test proving nested constructor patterns against a generic-enum specialization work correctly.
8059
+ - `Bash: grep -n "fn generic_enum_specialized_and_matched_runs_correctly\|fn generic_enum_multiple_instantiations_coexist_and_run_correctly" -A 20 /Users/pyrossh/Code/pl`
8060
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8061
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests nested_constructor_pattern_against -- --nocapture 2>&1 | tail -40`
8062
+
8063
+ **Claude:** Passes. Let's run the full suite once more to confirm everything's still green.
8064
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -60`
8065
+
8066
+ **Claude:** Gap #3 done. Let's update the README.
8067
+ - `Read: /Users/pyrossh/Code/plum/README.md`
8068
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
8069
+
8070
+ **Claude:** Now gap #4 — the scanner surgery for multi-line closure bodies followed by a closing bracket on the same line. Let's study the scanner carefully first.
8071
+ - `Bash: wc -l /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8072
+ - `Read: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8073
+ - `Read: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8074
+ - `Edit: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8075
+
8076
+ **Claude:** Now let's rebuild the parser (native scanner) and regenerate.
8077
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum make build 2>&1 | tail -40`
8078
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum make corpus_test 2>&1 | tail -40`
8079
+
8080
+ **Claude:** All 28 existing corpus tests still pass. Now let's directly test the exact target syntax with the tree-sitter CLI parse command.
8081
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum cat > /tmp/multiline_closure.plum << 'EOF' each(cb: fn(Int) -> Int) -> Int = cb(5) main() -> Int = each(|v|`
8082
+
8083
+ **Claude:** It parses correctly with zero errors. Let's test a multi-statement body too, and the case with a trailing comma/multiple args.
8084
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum cat > /tmp/multiline_closure2.plum << 'EOF' each(cb: fn(Int) -> Int) -> Int = cb(5) main() -> Int = each(|v`
8085
+
8086
+ **Claude:** Excellent — multi-statement bodies work too. Let's check a couple more edge cases (nested brackets, additional args after the closure) before locking this in.
8087
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum cat > /tmp/multiline_closure3.plum << 'EOF' each(cb: fn(Int) -> Int, extra: Int) -> Int = cb(5) + extra mai`
8088
+ - `Bash: cat /tmp/out3.txt`
8089
+
8090
+ **Claude:** Let's handle the comma-continuation case too (closure followed by more call arguments on the same line) — same root cause.
8091
+ - `Bash: grep -n "at_closing_bracket_without_newline" /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8092
+ - `Read: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8093
+ - `Edit: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8094
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum make build 2>&1 | tail -20 npx --yes tree-sitter-cli parse /tmp/multiline_closure3.plum > /tmp/out3.txt 2>&`
8095
+
8096
+ **Claude:** That specific case (comma continuation) doesn't have `DEDENT` as a valid symbol at that exact parser state, so my extension didn't help — and it's outside the documented gap anyway (which specifically calls out the closing-bracket case). Let's revert that speculative addition and keep scope to what's proven and documented.
8097
+ - `Edit: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/src/scanner.c`
8098
+
8099
+ **Claude:** Let's rebuild and re-verify everything, then run the full corpus + Rust test suites.
8100
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum make build 2>&1 | tail -20 make corpus_test 2>&1 | tail -15`
8101
+
8102
+ **Claude:** Now let's add a corpus test locking in this new behavior, then run the full Rust workspace suite (which will actually compile & execute wasm for this shape).
8103
+ - `Bash: grep -n "function - inline closure literal as a call argument" -A 20 /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/test/corpus/function.txt`
8104
+ - `Read: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/test/corpus/function.txt`
8105
+
8106
+ **Claude:** Let's add a new corpus case right after this for the multi-line body + same-line closing paren.
8107
+ - `Edit: /Users/pyrossh/Code/plum/tooling/tree-sitter-plum/test/corpus/function.txt`
8108
+ - `Bash: cd /Users/pyrossh/Code/plum/tooling/tree-sitter-plum make corpus_test 2>&1 | tail -20`
8109
+
8110
+ **Claude:** All 29 pass. Now let's run the full Rust workspace test suite (which rebuilds the vendored scanner.c via cargo's build.rs) to confirm nothing regressed.
8111
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -100`
8112
+
8113
+ **Claude:** Now let's update the existing codegen test that explicitly avoided this shape, and add a real multi-statement version.
8114
+ - `Bash: grep -n "non_capturing_closure_passed_and_called_runs_correctly" -A 20 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8115
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8116
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests multi_line_closure -- --nocapture 2>&1 | tail -30`
8117
+
8118
+ **Claude:** Real end-to-end wasm execution proves it. Let's run the full suite once more and update the README, then also simplify `libs/std/list.plum`'s `join()` now that this actually works.
8119
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -15`
8120
+ - `Read: /Users/pyrossh/Code/plum/README.md`
8121
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
8122
+
8123
+ **Claude:** Let's tackle gap #1: string interpolation codegen. First, let's understand the existing string representation exactly.
8124
+ - `Bash: grep -n "ast::Expr::String(s) =>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8125
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8126
+ - `Bash: cat /Users/pyrossh/Code/plum/examples/strings.plum`
8127
+ - `Bash: grep -n "wasm-encoder" /Users/pyrossh/Code/plum/plum-wasm-codegen/Cargo.toml grep -rn "MemoryCopy\|memory.copy\|bulk" /Users/pyrossh/Code/plum/plum-wasm-codegen`
8128
+ - `Bash: grep -n "MemoryCopy\|MemoryFill" ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wasm-encoder-0.224.1/src/core/code.rs | head -5`
8129
+ - `Bash: grep -n "Instruction::Loop\|Instruction::Block\|Instruction::BrIf\|Instruction::Br(" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -20`
8130
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8131
+
8132
+ **Claude:** This is a substantial addition — I need to hand-write two runtime helper functions in raw wasm bytecode (string concatenation and int-to-string conversion), since wasm has no built-in string manipulation. Let me implement carefully and test thoroughly.
8133
+ - `Bash: grep -n "pub struct FuncSig\|struct CompileCtx" -A 20 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -25`
8134
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8135
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8136
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8137
+
8138
+ **Claude:** Now `LocalCtx`:
8139
+ - `Bash: grep -n "named_fn_values: &'a HashMap<String, u32>," /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8140
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8141
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8142
+
8143
+ **Claude:** Now let's update both `LocalCtx` construction sites:
8144
+ - `Bash: grep -n "named_fn_values: &ctx.named_fn_values," /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8145
+ - `Bash: cd /Users/pyrossh/Code/plum python3 - << 'EOF' path = "plum-wasm-codegen/src/lib.rs" src = open(path).read() old = " named_fn_values: &ctx.named_fn_values,\n" n`
8146
+
8147
+ **Claude:** Now let's write the two helper-function-generator functions and wire them into `compile_source`.
8148
+ - `Bash: grep -n "let ctx = CompileCtx {" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8149
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8150
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8151
+ - `Bash: grep -n "^pub fn compile_source" -A 2 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs awk '/^pub fn compile_source/{f=1} f && /^}/{print NR; exit}' /Users`
8152
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8153
+
8154
+ **Claude:** Now let's insert the two helper-generator functions right after `compile_source`:
8155
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8156
+
8157
+ **Claude:** Now let's wire up the actual `Expr::String` interpolation-lowering logic.
8158
+ - `Bash: grep -n "ast::Expr::String(s) =>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8159
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8160
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8161
+
8162
+ **Claude:** Now let's add `compile_static_string` and `compile_interpolated_string` helper functions right after this. Let's find a good spot (near `emit_load`/`emit_store`):
8163
+ - `Bash: grep -n "^fn emit_load" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8164
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8165
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8166
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -150`
8167
+ - `Bash: grep -n "struct Str\|enum StringPart\|pub struct.*Str" /Users/pyrossh/Code/plum/plum-core/src/ast.rs`
8168
+ - `Read: /Users/pyrossh/Code/plum/plum-core/src/ast.rs`
8169
+ - `Bash: grep -n "String(" /Users/pyrossh/Code/plum/plum-core/src/ast.rs | head -5`
8170
+ - `Bash: grep -n "fn compile_interpolated_string" -A 15 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8171
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8172
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -100`
8173
+
8174
+ **Claude:** Now let's update the two existing "clear error" tests that assumed interpolation was unsupported, and write real positive tests that read the string back from wasm memory.
8175
+ - `Bash: grep -n "string_interpolation_is_a_clear_error" -A 10 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs echo "---" grep -n "strings_example_repo`
8176
+ - `Bash: grep -n "add_export.*[Mm]emory\|ExportKind::Memory" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8177
+ - `Bash: grep -n "enum ExportKind\|fn add_memory" -A 8 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -30`
8178
+ - `Bash: grep -n "ExportKind" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -5`
8179
+ - `Bash: grep -n "module.add_memory" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8180
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8181
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8182
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -30`
8183
+
8184
+ **Claude:** Now let's write the test helper and real tests.
8185
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8186
+ - `Bash: grep -n "^fn run_main" -A 10 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8187
+
8188
+ **Claude:** Let's add a `run_main_str` helper right after `run_main`.
8189
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8190
+
8191
+ **Claude:** Now let's replace the "clear error" test with real positive tests.
8192
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8193
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8194
+
8195
+ **Claude:** Now let's fix the `examples_test.rs` one and check `examples/strings.plum` content, since it uses interpolation extensively.
8196
+ - `Bash: cat /Users/pyrossh/Code/plum/examples/strings.plum echo "---" sed -n '1,120p' /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/examples_test.rs`
8197
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/examples_test.rs`
8198
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests string_interpolation -- --nocapture 2>&1 | tail -60`
8199
+ - `Bash: which wasm2wat wasm-tools 2>&1 cargo tree -p plum-wasm-codegen 2>&1 | grep -i "wasmprinter\|wat "`
8200
+
8201
+ **Claude:** `wasm-tools` is available. Let's dump the failing module and inspect around the byte offset.
8202
+ - `Bash: cd /Users/pyrossh/Code/plum cat > /tmp/dump_wasm.rs << 'EOF' fn main() { let src = "main() -> Str =\n x = 42\n \"{x}\"\n"; let mut parser = tree_sitter::Parser`
8203
+ - `Bash: wasm-tools print /tmp/out.wasm 2>&1 | head -150`
8204
+ - `Bash: wasm-tools print /tmp/out.wasm 2>&1 | sed -n '150,220p'`
8205
+ - `Bash: wasm-tools validate /tmp/out.wasm 2>&1`
8206
+
8207
+ **Claude:** Found the bug — the `string_concat` helper's bump-allocation math is missing a final `I32Add` to combine the saved base pointer with the computed size (the `int_to_string` helper has it correctly, `string_concat` doesn't). Let's fix it.
8208
+ - `Bash: grep -n "register_string_concat_helper" -A 25 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -30`
8209
+ - `Bash: grep -n "result = bump; bump += 4 + len_a + len_b" -A 10 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8210
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8211
+
8212
+ **Claude:** Now let's rebuild, re-validate, and re-run the tests.
8213
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -10 cd /tmp/dumpwasm && cargo run 2>&1 | tail -5 wasm-tools validate /tmp/out.wasm 2>&1`
8214
+ - `Bash: wasm-tools print /tmp/out.wasm 2>&1 | sed -n '95,145p'`
8215
+ - `Bash: wasm-tools print /tmp/out.wasm 2>&1 | sed -n '140,180p'`
8216
+ - `Bash: wasm-tools print /tmp/out.wasm 2>&1 | sed -n '85,100p'`
8217
+
8218
+ **Claude:** Found it — a local-index/type mismatch: I declared locals as one group of 5 `i32` then one group of 2 `i64`, but my constants interleaved i32/i64 assignments assuming per-variable ordering. Let me fix the constant indices to match the actual declared groups.
8219
+ - `Bash: grep -n "const N: u32 = 0;" -A 10 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8220
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8221
+
8222
+ **Claude:** Let's verify the locals group declaration matches (5 i32, then 2 i64), rebuild, and re-validate.
8223
+ - `Bash: grep -n "body.extend(encode_leb128_u32(2)); // two locals groups" -A 5 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8224
+
8225
+ **Claude:** Good, matches. Let's rebuild and re-validate.
8226
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -10 cd /tmp/dumpwasm && cargo run 2>&1 | tail -5 wasm-tools validate /tmp/out.wasm 2>&1`
8227
+
8228
+ **Claude:** Valid now. Let's clean up the scratch project and run the full test suite.
8229
+ - `Bash: rm -rf /tmp/dumpwasm /tmp/out.wasm /tmp/dump_wasm.rs cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests string_interpolation -- --`
8230
+
8231
+ **Claude:** All 7 pass, including negative numbers, zero, multi-interpolation, Str, and Bool. Let's run the complete workspace suite.
8232
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -100`
8233
+
8234
+ **Claude:** Gap #1 done. Let's update the README.
8235
+ - `Read: /Users/pyrossh/Code/plum/README.md`
8236
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
8237
+
8238
+ **Claude:** Now let's tackle gap #5 — the `call_indirect` trap. Let's look at the exact checker logic for closure inference first.
8239
+ - `Bash: grep -n "ast::Expr::Closure(cl) =>" -A 20 /Users/pyrossh/Code/plum/plum-checker/src/lib.rs`
8240
+ - `Bash: grep -n "fn record_closure" -A 40 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -45`
8241
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8242
+ - `Bash: grep -n "^fn collect_free_vars" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8243
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8244
+
8245
+ **Claude:** Let's insert the new helper functions right before `collect_free_vars`:
8246
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8247
+
8248
+ **Claude:** That got garbled — let me fix the `Not`/`Unary` arm properly.
8249
+ - `Bash: grep -n "ast::Expr::Not(inner) | ast::Expr::Unary" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8250
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8251
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8252
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -150`
8253
+
8254
+ **Claude:** I need to check the call-site's own signature re-derivation too, since it independently re-infers the closure's type — let's find that.
8255
+ - `Bash: grep -n "ast::Stmt::Assign(a) =>" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8256
+ - `Bash: grep -n "type_env.borrow_mut().insert" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -20`
8257
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8258
+ - `Bash: grep -n "^fn compile_closure_call" -A 45 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8259
+
8260
+ **Claude:** I also need to fix the call-site's own signature re-derivation, which independently re-infers the closure's type from `ctx.type_env` (populated via the same buggy checker inference at the assignment site) — otherwise the two would now disagree. Let's add a side-channel that threads the already-correct `ClosureInfo` through.
8261
+ - `Bash: grep -n "string_concat_func: u32,\s*$" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8262
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8263
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8264
+
8265
+ **Claude:** Now let's initialize this field at both `LocalCtx` construction sites:
8266
+ - `Bash: grep -n "type_env: RefCell::new(base_env)," /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8267
+ - `Bash: cd /Users/pyrossh/Code/plum python3 - << 'EOF' path = "plum-wasm-codegen/src/lib.rs" src = open(path).read() old = " type_env: RefCell::new(base_env),\n" new =`
8268
+
8269
+ **Claude:** Now let's update the `Assign` handling in `compile_stmt` to populate this map when the value is a closure literal:
8270
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8271
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8272
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8273
+
8274
+ **Claude:** Now let's update `compile_closure_call` to check this map first:
8275
+ - `Bash: grep -n "^fn compile_closure_call" -A 25 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8276
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8277
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -100`
8278
+
8279
+ **Claude:** Compiles cleanly. Now let's write the exact regression test for the documented bug — a closure assigned via `=` and later called at Float and at a class/pointer type.
8280
+ - `Bash: grep -n "fn capturing_closure_snapshots_value_at_creation_time_runs_correctly" -A 20 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8281
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8282
+ - `Bash: grep -n "^fn run_main(bytes" -A 10 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8283
+
8284
+ **Claude:** Let's add an `f64` runner right after `run_main`.
8285
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8286
+
8287
+ **Claude:** Now let's add the regression tests right after `capturing_closure_snapshots_value_at_creation_time_runs_correctly`.
8288
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8289
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8290
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8291
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests closure_assigned_then_called -- --nocapture 2>&1 | tail -40`
8292
+
8293
+ **Claude:** Let's extend the resolution to handle the class/pointer case via attribute access (`c.age` implies `c`'s type has an `age` field).
8294
+ - `Bash: grep -n "pub type ClassEnv" /Users/pyrossh/Code/plum/plum-checker/src/lib.rs`
8295
+ - `Bash: grep -n "pub struct CheckCtx" -A 6 /Users/pyrossh/Code/plum/plum-checker/src/lib.rs`
8296
+
8297
+ **Claude:** Now let's update the `Attribute` arm in `scan_expr_for_param_types`:
8298
+ - `Bash: grep -n "ast::Expr::Attribute(a) => {" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8299
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8300
+ - `Bash: grep -n "enum AttrKind" -A 6 /Users/pyrossh/Code/plum/plum-core/src/ast.rs`
8301
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8302
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8303
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -60`
8304
+ - `Bash: grep -n "struct Attribute" -A 5 /Users/pyrossh/Code/plum/plum-core/src/ast.rs`
8305
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8306
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -60`
8307
+
8308
+ **Claude:** Compiles. Let's test.
8309
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests closure_assigned_then_called -- --nocapture 2>&1 | tail -30`
8310
+ - `Bash: cd /Users/pyrossh/Code/plum mkdir -p /tmp/dumpwasm2/src cat > /tmp/dumpwasm2/src/main.rs << 'EOF' fn main() { let src = "\ type Cat = age: Int eachCat(cb: fn(Ca`
8311
+ - `Bash: wasm-tools print /tmp/out2.wasm 2>&1`
8312
+ - `Bash: rm -rf /tmp/dumpwasm2 /tmp/out2.wasm grep -n "impl<'a> Collector" -A 3 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8313
+ - `Bash: sed -n '1490,1560p' /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8314
+ - `Bash: grep -n "ast::Expr::Closure" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8315
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8316
+ - `Bash: grep -n "let mut collector = Collector {" -A 15 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -20`
8317
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8318
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8319
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -10 mkdir -p /tmp/dumpwasm3/src cat > /tmp/dumpwasm3/src/main.rs << 'EOF' fn main() { l`
8320
+
8321
+ **Claude:** Found it — a *third* place with the same root problem: the `Collector` (which decides each local's wasm `ValType`) also calls the checker's fragile closure inference, and here it fails outright (returns `Err`, not just a bad `TVar`), silently defaulting `cb`'s local slot to `Int` (i64) instead of the pointer type (i32) every closure actually is. Let's remove my debug print and fix this properly.
8322
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8323
+
8324
+ **Claude:** Now let's fix `Collector::walk_stmt`'s `Assign` arm to not rely on the fragile whole-closure inference for local-slot typing:
8325
+ - `Bash: grep -n "fn walk_stmt(&mut self, stmt: &ast::Stmt) {" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8326
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8327
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8328
+
8329
+ **Claude:** Let's apply the same defensive fix to `compile_stmt`'s runtime type-tracking for consistency, then rebuild and test.
8330
+ - `Bash: grep -n "let vty = infer_local_type(value, ctx);" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8331
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8332
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8333
+
8334
+ **Claude:** Now let's rebuild and test.
8335
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -20 cargo test -p plum-wasm-codegen --test codegen_tests closure_assigned_then_called -`
8336
+
8337
+ **Claude:** Both pass. Let's clean up scratch files and run the full workspace suite.
8338
+ - `Bash: rm -rf /tmp/dumpwasm3 cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -100`
8339
+
8340
+ **Claude:** All green. Gap #5 done — let's update the README.
8341
+ - `Read: /Users/pyrossh/Code/plum/README.md`
8342
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
8343
+
8344
+ **Claude:** Now the final one — gap #7, nested closures. Let's implement it by making `record_closure` recurse into the closure's own body using the same `ClosureWalker` machinery.
8345
+ - `Bash: grep -n "fn record_closure" -A 40 /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -45`
8346
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8347
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8348
+
8349
+ **Claude:** Now let's fix `fv_collect_refs_expr`'s catch-all, which currently skips into nested closures entirely (needed for the outer closure's own capture list to include names its nested closure needs from the outer's scope):
8350
+ - `Bash: grep -n "A nested closure isn't supported by the discovery pre-pass" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8351
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8352
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8353
+
8354
+ **Claude:** Let's also clean up the now-inaccurate error message in `compile_closure_literal`.
8355
+ - `Bash: grep -n "nested closures are not supported" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8356
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8357
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8358
+
8359
+ **Claude:** Now let's build and test.
8360
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -100`
8361
+ - `Bash: grep -n "param_ptypes,$" /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs | head -5`
8362
+ - `Read: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8363
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/src/lib.rs`
8364
+ - `Bash: cd /Users/pyrossh/Code/plum cargo build -p plum-wasm-codegen 2>&1 | tail -60`
8365
+
8366
+ **Claude:** Compiles. Now let's write tests for nested closures.
8367
+ - `Bash: grep -n "fn closure_passed_through_already_generic_higher_order_function_runs_correctly" -A 15 /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8368
+ - `Edit: /Users/pyrossh/Code/plum/plum-wasm-codegen/tests/codegen_tests.rs`
8369
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test -p plum-wasm-codegen --test codegen_tests nested_closure -- --nocapture 2>&1 | tail -60`
8370
+
8371
+ **Claude:** Both pass on the first try. Let's run the complete workspace suite one final time.
8372
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | tail -110`
8373
+
8374
+ **Claude:** All 7 gaps done. Let's do the final README update.
8375
+ - `Read: /Users/pyrossh/Code/plum/README.md`
8376
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
8377
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
8378
+
8379
+ **Claude:** Now the final Known Gaps section:
8380
+ - `Edit: /Users/pyrossh/Code/plum/README.md`
8381
+
8382
+ **Claude:** Let's do one final full verification pass and review the overall diff.
8383
+ - `Bash: cd /Users/pyrossh/Code/plum cargo test --workspace 2>&1 | grep -E "test result:|FAILED|error\[" echo "---" git status --short | head -30`
8384
+
8385
+ **Claude:** All 7 known gaps from the README are now closed, and the entire workspace test suite is green (0 failures across every crate).
8386
+
8387
+ ## Summary
8388
+
8389
+ Worked through all 7 gaps in the agreed order (6 → 2 → 3 → 4 → 1 → 5 → 7), verifying each with real `wasmtime` execution before moving on:
8390
+
8391
+ 1. **Named function as a `fn(...)`-typed value** (`each(double)`) — compiles as a zero-capture "trampoline" closure: a synthetic wasm function forwarding to the real one, registered once in the funcref table with a compile-time-constant `{table_idx, env_ptr=0}` struct (no runtime allocation needed).
8392
+
8393
+ 2. **Multi-subject `match`** (`match a, b`) — reworked the match compiler to evaluate N subjects into N scratch locals and check patterns position-by-position with correct AND/fallthrough semantics. Bonus: generalized the exhaustiveness heuristic to full cross-products, which is what let `libs/std/bool.plum`'s `and`/`or` (`match self, o` over all 4 `Bool×Bool` combinations) compile at all. Also fixed a monomorphize.rs bug that only mangled the first subject's generic-enum variants.
8394
+
8395
+ 3. **Nested constructor patterns** (`Some(Some(v))`) — made the constructor-arm field-matching recursive, arbitrary depth, with correct case-to-case fallthrough on a nested mismatch. Also fixed monomorphize.rs to recurse into nested patterns' own field types for generic-enum mangling.
8396
+
8397
+ 4. **Multi-line closure body + closing `)` on the same line** — real scanner surgery in `tooling/tree-sitter-plum/src/scanner.c`: taught the external scanner to emit a DEDENT when the parser needs one but no newline preceded (only when gated by the grammar's own `valid_symbols[DEDENT]`, so it can't misfire elsewhere).
8398
+
8399
+ 5. **String interpolation codegen** — built two hand-written wasm runtime helpers (`__string_concat`, `__int_to_string`) from scratch, byte-copy loops and all. Caught and fixed two real bugs this way (a missing pointer-arithmetic `add` and a local-index/type mismatch) — both found via `wasm-tools validate`, not guessing. `Str`/`Int`/`Bool` interpolation all work; `Float` gets a clear error (real decimal formatting is its own project).
8400
+
8401
+ 6. **`call_indirect` trap for closures assigned then called at Float/class type** — the checker's closure inference gives every param a fresh, never-unified `TVar`, defaulting silently to `Int`. Added a usage-based resolution pass (`resolve_closure_param_types_from_usage`) plus a side-channel (`closure_local_sigs`) so the call site's signature can't drift from what the closure's body was actually compiled with.
8402
+
8403
+ 7. **Nested closures** — made the discovery pass recurse into a closure's own body, and fixed free-variable collection to propagate multi-level captures correctly (a name only the inner closure needs still gets captured by the outer, but a name the outer already binds doesn't).
8404
+
8405
+ Also updated the README's closure/match sections to drop language describing now-fixed limitations, and cleaned up now-inaccurate error messages/test names along the way.
README.md CHANGED
@@ -262,11 +262,11 @@ main() -> Int =
262
262
 
263
263
  A closure literal is `|params| body`: zero or more bare parameter names between pipes, followed by an expression or an indented block body. A parameter's type is inferred from context (e.g. the declared type of the `fn(...)` slot it's passed into) rather than annotated inline.
264
264
 
265
- A `fn(...)` / `fn(...) -> T` type annotates a closure-typed parameter or field: `fn(Int) -> Int` is a function from one `Int` to an `Int`; parameter types are positional only (no names). Closures compile to a `{table_index, env_pointer}` pair and are called via `call_indirect` through a function table, so a `fn(...)`-typed value can hold either a closure literal or (once that gap below is closed) a plain function.
265
+ A `fn(...)` / `fn(...) -> T` type annotates a closure-typed parameter or field: `fn(Int) -> Int` is a function from one `Int` to an `Int`; parameter types are positional only (no names). Closures compile to a `{table_index, env_pointer}` pair and are called via `call_indirect` through a function table, so a `fn(...)`-typed value can hold a closure literal, a plain top-level named function (`each(double)` compiled as a zero-capture "trampoline" closure), or a closure nested inside another closure's body.
266
266
 
267
267
  Capture is **snapshot-by-value**: any outer variable a closure body references is copied into the closure's environment at the moment the closure literal is evaluated, not re-read live later. Reassigning the captured variable afterward does not change what the closure sees.
268
268
 
269
- Closures parse and compile in two shapes: passed directly as a call argument (`each(|v| v * 3)`, single-line body only — see Known gaps), or assigned to a local first and called or passed on later (`cb = |v|\n v + offset` followed by `cb(5)` or `each(cb)`, which does support a multi-line indented body).
269
+ Closures parse and compile in two shapes: passed directly as a call argument (`each(|v| v * 3)`), or assigned to a local first and called or passed on later (`cb = |v|\n v + offset` followed by `cb(5)` or `each(cb)`). Both support a multi-line indented body.
270
270
 
271
271
  Full example: [`examples/closures.plum`](examples/closures.plum).
272
272
 
@@ -327,7 +327,7 @@ match opt
327
327
  0
328
328
  ```
329
329
 
330
- A case body can be a single inline expression right after `=>`, or an indented block — pick whichever reads better for that arm. Patterns can be: integer/float/string literals, a bare identifier (binds a new local to the subject's value), a bare capitalized tag (`True`, `False`, or any declared `enum` variant with no payload — compared, not bound), a constructor pattern (`Some(v)`, binding its argument), or `_` (wildcard). Multiple comma-separated subjects/patterns are accepted by the grammar but not yet lowered by codegen.
330
+ A case body can be a single inline expression right after `=>`, or an indented block — pick whichever reads better for that arm. Patterns can be: integer/float/string literals, a bare identifier (binds a new local to the subject's value), a bare capitalized tag (`True`, `False`, or any declared `enum` variant with no payload — compared, not bound), a constructor pattern (`Some(v)`, binding its argument — sub-patterns can themselves be constructor patterns, e.g. `Wrap(Some(v))`), or `_` (wildcard). `match a, b, ...` against multiple comma-separated subjects is also supported each case supplies one pattern per subject, and every position must match for that case to apply.
331
331
 
332
332
  Full example: [`examples/match.plum`](examples/match.plum).
333
333
 
@@ -338,7 +338,7 @@ greet(name: Str) -> Str =
338
338
  "Hello, {name}!"
339
339
  ```
340
340
 
341
- `{expr}` inside a string interpolates a single primary expression (a variable, literal, attribute access, etc). Parses and type-checks; codegen doesn't lower it yet (see below).
341
+ `{expr}` inside a string interpolates a single primary expression (a variable, literal, attribute access, etc). Parses, type-checks, and compiles for `Str`, `Int`, and `Bool` values; interpolating a `Float` reports a clear error (see below).
342
342
 
343
343
  Full example: [`examples/strings.plum`](examples/strings.plum).
344
344
 
@@ -346,11 +346,5 @@ Full example: [`examples/strings.plum`](examples/strings.plum).
346
346
 
347
347
  Some things parse and type-check but don't compile to wasm yet — `plum-wasm-codegen` reports a clear error rather than silently producing wrong code:
348
348
 
349
- - string interpolation (plain, non-interpolated string literals do compile)
350
- - multi-subject `match` (`match a, b`)
351
- - nested constructor patterns inside `match` (`Some(Some(v))`) — a constructor pattern's own sub-patterns must be a bare binding or `_`
352
- - `libs/std`'s actual `List`/`Map`/`Option`/`Result` still don't compile — they use several other unimplemented features (`Nil`/optional chaining, decorators, colon-arrow return syntax) unrelated to generics, which are themselves now monomorphized and compiled correctly for the currently-documented generic syntax
353
- - a closure literal used directly as a call argument only parses with a single-line body (`each(|v| v * 3)`); a multi-line indented body immediately followed by a closing `)` on the same line as the body's last statement (e.g. `each(|v|\n v)`) does not yet parse — the external scanner doesn't emit a `DEDENT` at a closing bracket. Assigning the closure to a local first (`cb = |v|\n v + offset` then `cb(...)` or `each(cb)`) does support a multi-line body and sidesteps this
354
- - a closure created via assignment (not passed directly as a call argument) and later called at a concrete non-`Int` type (`Float` or a class/pointer type) can hit a wasm runtime trap due to a signature-resolution gap in `call_indirect` dispatch; this does not affect closures over `Int`, or closures passed directly as call arguments at any type. Tracked as a separate follow-up task
355
- - a plain top-level named function (not a `|params| body` closure literal) cannot yet be used directly as a `fn(...)`-typed value (e.g. `each(double)`) — only closure literals are supported at closure-typed call sites today
356
- - a closure literal nested inside another closure's body is not supported (produces a clear compile error)
349
+ - interpolating a `Float` value in a string (`Str`/`Int`/`Bool` interpolation, and plain non-interpolated literals, all compile) — correct decimal formatting of a float is a substantial separate undertaking (something like Grisu/Ryu), scoped out for now
350
+ - `libs/std`'s actual `List`/`Map` still don't fully compile — mutating a field or attribute (`self.head = ...`) isn't a supported assignment target yet (only a plain local variable is), and there's no cross-file import resolution yet either, so a file that references a type/enum declared in a different `libs/std` file won't type-check standalone
plum-checker/src/monomorphize.rs CHANGED
@@ -326,6 +326,55 @@ impl<'a> Monomorphizer<'a> {
326
326
  Ok(())
327
327
  }
328
328
 
329
+ /// Rewrites `pat` (bare-name/constructor pattern) in place: an uppercase variant
330
+ /// name gets mangled to its specialized form (`Some` -> `Some$Int`) if `mangling`
331
+ /// says this position's subject is a specialized generic enum; a plain binding
332
+ /// name is inserted into `case_env` at `ty`. Recurses into a constructor
333
+ /// pattern's own fields (`Some(Some(v))`), looking up *that* field's own
334
+ /// mangling table from `self.enum_variant_mangling` — a nested sub-pattern can
335
+ /// be a specialization independent of its enclosing pattern's.
336
+ fn mangle_pattern(
337
+ &self,
338
+ pat: &mut ast::CasePattern,
339
+ ty: &PlumType,
340
+ mangling: Option<&BTreeMap<String, String>>,
341
+ case_env: &mut TypeEnv,
342
+ ) {
343
+ match pat {
344
+ ast::CasePattern::Name(n) => {
345
+ let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
346
+ && self.enum_variants.contains_key(n.as_str());
347
+ if is_variant {
348
+ if let Some(table) = mangling {
349
+ if let Some(mangled_variant) = table.get(n) {
350
+ *n = mangled_variant.clone();
351
+ }
352
+ }
353
+ } else {
354
+ case_env.insert(n.clone(), TypeScheme::mono(ty.clone()));
355
+ }
356
+ }
357
+ ast::CasePattern::Class { name, fields } => {
358
+ if let Some(table) = mangling {
359
+ if let Some(mangled_variant) = table.get(name) {
360
+ *name = mangled_variant.clone();
361
+ }
362
+ }
363
+ if let Some(info) = self.enum_variants.get(name.as_str()) {
364
+ let field_types = info.field_types.clone();
365
+ for (f, fty) in fields.iter_mut().zip(field_types.iter()) {
366
+ let field_mangling = match fty {
367
+ PlumType::TNamed(n) => self.enum_variant_mangling.get(n),
368
+ _ => None,
369
+ };
370
+ self.mangle_pattern(f, fty, field_mangling, case_env);
371
+ }
372
+ }
373
+ }
374
+ _ => {}
375
+ }
376
+ }
377
+
329
378
  fn rewrite_stmt(&mut self, stmt: &mut ast::Stmt, env: &mut TypeEnv) -> Result<(), String> {
330
379
  match stmt {
331
380
  ast::Stmt::Assign(a) => {
@@ -366,42 +415,28 @@ impl<'a> Monomorphizer<'a> {
366
415
  for s in &mut m.subjects {
367
416
  self.rewrite_expr(s, env)?;
368
417
  }
418
+ // One (type, variant-mangling table) pair per subject — `match a, b`
419
+ // needs each position's own generic-enum specialization handled
420
+ // independently, not just the first subject's.
369
- let subject_ty = m.subjects.first().map(|s| self.infer(s, env)).unwrap_or(PlumType::TInt);
421
+ let subject_types: Vec<PlumType> = m.subjects.iter().map(|s| self.infer(s, env)).collect();
370
- // If the subject's concrete type is a specialized generic enum, its
422
+ // If a subject's concrete type is a specialized generic enum, its
371
- // variant-name mangling table lets us rewrite this match's patterns
423
+ // variant-name mangling table lets us rewrite that position's patterns
372
424
  // (`Some`/`None` -> `Some$Int`/`None$Int`) to reference the correct
373
425
  // specialization, so the checker/codegen's unmodified, bare-name-keyed
374
426
  // `EnumVariants` lookup still resolves each pattern correctly.
375
- let variant_mangling: Option<BTreeMap<String, String>> = match &subject_ty {
427
+ let variant_manglings: Vec<Option<BTreeMap<String, String>>> = subject_types
428
+ .iter()
429
+ .map(|ty| match ty {
376
- PlumType::TNamed(n) => self.enum_variant_mangling.get(n).cloned(),
430
+ PlumType::TNamed(n) => self.enum_variant_mangling.get(n).cloned(),
377
- _ => None,
431
+ _ => None,
378
- };
432
+ })
433
+ .collect();
379
434
  for case in &mut m.cases {
380
435
  let mut case_env = env.clone();
381
- if let Some(pat) = case.patterns.first_mut() {
382
- match pat {
383
- ast::CasePattern::Name(n) => {
384
- let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
385
- && self.enum_variants.contains_key(n.as_str());
386
- if is_variant {
387
- if let Some(table) = &variant_mangling {
436
+ for (pat, (subject_ty, variant_mangling)) in
388
- if let Some(mangled_variant) = table.get(n) {
389
- *n = mangled_variant.clone();
390
- }
391
- }
392
- } else {
393
- case_env.insert(n.clone(), TypeScheme::mono(subject_ty.clone()));
437
+ case.patterns.iter_mut().zip(subject_types.iter().zip(variant_manglings.iter()))
394
- }
438
+ {
395
- }
396
- ast::CasePattern::Class { name, .. } => {
397
- if let Some(table) = &variant_mangling {
439
+ self.mangle_pattern(pat, subject_ty, variant_mangling.as_ref(), &mut case_env);
398
- if let Some(mangled_variant) = table.get(name) {
399
- *name = mangled_variant.clone();
400
- }
401
- }
402
- }
403
- _ => {}
404
- }
405
440
  }
406
441
  self.rewrite_block(&mut case.body, &mut case_env)?;
407
442
  }
plum-wasm-codegen/src/lib.rs CHANGED
@@ -1,6 +1,6 @@
1
1
  use wasm_encoder::*;
2
2
  use std::cell::RefCell;
3
- use std::collections::HashMap;
3
+ use std::collections::{HashMap, HashSet};
4
4
  use plum_core::ast;
5
5
  use plum_checker::types::{PlumType, TypeEnv, TypeScheme};
6
6
  use plum_checker::{ClassEnv, MethodEnv, EnumVariants, EnumVariantInfo};
@@ -256,6 +256,19 @@ pub struct CompileCtx<'a> {
256
256
  pub closure_asts: HashMap<usize, &'a ast::Closure>,
257
257
  /// Closure wasm signature -> function-type index, for `call_indirect` at call sites.
258
258
  pub closure_call_types: HashMap<ClosureSigKey, u32>,
259
+ /// Top-level function name -> static data offset of its zero-capture "trampoline"
260
+ /// closure struct `{table_idx, env_ptr=0}`, for using a plain named function
261
+ /// wherever a `fn(...)`-typed value is expected (e.g. `each(double)`). Since the
262
+ /// struct has no captures it never changes, so it's baked into a data segment
263
+ /// once at compile time instead of being bump-allocated per reference.
264
+ pub named_fn_values: HashMap<String, u32>,
265
+ /// Shared runtime helper `(a: i32, b: i32) -> i32`: bump-allocates a new
266
+ /// length-prefixed string exactly long enough to hold `a`'s bytes followed by
267
+ /// `b`'s, for lowering string interpolation (`"{expr}"`).
268
+ pub string_concat_func: u32,
269
+ /// Shared runtime helper `(n: i64) -> i32`: bump-allocates a new length-prefixed
270
+ /// string holding `n`'s decimal representation, for interpolating an `Int`.
271
+ pub int_to_string_func: u32,
259
272
  }
260
273
 
261
274
  /// Per-module state that accumulates as function bodies are compiled: the running
@@ -280,10 +293,19 @@ struct LocalCtx<'a> {
280
293
  closure_scratch_base: u32,
281
294
  /// `Closure` expr identity (pointer address) -> offset of its first (of two) slots.
282
295
  closure_scratch: HashMap<usize, u32>,
296
+ /// First local index reserved for nested-constructor-pattern scratch temporaries
297
+ /// (`Some(Some(v))`'s inner `Some(v)`); the outermost pattern uses a
298
+ /// `match_scratch` slot instead, so this only covers depth >= 1.
299
+ nested_class_scratch_base: u32,
300
+ /// `CasePattern::Class` identity (pointer address) -> scratch slot offset.
301
+ nested_class_scratch: HashMap<usize, u32>,
283
302
  func_ids: &'a HashMap<String, u32>,
284
303
  func_sigs: &'a HashMap<String, FuncSig>,
285
304
  closures: &'a HashMap<usize, ClosureInfo>,
286
305
  closure_call_types: &'a HashMap<ClosureSigKey, u32>,
306
+ named_fn_values: &'a HashMap<String, u32>,
307
+ string_concat_func: u32,
308
+ int_to_string_func: u32,
287
309
  classes: &'a ClassEnv,
288
310
  methods: &'a MethodEnv,
289
311
  enum_variants: &'a EnumVariants,
@@ -291,6 +313,15 @@ struct LocalCtx<'a> {
291
313
  /// statements in order, mirroring `plum-checker`'s own env evolution — needed
292
314
  /// to resolve `Attribute`/`ClassCall` targets and pick the right load/store width.
293
315
  type_env: RefCell<TypeEnv>,
316
+ /// Local name -> the exact wasm `call_indirect` signature of the closure literal
317
+ /// assigned to it (populated when compiling that `Stmt::Assign`, straight from
318
+ /// the already-correct `ClosureInfo` the discovery pass computed). Exists so
319
+ /// `compile_closure_call` doesn't have to re-derive the signature via
320
+ /// `plum_checker::infer_expr` on the closure a second time — which, unlike the
321
+ /// discovery pass, doesn't have `resolve_closure_param_types_from_usage`'s fix
322
+ /// and would fall back to its old TVar-defaults-to-Int behavior, disagreeing
323
+ /// with the (now correct) signature the closure's body was actually compiled with.
324
+ closure_local_sigs: RefCell<HashMap<String, ClosureSigKey>>,
294
325
  bump_global: u32,
295
326
  }
296
327
 
@@ -390,6 +421,7 @@ pub fn compile_source(source: &ast::Source) -> Result<Vec<u8>, String> {
390
421
 
391
422
  let mut module = WasmModule::new();
392
423
  module.add_memory(2, None);
424
+ module.add_export("memory", ExportKind::Memory, 0);
393
425
  let mut bump_init = Vec::new();
394
426
  Instruction::I32Const(HEAP_BASE as i32).encode(&mut bump_init);
395
427
  let bump_global = module.add_global(ValType::I32, true, &bump_init);
@@ -447,25 +479,31 @@ pub fn compile_source(source: &ast::Source) -> Result<Vec<u8>, String> {
447
479
  // element and records the free variables it must capture.
448
480
  let fn_decls: HashMap<String, &ast::Fn> = fns.iter().map(|f| (f.name.clone(), *f)).collect();
449
481
  let mut raw_closures: Vec<RawClosure> = Vec::new();
482
+ let mut named_fn_refs: HashSet<String> = HashSet::new();
450
483
  for f in &fns {
451
484
  let mut env = global_env.clone();
452
485
  if let Some(recv) = &f.type_param {
453
486
  env.insert("self".to_string(), TypeScheme::mono(PlumType::TNamed(recv.clone())));
454
487
  }
488
+ let mut locals: HashSet<String> = HashSet::new();
455
489
  for p in &f.params {
456
490
  env.insert(p.name.clone(), TypeScheme::mono(param_plum_type(&p.ty)));
491
+ locals.insert(p.name.clone());
457
492
  }
458
493
  let mut walker = ClosureWalker {
459
494
  env,
460
495
  cctx: check_ctx_of(&classes, &methods, &enum_variants),
461
496
  fn_decls: &fn_decls,
462
497
  found: Vec::new(),
498
+ locals,
499
+ named_fn_refs: HashSet::new(),
463
500
  };
464
501
  match &f.body {
465
502
  ast::FnBody::Block(block) => walker.walk_block(block),
466
503
  ast::FnBody::Expr(e) => walker.walk_expr(e, None),
467
504
  }
468
505
  raw_closures.extend(walker.found);
506
+ named_fn_refs.extend(walker.named_fn_refs);
469
507
  }
470
508
 
471
509
  let mut closures: HashMap<usize, ClosureInfo> = HashMap::new();
@@ -496,12 +534,64 @@ pub fn compile_source(source: &ast::Source) -> Result<Vec<u8>, String> {
496
534
  closure_asts.insert(rc.ptr, rc.closure);
497
535
  }
498
536
 
537
+ // Register a zero-capture "trampoline" closure for every top-level function
538
+ // referenced as a bare value (e.g. `each(double)`): a real wasm function with the
539
+ // closure calling convention `(env_ptr, ...real_params) -> ret` that ignores its
540
+ // env pointer and forwards straight to the real function, plus a funcref-table
541
+ // entry for it. Since it never captures anything, its `{table_idx, env_ptr=0}`
542
+ // closure struct is a compile-time constant — baked into a data segment once
543
+ // rather than bump-allocated at every reference site.
544
+ let mut named_fn_values: HashMap<String, u32> = HashMap::new();
545
+ let mut named_fn_data_segments: Vec<(u32, Vec<u8>)> = Vec::new();
546
+ let mut next_static_offset = STRING_BASE;
547
+ for name in &named_fn_refs {
548
+ let sig = func_sigs.get(name).expect("named fn ref must be a registered top-level function");
549
+ let real_func_idx = *func_ids.get(name).expect("named fn ref must be a registered top-level function");
550
+
551
+ let mut sig_params = vec![ValType::I32]; // env pointer
552
+ sig_params.extend(sig.params.iter().copied());
553
+ let sig_key: ClosureSigKey = (sig_params.clone(), sig.ret);
554
+ let type_idx = match closure_call_types.get(&sig_key) {
555
+ Some(t) => *t,
556
+ None => {
557
+ let results: Vec<ValType> = sig.ret.into_iter().collect();
558
+ let t = module.add_type(&sig_params, &results);
559
+ closure_call_types.insert(sig_key, t);
560
+ t
561
+ }
562
+ };
563
+
564
+ let mut tbody = Vec::new();
565
+ tbody.push(0u8); // no locals beyond the params already in the signature
566
+ for i in 0..sig.params.len() {
567
+ Instruction::LocalGet((i + 1) as u32).encode(&mut tbody); // local 0 is env_ptr, ignored
568
+ }
569
+ Instruction::Call(real_func_idx).encode(&mut tbody);
570
+ Instruction::End.encode(&mut tbody);
571
+ let func_idx = module.add_function(type_idx, &tbody);
572
+ let table_idx = module.add_table_element(func_idx);
573
+
574
+ let offset = next_static_offset;
575
+ next_static_offset += 8;
576
+ let mut bytes = Vec::with_capacity(8);
577
+ bytes.extend_from_slice(&(table_idx as i32).to_le_bytes());
578
+ bytes.extend_from_slice(&0i32.to_le_bytes());
579
+ named_fn_data_segments.push((offset, bytes));
580
+ named_fn_values.insert(name.clone(), offset);
581
+ }
582
+
583
+ // Runtime helpers backing string interpolation (`"{expr}"`): always registered
584
+ // (unconditionally, for simplicity) since they're cheap and self-contained.
585
+ let string_concat_func = register_string_concat_helper(&mut module, bump_global);
586
+ let int_to_string_func = register_int_to_string_helper(&mut module, bump_global);
587
+
499
588
  let ctx = CompileCtx {
500
589
  func_ids, func_sigs, classes, methods, enum_variants, global_env, bump_global,
501
- closures, closure_asts, closure_call_types,
590
+ closures, closure_asts, closure_call_types, named_fn_values,
591
+ string_concat_func, int_to_string_func,
502
592
  };
503
593
 
504
- let mut state = ModuleState { next_string_offset: STRING_BASE, data_segments: Vec::new() };
594
+ let mut state = ModuleState { next_string_offset: next_static_offset, data_segments: named_fn_data_segments };
505
595
 
506
596
  let mut compiled_bodies: Vec<(String, Vec<u8>)> = Vec::new();
507
597
  for f in &fns {
@@ -539,6 +629,259 @@ pub fn compile_source(source: &ast::Source) -> Result<Vec<u8>, String> {
539
629
  Ok(module.finish())
540
630
  }
541
631
 
632
+ /// Registers `__string_concat(a: i32, b: i32) -> i32`, a hand-written runtime
633
+ /// helper backing string interpolation: bump-allocates a new length-prefixed
634
+ /// string (`[len: u32][bytes]`, matching a literal string's own layout) exactly
635
+ /// long enough for `a`'s bytes followed by `b`'s, and copies them in. There's no
636
+ /// bulk-memory `memory.copy` assumed available, so each byte is copied by an
637
+ /// explicit loop.
638
+ fn register_string_concat_helper(module: &mut WasmModule, bump_global: u32) -> u32 {
639
+ let type_idx = module.add_type(&[ValType::I32, ValType::I32], &[ValType::I32]);
640
+
641
+ // locals: 0=a (param), 1=b (param), 2=len_a, 3=len_b, 4=result, 5=i
642
+ const A: u32 = 0;
643
+ const B: u32 = 1;
644
+ const LEN_A: u32 = 2;
645
+ const LEN_B: u32 = 3;
646
+ const RESULT: u32 = 4;
647
+ const I: u32 = 5;
648
+
649
+ let mut body = Vec::new();
650
+ body.extend(encode_leb128_u32(1)); // one locals group
651
+ body.extend(encode_leb128_u32(4)); // 4 locals (len_a, len_b, result, i)
652
+ ValType::I32.encode(&mut body);
653
+
654
+ // len_a = load(a); len_b = load(b)
655
+ Instruction::LocalGet(A).encode(&mut body);
656
+ Instruction::I32Load(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(&mut body);
657
+ Instruction::LocalSet(LEN_A).encode(&mut body);
658
+ Instruction::LocalGet(B).encode(&mut body);
659
+ Instruction::I32Load(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(&mut body);
660
+ Instruction::LocalSet(LEN_B).encode(&mut body);
661
+
662
+ // result = bump; bump += 4 + len_a + len_b
663
+ Instruction::GlobalGet(bump_global).encode(&mut body);
664
+ Instruction::LocalSet(RESULT).encode(&mut body);
665
+ Instruction::GlobalGet(bump_global).encode(&mut body);
666
+ Instruction::LocalGet(LEN_A).encode(&mut body);
667
+ Instruction::LocalGet(LEN_B).encode(&mut body);
668
+ Instruction::I32Add.encode(&mut body);
669
+ Instruction::I32Const(4).encode(&mut body);
670
+ Instruction::I32Add.encode(&mut body);
671
+ Instruction::I32Add.encode(&mut body);
672
+ Instruction::GlobalSet(bump_global).encode(&mut body);
673
+
674
+ // store combined length at result+0
675
+ Instruction::LocalGet(RESULT).encode(&mut body);
676
+ Instruction::LocalGet(LEN_A).encode(&mut body);
677
+ Instruction::LocalGet(LEN_B).encode(&mut body);
678
+ Instruction::I32Add.encode(&mut body);
679
+ Instruction::I32Store(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(&mut body);
680
+
681
+ // for (i = 0; i < len_a; i++) result[4+i] = a[4+i]
682
+ Instruction::I32Const(0).encode(&mut body);
683
+ Instruction::LocalSet(I).encode(&mut body);
684
+ Instruction::Block(BlockType::Empty).encode(&mut body);
685
+ Instruction::Loop(BlockType::Empty).encode(&mut body);
686
+ Instruction::LocalGet(I).encode(&mut body);
687
+ Instruction::LocalGet(LEN_A).encode(&mut body);
688
+ Instruction::I32GeU.encode(&mut body);
689
+ Instruction::BrIf(1).encode(&mut body);
690
+ // address = result + i
691
+ Instruction::LocalGet(RESULT).encode(&mut body);
692
+ Instruction::LocalGet(I).encode(&mut body);
693
+ Instruction::I32Add.encode(&mut body);
694
+ // value = load8u(a + i, offset 4)
695
+ Instruction::LocalGet(A).encode(&mut body);
696
+ Instruction::LocalGet(I).encode(&mut body);
697
+ Instruction::I32Add.encode(&mut body);
698
+ Instruction::I32Load8U(MemArg { offset: 4, align: 0, memory_index: 0 }).encode(&mut body);
699
+ Instruction::I32Store8(MemArg { offset: 4, align: 0, memory_index: 0 }).encode(&mut body);
700
+ Instruction::LocalGet(I).encode(&mut body);
701
+ Instruction::I32Const(1).encode(&mut body);
702
+ Instruction::I32Add.encode(&mut body);
703
+ Instruction::LocalSet(I).encode(&mut body);
704
+ Instruction::Br(0).encode(&mut body);
705
+ Instruction::End.encode(&mut body);
706
+ Instruction::End.encode(&mut body);
707
+
708
+ // for (i = 0; i < len_b; i++) result[4+len_a+i] = b[4+i]
709
+ Instruction::I32Const(0).encode(&mut body);
710
+ Instruction::LocalSet(I).encode(&mut body);
711
+ Instruction::Block(BlockType::Empty).encode(&mut body);
712
+ Instruction::Loop(BlockType::Empty).encode(&mut body);
713
+ Instruction::LocalGet(I).encode(&mut body);
714
+ Instruction::LocalGet(LEN_B).encode(&mut body);
715
+ Instruction::I32GeU.encode(&mut body);
716
+ Instruction::BrIf(1).encode(&mut body);
717
+ // address = result + len_a + i
718
+ Instruction::LocalGet(RESULT).encode(&mut body);
719
+ Instruction::LocalGet(LEN_A).encode(&mut body);
720
+ Instruction::I32Add.encode(&mut body);
721
+ Instruction::LocalGet(I).encode(&mut body);
722
+ Instruction::I32Add.encode(&mut body);
723
+ // value = load8u(b + i, offset 4)
724
+ Instruction::LocalGet(B).encode(&mut body);
725
+ Instruction::LocalGet(I).encode(&mut body);
726
+ Instruction::I32Add.encode(&mut body);
727
+ Instruction::I32Load8U(MemArg { offset: 4, align: 0, memory_index: 0 }).encode(&mut body);
728
+ Instruction::I32Store8(MemArg { offset: 4, align: 0, memory_index: 0 }).encode(&mut body);
729
+ Instruction::LocalGet(I).encode(&mut body);
730
+ Instruction::I32Const(1).encode(&mut body);
731
+ Instruction::I32Add.encode(&mut body);
732
+ Instruction::LocalSet(I).encode(&mut body);
733
+ Instruction::Br(0).encode(&mut body);
734
+ Instruction::End.encode(&mut body);
735
+ Instruction::End.encode(&mut body);
736
+
737
+ Instruction::LocalGet(RESULT).encode(&mut body);
738
+ Instruction::End.encode(&mut body);
739
+
740
+ module.add_function(type_idx, &body)
741
+ }
742
+
743
+ /// Registers `__int_to_string(n: i64) -> i32`, a hand-written runtime helper
744
+ /// backing string interpolation: bump-allocates a new length-prefixed string
745
+ /// holding `n`'s decimal representation (handling a leading `-` for negatives, and
746
+ /// `0` correctly via a do-while digit count that always runs at least once).
747
+ fn register_int_to_string_helper(module: &mut WasmModule, bump_global: u32) -> u32 {
748
+ let type_idx = module.add_type(&[ValType::I64], &[ValType::I32]);
749
+
750
+ // locals: 0=n (param, i64), 1=is_neg (i32), 2=abs_n (i64), 3=count (i32),
751
+ // 4=total_len (i32), 5=result (i32), 6=pos (i32), 7=temp (i64)
752
+ // Locals are declared as one group of 5 `i32`s followed by one group of 2
753
+ // `i64`s (see below), so indices must stay grouped by type in that same order —
754
+ // NOT in whatever order reads best logically.
755
+ const N: u32 = 0;
756
+ const IS_NEG: u32 = 1;
757
+ const COUNT: u32 = 2;
758
+ const TOTAL_LEN: u32 = 3;
759
+ const RESULT: u32 = 4;
760
+ const POS: u32 = 5;
761
+ const ABS_N: u32 = 6;
762
+ const TEMP: u32 = 7;
763
+
764
+ let mut body = Vec::new();
765
+ body.extend(encode_leb128_u32(2)); // two locals groups
766
+ body.extend(encode_leb128_u32(5)); // is_neg, count, total_len, result, pos: i32
767
+ ValType::I32.encode(&mut body);
768
+ body.extend(encode_leb128_u32(2)); // abs_n, temp: i64
769
+ ValType::I64.encode(&mut body);
770
+
771
+ // is_neg = n < 0
772
+ Instruction::LocalGet(N).encode(&mut body);
773
+ Instruction::I64Const(0).encode(&mut body);
774
+ Instruction::I64LtS.encode(&mut body);
775
+ Instruction::LocalSet(IS_NEG).encode(&mut body);
776
+
777
+ // abs_n = is_neg ? (0 - n) : n
778
+ Instruction::LocalGet(IS_NEG).encode(&mut body);
779
+ Instruction::If(BlockType::Result(ValType::I64)).encode(&mut body);
780
+ Instruction::I64Const(0).encode(&mut body);
781
+ Instruction::LocalGet(N).encode(&mut body);
782
+ Instruction::I64Sub.encode(&mut body);
783
+ Instruction::Else.encode(&mut body);
784
+ Instruction::LocalGet(N).encode(&mut body);
785
+ Instruction::End.encode(&mut body);
786
+ Instruction::LocalSet(ABS_N).encode(&mut body);
787
+
788
+ // count digits: do { temp /= 10; count++ } while (temp != 0); temp starts as abs_n
789
+ Instruction::I32Const(0).encode(&mut body);
790
+ Instruction::LocalSet(COUNT).encode(&mut body);
791
+ Instruction::LocalGet(ABS_N).encode(&mut body);
792
+ Instruction::LocalSet(TEMP).encode(&mut body);
793
+ Instruction::Loop(BlockType::Empty).encode(&mut body);
794
+ Instruction::LocalGet(TEMP).encode(&mut body);
795
+ Instruction::I64Const(10).encode(&mut body);
796
+ Instruction::I64DivS.encode(&mut body);
797
+ Instruction::LocalSet(TEMP).encode(&mut body);
798
+ Instruction::LocalGet(COUNT).encode(&mut body);
799
+ Instruction::I32Const(1).encode(&mut body);
800
+ Instruction::I32Add.encode(&mut body);
801
+ Instruction::LocalSet(COUNT).encode(&mut body);
802
+ Instruction::LocalGet(TEMP).encode(&mut body);
803
+ Instruction::I64Const(0).encode(&mut body);
804
+ Instruction::I64Ne.encode(&mut body);
805
+ Instruction::BrIf(0).encode(&mut body);
806
+ Instruction::End.encode(&mut body);
807
+
808
+ // total_len = count + is_neg
809
+ Instruction::LocalGet(COUNT).encode(&mut body);
810
+ Instruction::LocalGet(IS_NEG).encode(&mut body);
811
+ Instruction::I32Add.encode(&mut body);
812
+ Instruction::LocalSet(TOTAL_LEN).encode(&mut body);
813
+
814
+ // result = bump; bump += 4 + total_len
815
+ Instruction::GlobalGet(bump_global).encode(&mut body);
816
+ Instruction::LocalSet(RESULT).encode(&mut body);
817
+ Instruction::GlobalGet(bump_global).encode(&mut body);
818
+ Instruction::LocalGet(TOTAL_LEN).encode(&mut body);
819
+ Instruction::I32Const(4).encode(&mut body);
820
+ Instruction::I32Add.encode(&mut body);
821
+ Instruction::I32Add.encode(&mut body);
822
+ Instruction::GlobalSet(bump_global).encode(&mut body);
823
+
824
+ // store total_len at result+0
825
+ Instruction::LocalGet(RESULT).encode(&mut body);
826
+ Instruction::LocalGet(TOTAL_LEN).encode(&mut body);
827
+ Instruction::I32Store(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(&mut body);
828
+
829
+ // pos = 4 + total_len; temp = abs_n
830
+ Instruction::I32Const(4).encode(&mut body);
831
+ Instruction::LocalGet(TOTAL_LEN).encode(&mut body);
832
+ Instruction::I32Add.encode(&mut body);
833
+ Instruction::LocalSet(POS).encode(&mut body);
834
+ Instruction::LocalGet(ABS_N).encode(&mut body);
835
+ Instruction::LocalSet(TEMP).encode(&mut body);
836
+
837
+ // do { pos--; result[pos] = '0' + temp % 10; temp /= 10 } while (pos > 4 + is_neg)
838
+ Instruction::Loop(BlockType::Empty).encode(&mut body);
839
+ Instruction::LocalGet(POS).encode(&mut body);
840
+ Instruction::I32Const(1).encode(&mut body);
841
+ Instruction::I32Sub.encode(&mut body);
842
+ Instruction::LocalSet(POS).encode(&mut body);
843
+ // address = result + pos
844
+ Instruction::LocalGet(RESULT).encode(&mut body);
845
+ Instruction::LocalGet(POS).encode(&mut body);
846
+ Instruction::I32Add.encode(&mut body);
847
+ // value = '0' + (temp % 10)
848
+ Instruction::LocalGet(TEMP).encode(&mut body);
849
+ Instruction::I64Const(10).encode(&mut body);
850
+ Instruction::I64RemS.encode(&mut body);
851
+ Instruction::I64Const(48).encode(&mut body);
852
+ Instruction::I64Add.encode(&mut body);
853
+ Instruction::I32WrapI64.encode(&mut body);
854
+ Instruction::I32Store8(MemArg { offset: 0, align: 0, memory_index: 0 }).encode(&mut body);
855
+ // temp /= 10
856
+ Instruction::LocalGet(TEMP).encode(&mut body);
857
+ Instruction::I64Const(10).encode(&mut body);
858
+ Instruction::I64DivS.encode(&mut body);
859
+ Instruction::LocalSet(TEMP).encode(&mut body);
860
+ // while (pos > 4 + is_neg)
861
+ Instruction::LocalGet(POS).encode(&mut body);
862
+ Instruction::I32Const(4).encode(&mut body);
863
+ Instruction::LocalGet(IS_NEG).encode(&mut body);
864
+ Instruction::I32Add.encode(&mut body);
865
+ Instruction::I32GtU.encode(&mut body);
866
+ Instruction::BrIf(0).encode(&mut body);
867
+ Instruction::End.encode(&mut body);
868
+
869
+ // if (is_neg) result[4] = '-'
870
+ Instruction::LocalGet(IS_NEG).encode(&mut body);
871
+ Instruction::If(BlockType::Empty).encode(&mut body);
872
+ Instruction::LocalGet(RESULT).encode(&mut body);
873
+ Instruction::I32Const(4).encode(&mut body);
874
+ Instruction::I32Add.encode(&mut body);
875
+ Instruction::I32Const(45).encode(&mut body); // '-'
876
+ Instruction::I32Store8(MemArg { offset: 0, align: 0, memory_index: 0 }).encode(&mut body);
877
+ Instruction::End.encode(&mut body);
878
+
879
+ Instruction::LocalGet(RESULT).encode(&mut body);
880
+ Instruction::End.encode(&mut body);
881
+
882
+ module.add_function(type_idx, &body)
883
+ }
884
+
542
885
  /// The `PlumType` of a declared parameter, including `fn(...)`-typed params as `TFun`.
543
886
  fn param_plum_type(pt: &ast::ParamType) -> PlumType {
544
887
  match pt {
@@ -573,6 +916,16 @@ struct ClosureWalker<'a, 'c> {
573
916
  cctx: plum_checker::CheckCtx<'c>,
574
917
  fn_decls: &'a HashMap<String, &'a ast::Fn>,
575
918
  found: Vec<RawClosure<'a>>,
919
+ /// Local names bound in the function currently being walked (params, assign
920
+ /// targets, for-loop vars) — used to tell a local variable reference apart from a
921
+ /// bare reference to a top-level function name (see `named_fn_refs`).
922
+ locals: HashSet<String>,
923
+ /// Top-level (non-method) function names referenced as a bare value (e.g.
924
+ /// `each(double)`) rather than called directly (`double(x)`, which compiles via
925
+ /// `Expr::FnCall` and never reaches here). Each one needs a zero-capture
926
+ /// "trampoline" closure so it can be used wherever a `fn(...)`-typed value is
927
+ /// expected.
928
+ named_fn_refs: HashSet<String>,
576
929
  }
577
930
 
578
931
  impl<'a, 'c> ClosureWalker<'a, 'c> {
@@ -589,6 +942,7 @@ impl<'a, 'c> ClosureWalker<'a, 'c> {
589
942
  self.walk_expr(value, None);
590
943
  let ty = plum_checker::infer_expr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt);
591
944
  self.env.insert(target.clone(), TypeScheme::mono(ty));
945
+ self.locals.insert(target.clone());
592
946
  }
593
947
  }
594
948
  ast::Stmt::Return(Some(e)) => self.walk_expr(e, None),
@@ -612,6 +966,7 @@ impl<'a, 'c> ClosureWalker<'a, 'c> {
612
966
  self.walk_expr(&f.iter, None);
613
967
  for v in &f.vars {
614
968
  self.env.insert(v.clone(), TypeScheme::mono(PlumType::TInt));
969
+ self.locals.insert(v.clone());
615
970
  }
616
971
  self.walk_block(&f.body);
617
972
  }
@@ -668,11 +1023,23 @@ impl<'a, 'c> ClosureWalker<'a, 'c> {
668
1023
  }
669
1024
  }
670
1025
  }
1026
+ ast::Expr::Var(name) => {
1027
+ // A bare name that isn't a local in this scope but does name a
1028
+ // top-level (non-method) function is a reference to that function as
1029
+ // a value (e.g. `each(double)`), not a direct call — a direct call
1030
+ // compiles via `Expr::FnCall` and never reaches this arm.
1031
+ if !self.locals.contains(name) {
1032
+ if let Some(f) = self.fn_decls.get(name) {
1033
+ if f.type_param.is_none() {
1034
+ self.named_fn_refs.insert(name.clone());
1035
+ }
1036
+ }
1037
+ }
1038
+ }
671
1039
  ast::Expr::Int(_)
672
1040
  | ast::Expr::Float(_)
673
1041
  | ast::Expr::String(_)
674
1042
  | ast::Expr::Self_
675
- | ast::Expr::Var(_)
676
1043
  | ast::Expr::TypeName(_) => {}
677
1044
  }
678
1045
  }
@@ -686,33 +1053,78 @@ impl<'a, 'c> ClosureWalker<'a, 'c> {
686
1053
  (param_vts, param_ptypes, ret_vt)
687
1054
  }
688
1055
  _ => {
689
- // Not a direct `fn`-typed call argument: infer the closure's own type.
1056
+ // Not a direct `fn`-typed call argument (e.g. assigned to a local
1057
+ // first, then called/passed on later): the checker's own closure
1058
+ // inference (`plum_checker::infer_expr` on `Expr::Closure`) gives
1059
+ // each param a fresh `TVar` and never actually unifies it against
1060
+ // how the param is used in the body — `unify` is a no-op for any
1061
+ // `TVar` — so a param that's genuinely Float or a class/pointer
1062
+ // silently comes back as an unresolved `TVar`, which
1063
+ // `plum_type_to_valtype` then defaults to `Int`. If the closure is
1064
+ // later called at its real (non-Int) type, the wasm function actually
1065
+ // compiled for its body (built from this wrong, Int-assumed signature)
1066
+ // won't match the `call_indirect` type the real call site expects —
1067
+ // a mismatch `call_indirect` only traps on at runtime, not at compile
1068
+ // or validation time.
1069
+ //
1070
+ // Fix: resolve each param's type from a direct usage in the body
1071
+ // first (see `resolve_closure_param_types_from_usage`) — e.g. `cb = |v|
1072
+ // x + v` with a captured `Float` `x` resolves `v` to `Float` from that
1073
+ // `Binary` op — then infer the return type against an env where the
1074
+ // params are already bound concretely (bypassing the checker's
1075
+ // Closure-inference arm entirely, since it always re-binds params to
1076
+ // fresh TVars regardless of what's already in the env passed to it).
690
- match plum_checker::infer_expr(&ast::Expr::Closure(Box::new(cl.clone())), &self.env, &self.cctx) {
1077
+ let resolved_params = resolve_closure_param_types_from_usage(cl, &self.env, &self.cctx);
691
- Ok(PlumType::TFun(ptypes, ret)) => {
1078
+ let param_ptypes: Vec<PlumType> = cl.params.iter()
1079
+ .map(|p| resolved_params.get(p).cloned().unwrap_or(PlumType::TInt))
1080
+ .collect();
692
- let param_vts = ptypes.iter().map(plum_type_to_valtype).collect();
1081
+ let param_vts: Vec<ValType> = param_ptypes.iter().map(plum_type_to_valtype).collect();
1082
+
693
- let ret_vt = match *ret {
1083
+ let mut body_env = self.env.clone();
694
- PlumType::TUnit => None,
695
- other => Some(plum_type_to_valtype(&other)),
696
- };
697
- (param_vts, ptypes, ret_vt)
698
- }
699
- _ => {
700
- let param_vts = cl.params.iter().map(|_| ValType::I64).collect();
1084
+ for (p, ty) in cl.params.iter().zip(param_ptypes.iter()) {
701
- let param_ptypes = cl.params.iter().map(|_| PlumType::TInt).collect();
1085
+ body_env.insert(p.clone(), TypeScheme::mono(ty.clone()));
702
- (param_vts, param_ptypes, Some(ValType::I64))
703
- }
704
1086
  }
1087
+ let ret_ty = match cl.body.stmts.last() {
1088
+ Some(ast::Stmt::Expr(e)) => plum_checker::infer_expr(e, &body_env, &self.cctx).ok(),
1089
+ Some(ast::Stmt::Return(Some(e))) => plum_checker::infer_expr(e, &body_env, &self.cctx).ok(),
1090
+ _ => Some(PlumType::TUnit),
1091
+ };
1092
+ let ret_vt = match ret_ty {
1093
+ Some(PlumType::TUnit) => None,
1094
+ Some(PlumType::TVar(_)) | None => Some(ValType::I64), // unresolved: preserve prior Int-default behavior
1095
+ Some(other) => Some(plum_type_to_valtype(&other)),
1096
+ };
1097
+ (param_vts, param_ptypes, ret_vt)
705
1098
  }
706
1099
  };
707
- let free_vars = collect_free_vars(cl, &self.env);
1100
+ let free_vars = collect_free_vars(cl, &self.env, self.fn_decls);
708
1101
  self.found.push(RawClosure {
709
1102
  ptr: cl as *const ast::Closure as usize,
710
1103
  closure: cl,
711
1104
  param_vts,
712
- param_ptypes,
1105
+ param_ptypes: param_ptypes.clone(),
713
1106
  ret_vt,
714
1107
  free_vars,
715
1108
  });
1109
+
1110
+ // Recurse into this closure's own body to find any closure literals nested
1111
+ // inside it (`|v| |w| v + w`, or a closure literal used inside a `match`/`if`
1112
+ // within this one's body). Each nested closure gets registered exactly like a
1113
+ // top-level one, seeing this closure's own params as locals in scope — which
1114
+ // is also what makes its free-variable analysis correctly capture a name from
1115
+ // *this* closure's scope (rather than silently missing it): once discovery
1116
+ // finishes, `ctx.closures` holds every closure at every depth before any body
1117
+ // is compiled, so the single fixed "compile each registered closure" pass in
1118
+ // `compile_source` already handles arbitrary nesting with no further changes.
1119
+ let saved_env = self.env.clone();
1120
+ let saved_locals = self.locals.clone();
1121
+ for (p, ty) in cl.params.iter().zip(param_ptypes.iter()) {
1122
+ self.env.insert(p.clone(), TypeScheme::mono(ty.clone()));
1123
+ self.locals.insert(p.clone());
1124
+ }
1125
+ self.walk_block(&cl.body);
1126
+ self.env = saved_env;
1127
+ self.locals = saved_locals;
716
1128
  }
717
1129
  }
718
1130
 
@@ -724,17 +1136,197 @@ fn arg_expr_of(arg: &ast::Arg) -> &ast::Expr {
724
1136
  }
725
1137
  }
726
1138
 
1139
+ /// Resolves as many of a closure's param types as possible from how they're actually
1140
+ /// used in its body — e.g. `|v| x + v` with a captured `Float` `x` resolves `v` to
1141
+ /// `Float` from that `Binary` op, or `|v| helper(v)` where `helper`'s declared param
1142
+ /// type is concrete resolves `v` to that. A param never used in a way that pins down
1143
+ /// a concrete type simply doesn't appear in the returned map (callers fall back to
1144
+ /// `Int`, matching the prior default). This is intentionally a shallow, best-effort
1145
+ /// scan — not full unification — scoped to fixing the specific `call_indirect`
1146
+ /// signature-mismatch gap this exists for, not replacing the checker's inference.
1147
+ fn resolve_closure_param_types_from_usage(
1148
+ cl: &ast::Closure,
1149
+ env: &TypeEnv,
1150
+ cctx: &plum_checker::CheckCtx,
1151
+ ) -> HashMap<String, PlumType> {
1152
+ let params: std::collections::HashSet<String> = cl.params.iter().cloned().collect();
1153
+ let mut resolved: HashMap<String, PlumType> = HashMap::new();
1154
+ for stmt in &cl.body.stmts {
1155
+ scan_stmt_for_param_types(stmt, &params, env, cctx, &mut resolved);
1156
+ }
1157
+ resolved
1158
+ }
1159
+
1160
+ fn scan_stmt_for_param_types(
1161
+ stmt: &ast::Stmt,
1162
+ params: &std::collections::HashSet<String>,
1163
+ env: &TypeEnv,
1164
+ cctx: &plum_checker::CheckCtx,
1165
+ resolved: &mut HashMap<String, PlumType>,
1166
+ ) {
1167
+ match stmt {
1168
+ ast::Stmt::Assign(a) => {
1169
+ for v in &a.values {
1170
+ scan_expr_for_param_types(v, params, env, cctx, resolved);
1171
+ }
1172
+ }
1173
+ ast::Stmt::Return(Some(e)) | ast::Stmt::Expr(e) | ast::Stmt::Assert(e) => {
1174
+ scan_expr_for_param_types(e, params, env, cctx, resolved);
1175
+ }
1176
+ ast::Stmt::If(i) => {
1177
+ scan_expr_for_param_types(&i.condition, params, env, cctx, resolved);
1178
+ for s in &i.body.stmts {
1179
+ scan_stmt_for_param_types(s, params, env, cctx, resolved);
1180
+ }
1181
+ for ei in &i.else_ifs {
1182
+ scan_expr_for_param_types(&ei.condition, params, env, cctx, resolved);
1183
+ for s in &ei.body.stmts {
1184
+ scan_stmt_for_param_types(s, params, env, cctx, resolved);
1185
+ }
1186
+ }
1187
+ if let Some(e) = &i.else_ {
1188
+ for s in &e.stmts {
1189
+ scan_stmt_for_param_types(s, params, env, cctx, resolved);
1190
+ }
1191
+ }
1192
+ }
1193
+ ast::Stmt::While(w) => {
1194
+ scan_expr_for_param_types(&w.condition, params, env, cctx, resolved);
1195
+ for s in &w.body.stmts {
1196
+ scan_stmt_for_param_types(s, params, env, cctx, resolved);
1197
+ }
1198
+ }
1199
+ ast::Stmt::For(f) => {
1200
+ scan_expr_for_param_types(&f.iter, params, env, cctx, resolved);
1201
+ for s in &f.body.stmts {
1202
+ scan_stmt_for_param_types(s, params, env, cctx, resolved);
1203
+ }
1204
+ }
1205
+ _ => {}
1206
+ }
1207
+ }
1208
+
1209
+ fn scan_expr_for_param_types(
1210
+ expr: &ast::Expr,
1211
+ params: &std::collections::HashSet<String>,
1212
+ env: &TypeEnv,
1213
+ cctx: &plum_checker::CheckCtx,
1214
+ resolved: &mut HashMap<String, PlumType>,
1215
+ ) {
1216
+ match expr {
1217
+ ast::Expr::Binary(b) => {
1218
+ try_resolve_param_from_pair(&b.left, &b.right, params, env, cctx, resolved);
1219
+ scan_expr_for_param_types(&b.left, params, env, cctx, resolved);
1220
+ scan_expr_for_param_types(&b.right, params, env, cctx, resolved);
1221
+ }
1222
+ ast::Expr::Compare(c) => {
1223
+ try_resolve_param_from_pair(&c.left, &c.right, params, env, cctx, resolved);
1224
+ scan_expr_for_param_types(&c.left, params, env, cctx, resolved);
1225
+ scan_expr_for_param_types(&c.right, params, env, cctx, resolved);
1226
+ }
1227
+ ast::Expr::Bool(b) => {
1228
+ scan_expr_for_param_types(&b.left, params, env, cctx, resolved);
1229
+ scan_expr_for_param_types(&b.right, params, env, cctx, resolved);
1230
+ }
1231
+ ast::Expr::Not(inner) => scan_expr_for_param_types(inner, params, env, cctx, resolved),
1232
+ ast::Expr::Unary(u) => scan_expr_for_param_types(&u.operand, params, env, cctx, resolved),
1233
+ ast::Expr::Paren(inner) => scan_expr_for_param_types(inner, params, env, cctx, resolved),
1234
+ ast::Expr::Ternary(t) => {
1235
+ scan_expr_for_param_types(&t.condition, params, env, cctx, resolved);
1236
+ scan_expr_for_param_types(&t.then, params, env, cctx, resolved);
1237
+ scan_expr_for_param_types(&t.else_, params, env, cctx, resolved);
1238
+ }
1239
+ ast::Expr::FnCall(call) => {
1240
+ if let Ok(PlumType::TFun(param_types, _)) = plum_checker::lookup(env, &call.name) {
1241
+ for (arg, expected) in call.args.iter().zip(param_types.iter()) {
1242
+ let arg_expr = arg_expr_of(arg);
1243
+ if let ast::Expr::Var(n) = arg_expr {
1244
+ if params.contains(n) && !resolved.contains_key(n) && !matches!(expected, PlumType::TVar(_)) {
1245
+ resolved.insert(n.clone(), expected.clone());
1246
+ }
1247
+ }
1248
+ }
1249
+ }
1250
+ for arg in &call.args {
1251
+ scan_expr_for_param_types(arg_expr_of(arg), params, env, cctx, resolved);
1252
+ }
1253
+ }
1254
+ ast::Expr::ClassCall(call) => {
1255
+ for fa in &call.fields {
1256
+ scan_expr_for_param_types(&fa.value, params, env, cctx, resolved);
1257
+ }
1258
+ }
1259
+ ast::Expr::Attribute(a) => {
1260
+ // `c.field` on a bare, unresolved param implies `c`'s type is whichever
1261
+ // class declares that field name — ambiguous if more than one class has
1262
+ // a field by that name, but resolvable in the common case.
1263
+ if let ast::AttrKind::Field(field_name) = &a.attr {
1264
+ if let ast::Expr::Var(n) = &a.object {
1265
+ if params.contains(n) && !resolved.contains_key(n) {
1266
+ let mut matches = cctx.classes.iter().filter(|(_, fields)| fields.iter().any(|(fname, _)| fname == field_name));
1267
+ if let (Some((class_name, _)), None) = (matches.next(), matches.next()) {
1268
+ resolved.insert(n.clone(), PlumType::TNamed(class_name.clone()));
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+ scan_expr_for_param_types(&a.object, params, env, cctx, resolved);
1274
+ if let ast::AttrKind::Method(call) = &a.attr {
1275
+ for arg in &call.args {
1276
+ scan_expr_for_param_types(arg_expr_of(arg), params, env, cctx, resolved);
1277
+ }
1278
+ }
1279
+ }
1280
+ _ => {}
1281
+ }
1282
+ }
1283
+
1284
+ /// If either side of a `Binary`/`Compare` operand pair is a bare reference to an
1285
+ /// unresolved param and the *other* side has a concrete (non-`TVar`) inferred type,
1286
+ /// binds the param to that type.
1287
+ fn try_resolve_param_from_pair(
1288
+ left: &ast::Expr,
1289
+ right: &ast::Expr,
1290
+ params: &std::collections::HashSet<String>,
1291
+ env: &TypeEnv,
1292
+ cctx: &plum_checker::CheckCtx,
1293
+ resolved: &mut HashMap<String, PlumType>,
1294
+ ) {
1295
+ if let ast::Expr::Var(n) = left {
1296
+ if params.contains(n) && !resolved.contains_key(n) {
1297
+ if let Ok(ty) = plum_checker::infer_expr(right, env, cctx) {
1298
+ if !matches!(ty, PlumType::TVar(_)) {
1299
+ resolved.insert(n.clone(), ty);
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+ if let ast::Expr::Var(n) = right {
1305
+ if params.contains(n) && !resolved.contains_key(n) {
1306
+ if let Ok(ty) = plum_checker::infer_expr(left, env, cctx) {
1307
+ if !matches!(ty, PlumType::TVar(_)) {
1308
+ resolved.insert(n.clone(), ty);
1309
+ }
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+
727
1315
  /// Determines a closure's captured free variables: every `Var` referenced in its body
728
1316
  /// that is neither one of the closure's own params nor assigned locally inside the body,
729
1317
  /// in first-appearance order. Each free variable's type is looked up in the *enclosing*
730
1318
  /// scope's type env.
1319
+ fn collect_free_vars(
1320
+ cl: &ast::Closure,
1321
+ env: &TypeEnv,
1322
+ fn_decls: &HashMap<String, &ast::Fn>,
731
- fn collect_free_vars(cl: &ast::Closure, env: &TypeEnv) -> Vec<(String, PlumType)> {
1323
+ ) -> Vec<(String, PlumType)> {
732
1324
  let mut bound: std::collections::HashSet<String> = cl.params.iter().cloned().collect();
733
1325
  fv_collect_bound_block(&cl.body, &mut bound);
734
1326
 
735
1327
  let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
736
1328
  let mut free: Vec<(String, PlumType)> = Vec::new();
737
- fv_collect_refs_block(&cl.body, &bound, &mut seen, &mut free, env);
1329
+ fv_collect_refs_block(&cl.body, &bound, &mut seen, &mut free, env, fn_decls);
738
1330
  free
739
1331
  }
740
1332
 
@@ -793,42 +1385,43 @@ fn fv_collect_refs_block(
793
1385
  seen: &mut std::collections::HashSet<String>,
794
1386
  free: &mut Vec<(String, PlumType)>,
795
1387
  env: &TypeEnv,
1388
+ fn_decls: &HashMap<String, &ast::Fn>,
796
1389
  ) {
797
1390
  for s in &block.stmts {
798
1391
  match s {
799
1392
  ast::Stmt::Assign(a) => {
800
1393
  for v in &a.values {
801
- fv_collect_refs_expr(v, bound, seen, free, env);
1394
+ fv_collect_refs_expr(v, bound, seen, free, env, fn_decls);
802
1395
  }
803
1396
  }
804
1397
  ast::Stmt::Return(Some(e)) | ast::Stmt::Expr(e) | ast::Stmt::Assert(e) => {
805
- fv_collect_refs_expr(e, bound, seen, free, env);
1398
+ fv_collect_refs_expr(e, bound, seen, free, env, fn_decls);
806
1399
  }
807
1400
  ast::Stmt::If(i) => {
808
- fv_collect_refs_expr(&i.condition, bound, seen, free, env);
1401
+ fv_collect_refs_expr(&i.condition, bound, seen, free, env, fn_decls);
809
- fv_collect_refs_block(&i.body, bound, seen, free, env);
1402
+ fv_collect_refs_block(&i.body, bound, seen, free, env, fn_decls);
810
1403
  for ei in &i.else_ifs {
811
- fv_collect_refs_expr(&ei.condition, bound, seen, free, env);
1404
+ fv_collect_refs_expr(&ei.condition, bound, seen, free, env, fn_decls);
812
- fv_collect_refs_block(&ei.body, bound, seen, free, env);
1405
+ fv_collect_refs_block(&ei.body, bound, seen, free, env, fn_decls);
813
1406
  }
814
1407
  if let Some(e) = &i.else_ {
815
- fv_collect_refs_block(e, bound, seen, free, env);
1408
+ fv_collect_refs_block(e, bound, seen, free, env, fn_decls);
816
1409
  }
817
1410
  }
818
1411
  ast::Stmt::While(w) => {
819
- fv_collect_refs_expr(&w.condition, bound, seen, free, env);
1412
+ fv_collect_refs_expr(&w.condition, bound, seen, free, env, fn_decls);
820
- fv_collect_refs_block(&w.body, bound, seen, free, env);
1413
+ fv_collect_refs_block(&w.body, bound, seen, free, env, fn_decls);
821
1414
  }
822
1415
  ast::Stmt::For(f) => {
823
- fv_collect_refs_expr(&f.iter, bound, seen, free, env);
1416
+ fv_collect_refs_expr(&f.iter, bound, seen, free, env, fn_decls);
824
- fv_collect_refs_block(&f.body, bound, seen, free, env);
1417
+ fv_collect_refs_block(&f.body, bound, seen, free, env, fn_decls);
825
1418
  }
826
1419
  ast::Stmt::Match(m) => {
827
1420
  for subj in &m.subjects {
828
- fv_collect_refs_expr(subj, bound, seen, free, env);
1421
+ fv_collect_refs_expr(subj, bound, seen, free, env, fn_decls);
829
1422
  }
830
1423
  for case in &m.cases {
831
- fv_collect_refs_block(&case.body, bound, seen, free, env);
1424
+ fv_collect_refs_block(&case.body, bound, seen, free, env, fn_decls);
832
1425
  }
833
1426
  }
834
1427
  _ => {}
@@ -842,45 +1435,67 @@ fn fv_collect_refs_expr(
842
1435
  seen: &mut std::collections::HashSet<String>,
843
1436
  free: &mut Vec<(String, PlumType)>,
844
1437
  env: &TypeEnv,
1438
+ fn_decls: &HashMap<String, &ast::Fn>,
845
1439
  ) {
846
1440
  match expr {
847
1441
  ast::Expr::Var(name) => {
1442
+ // A top-level (non-method) function referenced bare (e.g. `each(double)`)
1443
+ // is not a captured variable — it's compiled as a static trampoline
1444
+ // reference (see `named_fn_values`), not loaded from an enclosing local.
1445
+ let is_named_fn_ref = fn_decls.get(name).is_some_and(|f| f.type_param.is_none());
848
- if !bound.contains(name) && seen.insert(name.clone()) {
1446
+ if !is_named_fn_ref && !bound.contains(name) && seen.insert(name.clone()) {
849
1447
  let ty = plum_checker::lookup(env, name).unwrap_or(PlumType::TInt);
850
1448
  free.push((name.clone(), ty));
851
1449
  }
852
1450
  }
853
- ast::Expr::Binary(b) => { fv_collect_refs_expr(&b.left, bound, seen, free, env); fv_collect_refs_expr(&b.right, bound, seen, free, env); }
1451
+ ast::Expr::Binary(b) => { fv_collect_refs_expr(&b.left, bound, seen, free, env, fn_decls); fv_collect_refs_expr(&b.right, bound, seen, free, env, fn_decls); }
854
- ast::Expr::Bool(b) => { fv_collect_refs_expr(&b.left, bound, seen, free, env); fv_collect_refs_expr(&b.right, bound, seen, free, env); }
1452
+ ast::Expr::Bool(b) => { fv_collect_refs_expr(&b.left, bound, seen, free, env, fn_decls); fv_collect_refs_expr(&b.right, bound, seen, free, env, fn_decls); }
855
- ast::Expr::Compare(c) => { fv_collect_refs_expr(&c.left, bound, seen, free, env); fv_collect_refs_expr(&c.right, bound, seen, free, env); }
1453
+ ast::Expr::Compare(c) => { fv_collect_refs_expr(&c.left, bound, seen, free, env, fn_decls); fv_collect_refs_expr(&c.right, bound, seen, free, env, fn_decls); }
856
- ast::Expr::Not(inner) => fv_collect_refs_expr(inner, bound, seen, free, env),
1454
+ ast::Expr::Not(inner) => fv_collect_refs_expr(inner, bound, seen, free, env, fn_decls),
857
- ast::Expr::Unary(u) => fv_collect_refs_expr(&u.operand, bound, seen, free, env),
1455
+ ast::Expr::Unary(u) => fv_collect_refs_expr(&u.operand, bound, seen, free, env, fn_decls),
858
- ast::Expr::Paren(inner) => fv_collect_refs_expr(inner, bound, seen, free, env),
1456
+ ast::Expr::Paren(inner) => fv_collect_refs_expr(inner, bound, seen, free, env, fn_decls),
859
1457
  ast::Expr::Ternary(t) => {
860
- fv_collect_refs_expr(&t.condition, bound, seen, free, env);
1458
+ fv_collect_refs_expr(&t.condition, bound, seen, free, env, fn_decls);
861
- fv_collect_refs_expr(&t.then, bound, seen, free, env);
1459
+ fv_collect_refs_expr(&t.then, bound, seen, free, env, fn_decls);
862
- fv_collect_refs_expr(&t.else_, bound, seen, free, env);
1460
+ fv_collect_refs_expr(&t.else_, bound, seen, free, env, fn_decls);
863
1461
  }
864
1462
  ast::Expr::FnCall(call) => {
865
1463
  for arg in &call.args {
866
- fv_collect_refs_expr(arg_expr_of(arg), bound, seen, free, env);
1464
+ fv_collect_refs_expr(arg_expr_of(arg), bound, seen, free, env, fn_decls);
867
1465
  }
868
1466
  }
869
1467
  ast::Expr::ClassCall(call) => {
870
1468
  for fa in &call.fields {
871
- fv_collect_refs_expr(&fa.value, bound, seen, free, env);
1469
+ fv_collect_refs_expr(&fa.value, bound, seen, free, env, fn_decls);
872
1470
  }
873
1471
  }
874
1472
  ast::Expr::Attribute(a) => {
875
- fv_collect_refs_expr(&a.object, bound, seen, free, env);
1473
+ fv_collect_refs_expr(&a.object, bound, seen, free, env, fn_decls);
876
1474
  if let ast::AttrKind::Method(call) = &a.attr {
877
1475
  for arg in &call.args {
878
- fv_collect_refs_expr(arg_expr_of(arg), bound, seen, free, env);
1476
+ fv_collect_refs_expr(arg_expr_of(arg), bound, seen, free, env, fn_decls);
879
1477
  }
880
1478
  }
881
1479
  }
1480
+ ast::Expr::Closure(inner) => {
1481
+ // A name the *inner* closure references that isn't bound by the inner
1482
+ // itself (its own params/locals) and isn't bound by *this* (outer)
1483
+ // closure either is a genuine multi-level capture: this outer closure
1484
+ // also needs to capture it from its own enclosing scope, in order to
1485
+ // pass it down when it later constructs the inner closure. A name the
1486
+ // inner references that the outer already binds (e.g. one of the
1487
+ // outer's own params) needs no such propagation — the outer's compiled
1488
+ // body can already reference it as an ordinary local when snapshotting
882
- // A nested closure isn't supported by the discovery pre-pass; its own free-var
1489
+ // the inner closure's env, so it's deliberately excluded here by
1490
+ // unioning `bound` (outer) with the inner's own bound set below, rather
883
- // references are not hoisted here.
1491
+ // than passing the inner's bound set alone.
1492
+ let mut inner_bound = bound.clone();
1493
+ for p in &inner.params {
1494
+ inner_bound.insert(p.clone());
1495
+ }
1496
+ fv_collect_bound_block(&inner.body, &mut inner_bound);
1497
+ fv_collect_refs_block(&inner.body, &inner_bound, seen, free, env, fn_decls);
1498
+ }
884
1499
  _ => {}
885
1500
  }
886
1501
  }
@@ -894,11 +1509,18 @@ struct Collector<'a> {
894
1509
  named: Vec<(String, PlumType)>,
895
1510
  named_set: std::collections::HashSet<String>,
896
1511
  classcall_scratch: HashMap<usize, u32>,
1512
+ /// One scratch-local type per subject (usually one, more for `match a, b, ...`).
897
- match_scratch: HashMap<usize, PlumType>,
1513
+ match_scratch: HashMap<usize, Vec<PlumType>>,
898
1514
  next_classcall_slot: u32,
899
1515
  /// `Closure` expr identity -> offset of its first (of two) construction scratch slots.
900
1516
  closure_scratch: HashMap<usize, u32>,
901
1517
  next_closure_slot: u32,
1518
+ /// `CasePattern::Class` identity -> its scratch local, for a constructor pattern
1519
+ /// *nested* inside another constructor pattern's fields (`Some(Some(v))`) — the
1520
+ /// outermost, per-subject pattern already has a scratch local via `match_scratch`
1521
+ /// / `all_subjects`, so this only covers depth >= 1.
1522
+ nested_class_scratch: HashMap<usize, u32>,
1523
+ next_nested_class_slot: u32,
902
1524
  }
903
1525
 
904
1526
  impl<'a> Collector<'a> {
@@ -909,6 +1531,37 @@ impl<'a> Collector<'a> {
909
1531
  self.env.insert(name.to_string(), TypeScheme::mono(ty));
910
1532
  }
911
1533
 
1534
+ /// Binds every `Name` sub-pattern anywhere inside `pat` (at any nesting depth) to
1535
+ /// its correct field type, and reserves a scratch local for every `Class`
1536
+ /// sub-pattern found *nested* inside another constructor pattern's fields (the
1537
+ /// outermost, per-subject pattern doesn't need one — see `nested_class_scratch`).
1538
+ fn collect_pattern(&mut self, pat: &ast::CasePattern, ty: &PlumType, top_level: bool) {
1539
+ match pat {
1540
+ ast::CasePattern::Name(n) => {
1541
+ let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
1542
+ && self.cctx.enum_variants.contains_key(n);
1543
+ if !is_variant {
1544
+ self.bind(n, ty.clone());
1545
+ }
1546
+ }
1547
+ ast::CasePattern::Class { name, fields } => {
1548
+ if !top_level {
1549
+ let key = pat as *const ast::CasePattern as usize;
1550
+ let slot = self.next_nested_class_slot;
1551
+ self.next_nested_class_slot += 1;
1552
+ self.nested_class_scratch.insert(key, slot);
1553
+ }
1554
+ if let Some(info) = self.cctx.enum_variants.get(name) {
1555
+ let field_types = info.field_types.clone();
1556
+ for (f, fty) in fields.iter().zip(field_types.iter()) {
1557
+ self.collect_pattern(f, fty, false);
1558
+ }
1559
+ }
1560
+ }
1561
+ _ => {}
1562
+ }
1563
+ }
1564
+
912
1565
  fn walk_block(&mut self, block: &ast::Block) {
913
1566
  for s in &block.stmts {
914
1567
  self.walk_stmt(s);
@@ -920,7 +1573,22 @@ impl<'a> Collector<'a> {
920
1573
  ast::Stmt::Assign(a) => {
921
1574
  for (target, value) in a.targets.iter().zip(a.values.iter()) {
922
1575
  self.walk_expr(value);
1576
+ let ty = if matches!(value, ast::Expr::Closure(_)) {
1577
+ // The checker's own closure inference (`infer_expr` on
1578
+ // `Expr::Closure`) infers the return type by recursively
1579
+ // inferring the body's tail expression with each param bound
1580
+ // to a fresh, unconstrained `TVar` — e.g. a captured/param
1581
+ // attribute access (`c.age`) on a `TVar`-typed object isn't a
1582
+ // known class, so it errors out entirely, and this call site
1583
+ // then silently defaults to `TInt` — the *wrong* wasm local
1584
+ // width for what's actually always an `i32` pointer. All that
1585
+ // actually matters here is the local's wasm width, and every
1586
+ // closure value is an i32 pointer regardless of its
1587
+ // parameter/return types, so skip inference entirely.
1588
+ PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
1589
+ } else {
923
- let ty = plum_checker::infer_expr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt);
1590
+ plum_checker::infer_expr(value, &self.env, &self.cctx).unwrap_or(PlumType::TInt)
1591
+ };
924
1592
  self.bind(target, ty);
925
1593
  }
926
1594
  }
@@ -951,39 +1619,15 @@ impl<'a> Collector<'a> {
951
1619
  ast::Stmt::Expr(e) => self.walk_expr(e),
952
1620
  ast::Stmt::Assert(e) => self.walk_expr(e),
953
1621
  ast::Stmt::Match(m) => {
954
- let subject_ty = if m.subjects.len() == 1 {
1622
+ let subject_types: Vec<PlumType> = m.subjects.iter().map(|s| {
955
- self.walk_expr(&m.subjects[0]);
956
- plum_checker::infer_expr(&m.subjects[0], &self.env, &self.cctx).unwrap_or(PlumType::TInt)
957
- } else {
958
- for s in &m.subjects {
959
- self.walk_expr(s);
1623
+ self.walk_expr(s);
960
- }
1624
+ plum_checker::infer_expr(s, &self.env, &self.cctx).unwrap_or(PlumType::TInt)
961
- PlumType::TInt
1625
+ }).collect();
962
- };
963
- self.match_scratch.insert(m as *const ast::Match as usize, subject_ty.clone());
1626
+ self.match_scratch.insert(m as *const ast::Match as usize, subject_types.clone());
964
1627
  for case in &m.cases {
965
1628
  let saved = self.env.clone();
966
- if m.subjects.len() == 1 {
967
- match case.patterns.first() {
968
- Some(ast::CasePattern::Name(n)) => {
969
- let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
970
- && self.cctx.enum_variants.contains_key(n);
971
- if !is_variant {
972
- self.bind(n, subject_ty.clone());
973
- }
974
- }
975
- Some(ast::CasePattern::Class { name, fields }) => {
976
- if let Some(info) = self.cctx.enum_variants.get(name) {
977
- let field_types = info.field_types.clone();
978
- for (f, fty) in fields.iter().zip(field_types.iter()) {
1629
+ for (pat, subject_ty) in case.patterns.iter().zip(subject_types.iter()) {
979
- if let ast::CasePattern::Name(n) = f {
1630
+ self.collect_pattern(pat, subject_ty, true);
980
- self.bind(n, fty.clone());
981
- }
982
- }
983
- }
984
- }
985
- _ => {}
986
- }
987
1631
  }
988
1632
  self.walk_block(&case.body);
989
1633
  self.env = saved;
@@ -1101,6 +1745,8 @@ fn compile_fn_body(f: &ast::Fn, ctx: &CompileCtx, state: &mut ModuleState) -> Re
1101
1745
  next_classcall_slot: 0,
1102
1746
  closure_scratch: HashMap::new(),
1103
1747
  next_closure_slot: 0,
1748
+ nested_class_scratch: HashMap::new(),
1749
+ next_nested_class_slot: 0,
1104
1750
  };
1105
1751
  if let ast::FnBody::Block(block) = &f.body {
1106
1752
  collector.walk_block(block);
@@ -1109,7 +1755,6 @@ fn compile_fn_body(f: &ast::Fn, ctx: &CompileCtx, state: &mut ModuleState) -> Re
1109
1755
  // `main() -> Int = each(|v| v)`), which needs construction scratch slots.
1110
1756
  collector.walk_expr(e);
1111
1757
  }
1112
-
1113
1758
  // ---- assign local indices: [self?][params][named...][classcall scratch...][match scratch...] ----
1114
1759
  let mut locals: HashMap<String, u32> = HashMap::new();
1115
1760
  let mut groups: Vec<ValType> = Vec::new();
@@ -1139,9 +1784,18 @@ fn compile_fn_body(f: &ast::Fn, ctx: &CompileCtx, state: &mut ModuleState) -> Re
1139
1784
 
1140
1785
  let match_scratch_base = idx;
1141
1786
  let mut match_scratch_index: HashMap<usize, u32> = HashMap::new();
1142
- for (ptr, ty) in collector.match_scratch.iter() {
1787
+ for (ptr, types) in collector.match_scratch.iter() {
1143
1788
  match_scratch_index.insert(*ptr, idx - match_scratch_base);
1789
+ for ty in types {
1144
- groups.push(plum_type_to_valtype(ty));
1790
+ groups.push(plum_type_to_valtype(ty));
1791
+ idx += 1;
1792
+ }
1793
+ }
1794
+
1795
+ let nested_class_scratch_base = idx;
1796
+ let nested_class_scratch_count = collector.nested_class_scratch.values().copied().max().map(|m| m + 1).unwrap_or(0);
1797
+ for _ in 0..nested_class_scratch_count {
1798
+ groups.push(ValType::I32);
1145
1799
  idx += 1;
1146
1800
  }
1147
1801
 
@@ -1169,14 +1823,20 @@ fn compile_fn_body(f: &ast::Fn, ctx: &CompileCtx, state: &mut ModuleState) -> Re
1169
1823
  match_scratch_index,
1170
1824
  closure_scratch_base,
1171
1825
  closure_scratch: collector.closure_scratch,
1826
+ nested_class_scratch_base,
1827
+ nested_class_scratch: collector.nested_class_scratch,
1172
1828
  func_ids: &ctx.func_ids,
1173
1829
  func_sigs: &ctx.func_sigs,
1174
1830
  closures: &ctx.closures,
1175
1831
  closure_call_types: &ctx.closure_call_types,
1832
+ named_fn_values: &ctx.named_fn_values,
1833
+ string_concat_func: ctx.string_concat_func,
1834
+ int_to_string_func: ctx.int_to_string_func,
1176
1835
  classes: &ctx.classes,
1177
1836
  methods: &ctx.methods,
1178
1837
  enum_variants: &ctx.enum_variants,
1179
1838
  type_env: RefCell::new(base_env),
1839
+ closure_local_sigs: RefCell::new(HashMap::new()),
1180
1840
  bump_global: ctx.bump_global,
1181
1841
  };
1182
1842
 
@@ -1321,7 +1981,15 @@ fn compile_stmt(stmt: &ast::Stmt, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mu
1321
1981
  match stmt {
1322
1982
  ast::Stmt::Assign(a) => {
1323
1983
  for (target, value) in a.targets.iter().zip(a.values.iter()) {
1984
+ // See the matching comment in `Collector::walk_stmt`: the checker's
1985
+ // closure inference is unreliable (can error out entirely depending
1986
+ // on the body), but every closure value is an i32 pointer regardless
1987
+ // of its real signature, so don't bother inferring it at all here.
1988
+ let vty = if matches!(value, ast::Expr::Closure(_)) {
1989
+ PlumType::TFun(Vec::new(), Box::new(PlumType::TUnit))
1990
+ } else {
1324
- let vty = infer_local_type(value, ctx);
1991
+ infer_local_type(value, ctx)
1992
+ };
1325
1993
  compile_expr(value, body, ctx, state)?;
1326
1994
  let idx = ctx
1327
1995
  .locals
@@ -1330,6 +1998,17 @@ fn compile_stmt(stmt: &ast::Stmt, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mu
1330
1998
  .ok_or_else(|| format!("undeclared local '{}'", target))?;
1331
1999
  Instruction::LocalSet(idx).encode(body);
1332
2000
  ctx.type_env.borrow_mut().insert(target.clone(), TypeScheme::mono(vty));
2001
+ // If this assigns a closure *literal*, remember its exact, already-
2002
+ // correct signature (computed by the discovery pass) so a later call
2003
+ // to it doesn't have to re-derive one — see `closure_local_sigs`.
2004
+ if let ast::Expr::Closure(cl) = value {
2005
+ let key = cl.as_ref() as *const ast::Closure as usize;
2006
+ if let Some(info) = ctx.closures.get(&key) {
2007
+ let mut sig_params = vec![ValType::I32];
2008
+ sig_params.extend(info.param_vts.iter().copied());
2009
+ ctx.closure_local_sigs.borrow_mut().insert(target.clone(), (sig_params, info.ret_vt));
2010
+ }
2011
+ }
1333
2012
  }
1334
2013
  }
1335
2014
  ast::Stmt::Return(Some(e)) => {
@@ -1453,40 +2132,64 @@ fn expr_has_result(expr: &ast::Expr, ctx: &LocalCtx) -> bool {
1453
2132
  /// so previously-working exhaustive enum matches (e.g. `Some`/`None`, `True`/`False`)
1454
2133
  /// keep compiling even without a trailing wildcard, while a genuinely non-exhaustive
1455
2134
  /// match (an `Int` match, or an enum match missing a variant) still gets a clear error.
2135
+ /// True if `cases` already covers every combination of enum variants across all
2136
+ /// subject positions (by explicit tag/constructor patterns only — no binding or
2137
+ /// wildcard in any position), i.e. the match is exhaustive at runtime even though
2138
+ /// `compile_match_arms_multi` can't see that from the remaining-cases slice alone.
2139
+ /// For a single subject this is "every variant of its enum is named somewhere";
2140
+ /// for `match a, b, ...` it's the full cross product (e.g. `Bool, Bool` needs all
2141
+ /// 4 combinations named, matching `libs/std/bool.plum`'s `and`/`or`).
1456
- fn match_covers_every_enum_variant(cases: &[ast::Case], subject_vt: ValType, ctx: &LocalCtx) -> bool {
2142
+ fn match_covers_every_enum_variant(cases: &[ast::Case], subject_vts: &[ValType], ctx: &LocalCtx) -> bool {
1457
- if subject_vt != ValType::I32 {
2143
+ if subject_vts.is_empty() || subject_vts.iter().any(|vt| *vt != ValType::I32) {
1458
2144
  return false;
1459
2145
  }
2146
+ let n = subject_vts.len();
1460
- let mut enum_name: Option<&str> = None;
2147
+ let mut enum_names: Vec<Option<String>> = vec![None; n];
1461
- let mut tags_seen: std::collections::BTreeSet<i32> = std::collections::BTreeSet::new();
2148
+ let mut tuples_seen: std::collections::BTreeSet<Vec<i32>> = std::collections::BTreeSet::new();
1462
2149
  for case in cases {
2150
+ if case.patterns.len() != n {
2151
+ return false;
2152
+ }
2153
+ let mut tuple = Vec::with_capacity(n);
2154
+ for (i, pat) in case.patterns.iter().enumerate() {
1463
- let variant_name = match case.patterns.first() {
2155
+ let variant_name = match pat {
1464
- Some(ast::CasePattern::Name(n))
2156
+ ast::CasePattern::Name(nm)
1465
- if n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) && ctx.enum_variants.contains_key(n) =>
2157
+ if nm.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) && ctx.enum_variants.contains_key(nm) =>
1466
- {
2158
+ {
1467
- n.as_str()
2159
+ nm.as_str()
1468
- }
2160
+ }
1469
- Some(ast::CasePattern::Class { name, .. }) => name.as_str(),
2161
+ ast::CasePattern::Class { name, .. } => name.as_str(),
2162
+ // A binding or wildcard at any position could match variants we
2163
+ // haven't otherwise named, so we can't prove full coverage this way.
1470
- _ => return false,
2164
+ _ => return false,
1471
- };
2165
+ };
1472
- let info = match ctx.enum_variants.get(variant_name) {
2166
+ let info = match ctx.enum_variants.get(variant_name) {
1473
- Some(info) => info,
2167
+ Some(info) => info,
2168
+ None => return false,
2169
+ };
2170
+ match &enum_names[i] {
2171
+ Some(en) if en != &info.enum_name => return false,
2172
+ Some(_) => {}
2173
+ None => enum_names[i] = Some(info.enum_name.clone()),
2174
+ }
2175
+ tuple.push(info.tag);
2176
+ }
2177
+ tuples_seen.insert(tuple);
2178
+ }
2179
+ let mut total_combinations: usize = 1;
2180
+ for en in &enum_names {
2181
+ match en {
2182
+ Some(name) => {
2183
+ let count = ctx.enum_variants.values().filter(|v| &v.enum_name == name).count();
2184
+ total_combinations = match total_combinations.checked_mul(count) {
2185
+ Some(t) => t,
2186
+ None => return false,
2187
+ };
2188
+ }
1474
2189
  None => return false,
1475
- };
1476
- match enum_name {
1477
- Some(en) if en != info.enum_name => return false,
1478
- Some(_) => {}
1479
- None => enum_name = Some(&info.enum_name),
1480
2190
  }
1481
- tags_seen.insert(info.tag);
1482
- }
1483
- match enum_name {
1484
- Some(en) => {
1485
- let total_variants = ctx.enum_variants.values().filter(|v| v.enum_name == en).count();
1486
- !tags_seen.is_empty() && tags_seen.len() == total_variants
1487
- }
1488
- None => false,
1489
2191
  }
2192
+ !tuples_seen.is_empty() && tuples_seen.len() == total_combinations
1490
2193
  }
1491
2194
 
1492
2195
  fn compile_match(
@@ -1496,46 +2199,51 @@ fn compile_match(
1496
2199
  state: &mut ModuleState,
1497
2200
  result_vt: Option<ValType>,
1498
2201
  ) -> Result<(), String> {
1499
- if m.subjects.len() != 1 {
1500
- return Err("codegen: multi-subject match is not yet supported".to_string());
1501
- }
1502
- let subject = &m.subjects[0];
1503
- let subject_ty = infer_local_type(subject, ctx);
1504
- let subject_vt = plum_type_to_valtype(&subject_ty);
1505
-
1506
2202
  let key = m as *const ast::Match as usize;
1507
- let slot = *ctx
2203
+ let base_slot = *ctx
1508
2204
  .match_scratch_index
1509
2205
  .get(&key)
1510
2206
  .ok_or_else(|| "internal codegen error: missing match scratch slot".to_string())?;
1511
- let scratch_local = ctx.match_scratch_base + slot;
1512
-
1513
- compile_expr(subject, body, ctx, state)?;
1514
- Instruction::LocalSet(scratch_local).encode(body);
1515
-
1516
- // A match in value position whose arms already cover every variant of the
1517
- // subject's enum type (by explicit tag/constructor patterns, no wildcard) is
1518
- // exhaustive at runtime even though `compile_match_arms` can't see that from the
1519
- // remaining-cases slice alone. Give it a synthetic trailing `_ => todo` arm so the
1520
- // "out of patterns" fallback compiles to an (unreachable, but valid) trap instead
1521
- // of a spurious non-exhaustive-match error.
1522
- if result_vt.is_some() && match_covers_every_enum_variant(&m.cases, subject_vt, ctx) {
1523
- let mut cases = m.cases.clone();
1524
- cases.push(ast::Case {
1525
- patterns: vec![ast::CasePattern::Wildcard],
1526
- body: ast::Block { stmts: vec![ast::Stmt::Todo] },
1527
- });
1528
- return compile_match_arms(&cases, subject_vt, scratch_local, result_vt, body, ctx, state);
1529
- }
1530
2207
 
2208
+ // Evaluate every subject into its own consecutive scratch local (one per
2209
+ // subject, in `match a, b, ...` order) before checking any pattern.
2210
+ let mut all_subjects: Vec<(ValType, u32)> = Vec::with_capacity(m.subjects.len());
2211
+ for (i, subject) in m.subjects.iter().enumerate() {
2212
+ let subject_ty = infer_local_type(subject, ctx);
2213
+ let subject_vt = plum_type_to_valtype(&subject_ty);
2214
+ let scratch_local = ctx.match_scratch_base + base_slot + i as u32;
2215
+ compile_expr(subject, body, ctx, state)?;
2216
+ Instruction::LocalSet(scratch_local).encode(body);
2217
+ all_subjects.push((subject_vt, scratch_local));
2218
+ }
2219
+
2220
+ // A match in value position whose arms already cover every combination of
2221
+ // enum variants across all subjects (by explicit tag/constructor patterns, no
2222
+ // wildcard) is exhaustive at runtime even though `compile_match_arms_multi` can't
2223
+ // see that from the remaining-cases slice alone. `exhaustive_fallback` tells it to
2224
+ // compile the "ran out of cases" path as an (unreachable, but valid) trap instead
2225
+ // of a spurious non-exhaustive-match error — threaded through as a flag, rather
2226
+ // than appending a synthetic wildcard case by cloning `m.cases`, because cloning
2227
+ // would reallocate every nested `CasePattern::Class` node at a new address and
2228
+ // break `nested_class_scratch`'s pointer-identity-keyed lookup.
2229
+ let subject_vts: Vec<ValType> = all_subjects.iter().map(|(vt, _)| *vt).collect();
2230
+ let exhaustive_fallback = result_vt.is_some() && match_covers_every_enum_variant(&m.cases, &subject_vts, ctx);
2231
+
1531
- compile_match_arms(&m.cases, subject_vt, scratch_local, result_vt, body, ctx, state)
2232
+ compile_match_arms_multi(&m.cases, &all_subjects, result_vt, exhaustive_fallback, body, ctx, state)
1532
2233
  }
1533
2234
 
2235
+ /// Tries each case in turn (in source order); a case that fails to match falls
2236
+ /// through to the next one. `all_subjects` is the full `(valtype, scratch_local)`
2237
+ /// list for every subject of the enclosing `match`, shared unchanged across every
2238
+ /// case (each case's own pattern list is checked position-by-position against it
2239
+ /// via `compile_case_positions`). `exhaustive_fallback` (see `compile_match`) says
2240
+ /// what to do once `cases` runs out: trap (proven exhaustive) or report a
2241
+ /// non-exhaustive-match error.
1534
- fn compile_match_arms(
2242
+ fn compile_match_arms_multi(
1535
2243
  cases: &[ast::Case],
1536
- subject_vt: ValType,
2244
+ all_subjects: &[(ValType, u32)],
1537
- scratch_local: u32,
1538
2245
  result_vt: Option<ValType>,
2246
+ exhaustive_fallback: bool,
1539
2247
  body: &mut Vec<u8>,
1540
2248
  ctx: &LocalCtx,
1541
2249
  state: &mut ModuleState,
@@ -1543,6 +2251,10 @@ fn compile_match_arms(
1543
2251
  let (case, rest) = match cases.split_first() {
1544
2252
  None => {
1545
2253
  return match result_vt {
2254
+ Some(_) if exhaustive_fallback => {
2255
+ Instruction::Unreachable.encode(body);
2256
+ Ok(())
2257
+ }
1546
2258
  Some(_) => Err(
1547
2259
  "codegen: function has a control-flow path that doesn't produce a return value (non-exhaustive match)".to_string(),
1548
2260
  ),
@@ -1551,17 +2263,49 @@ fn compile_match_arms(
1551
2263
  }
1552
2264
  Some(pair) => pair,
1553
2265
  };
2266
+ if case.patterns.len() != all_subjects.len() {
2267
+ return Err(format!(
2268
+ "codegen: match case has {} pattern(s), expected {} (one per subject)",
2269
+ case.patterns.len(), all_subjects.len()
2270
+ ));
2271
+ }
2272
+ compile_case_positions(case, 0, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
2273
+ }
2274
+
2275
+ /// Checks `case.patterns[pos]` against `all_subjects[pos]`; on success, recurses to
2276
+ /// `pos + 1` (or, once every position has matched, compiles the case body). On
2277
+ /// failure at any position, falls through to `compile_match_arms_multi(rest, ...)`
2278
+ /// — i.e. the *entire next case*, restarting from its own position 0, not the next
1554
- let pat = case.patterns.first().ok_or_else(|| "codegen: match case has no pattern".to_string())?;
2279
+ /// position of this case. This is what gives `match a, b` its "all positions must
2280
+ /// match" (AND) semantics while still trying cases in order.
2281
+ #[allow(clippy::too_many_arguments)]
2282
+ fn compile_case_positions(
2283
+ case: &ast::Case,
2284
+ pos: usize,
2285
+ all_subjects: &[(ValType, u32)],
2286
+ rest: &[ast::Case],
2287
+ result_vt: Option<ValType>,
2288
+ exhaustive_fallback: bool,
2289
+ body: &mut Vec<u8>,
2290
+ ctx: &LocalCtx,
2291
+ state: &mut ModuleState,
2292
+ ) -> Result<(), String> {
2293
+ if pos == case.patterns.len() {
2294
+ // Every position matched.
2295
+ return compile_case_body(&case.body, result_vt, body, ctx, state);
2296
+ }
2297
+ let pat = &case.patterns[pos];
2298
+ let (subject_vt, scratch_local) = all_subjects[pos];
1555
2299
  match pat {
1556
2300
  ast::CasePattern::Wildcard => {
1557
- // Any cases after a wildcard are unreachable, matching real match semantics.
2301
+ // Always matches this position; move on to the next one.
1558
- compile_case_body(&case.body, result_vt, body, ctx, state)
2302
+ compile_case_positions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
1559
2303
  }
1560
2304
  ast::CasePattern::Name(n) => {
1561
2305
  let is_variant = n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
1562
2306
  && ctx.enum_variants.contains_key(n);
1563
2307
  if is_variant {
1564
- compile_variant_eq_arm(n, subject_vt, scratch_local, result_vt, case, rest, body, ctx, state)
2308
+ compile_variant_eq_arm(n, subject_vt, scratch_local, case, pos, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
1565
2309
  } else {
1566
2310
  let idx = ctx
1567
2311
  .locals
@@ -1571,8 +2315,8 @@ fn compile_match_arms(
1571
2315
  Instruction::LocalGet(scratch_local).encode(body);
1572
2316
  Instruction::LocalSet(idx).encode(body);
1573
2317
  ctx.type_env.borrow_mut().insert(n.clone(), TypeScheme::mono(plum_type_from_valtype_hint(subject_vt)));
1574
- compile_case_body(&case.body, result_vt, body, ctx, state)
1575
- // A binding arm always matches any following cases are unreachable.
2318
+ // A binding always matches this position; move on to the next one.
2319
+ compile_case_positions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
1576
2320
  }
1577
2321
  }
1578
2322
  ast::CasePattern::Int(n) => {
@@ -1583,16 +2327,16 @@ fn compile_match_arms(
1583
2327
  Instruction::I64Const(*n).encode(body);
1584
2328
  Instruction::I64Eq.encode(body);
1585
2329
  Instruction::If(block_type_for(result_vt)).encode(body);
1586
- compile_case_body(&case.body, result_vt, body, ctx, state)?;
2330
+ compile_case_positions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)?;
1587
2331
  Instruction::Else.encode(body);
1588
- compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
2332
+ compile_match_arms_multi(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
1589
2333
  Instruction::End.encode(body);
1590
2334
  Ok(())
1591
2335
  }
1592
2336
  ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
1593
2337
  ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
1594
2338
  ast::CasePattern::Class { name, fields } => {
1595
- compile_variant_constructor_arm(name, fields, subject_vt, scratch_local, result_vt, case, rest, body, ctx, state)
2339
+ compile_variant_constructor_arm(name, fields, subject_vt, scratch_local, case, pos, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)
1596
2340
  }
1597
2341
  }
1598
2342
  }
@@ -1602,9 +2346,12 @@ fn compile_variant_eq_arm(
1602
2346
  name: &str,
1603
2347
  subject_vt: ValType,
1604
2348
  scratch_local: u32,
1605
- result_vt: Option<ValType>,
1606
2349
  case: &ast::Case,
2350
+ pos: usize,
2351
+ all_subjects: &[(ValType, u32)],
1607
2352
  rest: &[ast::Case],
2353
+ result_vt: Option<ValType>,
2354
+ exhaustive_fallback: bool,
1608
2355
  body: &mut Vec<u8>,
1609
2356
  ctx: &LocalCtx,
1610
2357
  state: &mut ModuleState,
@@ -1621,9 +2368,9 @@ fn compile_variant_eq_arm(
1621
2368
  Instruction::I32Const(tag).encode(body);
1622
2369
  Instruction::I32Eq.encode(body);
1623
2370
  Instruction::If(block_type_for(result_vt)).encode(body);
1624
- compile_case_body(&case.body, result_vt, body, ctx, state)?;
2371
+ compile_case_positions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state)?;
1625
2372
  Instruction::Else.encode(body);
1626
- compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
2373
+ compile_match_arms_multi(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
1627
2374
  Instruction::End.encode(body);
1628
2375
  Ok(())
1629
2376
  }
@@ -1634,9 +2381,12 @@ fn compile_variant_constructor_arm(
1634
2381
  fields: &[ast::CasePattern],
1635
2382
  subject_vt: ValType,
1636
2383
  scratch_local: u32,
1637
- result_vt: Option<ValType>,
1638
2384
  case: &ast::Case,
2385
+ pos: usize,
2386
+ all_subjects: &[(ValType, u32)],
1639
2387
  rest: &[ast::Case],
2388
+ result_vt: Option<ValType>,
2389
+ exhaustive_fallback: bool,
1640
2390
  body: &mut Vec<u8>,
1641
2391
  ctx: &LocalCtx,
1642
2392
  state: &mut ModuleState,
@@ -1674,34 +2424,151 @@ fn compile_variant_constructor_arm(
1674
2424
  Instruction::I32Const(0).encode(body);
1675
2425
  Instruction::End.encode(body);
1676
2426
  Instruction::If(block_type_for(result_vt)).encode(body);
2427
+ compile_field_patterns(
2428
+ fields, &field_types, 0, scratch_local, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state,
2429
+ &mut |body, state| compile_case_positions(case, pos + 1, all_subjects, rest, result_vt, exhaustive_fallback, body, ctx, state),
2430
+ )?;
2431
+ Instruction::Else.encode(body);
2432
+ compile_match_arms_multi(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
2433
+ Instruction::End.encode(body);
2434
+ Ok(())
2435
+ }
2436
+
2437
+ /// Checks `fields[fpos..]` (a constructor pattern's own sub-patterns, e.g. the `v` in
2438
+ /// `Some(v)`, or — recursively — the `Some(v)` in `Wrap(Some(v))`) against the
1677
- for (i, (pat, field_ty)) in fields.iter().zip(field_types.iter()).enumerate() {
2439
+ /// already-loaded value in `container_local`, one field at a time. Once every field
2440
+ /// has matched, calls `on_match` (typically: proceed to the next top-level subject
2441
+ /// position). A mismatch at any field — at any nesting depth — falls through to
2442
+ /// `compile_match_arms_multi(rest, ...)`, exactly like a top-level pattern mismatch.
2443
+ #[allow(clippy::too_many_arguments)]
2444
+ fn compile_field_patterns(
2445
+ fields: &[ast::CasePattern],
2446
+ field_types: &[PlumType],
2447
+ fpos: usize,
2448
+ container_local: u32,
2449
+ rest: &[ast::Case],
2450
+ all_subjects: &[(ValType, u32)],
2451
+ result_vt: Option<ValType>,
2452
+ exhaustive_fallback: bool,
2453
+ body: &mut Vec<u8>,
2454
+ ctx: &LocalCtx,
2455
+ state: &mut ModuleState,
2456
+ on_match: &mut dyn FnMut(&mut Vec<u8>, &mut ModuleState) -> Result<(), String>,
2457
+ ) -> Result<(), String> {
2458
+ if fpos == fields.len() {
2459
+ return on_match(body, state);
2460
+ }
1678
- let bind_name = match pat {
2461
+ let pat = &fields[fpos];
2462
+ let field_ty = &field_types[fpos];
1679
- ast::CasePattern::Name(n) => Some(n.as_str()),
2463
+ let field_vt = plum_type_to_valtype(field_ty);
2464
+ let offset = ((fpos + 1) as u64) * 8;
2465
+
2466
+ match pat {
1680
- ast::CasePattern::Wildcard => None,
2467
+ ast::CasePattern::Wildcard => {
1681
- _ => return Err("codegen: only bare bindings or '_' are supported inside a constructor pattern".to_string()),
2468
+ compile_field_patterns(fields, field_types, fpos + 1, container_local, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)
1682
- };
2469
+ }
2470
+ ast::CasePattern::Name(n) if !(n.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) && ctx.enum_variants.contains_key(n)) => {
2471
+ // A plain binding always matches this field; load it straight into its
1683
- if let Some(n) = bind_name {
2472
+ // binding local and move on to the next field.
1684
2473
  let idx = ctx
1685
2474
  .locals
1686
2475
  .get(n)
1687
2476
  .copied()
1688
2477
  .ok_or_else(|| format!("internal codegen error: missing binding local '{}'", n))?;
1689
- Instruction::LocalGet(scratch_local).encode(body);
2478
+ Instruction::LocalGet(container_local).encode(body);
1690
- let offset = ((i + 1) as u64) * 8;
1691
- match plum_type_to_valtype(field_ty) {
2479
+ emit_load(field_vt, offset, body);
1692
- ValType::I64 => Instruction::I64Load(MemArg { offset, align: 3, memory_index: 0 }),
1693
- ValType::F64 => Instruction::F64Load(MemArg { offset, align: 3, memory_index: 0 }),
1694
- _ => Instruction::I32Load(MemArg { offset, align: 2, memory_index: 0 }),
1695
- }.encode(body);
1696
2480
  Instruction::LocalSet(idx).encode(body);
1697
2481
  ctx.type_env.borrow_mut().insert(n.to_string(), TypeScheme::mono(field_ty.clone()));
2482
+ compile_field_patterns(fields, field_types, fpos + 1, container_local, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)
2483
+ }
2484
+ ast::CasePattern::Name(n) => {
2485
+ // An uppercase, payload-free variant name used as a field pattern (e.g.
2486
+ // matching a nested `None` rather than binding a name to it).
2487
+ let info = ctx
2488
+ .enum_variants
2489
+ .get(n)
2490
+ .ok_or_else(|| format!("codegen: unknown enum variant '{}'", n))?;
2491
+ if field_vt != ValType::I32 {
2492
+ return Err(format!("codegen: enum tag pattern '{}' against a non-enum field", n));
2493
+ }
2494
+ Instruction::LocalGet(container_local).encode(body);
2495
+ emit_load(field_vt, offset, body);
2496
+ Instruction::I32Const(info.tag).encode(body);
2497
+ Instruction::I32Eq.encode(body);
2498
+ Instruction::If(block_type_for(result_vt)).encode(body);
2499
+ compile_field_patterns(fields, field_types, fpos + 1, container_local, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)?;
2500
+ Instruction::Else.encode(body);
2501
+ compile_match_arms_multi(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
2502
+ Instruction::End.encode(body);
2503
+ Ok(())
2504
+ }
2505
+ ast::CasePattern::Int(n) => {
2506
+ if field_vt != ValType::I64 {
2507
+ return Err("codegen: integer match pattern against a non-Int field".to_string());
2508
+ }
2509
+ Instruction::LocalGet(container_local).encode(body);
2510
+ emit_load(field_vt, offset, body);
2511
+ Instruction::I64Const(*n).encode(body);
2512
+ Instruction::I64Eq.encode(body);
2513
+ Instruction::If(block_type_for(result_vt)).encode(body);
2514
+ compile_field_patterns(fields, field_types, fpos + 1, container_local, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)?;
2515
+ Instruction::Else.encode(body);
2516
+ compile_match_arms_multi(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
2517
+ Instruction::End.encode(body);
2518
+ Ok(())
2519
+ }
2520
+ ast::CasePattern::String(_) => Err("codegen: string match patterns are not yet supported".to_string()),
2521
+ ast::CasePattern::Float(_) => Err("codegen: float match patterns are not yet supported".to_string()),
2522
+ ast::CasePattern::Class { name, fields: inner_fields } => {
2523
+ let info = ctx
2524
+ .enum_variants
2525
+ .get(name)
2526
+ .ok_or_else(|| format!("codegen: unknown enum variant '{}'", name))?;
2527
+ if field_vt != ValType::I32 {
2528
+ return Err(format!("codegen: constructor pattern '{}' against a non-enum field", name));
2529
+ }
2530
+ if inner_fields.len() != info.field_types.len() {
2531
+ return Err(format!(
2532
+ "codegen: constructor pattern '{}' expects {} field(s), got {}",
2533
+ name, info.field_types.len(), inner_fields.len()
2534
+ ));
2535
+ }
2536
+ let key = pat as *const ast::CasePattern as usize;
2537
+ let slot = *ctx
2538
+ .nested_class_scratch
2539
+ .get(&key)
2540
+ .ok_or_else(|| "internal codegen error: missing nested constructor pattern scratch slot".to_string())?;
2541
+ let nested_local = ctx.nested_class_scratch_base + slot;
2542
+ let tag = info.tag;
2543
+ let inner_field_types = info.field_types.clone();
2544
+
2545
+ Instruction::LocalGet(container_local).encode(body);
2546
+ emit_load(field_vt, offset, body);
2547
+ Instruction::LocalSet(nested_local).encode(body);
2548
+
2549
+ // Same heap-pointer guard as the top-level constructor check: a
2550
+ // payload-free sibling variant is a small int tag, not a pointer.
2551
+ Instruction::LocalGet(nested_local).encode(body);
2552
+ Instruction::I32Const(HEAP_BASE as i32).encode(body);
2553
+ Instruction::I32GeU.encode(body);
2554
+ Instruction::If(BlockType::Result(ValType::I32)).encode(body);
2555
+ Instruction::LocalGet(nested_local).encode(body);
2556
+ Instruction::I32Load(MemArg { offset: 0, align: 2, memory_index: 0 }).encode(body);
2557
+ Instruction::I32Const(tag).encode(body);
2558
+ Instruction::I32Eq.encode(body);
2559
+ Instruction::Else.encode(body);
2560
+ Instruction::I32Const(0).encode(body);
2561
+ Instruction::End.encode(body);
2562
+ Instruction::If(block_type_for(result_vt)).encode(body);
2563
+ compile_field_patterns(inner_fields, &inner_field_types, 0, nested_local, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, &mut |body, state| {
2564
+ compile_field_patterns(fields, field_types, fpos + 1, container_local, rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state, on_match)
2565
+ })?;
2566
+ Instruction::Else.encode(body);
2567
+ compile_match_arms_multi(rest, all_subjects, result_vt, exhaustive_fallback, body, ctx, state)?;
2568
+ Instruction::End.encode(body);
2569
+ Ok(())
1698
2570
  }
1699
2571
  }
1700
- compile_case_body(&case.body, result_vt, body, ctx, state)?;
1701
- Instruction::Else.encode(body);
1702
- compile_match_arms(rest, subject_vt, scratch_local, result_vt, body, ctx, state)?;
1703
- Instruction::End.encode(body);
1704
- Ok(())
1705
2572
  }
1706
2573
 
1707
2574
  fn plum_type_from_valtype_hint(vt: ValType) -> PlumType {
@@ -1721,11 +2588,19 @@ fn compile_expr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mu
1721
2588
  Instruction::F64Const(*f).encode(body);
1722
2589
  }
1723
2590
  ast::Expr::Var(name) => {
2591
+ match ctx.locals.get(name.as_str()) {
2592
+ Some(idx) => Instruction::LocalGet(*idx).encode(body),
2593
+ // Not a local: a bare reference to a top-level function used as a
2594
+ // value (e.g. `each(double)`) — push its zero-capture trampoline
2595
+ // closure's (compile-time-constant) struct pointer.
2596
+ None => {
1724
- let idx = *ctx
2597
+ let offset = *ctx
1725
- .locals
2598
+ .named_fn_values
1726
- .get(name.as_str())
2599
+ .get(name.as_str())
1727
- .ok_or_else(|| format!("undeclared variable '{}'", name))?;
2600
+ .ok_or_else(|| format!("undeclared variable '{}'", name))?;
1728
- Instruction::LocalGet(idx).encode(body);
2601
+ Instruction::I32Const(offset as i32).encode(body);
2602
+ }
2603
+ }
1729
2604
  }
1730
2605
  ast::Expr::Paren(inner) => {
1731
2606
  compile_expr(inner, body, ctx, state)?;
@@ -1943,23 +2818,20 @@ fn compile_expr(expr: &ast::Expr, body: &mut Vec<u8>, ctx: &LocalCtx, state: &mu
1943
2818
  }
1944
2819
  }
1945
2820
  ast::Expr::String(s) => {
2821
+ let has_interp = s.parts.iter().any(|p| matches!(p, ast::StringPart::Interp(_)));
2822
+ if !has_interp {
2823
+ // Fast path: every part is static text, so the whole literal is one
2824
+ // fixed byte blob known at compile time — no runtime work at all.
1946
- let mut text = String::new();
2825
+ let mut text = String::new();
1947
- for part in &s.parts {
2826
+ for part in &s.parts {
1948
- match part {
1949
- ast::StringPart::Text(t) => text.push_str(t),
1950
- ast::StringPart::Interp(_) => {
2827
+ if let ast::StringPart::Text(t) = part {
1951
- return Err("codegen: string interpolation is not yet supported".to_string());
2828
+ text.push_str(t);
1952
2829
  }
1953
2830
  }
2831
+ compile_static_string(&text, body, state);
2832
+ } else {
2833
+ compile_interpolated_string(s, body, ctx, state)?;
1954
2834
  }
1955
- let bytes = text.as_bytes();
1956
- let offset = state.next_string_offset;
1957
- let mut data = Vec::with_capacity(4 + bytes.len());
1958
- data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
1959
- data.extend_from_slice(bytes);
1960
- state.next_string_offset += data.len() as u32;
1961
- state.data_segments.push((offset, data));
1962
- Instruction::I32Const(offset as i32).encode(body);
1963
2835
  }
1964
2836
  ast::Expr::Closure(cl) => {
1965
2837
  compile_closure_literal(cl, body, ctx, state)?;
@@ -2047,6 +2919,74 @@ fn emit_load(vt: ValType, offset: u64, body: &mut Vec<u8>) {
2047
2919
  }.encode(body);
2048
2920
  }
2049
2921
 
2922
+ /// Emits a static (compile-time-known) length-prefixed string as a fresh data
2923
+ /// segment, pushing its address.
2924
+ fn compile_static_string(text: &str, body: &mut Vec<u8>, state: &mut ModuleState) {
2925
+ let bytes = text.as_bytes();
2926
+ let offset = state.next_string_offset;
2927
+ let mut data = Vec::with_capacity(4 + bytes.len());
2928
+ data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
2929
+ data.extend_from_slice(bytes);
2930
+ state.next_string_offset += data.len() as u32;
2931
+ state.data_segments.push((offset, data));
2932
+ Instruction::I32Const(offset as i32).encode(body);
2933
+ }
2934
+
2935
+ /// Lowers a string literal that contains at least one `{expr}` interpolation.
2936
+ /// Every part becomes a string-pointer-valued expression (static text via
2937
+ /// `compile_static_string`; `Str`/`Int`/`Bool` interpolated values converted at
2938
+ /// runtime), then all parts are left-folded together with the `__string_concat`
2939
+ /// runtime helper.
2940
+ fn compile_interpolated_string(
2941
+ s: &ast::StringExpr,
2942
+ body: &mut Vec<u8>,
2943
+ ctx: &LocalCtx,
2944
+ state: &mut ModuleState,
2945
+ ) -> Result<(), String> {
2946
+ let mut first = true;
2947
+ for part in &s.parts {
2948
+ match part {
2949
+ ast::StringPart::Text(t) => {
2950
+ compile_static_string(t, body, state);
2951
+ }
2952
+ ast::StringPart::Interp(expr) => {
2953
+ let ty = infer_local_type(expr, ctx);
2954
+ match ty {
2955
+ PlumType::TStr => {
2956
+ compile_expr(expr, body, ctx, state)?;
2957
+ }
2958
+ PlumType::TInt => {
2959
+ compile_expr(expr, body, ctx, state)?;
2960
+ Instruction::Call(ctx.int_to_string_func).encode(body);
2961
+ }
2962
+ PlumType::TBool => {
2963
+ compile_expr(expr, body, ctx, state)?;
2964
+ Instruction::If(BlockType::Result(ValType::I32)).encode(body);
2965
+ compile_static_string("True", body, state);
2966
+ Instruction::Else.encode(body);
2967
+ compile_static_string("False", body, state);
2968
+ Instruction::End.encode(body);
2969
+ }
2970
+ PlumType::TFloat => {
2971
+ return Err("codegen: interpolating a Float value is not yet supported".to_string());
2972
+ }
2973
+ other => {
2974
+ return Err(format!(
2975
+ "codegen: interpolating a value of type {} is not yet supported",
2976
+ other
2977
+ ));
2978
+ }
2979
+ }
2980
+ }
2981
+ }
2982
+ if !first {
2983
+ Instruction::Call(ctx.string_concat_func).encode(body);
2984
+ }
2985
+ first = false;
2986
+ }
2987
+ Ok(())
2988
+ }
2989
+
2050
2990
  /// Compiles a closure *literal* at its construction site. Snapshots each captured free
2051
2991
  /// variable's CURRENT value into a fresh heap env struct, then builds the 2-word closure
2052
2992
  /// struct `{table_index @0, env_pointer @8}` and leaves its pointer on the stack.
@@ -2060,7 +3000,7 @@ fn compile_closure_literal(
2060
3000
  let info = ctx
2061
3001
  .closures
2062
3002
  .get(&key)
2063
- .ok_or_else(|| "codegen: closure literal was not discovered (nested closures are not supported)".to_string())?;
3003
+ .ok_or_else(|| "internal codegen error: closure literal was not discovered by the discovery pre-pass".to_string())?;
2064
3004
  let scratch_off = *ctx
2065
3005
  .closure_scratch
2066
3006
  .get(&key)
@@ -2124,20 +3064,28 @@ fn compile_closure_call(
2124
3064
  .get(&call.name)
2125
3065
  .ok_or_else(|| format!("codegen: closure '{}' is not a local", call.name))?;
2126
3066
 
3067
+ // Prefer the exact signature recorded when this local was assigned a closure
3068
+ // *literal* (see `closure_local_sigs`) — it's already correct. Otherwise (e.g.
3069
+ // `call.name` is a `fn(...)`-typed parameter, whose declared type is reliable on
3070
+ // its own) fall back to re-deriving it from the type env.
3071
+ let sig_key: ClosureSigKey = match ctx.closure_local_sigs.borrow().get(&call.name) {
3072
+ Some(key) => key.clone(),
3073
+ None => {
2127
- let (param_ptypes, ret_ptype) = match infer_local_type(&ast::Expr::Var(call.name.clone()), ctx) {
3074
+ let (param_ptypes, ret_ptype) = match infer_local_type(&ast::Expr::Var(call.name.clone()), ctx) {
2128
- PlumType::TFun(p, r) => (p, *r),
3075
+ PlumType::TFun(p, r) => (p, *r),
2129
- other => return Err(format!("codegen: '{}' is not callable (type {:?})", call.name, other)),
3076
+ other => return Err(format!("codegen: '{}' is not callable (type {:?})", call.name, other)),
2130
- };
3077
+ };
2131
-
2132
- let mut sig_params = vec![ValType::I32]; // env pointer
3078
+ let mut sig_params = vec![ValType::I32]; // env pointer
2133
- for p in &param_ptypes {
3079
+ for p in &param_ptypes {
2134
- sig_params.push(plum_type_to_valtype(p));
3080
+ sig_params.push(plum_type_to_valtype(p));
2135
- }
3081
+ }
2136
- let ret_vt = match ret_ptype {
3082
+ let ret_vt = match ret_ptype {
2137
- PlumType::TUnit => None,
3083
+ PlumType::TUnit => None,
2138
- other => Some(plum_type_to_valtype(&other)),
3084
+ other => Some(plum_type_to_valtype(&other)),
3085
+ };
3086
+ (sig_params, ret_vt)
3087
+ }
2139
3088
  };
2140
- let sig_key: ClosureSigKey = (sig_params, ret_vt);
2141
3089
  let type_index = *ctx
2142
3090
  .closure_call_types
2143
3091
  .get(&sig_key)
@@ -2187,6 +3135,8 @@ fn compile_closure_body(
2187
3135
  next_classcall_slot: 0,
2188
3136
  closure_scratch: HashMap::new(),
2189
3137
  next_closure_slot: 0,
3138
+ nested_class_scratch: HashMap::new(),
3139
+ next_nested_class_slot: 0,
2190
3140
  };
2191
3141
  collector.walk_block(&cl.body);
2192
3142
 
@@ -2224,9 +3174,18 @@ fn compile_closure_body(
2224
3174
 
2225
3175
  let match_scratch_base = idx;
2226
3176
  let mut match_scratch_index: HashMap<usize, u32> = HashMap::new();
2227
- for (ptr, ty) in collector.match_scratch.iter() {
3177
+ for (ptr, types) in collector.match_scratch.iter() {
2228
3178
  match_scratch_index.insert(*ptr, idx - match_scratch_base);
3179
+ for ty in types {
2229
- groups.push(plum_type_to_valtype(ty));
3180
+ groups.push(plum_type_to_valtype(ty));
3181
+ idx += 1;
3182
+ }
3183
+ }
3184
+
3185
+ let nested_class_scratch_base = idx;
3186
+ let nested_class_scratch_count = collector.nested_class_scratch.values().copied().max().map(|m| m + 1).unwrap_or(0);
3187
+ for _ in 0..nested_class_scratch_count {
3188
+ groups.push(ValType::I32);
2230
3189
  idx += 1;
2231
3190
  }
2232
3191
 
@@ -2262,14 +3221,20 @@ fn compile_closure_body(
2262
3221
  match_scratch_index,
2263
3222
  closure_scratch_base,
2264
3223
  closure_scratch: collector.closure_scratch,
3224
+ nested_class_scratch_base,
3225
+ nested_class_scratch: collector.nested_class_scratch,
2265
3226
  func_ids: &ctx.func_ids,
2266
3227
  func_sigs: &ctx.func_sigs,
2267
3228
  closures: &ctx.closures,
2268
3229
  closure_call_types: &ctx.closure_call_types,
3230
+ named_fn_values: &ctx.named_fn_values,
3231
+ string_concat_func: ctx.string_concat_func,
3232
+ int_to_string_func: ctx.int_to_string_func,
2269
3233
  classes: &ctx.classes,
2270
3234
  methods: &ctx.methods,
2271
3235
  enum_variants: &ctx.enum_variants,
2272
3236
  type_env: RefCell::new(base_env),
3237
+ closure_local_sigs: RefCell::new(HashMap::new()),
2273
3238
  bump_global: ctx.bump_global,
2274
3239
  };
2275
3240
 
plum-wasm-codegen/tests/codegen_tests.rs CHANGED
@@ -80,11 +80,62 @@ fn empty_string_literal_compiles() {
80
80
  }
81
81
 
82
82
  #[test]
83
- fn string_interpolation_is_a_clear_error() {
83
+ fn string_interpolation_of_an_int_runs_correctly() {
84
- let src = "main() -> Str =\n x = 1\n \"{x}\"\n";
84
+ let src = "main() -> Str =\n x = 42\n \"{x}\"\n";
85
85
  let source = parse(src);
86
+ let bytes = compile_source(&source).expect("compile failed");
87
+ assert_eq!(run_main_str(&bytes), "42");
88
+ }
89
+
90
+ #[test]
91
+ fn string_interpolation_of_a_negative_int_runs_correctly() {
92
+ let src = "main() -> Str =\n x = -7\n \"{x}\"\n";
93
+ let source = parse(src);
94
+ let bytes = compile_source(&source).expect("compile failed");
95
+ assert_eq!(run_main_str(&bytes), "-7");
96
+ }
97
+
98
+ #[test]
99
+ fn string_interpolation_of_zero_runs_correctly() {
100
+ let src = "main() -> Str =\n x = 0\n \"{x}\"\n";
101
+ let source = parse(src);
102
+ let bytes = compile_source(&source).expect("compile failed");
103
+ assert_eq!(run_main_str(&bytes), "0");
104
+ }
105
+
106
+ #[test]
107
+ fn string_interpolation_with_surrounding_text_and_multiple_interps_runs_correctly() {
108
+ let src = "main() -> Str =\n count = 3\n total = 10\n \"{count} of {total} complete\"\n";
109
+ let source = parse(src);
110
+ let bytes = compile_source(&source).expect("compile failed");
111
+ assert_eq!(run_main_str(&bytes), "3 of 10 complete");
112
+ }
113
+
114
+ #[test]
115
+ fn string_interpolation_of_a_str_runs_correctly() {
116
+ let src = "greet(name: Str) -> Str =\n \"Hello, {name}!\"\n\nmain() -> Str =\n greet(\"World\")\n";
117
+ let source = parse(src);
118
+ let bytes = compile_source(&source).expect("compile failed");
119
+ assert_eq!(run_main_str(&bytes), "Hello, World!");
120
+ }
121
+
122
+ #[test]
123
+ fn string_interpolation_of_a_bool_runs_correctly() {
124
+ let src = "main() -> Str =\n b = True\n \"is {b}\"\n";
125
+ let source = parse(src);
126
+ let bytes = compile_source(&source).expect("compile failed");
127
+ assert_eq!(run_main_str(&bytes), "is True");
128
+ }
129
+
130
+ #[test]
131
+ fn string_interpolation_of_a_float_is_a_clear_error() {
132
+ // Float-to-decimal-string formatting is a substantial separate undertaking
133
+ // (correct rounding needs something like Grisu/Ryu); scoped out for now with
134
+ // an explicit error rather than emitting an incorrect conversion.
135
+ let src = "main() -> Str =\n x = 1.5\n \"{x}\"\n";
136
+ let source = parse(src);
86
- let err = compile_source(&source).expect_err("interpolation should not silently compile");
137
+ let err = compile_source(&source).expect_err("float interpolation should not silently compile");
87
- assert!(err.contains("interpolation"), "got: {}", err);
138
+ assert!(err.contains("Float"), "got: {}", err);
88
139
  }
89
140
 
90
141
  #[test]
@@ -161,7 +212,7 @@ fn match_string_pattern_is_a_clear_error() {
161
212
  }
162
213
 
163
214
  #[test]
164
- fn nested_constructor_pattern_is_a_clear_error() {
215
+ fn nested_constructor_pattern_matches_and_binds_runs_correctly() {
165
216
  let src = "\
166
217
  enum Option =
167
218
  | Some(Int)
@@ -175,19 +226,101 @@ f(n: Nested) -> Int =
175
226
  match n
176
227
  Wrap(Some(v)) =>
177
228
  return v
229
+ Wrap(None) =>
230
+ return -1
178
231
  Empty =>
179
232
  return 0
180
233
 
181
234
  main() -> Int =
182
- f(Empty)
235
+ f(Wrap(Some(5)))
183
236
  ";
184
237
  let source = parse(src);
185
- let err = compile_source(&source).expect_err("nested constructor patterns are not yet supported");
238
+ let bytes = compile_source(&source).expect("compile failed");
239
+ assert_eq!(run_main(&bytes), 5);
240
+ }
241
+
186
- assert!(
242
+ #[test]
243
+ fn nested_constructor_pattern_mismatch_falls_through_to_next_case_runs_correctly() {
187
- err.contains("only bare bindings or '_' are supported inside a constructor pattern"),
244
+ // `Wrap(Some(v))` shouldn't match a runtime `Wrap(None)` codegen must fall
245
+ // through to the next *top-level* case, not just fail to bind `v`.
188
- "got: {}",
246
+ let src = "\
247
+ enum Option =
248
+ | Some(Int)
249
+ | None
250
+
251
+ enum Nested =
252
+ | Wrap(Option)
253
+ | Empty
254
+
255
+ f(n: Nested) -> Int =
256
+ match n
257
+ Wrap(Some(v)) =>
189
- err
258
+ return v
259
+ Wrap(None) =>
260
+ return -1
261
+ Empty =>
262
+ return 0
263
+
264
+ main() -> Int =
265
+ f(Wrap(None))
190
- );
266
+ ";
267
+ let source = parse(src);
268
+ let bytes = compile_source(&source).expect("compile failed");
269
+ assert_eq!(run_main(&bytes), -1);
270
+ }
271
+
272
+ #[test]
273
+ fn nested_constructor_pattern_against_a_specialized_generic_enum_runs_correctly() {
274
+ // Exercises monomorphize.rs's recursive mangling: the outer `Full(...)` pattern
275
+ // matches against `Box`'s own specialization, but the *inner* `Some(v)`/`None`
276
+ // sub-pattern matches against `Box`'s generic field type (`Option`, itself
277
+ // specialized to `Option$Int`) — each level needs its own mangling table, not
278
+ // just the outermost one.
279
+ let src = "\
280
+ enum Option =
281
+ | Some(a)
282
+ | None
283
+
284
+ enum Box =
285
+ | Full(a)
286
+ | Empty
287
+
288
+ unwrap(b: Box) -> Int =
289
+ match b
290
+ Full(Some(v)) => v
291
+ Full(None) => -1
292
+ Empty => 0
293
+
294
+ main() -> Int =
295
+ unwrap(Full(Some(7)))
296
+ ";
297
+ let source = parse(src);
298
+ let bytes = compile_source(&source).expect("compile failed");
299
+ assert_eq!(run_main(&bytes), 7);
300
+ }
301
+
302
+ #[test]
303
+ fn doubly_nested_constructor_pattern_runs_correctly() {
304
+ // `Some(Some(v))` — two levels of nesting, proving the recursion isn't just
305
+ // one level deep.
306
+ let src = "\
307
+ enum Option =
308
+ | Some(Option)
309
+ | None
310
+
311
+ unwrapTwice(o: Option) -> Int =
312
+ match o
313
+ Some(Some(None)) => 1
314
+ Some(None) => 2
315
+ None => 3
316
+ _ => 0
317
+
318
+ main() -> Int =
319
+ unwrapTwice(Some(Some(None)))
320
+ ";
321
+ let source = parse(src);
322
+ let bytes = compile_source(&source).expect("compile failed");
323
+ assert_eq!(run_main(&bytes), 1);
191
324
  }
192
325
 
193
326
  /// Runs `main`'s wasm bytes and returns its i64 result. wasm's own validator
@@ -204,6 +337,37 @@ fn run_main(bytes: &[u8]) -> i64 {
204
337
  main.call(&mut store, ()).expect("main should not trap")
205
338
  }
206
339
 
340
+ fn run_main_f64(bytes: &[u8]) -> f64 {
341
+ let engine = wasmtime::Engine::default();
342
+ let module = wasmtime::Module::new(&engine, bytes).expect("module should be loadable");
343
+ let mut store = wasmtime::Store::new(&engine, ());
344
+ let instance = wasmtime::Instance::new(&mut store, &module, &[]).expect("module should instantiate");
345
+ let main = instance
346
+ .get_typed_func::<(), f64>(&mut store, "main")
347
+ .expect("main should have signature () -> f64");
348
+ main.call(&mut store, ()).expect("main should not trap")
349
+ }
350
+
351
+ /// Runs a `() -> Str`-returning `main`, reading the length-prefixed
352
+ /// `[len: u32][utf8 bytes]` string back out of the module's exported memory.
353
+ fn run_main_str(bytes: &[u8]) -> String {
354
+ let engine = wasmtime::Engine::default();
355
+ let module = wasmtime::Module::new(&engine, bytes).expect("module should be loadable");
356
+ let mut store = wasmtime::Store::new(&engine, ());
357
+ let instance = wasmtime::Instance::new(&mut store, &module, &[]).expect("module should instantiate");
358
+ let main = instance
359
+ .get_typed_func::<(), i32>(&mut store, "main")
360
+ .expect("main should have signature () -> i32");
361
+ let ptr = main.call(&mut store, ()).expect("main should not trap") as u32 as usize;
362
+ let memory = instance.get_memory(&mut store, "memory").expect("module should export memory");
363
+ let mut len_bytes = [0u8; 4];
364
+ memory.read(&store, ptr, &mut len_bytes).expect("string length read should be in bounds");
365
+ let len = u32::from_le_bytes(len_bytes) as usize;
366
+ let mut str_bytes = vec![0u8; len];
367
+ memory.read(&store, ptr + 4, &mut str_bytes).expect("string bytes read should be in bounds");
368
+ String::from_utf8(str_bytes).expect("string bytes should be valid utf8")
369
+ }
370
+
207
371
  #[test]
208
372
  fn factorial_runs_correctly() {
209
373
  let src = "\
@@ -904,11 +1068,6 @@ fn wasm_module_with_a_table_element_validates_and_call_indirect_works() {
904
1068
 
905
1069
  #[test]
906
1070
  fn non_capturing_closure_passed_and_called_runs_correctly() {
907
- // The brief's plan scopes closures as "passable as an ordinary call argument
908
- // (`each(|v| ...)`)"; the inline single-expression body is that supported form.
909
- // (A multi-line indented closure body immediately followed by `)` on the same line
910
- // needs the external scanner to emit a dedent at a closing bracket, which is a
911
- // parser/scanner concern outside this codegen task — see task-5-report.md.)
912
1071
  let src = "\
913
1072
  each(cb: fn(Int) -> Int) -> Int =
914
1073
  cb(5)
@@ -921,6 +1080,25 @@ main() -> Int =
921
1080
  assert_eq!(run_main(&bytes), 5);
922
1081
  }
923
1082
 
1083
+ #[test]
1084
+ fn multi_line_closure_call_argument_with_closing_paren_on_last_statement_line_runs_correctly() {
1085
+ // Was a documented gap: the external scanner never emitted a dedent for a
1086
+ // multi-line closure body immediately followed by `)` on the same line as the
1087
+ // body's last statement, so this shape didn't parse at all before.
1088
+ let src = "\
1089
+ each(cb: fn(Int) -> Int) -> Int =
1090
+ cb(5)
1091
+
1092
+ main() -> Int =
1093
+ each(|v|
1094
+ x = v + 1
1095
+ x * 2)
1096
+ ";
1097
+ let source = parse(src);
1098
+ let bytes = compile_source(&source).expect("compile failed");
1099
+ assert_eq!(run_main(&bytes), 12);
1100
+ }
1101
+
924
1102
  #[test]
925
1103
  fn capturing_closure_snapshots_value_at_creation_time_runs_correctly() {
926
1104
  let src = "\
@@ -945,6 +1123,54 @@ main() -> Int =
945
1123
  assert_eq!(run_main(&bytes), 10);
946
1124
  }
947
1125
 
1126
+ #[test]
1127
+ fn closure_assigned_then_called_at_float_type_runs_correctly() {
1128
+ // Was a documented gap: a closure created via assignment (not passed directly as
1129
+ // a call argument) and later called at a concrete non-Int type could hit a wasm
1130
+ // runtime trap — the checker's own closure inference gives every param a fresh
1131
+ // TVar and never unifies it against how it's used in the body, so a genuinely
1132
+ // Float param silently defaulted to Int, producing a `call_indirect` signature
1133
+ // mismatch between the compiled closure body and its call site.
1134
+ let src = "\
1135
+ eachF(cb: fn(Float) -> Float) -> Float =
1136
+ cb(0.0)
1137
+
1138
+ useClosure() -> Float =
1139
+ offset = 2.5
1140
+ cb = |v|
1141
+ offset + v
1142
+ eachF(cb)
1143
+
1144
+ main() -> Float =
1145
+ useClosure()
1146
+ ";
1147
+ let source = parse(src);
1148
+ let bytes = compile_source(&source).expect("compile failed");
1149
+ assert_eq!(run_main_f64(&bytes), 2.5);
1150
+ }
1151
+
1152
+ #[test]
1153
+ fn closure_assigned_then_called_at_class_type_runs_correctly() {
1154
+ let src = "\
1155
+ type Cat =
1156
+ age: Int
1157
+
1158
+ eachCat(cb: fn(Cat) -> Int) -> Int =
1159
+ cb(Cat(age: 7))
1160
+
1161
+ useClosure() -> Int =
1162
+ cb = |c|
1163
+ c.age
1164
+ eachCat(cb)
1165
+
1166
+ main() -> Int =
1167
+ useClosure()
1168
+ ";
1169
+ let source = parse(src);
1170
+ let bytes = compile_source(&source).expect("compile failed");
1171
+ assert_eq!(run_main(&bytes), 7);
1172
+ }
1173
+
948
1174
  #[test]
949
1175
  fn closure_passed_through_already_generic_higher_order_function_runs_correctly() {
950
1176
  let src = "\
@@ -961,3 +1187,185 @@ main() -> Int =
961
1187
  let bytes = compile_source(&source).expect("compile failed");
962
1188
  assert_eq!(run_main(&bytes), 14);
963
1189
  }
1190
+
1191
+ #[test]
1192
+ fn nested_closure_literal_runs_correctly() {
1193
+ // Was a documented gap: a closure literal nested inside another closure's body
1194
+ // produced a clear compile error (the discovery pre-pass never recursed into a
1195
+ // closure's own body to find closures nested inside it). Here `inner` (nested
1196
+ // inside `outer`'s body) needs `offset` — a name from `useNested`'s scope, two
1197
+ // levels up from `inner` itself, and not referenced by `outer` directly — which
1198
+ // exercises the multi-level capture chain: `outer` must itself capture `offset`
1199
+ // purely because `inner` needs it, not because `outer` uses it.
1200
+ let src = "\
1201
+ each(cb: fn(Int) -> Int) -> Int =
1202
+ cb(5)
1203
+
1204
+ useNested() -> Int =
1205
+ offset = 100
1206
+ outer = |v|
1207
+ inner = |w|
1208
+ w + offset
1209
+ inner(v)
1210
+ each(outer)
1211
+
1212
+ main() -> Int =
1213
+ useNested()
1214
+ ";
1215
+ let source = parse(src);
1216
+ let bytes = compile_source(&source).expect("compile failed");
1217
+ assert_eq!(run_main(&bytes), 105);
1218
+ }
1219
+
1220
+ #[test]
1221
+ fn nested_closure_literal_passed_directly_as_call_argument_runs_correctly() {
1222
+ let src = "\
1223
+ each(cb: fn(Int) -> Int) -> Int =
1224
+ cb(5)
1225
+
1226
+ main() -> Int =
1227
+ each(|v|
1228
+ each(|w| w + v))
1229
+ ";
1230
+ let source = parse(src);
1231
+ let bytes = compile_source(&source).expect("compile failed");
1232
+ assert_eq!(run_main(&bytes), 10);
1233
+ }
1234
+
1235
+ #[test]
1236
+ fn named_function_used_as_a_closure_typed_value_runs_correctly() {
1237
+ // A plain top-level named function (not a `|params| body` closure literal) used
1238
+ // wherever a `fn(...)`-typed value is expected — no closure literal involved at
1239
+ // the call site at all.
1240
+ let src = "\
1241
+ double(x: Int) -> Int =
1242
+ x * 2
1243
+
1244
+ each(cb: fn(Int) -> Int) -> Int =
1245
+ cb(21)
1246
+
1247
+ main() -> Int =
1248
+ each(double)
1249
+ ";
1250
+ let source = parse(src);
1251
+ let bytes = compile_source(&source).expect("compile failed");
1252
+ assert_eq!(run_main(&bytes), 42);
1253
+ }
1254
+
1255
+ #[test]
1256
+ fn named_function_used_as_a_value_assigned_then_called_runs_correctly() {
1257
+ let src = "\
1258
+ double(x: Int) -> Int =
1259
+ x * 2
1260
+
1261
+ main() -> Int =
1262
+ f = double
1263
+ f(21)
1264
+ ";
1265
+ let source = parse(src);
1266
+ let bytes = compile_source(&source).expect("compile failed");
1267
+ assert_eq!(run_main(&bytes), 42);
1268
+ }
1269
+
1270
+ #[test]
1271
+ fn named_function_used_as_a_value_alongside_a_closure_at_the_same_call_type_runs_correctly() {
1272
+ // Proves the trampoline shares the same call_indirect type as an ordinary
1273
+ // closure of the same signature (both must resolve to the same wasm function
1274
+ // type, since both flow through the exact same `cb(...)` call site).
1275
+ let src = "\
1276
+ double(x: Int) -> Int =
1277
+ x * 2
1278
+
1279
+ each(cb: fn(Int) -> Int) -> Int =
1280
+ cb(10)
1281
+
1282
+ main() -> Int =
1283
+ each(double) + each(|v| v + 1)
1284
+ ";
1285
+ let source = parse(src);
1286
+ let bytes = compile_source(&source).expect("compile failed");
1287
+ assert_eq!(run_main(&bytes), 31);
1288
+ }
1289
+
1290
+ #[test]
1291
+ fn multi_subject_match_with_enum_tags_runs_correctly() {
1292
+ // Mirrors libs/std/bool.plum's `and`/`or`: `match self, o` against two Bool
1293
+ // subjects, each case naming a tag pattern per position.
1294
+ let src = "\
1295
+ and(a: Bool, b: Bool) -> Bool =
1296
+ match a, b
1297
+ True, True => True
1298
+ True, False => False
1299
+ False, True => False
1300
+ False, False => False
1301
+
1302
+ main() -> Int =
1303
+ x = and(True, True)
1304
+ y = and(True, False)
1305
+ match x, y
1306
+ True, False => 1
1307
+ _, _ => 0
1308
+ ";
1309
+ let source = parse(src);
1310
+ let bytes = compile_source(&source).expect("compile failed");
1311
+ assert_eq!(run_main(&bytes), 1);
1312
+ }
1313
+
1314
+ #[test]
1315
+ fn multi_subject_match_falls_through_to_next_case_when_only_first_position_matches() {
1316
+ // The first case's position-0 pattern (`1`) matches, but position-1 (`1`)
1317
+ // doesn't (b is 2) — codegen must fall through to the *next case* (trying its
1318
+ // own position 0 again), not just "move on" within the first case.
1319
+ let src = "\
1320
+ classify(a: Int, b: Int) -> Int =
1321
+ match a, b
1322
+ 1, 1 => 100
1323
+ 1, 2 => 200
1324
+ _, _ => 0
1325
+
1326
+ main() -> Int =
1327
+ classify(1, 2)
1328
+ ";
1329
+ let source = parse(src);
1330
+ let bytes = compile_source(&source).expect("compile failed");
1331
+ assert_eq!(run_main(&bytes), 200);
1332
+ }
1333
+
1334
+ #[test]
1335
+ fn multi_subject_match_with_binding_and_wildcard_runs_correctly() {
1336
+ let src = "\
1337
+ combine(a: Int, b: Int) -> Int =
1338
+ match a, b
1339
+ 0, y => y
1340
+ x, 0 => x
1341
+ x, y => x + y
1342
+
1343
+ main() -> Int =
1344
+ combine(3, 4)
1345
+ ";
1346
+ let source = parse(src);
1347
+ let bytes = compile_source(&source).expect("compile failed");
1348
+ assert_eq!(run_main(&bytes), 7);
1349
+ }
1350
+
1351
+ #[test]
1352
+ fn multi_subject_match_with_generic_enum_variant_runs_correctly() {
1353
+ // Exercises the monomorphize.rs fix: each subject's own generic-enum
1354
+ // specialization (`Some$Int`) must be mangled independently per position.
1355
+ let src = "\
1356
+ enum Option =
1357
+ | Some(a)
1358
+ | None
1359
+
1360
+ both(a: Option, b: Option) -> Int =
1361
+ match a, b
1362
+ Some(x), Some(y) => x + y
1363
+ _, _ => 0
1364
+
1365
+ main() -> Int =
1366
+ both(Some(3), Some(4))
1367
+ ";
1368
+ let source = parse(src);
1369
+ let bytes = compile_source(&source).expect("compile failed");
1370
+ assert_eq!(run_main(&bytes), 7);
1371
+ }
plum-wasm-codegen/tests/examples_test.rs CHANGED
@@ -100,10 +100,11 @@ fn closures_example_compiles_and_runs_correctly() {
100
100
  assert_eq!(main.call(&mut store, ()).expect("main should not trap"), 120);
101
101
  }
102
102
 
103
- /// strings.plum still exercises string interpolation, which remains unimplemented.
103
+ /// strings.plum exercises string interpolation (plain literals, `Str`/`Int`
104
+ /// interpolation, and escape sequences) — it has no `main` to execute, so (matching
105
+ /// `basics_compiles`/`control_flow_compiles`/etc. above for other main-less examples)
106
+ /// this only checks that it compiles to a valid module.
104
107
  #[test]
105
- fn strings_example_reports_clear_interpolation_error() {
108
+ fn strings_example_compiles() {
106
- let source = parse_file("strings.plum");
109
+ assert_compiles("strings.plum");
107
- let err = compile_source(&source).expect_err("string interpolation is not yet supported");
108
- assert!(err.contains("interpolation"), "got: {}", err);
109
110
  }
tooling/tree-sitter-plum/src/scanner.c CHANGED
@@ -263,7 +263,33 @@ bool tree_sitter_plum_external_scanner_scan(void *payload, TSLexer *lexer, const
263
263
  }
264
264
  }
265
265
 
266
+ // A closure literal's indented body, used directly as a call argument, can be
267
+ // immediately followed by a closing bracket with no intervening newline at all
268
+ // (`each(|v|\n v)`), since the body's last statement and the bracket that ends
269
+ // the enclosing call are on the same line. The loop above only ever sets
270
+ // `found_end_of_line` by actually consuming a `\n`, so that shape reaches here
271
+ // with `found_end_of_line == false` and no dedent is ever considered — even
272
+ // though the grammar, mid-parse, is already asking for one (`valid_symbols[DEDENT]`
273
+ // is true here exactly when a `body` node needs to close before the bracket can be
274
+ // shifted). Treat that specific situation as equivalent to "found a blank new
275
+ // line" for dedent purposes only: there's no partial line indentation to measure
276
+ // (we're mid-expression, not at a line start), so it behaves as `indent_length == 0`,
277
+ // which is `< current_indent_length` for any real indented block. Never treat it as
278
+ // a NEWLINE — the grammar isn't expecting one at a raw closing bracket.
279
+ //
280
+ // (A comma continuing the argument list on the same line, e.g.
281
+ // `each(|v|\n v, extra)`, is the same underlying shape but isn't handled here:
282
+ // at that exact position the parser's automaton doesn't have `valid_symbols[DEDENT]`
283
+ // true, so there's no signal to act on without guessing. Out of scope for now —
284
+ // put the closure last, or give it its own line, to work around it.)
285
+ bool at_closing_bracket_without_newline =
286
+ !found_end_of_line && within_brackets && valid_symbols[DEDENT] &&
287
+ (lexer->lookahead == ')' || lexer->lookahead == ']' || lexer->lookahead == '}');
266
- if (found_end_of_line) {
288
+ if (at_closing_bracket_without_newline) {
289
+ indent_length = 0;
290
+ }
291
+
292
+ if (found_end_of_line || at_closing_bracket_without_newline) {
267
293
  if (scanner->indents.size > 0) {
268
294
  uint16_t current_indent_length = *array_back(&scanner->indents);
269
295
 
@@ -291,7 +317,7 @@ bool tree_sitter_plum_external_scanner_scan(void *payload, TSLexer *lexer, const
291
317
  }
292
318
  }
293
319
 
294
- if (valid_symbols[NEWLINE] && !error_recovery_mode) {
320
+ if (found_end_of_line && valid_symbols[NEWLINE] && !error_recovery_mode) {
295
321
  lexer->result_symbol = NEWLINE;
296
322
  return true;
297
323
  }
tooling/tree-sitter-plum/test/corpus/function.txt CHANGED
@@ -405,3 +405,32 @@ main() -> Int =
405
405
  body: (expression
406
406
  (primary_expression
407
407
  (var_identifier))))))))))))
408
+
409
+ ================================================================================
410
+ function - multi-line closure literal call argument, closing paren on same line as body
411
+ ================================================================================
412
+
413
+ main() -> Int =
414
+ each(|v|
415
+ v)
416
+
417
+ --------------------------------------------------------------------------------
418
+
419
+ (source
420
+ (fn
421
+ name: (fn_identifier)
422
+ returns: (return_type
423
+ (type_identifier))
424
+ body: (body
425
+ (expression
426
+ (primary_expression
427
+ (fn_call
428
+ function: (var_identifier)
429
+ arguments: (fn_argument_list
430
+ (expression
431
+ (closure
432
+ parameters: (var_identifier)
433
+ body: (body
434
+ (expression
435
+ (primary_expression
436
+ (var_identifier)))))))))))))