plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/plans/2026-07-23-variadic-params.md
# Variadic Parameters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `fn(..., values: ...a)` a real variadic parameter — call-site arity of 0+ trailing args, and `for v in values` iteration inside the body — using `examples/functions.plum`'s `sumAll(nums: ...Int) -> Int` as the end-to-end target.
**Architecture:** Add `PlumType::TVariadic(Box<PlumType>)`, used both as the trailing entry of a `TFun`'s param-types (call-site arity/unification) and as the type bound to the param's name inside the body (only legal use: a `for` loop's iterable). At the call site, codegen builds a length-prefixed buffer (`[count: i64][elem0]...`) in bump memory, reusing the existing `ClassCall` scratch-local/bump-alloc pattern verbatim. The callee receives one `i32` pointer, exactly like a class instance. `for v in nums` is a new codegen branch (selected by the iterable's checker type, not its AST shape) alongside the existing range-only `for` codegen, computing a *dynamic* per-index address — the one genuinely new pattern here, since every other load/store in this codegen is a static field offset.
**Tech Stack:** Rust, wasm-encoder/wasmparser, wasmtime (test execution). No grammar/tree-sitter changes — `...Type` already parses.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-23-variadic-params-design.md`
- In scope: call-site arity (0+ trailing args), type-checking each trailing arg against the element type, and `for v in nums` iteration. `sumAll` from `examples/functions.plum` is the target end-to-end example.
- Out of scope: indexing syntax (`values[i]`), a `.length()` builtin, wiring up `libs/std/list.plum`'s methods, spread-call syntax, variadic params anywhere but last position.
- **Known, deliberately accepted limitation**: codegen's call-site buffer construction is implemented only for free-function calls (`Expr::FnCall`), not method calls (`Attribute`/`AttrKind::Method`). No test or example in this plan calls a method with a variadic param (that's `libs/std`'s `List`/`Map`, explicitly out of scope), so this is safe today, but is a real gap if a future method-call site ever declares `...a` — flag it, don't silently "fix" it by expanding scope.
- Run `cargo test --workspace` after every task that touches Rust code — all pre-existing tests must keep passing throughout.
---
### Task 1: `PlumType::TVariadic` representation
**Files:**
- Modify: `plum-checker/src/types.rs` (the `PlumType` enum and its `Display` impl)
- Modify: `plum-checker/src/lib.rs:117,187` (two `ast::ParamType::Variadic(t) => plum_type_from_ast(t)` sites)
- Modify: `plum-checker/src/monomorphize.rs:78-88` (`plum_type_to_ast_type`'s exhaustive match), `:253`, `:837`, `:860` (three more `ast::ParamType::Variadic(t) => crate::plum_type_from_ast(t)` sites)
- Modify: `plum-wasm-codegen/src/lib.rs:357-366` (`plum_type_to_valtype`'s exhaustive match), `:889` (`param_plum_type`), `:1749` (`compile_fn_body`'s `base_env` construction)
**Interfaces:**
- Consumes: nothing new.
- Produces: `pub enum PlumType { ..., TVariadic(Box<PlumType>) }`. Every later task matches on `PlumType::TVariadic(elem)`.
- [ ] **Step 1: Add the type and its `Display` arm**
In `plum-checker/src/types.rs`, change:
```rust
pub enum PlumType {
TInt,
TFloat,
TBool,
TStr,
TUnit,
TVar(String),
TFun(Vec<PlumType>, Box<PlumType>),
TNamed(String),
}
```
to:
```rust
pub enum PlumType {
TInt,
TFloat,
TBool,
TStr,
TUnit,
TVar(String),
TFun(Vec<PlumType>, Box<PlumType>),
TNamed(String),
/// The type of a variadic parameter, e.g. `...Int` -> `TVariadic(TInt)`.
/// Appears in exactly two places: as the trailing entry of a `TFun`'s
/// param-types list (call-site arity/type checking), and as the type bound
/// to the param's name inside the function body. Its only legal use inside
/// a body is as a `for` loop's iterable — no other `unify`/`infer_expr` arm
/// handles it, so any other use is a type error by construction.
TVariadic(Box<PlumType>),
}
```
And in the `Display` impl, add (before the closing `}` of the `match`):
```rust
PlumType::TVariadic(inner) => write!(f, "...{}", inner),
```
- [ ] **Step 2: Build the workspace to find every exhaustive match that needs a new arm**
Run: `cargo build --workspace 2>&1 | tail -80`
Expected: compile errors for non-exhaustive `match` on `PlumType` at exactly these locations (matching the Files list above) — `plum-checker/src/monomorphize.rs`'s `plum_type_to_ast_type` and `plum-wasm-codegen/src/lib.rs`'s `plum_type_to_valtype`. (`unify` in `plum-checker/src/lib.rs` has a wildcard `_ => Err(...)` arm already and needs no change.)
- [ ] **Step 3: Fix `plum_type_to_ast_type`'s exhaustive match**
In `plum-checker/src/monomorphize.rs`, this function's doc comment already explains `TVar`/`TFun` are "an internal-error case rather than something this needs to model" because they never arise from a concrete call-site argument's inferred type — `TVariadic` is the same kind of case (it only ever appears as a *declared parameter's* type, never as an argument's own inferred type). Change:
```rust
PlumType::TVar(_) | PlumType::TFun(_, _) => t.to_string(),
```
to:
```rust
PlumType::TVar(_) | PlumType::TFun(_, _) | PlumType::TVariadic(_) => t.to_string(),
```
- [ ] **Step 4: Fix `plum_type_to_valtype`'s exhaustive match**
In `plum-wasm-codegen/src/lib.rs`, a `TVariadic` value is always a pointer to a bump-allocated buffer, exactly like a class instance — add it to the existing pointer-typed arm:
```rust
fn plum_type_to_valtype(t: &PlumType) -> ValType {
match t {
PlumType::TInt => ValType::I64,
PlumType::TFloat => ValType::F64,
PlumType::TBool | PlumType::TStr | PlumType::TNamed(_) | PlumType::TFun(_, _) | PlumType::TVariadic(_) => ValType::I32,
PlumType::TVar(_) | PlumType::TUnit => ValType::I64,
}
}
```
- [ ] **Step 5: Wrap the five `ast::ParamType::Variadic` conversion sites in `plum-checker`**
In `plum-checker/src/lib.rs`, both occurrences of:
```rust
ast::ParamType::Variadic(t) => plum_type_from_ast(t),
```
(at line 117, inside `build_global_tables`'s function-signature loop, and line 187, inside `check_fn`'s param-binding loop) become:
```rust
ast::ParamType::Variadic(t) => PlumType::TVariadic(Box::new(plum_type_from_ast(t))),
```
In `plum-checker/src/monomorphize.rs`, all three occurrences of:
```rust
ast::ParamType::Variadic(t) => crate::plum_type_from_ast(t),
```
(at line 253 inside `rewrite_fn_body`, line 837 inside the `Class` specialization arm's method-signature rebuild, and line 860 inside the `Fn` specialization arm's signature rebuild) become:
```rust
ast::ParamType::Variadic(t) => PlumType::TVariadic(Box::new(crate::plum_type_from_ast(t))),
```
- [ ] **Step 6: Wrap the two `ast::ParamType::Variadic` conversion sites in `plum-wasm-codegen`**
In `plum-wasm-codegen/src/lib.rs`'s `param_plum_type` (~line 889):
```rust
ast::ParamType::Variadic(t) => plum_checker::plum_type_from_ast(t),
```
becomes:
```rust
ast::ParamType::Variadic(t) => PlumType::TVariadic(Box::new(plum_checker::plum_type_from_ast(t))),
```
In `compile_fn_body`'s `base_env` construction (~line 1749), the identical line becomes the identical fix:
```rust
ast::ParamType::Variadic(t) => PlumType::TVariadic(Box::new(plum_checker::plum_type_from_ast(t))),
```
(Leave `param_type_name`, at line ~338, and the wasm function-*signature* registration loop at line ~444 untouched in this task — those are fixed in Task 3, which handles the actual wasm-level calling convention. This task is purely about the `PlumType` representation.)
- [ ] **Step 7: Build and run the full workspace test suite**
Run: `cargo build --workspace 2>&1 | tail -40` — expect a clean build.
Run: `cargo test --workspace 2>&1 | tail -100` — expect every pre-existing test to still pass. No test today declares or calls a variadic-param function (confirmed by grepping `plum-checker/tests/checker_tests.rs` and `plum-wasm-codegen/tests/codegen_tests.rs` for `\.\.\.` before writing this plan), so this representational change should have zero effect on any existing test's outcome.
- [ ] **Step 8: Commit**
```bash
git add plum-checker/src/types.rs plum-checker/src/lib.rs plum-checker/src/monomorphize.rs plum-wasm-codegen/src/lib.rs
git commit -m "feat(plum-checker,plum-wasm-codegen): add PlumType::TVariadic representation"
```
---
### Task 2: Checker — call-site arity, declaration validation, `for` typing
**Files:**
- Modify: `plum-checker/src/lib.rs` (`Expr::FnCall`'s arity/unify in `infer_expr`, ~line 542-556; `check_fn`, ~line 173; `check_stmt`'s `Stmt::For` arm, ~line 344-354)
- Test: `plum-checker/tests/checker_tests.rs`
**Interfaces:**
- Consumes: `PlumType::TVariadic(Box<PlumType>)` from Task 1.
- Produces: correct arity/unify for a variadic call; a `check_fn`-level validation rejecting more than one variadic param or a non-last variadic param; `for v in nums` binds `v` to the variadic's element type. No new public functions.
- [ ] **Step 1: Write the failing checker tests**
Add to `plum-checker/tests/checker_tests.rs`:
```rust
#[test]
fn variadic_call_with_zero_trailing_args_passes() {
let src = "\
sumAll(nums: ...Int) -> Int =
0
useSumAll() -> Int =
sumAll()
";
let source = parse(src);
assert!(check_source(&source).is_ok(), "expected Ok");
}
#[test]
fn variadic_call_with_several_trailing_args_passes() {
let src = "\
sumAll(nums: ...Int) -> Int =
0
useSumAll() -> Int =
sumAll(1, 2, 3)
";
let source = parse(src);
assert!(check_source(&source).is_ok(), "expected Ok");
}
#[test]
fn variadic_call_with_mismatched_trailing_arg_type_is_error() {
let src = "\
sumAll(nums: ...Int) -> Int =
0
useSumAll() -> Int =
sumAll(1, \"two\")
";
let source = parse(src);
assert!(check_source(&source).is_err());
}
#[test]
fn variadic_call_with_fixed_prefix_passes() {
let src = "\
combine(prefix: Int, rest: ...Int) -> Int =
prefix
useCombine() -> Int =
combine(1, 2, 3)
";
let source = parse(src);
assert!(check_source(&source).is_ok(), "expected Ok");
}
#[test]
fn two_variadic_params_is_error() {
let src = "\
bad(a: ...Int, b: ...Int) -> Int =
0
";
let source = parse(src);
assert!(check_source(&source).is_err());
}
#[test]
fn variadic_param_not_last_is_error() {
let src = "\
bad(a: ...Int, b: Int) -> Int =
0
";
let source = parse(src);
assert!(check_source(&source).is_err());
}
#[test]
fn for_loop_over_variadic_binds_element_type() {
let src = "\
sumAll(nums: ...Int) -> Int =
total = 0
for v in nums
total = total + v
total
";
let source = parse(src);
assert!(check_source(&source).is_ok(), "expected Ok");
}
#[test]
fn for_loop_over_variadic_with_two_vars_is_error() {
let src = "\
bad(nums: ...Int) -> Int =
for v, i in nums
v
0
";
let source = parse(src);
assert!(check_source(&source).is_err());
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p plum-checker variadic_call for_loop_over_variadic two_variadic variadic_param_not_last 2>&1 | tail -60`
Expected: some pass by accident (e.g. `variadic_call_with_mismatched_trailing_arg_type_is_error` may already fail-to-typecheck under the OLD passthrough semantics too, for the wrong reason), but `variadic_call_with_zero_trailing_args_passes`, `variadic_call_with_fixed_prefix_passes`, and `for_loop_over_variadic_binds_element_type` FAIL — today's arity check requires exact `args.len() == param_types.len()`, and `for` unconditionally binds `TInt` without checking `nums`'s actual iterated-element semantics (this one happens to also "pass" today only because both sides are already `Int` — the real test of intent is that it must be a *deliberate* codepath, not an accident; the next steps make it one).
- [ ] **Step 3: Fix `Expr::FnCall`'s arity/unify in `infer_expr`**
In `plum-checker/src/lib.rs` (~line 541-559), replace:
```rust
match lookup(env, &call.name) {
Ok(PlumType::TFun(param_types, ret)) => {
if call.args.len() != param_types.len() {
return Err(format!("call '{}': expected {} args, got {}", call.name, param_types.len(), call.args.len()));
}
for (i, (arg, expected)) in call.args.iter().zip(param_types.iter()).enumerate() {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
let actual = infer_expr(arg_expr, env, ctx)?;
unify(expected, &actual).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
}
Ok(*ret)
}
Ok(_) => Err(format!("'{}' is not a function", call.name)),
Err(_) => Ok(PlumType::TVar("_".to_string())), // unknown fn: allow, codegen will catch
}
```
with:
```rust
match lookup(env, &call.name) {
Ok(PlumType::TFun(param_types, ret)) => {
match param_types.last() {
Some(PlumType::TVariadic(elem)) => {
let fixed = ¶m_types[..param_types.len() - 1];
if call.args.len() < fixed.len() {
return Err(format!("call '{}': expected at least {} arg(s), got {}", call.name, fixed.len(), call.args.len()));
}
for (i, (arg, expected)) in call.args.iter().zip(fixed.iter()).enumerate() {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
let actual = infer_expr(arg_expr, env, ctx)?;
unify(expected, &actual).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
}
for (i, arg) in call.args.iter().enumerate().skip(fixed.len()) {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
let actual = infer_expr(arg_expr, env, ctx)?;
unify(elem, &actual).map_err(|e| format!("call '{}' variadic arg {}: {}", call.name, i, e))?;
}
Ok(*ret)
}
_ => {
if call.args.len() != param_types.len() {
return Err(format!("call '{}': expected {} args, got {}", call.name, param_types.len(), call.args.len()));
}
for (i, (arg, expected)) in call.args.iter().zip(param_types.iter()).enumerate() {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
let actual = infer_expr(arg_expr, env, ctx)?;
unify(expected, &actual).map_err(|e| format!("call '{}' arg {}: {}", call.name, i, e))?;
}
Ok(*ret)
}
}
}
Ok(_) => Err(format!("'{}' is not a function", call.name)),
Err(_) => Ok(PlumType::TVar("_".to_string())), // unknown fn: allow, codegen will catch
}
```
- [ ] **Step 4: Add the "at most one variadic, must be last" validation to `check_fn`**
In `plum-checker/src/lib.rs`'s `check_fn` (~line 173), after the `errors` vector is created and before the existing param-binding loop, add:
```rust
let variadic_positions: Vec<usize> = f.params.iter().enumerate()
.filter(|(_, p)| matches!(p.ty, ast::ParamType::Variadic(_)))
.map(|(i, _)| i)
.collect();
if variadic_positions.len() > 1 {
errors.push(CheckError { message: format!("fn '{}': at most one variadic parameter is allowed", f.name) });
} else if let Some(&pos) = variadic_positions.first() {
if pos != f.params.len() - 1 {
errors.push(CheckError { message: format!("fn '{}': a variadic parameter must be last", f.name) });
}
}
```
- [ ] **Step 5: Fix `check_stmt`'s `Stmt::For` arm**
In `plum-checker/src/lib.rs` (~line 344-354), replace:
```rust
ast::Stmt::For(f_stmt) => {
match infer_expr(&f_stmt.iter, env, ctx) {
Ok(_) => {}
Err(msg) => errors.push(CheckError { message: format!("fn '{}': for iter: {}", fn_name, msg) }),
}
let mut inner_env = env.clone();
for var in &f_stmt.vars {
inner_env.insert(var.clone(), TypeScheme::mono(PlumType::TInt));
}
errors.append(&mut check_block(&f_stmt.body, &mut inner_env, declared_ret, fn_name, ctx));
}
```
with:
```rust
ast::Stmt::For(f_stmt) => {
let iter_ty = infer_expr(&f_stmt.iter, env, ctx);
let mut inner_env = env.clone();
match &iter_ty {
Ok(PlumType::TVariadic(elem)) => {
if f_stmt.vars.len() != 1 {
errors.push(CheckError { message: format!("fn '{}': for-loop over a variadic param must bind exactly one variable", fn_name) });
}
for var in &f_stmt.vars {
inner_env.insert(var.clone(), TypeScheme::mono((**elem).clone()));
}
}
Ok(_) => {
for var in &f_stmt.vars {
inner_env.insert(var.clone(), TypeScheme::mono(PlumType::TInt));
}
}
Err(msg) => {
errors.push(CheckError { message: format!("fn '{}': for iter: {}", fn_name, msg) });
for var in &f_stmt.vars {
inner_env.insert(var.clone(), TypeScheme::mono(PlumType::TInt));
}
}
}
errors.append(&mut check_block(&f_stmt.body, &mut inner_env, declared_ret, fn_name, ctx));
}
```
- [ ] **Step 6: Run the new tests**
Run: `cargo test -p plum-checker variadic_call for_loop_over_variadic two_variadic variadic_param_not_last 2>&1 | tail -60`
Expected: all 8 new tests PASS.
- [ ] **Step 7: Run the full checker test suite**
Run: `cargo test -p plum-checker 2>&1 | tail -60`
Expected: all tests PASS (pre-existing tests unaffected — the `for`-loop default-TInt behavior for a non-variadic iterable, i.e. every range `for` in the existing test suite, is unchanged, since it still takes the `Ok(_) =>` branch and binds `TInt` exactly as before).
- [ ] **Step 8: Commit**
```bash
git add plum-checker/src/lib.rs plum-checker/tests/checker_tests.rs
git commit -m "feat(plum-checker): type-check variadic call arity and for-in-variadic iteration"
```
---
### Task 3: Codegen — wasm signature + call-site buffer construction
**Files:**
- Modify: `plum-wasm-codegen/src/lib.rs`:
- the wasm function-signature registration loop (~line 443-444)
- `Collector`'s `Expr::FnCall` arm in `walk_expr` (~line 1691-1703)
- the real `Expr::FnCall` arm in `compile_expr` (~line 2740-2764)
- Test: `plum-wasm-codegen/tests/codegen_tests.rs`
**Interfaces:**
- Consumes: `PlumType::TVariadic(Box<PlumType>)` from Task 1; checker's already-correct arity/typing from Task 2 (codegen assumes checker-accepted input, per this file's existing convention — see `infer_local_type`'s doc comment).
- Produces: a variadic-declaring function's wasm signature ends in one `i32` (pointer) param; a call site to such a function builds the length-prefixed buffer and passes the pointer. No new public functions. This task deliberately does NOT implement `for v in nums` iteration (Task 4) — its test calls a variadic function whose body ignores the variadic param entirely, to isolate "does the call-site buffer construction produce valid, correctly-shaped wasm" from "can the body read the buffer back".
- [ ] **Step 1: Write the failing codegen test**
Add to `plum-wasm-codegen/tests/codegen_tests.rs`:
```rust
#[test]
fn variadic_call_with_varying_trailing_arg_counts_runs_correctly() {
let src = "\
combine(prefix: Int, rest: ...Int) -> Int =
prefix
main() -> Int =
a = combine(10)
b = combine(20, 1)
c = combine(30, 1, 2, 3)
a + b + c
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 60);
}
```
(This test only proves the buffer is built without corrupting the fixed `prefix` param or trapping/failing wasm validation across 0, 1, and 3 trailing args — it does not read `rest`'s contents, since `for v in nums` isn't implemented until Task 4.)
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test -p plum-wasm-codegen variadic_call_with_varying_trailing_arg_counts 2>&1 | tail -60`
Expected: FAIL — today's call-compiling code pushes `call.args` positionally 1:1 against the callee's wasm signature; since the signature isn't fixed yet either, this currently produces a wasm arity mismatch (`combine`'s wasm signature today has 2 `I64` params from the old `Variadic` passthrough, but `combine(20, 1)` pushes 2 args and `combine(30, 1, 2, 3)` pushes 4 — a real mismatch: invalid wasm, module fails to validate/instantiate).
- [ ] **Step 3: Fix the wasm function-signature registration loop**
In `plum-wasm-codegen/src/lib.rs` (~line 443-445), replace:
```rust
for p in &f.params {
param_types.push(ast_type_to_wasm(param_type_name(&p.ty)).unwrap_or(ValType::I32));
}
```
with:
```rust
for p in &f.params {
let vt = match &p.ty {
ast::ParamType::Variadic(_) => ValType::I32,
other => ast_type_to_wasm(param_type_name(other)).unwrap_or(ValType::I32),
};
param_types.push(vt);
}
```
- [ ] **Step 4: Reserve a `classcall_scratch` slot for a variadic call site**
`Collector` already reuses its `classcall_scratch`/`next_classcall_slot` pool for enum-variant payload construction (`Expr::FnCall`'s `carries_payload` branch, ~line 1691-1699) — a variadic call site needs the exact same "multi-step construction, need a scratch pointer across several stores, then a final value" shape, so it reuses the same pool rather than introducing a new one. In `plum-wasm-codegen/src/lib.rs`'s `Collector::walk_expr`, replace:
```rust
ast::Expr::FnCall(call) => {
let carries_payload = self.cctx.enum_variants.get(&call.name)
.map(|info| !info.field_types.is_empty())
.unwrap_or(false);
if carries_payload {
let idx = self.next_classcall_slot;
self.next_classcall_slot += 1;
self.classcall_scratch.insert(expr as *const ast::Expr as usize, idx);
}
for arg in &call.args {
self.walk_arg(arg);
}
}
```
with:
```rust
ast::Expr::FnCall(call) => {
let carries_payload = self.cctx.enum_variants.get(&call.name)
.map(|info| !info.field_types.is_empty())
.unwrap_or(false);
let is_variadic_call = matches!(
plum_checker::lookup(&self.env, &call.name),
Ok(PlumType::TFun(params, _)) if matches!(params.last(), Some(PlumType::TVariadic(_)))
);
if carries_payload || is_variadic_call {
let idx = self.next_classcall_slot;
self.next_classcall_slot += 1;
self.classcall_scratch.insert(expr as *const ast::Expr as usize, idx);
}
for arg in &call.args {
self.walk_arg(arg);
}
}
```
`plum_checker::lookup`'s signature is `pub fn lookup(env: &TypeEnv, name: &str) -> Result<PlumType, String>` (defined in `plum-checker/src/lib.rs`) — already used elsewhere in this codebase the same way, so it's already in scope via the `plum_checker::` path used throughout this file.
- [ ] **Step 5: Build the buffer in `compile_expr`'s real `Expr::FnCall` arm**
In `plum-wasm-codegen/src/lib.rs` (~line 2740-2764), replace:
```rust
ast::Expr::FnCall(call) => {
// A call whose callee name is a *local* of function type is a closure call,
// dispatched via `call_indirect` — not a direct `Call` to a named function.
let is_closure_call = ctx.locals.contains_key(&call.name)
&& matches!(infer_local_type(&ast::Expr::Var(call.name.clone()), ctx), PlumType::TFun(_, _));
if is_closure_call {
compile_closure_call(call, body, ctx, state)?;
} else if let Some(info) = ctx.enum_variants.get(&call.name) {
compile_variant_construction(info, call, expr, body, ctx, state)?;
} else {
for arg in &call.args {
let arg_expr = match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
};
compile_expr(arg_expr, body, ctx, state)?;
}
let func_idx = ctx
.func_ids
.get(&call.name)
.ok_or_else(|| format!("unknown function '{}'", call.name))?;
Instruction::Call(*func_idx).encode(body);
}
}
```
with:
```rust
ast::Expr::FnCall(call) => {
// A call whose callee name is a *local* of function type is a closure call,
// dispatched via `call_indirect` — not a direct `Call` to a named function.
let is_closure_call = ctx.locals.contains_key(&call.name)
&& matches!(infer_local_type(&ast::Expr::Var(call.name.clone()), ctx), PlumType::TFun(_, _));
if is_closure_call {
compile_closure_call(call, body, ctx, state)?;
} else if let Some(info) = ctx.enum_variants.get(&call.name) {
compile_variant_construction(info, call, expr, body, ctx, state)?;
} else {
let arg_expr_of = |arg: &ast::Arg| -> &ast::Expr {
match arg {
ast::Arg::Positional(e) => e,
ast::Arg::Keyword { value, .. } => value,
ast::Arg::Pair { value, .. } => value,
}
};
let callee_sig = infer_local_type(&ast::Expr::Var(call.name.clone()), ctx);
let variadic_split = match &callee_sig {
PlumType::TFun(params, _) => match params.last() {
Some(PlumType::TVariadic(elem)) => Some(((**elem).clone(), params.len() - 1)),
_ => None,
},
_ => None,
};
match variadic_split {
Some((elem_ty, fixed_count)) => {
for arg in call.args.iter().take(fixed_count) {
compile_expr(arg_expr_of(arg), body, ctx, state)?;
}
let trailing: Vec<&ast::Expr> = call.args.iter().skip(fixed_count).map(arg_expr_of).collect();
let count = trailing.len() as i32;
let size = 8 * (count + 1);
let scratch_key = expr as *const ast::Expr as usize;
let scratch_idx = *ctx
.classcall_scratch
.get(&scratch_key)
.ok_or_else(|| "internal codegen error: missing variadic-call scratch slot".to_string())?;
let scratch_local = ctx.classcall_scratch_base + scratch_idx;
Instruction::GlobalGet(ctx.bump_global).encode(body);
Instruction::LocalSet(scratch_local).encode(body);
Instruction::GlobalGet(ctx.bump_global).encode(body);
Instruction::I32Const(size).encode(body);
Instruction::I32Add.encode(body);
Instruction::GlobalSet(ctx.bump_global).encode(body);
Instruction::LocalGet(scratch_local).encode(body);
Instruction::I64Const(count as i64).encode(body);
emit_store(ValType::I64, 0, body);
let elem_vt = plum_type_to_valtype(&elem_ty);
for (i, arg_expr) in trailing.iter().enumerate() {
Instruction::LocalGet(scratch_local).encode(body);
compile_expr(arg_expr, body, ctx, state)?;
emit_store(elem_vt, 8 * (i as u64 + 1), body);
}
Instruction::LocalGet(scratch_local).encode(body);
let func_idx = ctx
.func_ids
.get(&call.name)
.ok_or_else(|| format!("unknown function '{}'", call.name))?;
Instruction::Call(*func_idx).encode(body);
}
None => {
for arg in &call.args {
compile_expr(arg_expr_of(arg), body, ctx, state)?;
}
let func_idx = ctx
.func_ids
.get(&call.name)
.ok_or_else(|| format!("unknown function '{}'", call.name))?;
Instruction::Call(*func_idx).encode(body);
}
}
}
}
```
- [ ] **Step 6: Run the new test**
Run: `cargo test -p plum-wasm-codegen variadic_call_with_varying_trailing_arg_counts 2>&1 | tail -60`
Expected: PASS (`60`). If it traps or fails wasm validation, check the store order first: `LocalGet(scratch_local)` (address) must be pushed *before* the value being stored (`I64Const(count)` or `compile_expr(arg_expr, ...)`) — `emit_store` pops value-then-address.
- [ ] **Step 7: Run the full workspace test suite**
Run: `cargo test --workspace 2>&1 | tail -100`
Expected: all tests PASS, including every pre-existing test (`ClassCall` construction, enum-variant payload construction, and every other call site are untouched in shape — only the `Expr::FnCall` arm's `None` branch is new code, behaviorally identical to the old unconditional loop).
- [ ] **Step 8: Commit**
```bash
git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs
git commit -m "feat(plum-wasm-codegen): compile variadic call sites into a length-prefixed buffer"
```
---
### Task 4: Codegen — `for v in nums` iteration
**Files:**
- Modify: `plum-wasm-codegen/src/lib.rs`:
- `LocalCtx` struct (~line 281-329) and `compile_fn_body`'s local-index-assignment block (~line 1780-1855)
- `ClosureWalker::walk_stmt`'s `Stmt::For` arm (~line 972-978)
- `Collector::walk_stmt`'s `Stmt::For` arm (~line 1633-1639)
- `compile_stmt`'s `Stmt::For` arm (~line 2084-2115)
- Modify: `examples/functions.plum` (`sumAll`, from `todo` to a real implementation)
- Test: `plum-wasm-codegen/tests/codegen_tests.rs`, `plum-wasm-codegen/tests/examples_test.rs`
**Interfaces:**
- Consumes: `PlumType::TVariadic(Box<PlumType>)` (Task 1); the call-site buffer layout from Task 3 (`[count: i64 @ offset 0][elem0 @ offset 8]...`, one `i32` pointer param per variadic).
- Produces: `for v in nums` (where `nums`'s checker type is `TVariadic`) iterates every element in call order, binding `v` to each. No new public functions.
- [ ] **Step 1: Write the failing codegen test (the plan's target example) and update `examples/functions.plum`**
Add to `plum-wasm-codegen/tests/codegen_tests.rs`:
```rust
#[test]
fn sum_all_variadic_int_runs_correctly() {
let src = "\
sumAll(nums: ...Int) -> Int =
total = 0
for v in nums
total = total + v
total
main() -> Int =
sumAll(1, 2, 3, 4)
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 10);
}
#[test]
fn sum_all_variadic_int_with_zero_args_runs_correctly() {
let src = "\
sumAll(nums: ...Int) -> Int =
total = 0
for v in nums
total = total + v
total
main() -> Int =
sumAll()
";
let source = parse(src);
let bytes = compile_source(&source).expect("compile failed");
assert_eq!(run_main(&bytes), 0);
}
```
In `examples/functions.plum`, replace:
```
sumAll(nums: ...Int) -> Int =
todo
```
with:
```
sumAll(nums: ...Int) -> Int =
total = 0
for v in nums
total = total + v
total
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p plum-wasm-codegen sum_all_variadic 2>&1 | tail -60`
Expected: FAIL — `compile_stmt`'s `Stmt::For` arm falls through to its final `else` branch (`compile_expr(&f.iter, ...); Drop;`) for any iterable that isn't a literal `a..b` range expression, so the loop body never runs at all; `total` stays `0` for the 4-arg case too (wrong — expected `10`), or the module may fail wasm validation depending on how the surrounding block is structured.
- [ ] **Step 3: Add the `variadic_for_scratch` pool to `LocalCtx` and `Collector`**
In `plum-wasm-codegen/src/lib.rs`'s `Collector` struct (~line 1506-1524), add a field (alongside the existing `nested_class_scratch`/`next_nested_class_slot` pair):
```rust
/// `For` stmt identity (pointer address) -> a slot number; each slot reserves 2
/// consecutive `i32` scratch locals for variadic iteration (`for v in nums`):
/// [count, loop index]. Only `for` statements whose iterable is a `TVariadic`
/// use this — an ordinary range `for` reuses its own loop var as the counter
/// and needs no extra scratch locals.
variadic_for_scratch: HashMap<usize, u32>,
next_variadic_for_slot: u32,
```
`compile_fn_body`'s `Collector { ... }` construction (~line 1759-1771) currently reads:
```rust
let mut collector = Collector {
env: base_env.clone(),
cctx: check_ctx_of(&ctx.classes, &ctx.methods, &ctx.enum_variants),
named: Vec::new(),
named_set: Default::default(),
classcall_scratch: HashMap::new(),
match_scratch: HashMap::new(),
next_classcall_slot: 0,
closure_scratch: HashMap::new(),
next_closure_slot: 0,
nested_class_scratch: HashMap::new(),
next_nested_class_slot: 0,
};
```
Add the two new fields (alongside `nested_class_scratch`/`next_nested_class_slot`):
```rust
let mut collector = Collector {
env: base_env.clone(),
cctx: check_ctx_of(&ctx.classes, &ctx.methods, &ctx.enum_variants),
named: Vec::new(),
named_set: Default::default(),
classcall_scratch: HashMap::new(),
match_scratch: HashMap::new(),
next_classcall_slot: 0,
closure_scratch: HashMap::new(),
next_closure_slot: 0,
nested_class_scratch: HashMap::new(),
next_nested_class_slot: 0,
variadic_for_scratch: HashMap::new(),
next_variadic_for_slot: 0,
};
```
In `LocalCtx` (~line 281-311), add (alongside `nested_class_scratch_base`/`nested_class_scratch`):
```rust
/// First local index reserved for variadic-`for` scratch temporaries (2 `i32`
/// slots per `for` statement that iterates a `TVariadic`: count, loop index).
variadic_for_scratch_base: u32,
/// `For` stmt identity (pointer address) -> slot number (multiply by 2 and add
/// `variadic_for_scratch_base` for the count local; +1 more for the index local).
variadic_for_scratch: HashMap<usize, u32>,
```
In `compile_fn_body`'s local-index-assignment block (~line 1816-1822), insert a new section *between* `nested_class_scratch` and `closure_scratch` (so `idx` stays correctly threaded — `closure_scratch`'s own block doesn't increment `idx` further since it's already last, so anything added after it would get a wrong base):
```rust
let variadic_for_scratch_base = idx;
let variadic_for_scratch_count = collector.variadic_for_scratch.values().copied().max().map(|m| m + 1).unwrap_or(0);
for _ in 0..variadic_for_scratch_count {
groups.push(ValType::I32); // count
groups.push(ValType::I32); // loop index
idx += 2;
}
```
The `LocalCtx { ... }` construction that follows (~line 1839-1862) currently reads:
```rust
let local_ctx = LocalCtx {
locals,
classcall_scratch_base,
classcall_scratch: collector.classcall_scratch,
match_scratch_base,
match_scratch_index,
closure_scratch_base,
closure_scratch: collector.closure_scratch,
nested_class_scratch_base,
nested_class_scratch: collector.nested_class_scratch,
func_ids: &ctx.func_ids,
func_sigs: &ctx.func_sigs,
closures: &ctx.closures,
closure_call_types: &ctx.closure_call_types,
named_fn_values: &ctx.named_fn_values,
string_concat_func: ctx.string_concat_func,
int_to_string_func: ctx.int_to_string_func,
classes: &ctx.classes,
methods: &ctx.methods,
enum_variants: &ctx.enum_variants,
type_env: RefCell::new(base_env),
closure_local_sigs: RefCell::new(HashMap::new()),
bump_global: ctx.bump_global,
};
```
Add the two new fields (alongside `nested_class_scratch_base`/`nested_class_scratch`):
```rust
let local_ctx = LocalCtx {
locals,
classcall_scratch_base,
classcall_scratch: collector.classcall_scratch,
match_scratch_base,
match_scratch_index,
closure_scratch_base,
closure_scratch: collector.closure_scratch,
nested_class_scratch_base,
nested_class_scratch: collector.nested_class_scratch,
variadic_for_scratch_base,
variadic_for_scratch: collector.variadic_for_scratch,
func_ids: &ctx.func_ids,
func_sigs: &ctx.func_sigs,
closures: &ctx.closures,
closure_call_types: &ctx.closure_call_types,
named_fn_values: &ctx.named_fn_values,
string_concat_func: ctx.string_concat_func,
int_to_string_func: ctx.int_to_string_func,
classes: &ctx.classes,
methods: &ctx.methods,
enum_variants: &ctx.enum_variants,
type_env: RefCell::new(base_env),
closure_local_sigs: RefCell::new(HashMap::new()),
bump_global: ctx.bump_global,
};
```
- [ ] **Step 4: Fix `ClosureWalker::walk_stmt`'s `Stmt::For` arm**
In `plum-wasm-codegen/src/lib.rs` (~line 972-978), replace:
```rust
ast::Stmt::For(f) => {
self.walk_expr(&f.iter, None);
for v in &f.vars {
self.env.insert(v.clone(), TypeScheme::mono(PlumType::TInt));
self.locals.insert(v.clone());
}
self.walk_block(&f.body);
}
```
with:
```rust
ast::Stmt::For(f) => {
self.walk_expr(&f.iter, None);
let elem_ty = match plum_checker::infer_expr(&f.iter, &self.env, &self.cctx) {
Ok(PlumType::TVariadic(elem)) => *elem,
_ => PlumType::TInt,
};
for v in &f.vars {
self.env.insert(v.clone(), TypeScheme::mono(elem_ty.clone()));
self.locals.insert(v.clone());
}
self.walk_block(&f.body);
}
```
- [ ] **Step 5: Fix `Collector::walk_stmt`'s `Stmt::For` arm**
In `plum-wasm-codegen/src/lib.rs` (~line 1633-1639), replace:
```rust
ast::Stmt::For(f) => {
self.walk_expr(&f.iter);
for v in &f.vars {
self.bind(v, PlumType::TInt);
}
self.walk_block(&f.body);
}
```
with:
```rust
ast::Stmt::For(f) => {
self.walk_expr(&f.iter);
let iter_ty = plum_checker::infer_expr(&f.iter, &self.env, &self.cctx).unwrap_or(PlumType::TInt);
if let PlumType::TVariadic(elem) = &iter_ty {
let idx = self.next_variadic_for_slot;
self.next_variadic_for_slot += 1;
self.variadic_for_scratch.insert(f as *const ast::For as usize, idx);
for v in &f.vars {
self.bind(v, (**elem).clone());
}
} else {
for v in &f.vars {
self.bind(v, PlumType::TInt);
}
}
self.walk_block(&f.body);
}
```
- [ ] **Step 6: Add the new branch to `compile_stmt`'s `Stmt::For` arm**
In `plum-wasm-codegen/src/lib.rs` (~line 2084-2115), replace:
```rust
ast::Stmt::For(f) => {
if let ast::Expr::Binary(b) = &f.iter {
if matches!(b.op, ast::BinOp::Range) && f.vars.len() == 1 {
let var_name = &f.vars[0];
let var_idx = ctx
.locals
.get(var_name)
.copied()
.ok_or_else(|| format!("undeclared loop var '{}'", var_name))?;
ctx.type_env.borrow_mut().insert(var_name.clone(), TypeScheme::mono(PlumType::TInt));
compile_expr(&b.left, body, ctx, state)?;
Instruction::LocalSet(var_idx).encode(body);
Instruction::Block(BlockType::Empty).encode(body);
Instruction::Loop(BlockType::Empty).encode(body);
Instruction::LocalGet(var_idx).encode(body);
compile_expr(&b.right, body, ctx, state)?;
Instruction::I64GeS.encode(body);
Instruction::BrIf(1).encode(body);
compile_block(&f.body, body, ctx, state)?;
Instruction::LocalGet(var_idx).encode(body);
Instruction::I64Const(1).encode(body);
Instruction::I64Add.encode(body);
Instruction::LocalSet(var_idx).encode(body);
Instruction::Br(0).encode(body);
Instruction::End.encode(body);
Instruction::End.encode(body);
return Ok(());
}
}
compile_expr(&f.iter, body, ctx, state)?;
Instruction::Drop.encode(body);
}
```
with:
```rust
ast::Stmt::For(f) => {
if let ast::Expr::Binary(b) = &f.iter {
if matches!(b.op, ast::BinOp::Range) && f.vars.len() == 1 {
let var_name = &f.vars[0];
let var_idx = ctx
.locals
.get(var_name)
.copied()
.ok_or_else(|| format!("undeclared loop var '{}'", var_name))?;
ctx.type_env.borrow_mut().insert(var_name.clone(), TypeScheme::mono(PlumType::TInt));
compile_expr(&b.left, body, ctx, state)?;
Instruction::LocalSet(var_idx).encode(body);
Instruction::Block(BlockType::Empty).encode(body);
Instruction::Loop(BlockType::Empty).encode(body);
Instruction::LocalGet(var_idx).encode(body);
compile_expr(&b.right, body, ctx, state)?;
Instruction::I64GeS.encode(body);
Instruction::BrIf(1).encode(body);
compile_block(&f.body, body, ctx, state)?;
Instruction::LocalGet(var_idx).encode(body);
Instruction::I64Const(1).encode(body);
Instruction::I64Add.encode(body);
Instruction::LocalSet(var_idx).encode(body);
Instruction::Br(0).encode(body);
Instruction::End.encode(body);
Instruction::End.encode(body);
return Ok(());
}
}
if let PlumType::TVariadic(elem_ty) = infer_local_type(&f.iter, ctx) {
if f.vars.len() != 1 {
return Err("codegen: for-loop over a variadic param must bind exactly one variable".to_string());
}
let var_name = &f.vars[0];
let var_idx = ctx
.locals
.get(var_name)
.copied()
.ok_or_else(|| format!("undeclared loop var '{}'", var_name))?;
ctx.type_env.borrow_mut().insert(var_name.clone(), TypeScheme::mono((*elem_ty).clone()));
let scratch_key = f as *const ast::For as usize;
let slot = *ctx
.variadic_for_scratch
.get(&scratch_key)
.ok_or_else(|| "internal codegen error: missing variadic-for scratch slot".to_string())?;
let count_local = ctx.variadic_for_scratch_base + slot * 2;
let index_local = count_local + 1;
let elem_vt = plum_type_to_valtype(&elem_ty);
// count_local = i32.wrap_i64(load_i64([nums + 0]))
compile_expr(&f.iter, body, ctx, state)?;
Instruction::I64Load(MemArg { offset: 0, align: 3, memory_index: 0 }).encode(body);
Instruction::I32WrapI64.encode(body);
Instruction::LocalSet(count_local).encode(body);
// index_local = 0
Instruction::I32Const(0).encode(body);
Instruction::LocalSet(index_local).encode(body);
Instruction::Block(BlockType::Empty).encode(body);
Instruction::Loop(BlockType::Empty).encode(body);
Instruction::LocalGet(index_local).encode(body);
Instruction::LocalGet(count_local).encode(body);
Instruction::I32GeS.encode(body);
Instruction::BrIf(1).encode(body);
// var = load_elem([nums + 8 + index * 8])
compile_expr(&f.iter, body, ctx, state)?;
Instruction::I32Const(8).encode(body);
Instruction::I32Add.encode(body);
Instruction::LocalGet(index_local).encode(body);
Instruction::I32Const(8).encode(body);
Instruction::I32Mul.encode(body);
Instruction::I32Add.encode(body);
emit_load(elem_vt, 0, body);
Instruction::LocalSet(var_idx).encode(body);
compile_block(&f.body, body, ctx, state)?;
Instruction::LocalGet(index_local).encode(body);
Instruction::I32Const(1).encode(body);
Instruction::I32Add.encode(body);
Instruction::LocalSet(index_local).encode(body);
Instruction::Br(0).encode(body);
Instruction::End.encode(body);
Instruction::End.encode(body);
return Ok(());
}
compile_expr(&f.iter, body, ctx, state)?;
Instruction::Drop.encode(body);
}
```
- [ ] **Step 7: Run the new tests**
Run: `cargo test -p plum-wasm-codegen sum_all_variadic 2>&1 | tail -60`
Expected: PASS (`10` and `0`). If the sum is wrong, double-check the element address computation order: `compile_expr(&f.iter)` pushes the pointer, `+8` skips the count slot, then `+ index*8` reaches the right element — verify against Task 3's store layout (`count @ offset 0`, `elem[i] @ offset 8*(i+1)`, i.e. `elem[0]` at byte 8, matching `8 + 0*8 = 8`).
- [ ] **Step 8: Update and run the examples test**
Check `plum-wasm-codegen/tests/examples_test.rs` for its existing `functions_compiles`-style test (confirmed present per the codebase's existing per-example compile check) — since `sumAll` now has a real, non-`todo` body, if that test only checks `compile_source(...).is_ok()`, no change is needed there beyond the source file update from Step 1 already making it compile. Run:
Run: `cargo test -p plum-wasm-codegen functions_compiles 2>&1 | tail -30`
Expected: PASS.
- [ ] **Step 9: Run the full workspace test suite**
Run: `cargo test --workspace 2>&1 | tail -100`
Expected: all tests PASS, including every pre-existing range-`for` test (unchanged branch, still taken first) and every Task 1-3 test.
- [ ] **Step 10: Commit**
```bash
git add plum-wasm-codegen/src/lib.rs plum-wasm-codegen/tests/codegen_tests.rs examples/functions.plum
git commit -m "feat(plum-wasm-codegen): compile for-v-in-variadic-param iteration"
```
---
### Task 5: README — close the gap
**Files:**
- Modify: `README.md` (the "Known gaps" section)
**Interfaces:**
- Consumes: nothing.
- Produces: nothing (docs only).
- [ ] **Step 1: Update the Known gaps bullet**
Run: `grep -n "variadic" README.md` to find the current bullet (added by the previous field-assignment cycle), which currently reads along the lines of "`List`'s methods beyond `get`/`length` are still `todo` pending variadic-parameter support (`values: ...a`)". Replace it to reflect that variadic parameters are now a real language feature (call-site arity, `for v in nums` iteration), and that `List`'s own methods remain a separate, still-open gap (not blocked on variadic support anymore, just not yet implemented):
```
- `libs/std`'s actual `List`/`Map` still don't fully compile — there's no cross-file import resolution yet, so a file that references a type/enum declared in a different `libs/std` file won't type-check standalone; separately, `List`'s methods beyond `get`/`length` (`add`, `set`, `removeAt`, `remove`, `clear`, `reverse`) are still `todo` — variadic parameters (`values: ...a`) now work as a language feature, but wiring these methods up is separate, unstarted work
```
Also check for a spot earlier in the README (wherever variadic parameters or `fn(...)` types are first introduced, if such a section exists) that might describe variadic params as unsupported, and update it if so — search first:
Run: `grep -n "variadic\|\.\.\.a\|\.\.\.Int" README.md`
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: variadic parameters are no longer a known gap"
```