plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-23-variadic-params-design.md
# Design: variadic parameters (`values: ...a`)
## Problem
`fn(..., values: ...a)` parses today (`ast::ParamType::Variadic(Type)`), but every
downstream stage — checker, `monomorphize.rs`, codegen — treats a `Variadic(t)`
param identically to a plain fixed-arity `Type(t)` param: arity checks require
`args.len() == params.len()` exactly, and the param is bound as a single `t`-typed
value, not a collection. There is no way to actually call a variadic function with
0, 1, or many arguments, or to do anything with the collected values inside the
body. `examples/functions.plum`'s own `sumAll(nums: ...Int) -> Int = todo` has never
been implemented because there's nothing to implement it with.
This is the second of the two remaining "Known gaps" follow-ups (the first,
field/attribute assignment, is already done); it's a prerequisite for wiring up
`libs/std/list.plum`'s `add`/`init` methods, which is explicitly **out of scope**
for this cycle — this spec covers only making variadic parameters real as a
language feature, with `examples/functions.plum`'s `sumAll` as the target sanity
check.
## Scope
In scope:
- Call-site arity: a variadic param accepts 0 or more trailing arguments.
- Type-checking: each trailing argument unifies against the variadic's declared
element type.
- The one supported operation on a variadic param inside the function body:
`for v in nums` (direct iteration, binding `v` to each element in call order).
- `sumAll(nums: ...Int) -> Int` (from `examples/functions.plum`) implemented and
tested end-to-end (parse → typecheck → compile → run).
Out of scope (tracked separately):
- Indexing syntax (`values[i]`) — no grammar for `[...]` expressions exists at all
today; not needed since direct iteration covers the target use case.
- A `.length()` builtin or any other method on a variadic param.
- `libs/std/list.plum`'s `add`/`init`/etc. — those need this feature plus separate
work once it lands.
- Passing an existing collection where variadic args are expected (i.e. no
"spread" call syntax); every call site must list its trailing args literally.
- Variadic params anywhere but the last position (grammar already allows only one
`variadic_type` per param list positionally by convention; the checker will
enforce it's declared last and that there's at most one).
## Type representation
Add one variant to `plum-checker/src/types.rs`'s `PlumType`:
```rust
pub enum PlumType {
// ...existing variants...
/// 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 (for 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; using it any other way
/// (returning it, passing it to another call, unifying it against a
/// concrete type) is a type error by construction — no other match arm
/// in `unify` or `infer_expr` handles it.
TVariadic(Box<PlumType>),
}
```
`Display` renders it as `...{inner}` (e.g. `...Int`), matching the source syntax,
for error messages.
Every place that currently converts `ast::ParamType::Variadic(t)` to a `PlumType`
by unwrapping straight to `plum_type_from_ast(t)` (there are several — in
`plum-checker/src/lib.rs`'s method/function signature building, `monomorphize.rs`,
and `plum-wasm-codegen/src/lib.rs`'s param-type resolution helpers) instead wraps it:
`PlumType::TVariadic(Box::new(plum_type_from_ast(t)))`.
## Checker changes
**Call-site arity + unification** (`plum-checker/src/lib.rs`'s `infer_expr` for
`Expr::FnCall`, and the analogous `ClassCall`/method-call arms if they can ever
target a variadic-param method — in practice today only free functions and
`name<Receiver>(...)` methods can declare `...a`, both go through the same
`TFun(param_types, ret)` shape): if `param_types.last()` is `TVariadic(elem)`,
require `args.len() >= param_types.len() - 1`; unify the fixed prefix positionally
as today; unify every remaining (trailing) arg against `elem`. If `param_types` is
empty this can't happen (a variadic-only function has `param_types.len() == 1`,
the variadic entry itself, so `args.len() >= 0` always holds — any number of args
including zero is valid).
**Declaring a variadic param**: when building a function/method's `TFun` signature
from its `ast::Param` list, error if more than one param is `ParamType::Variadic`,
or if a `Variadic` param isn't the last one in the list. This is a new validation,
not currently enforced anywhere (today it's moot since variadic isn't handled
specially at all).
**`for` loop typing** (`check_stmt`'s `Stmt::For` arm, `plum-checker/src/lib.rs`):
today it unconditionally binds every loop var to `TInt` without even checking the
iterable's shape. Change it to: infer the iterable's type; if it's
`TVariadic(elem)`, require exactly one loop var (`for v, i in nums` over a variadic
is a checker error — multi-var iteration isn't defined for this type) and bind
that var to `elem`; otherwise (the existing range case, `a..b`) keep today's
behavior of binding every loop var to `TInt` unconditionally, unchanged.
## Codegen changes
**Callee side**: a variadic param compiles to a single `i32` local — a pointer,
exactly like a class-instance param — carrying the calling convention used for all
of "unmodeled/pointer" types today (`ast_type_to_wasm`'s catch-all `Some(ValType::I32)`
arm). No new callee-side representation needed beyond wherever param `PlumType`s are
resolved for local typing (those sites already need the `TVariadic` unwrap from the
Type representation section above; the *wasm width* of a `TVariadic`-typed local is
always `ValType::I32`, same as any other pointer).
**Caller side** (the call-compiling code path used for `Expr::FnCall`, e.g. around
`plum-wasm-codegen/src/lib.rs`'s call-compilation logic): when the callee's last
param is variadic, compile the fixed-prefix args normally (pushed as ordinary wasm
call args), then build a length-prefixed buffer for the trailing args:
- `count = trailing_args.len()` (known at compile time — every call site lists its
variadic args literally per this spec's scope)
- bump-allocate `8 * (count + 1)` bytes: `result = bump_global; bump_global += 8 * (count + 1)`
(same inline bump-and-increment pattern already used for `ClassCall`/class-literal
construction — no new allocation helper function needed, this is a handful of
instructions inlined at the call site, matching how class construction already
works)
- store `count` as an `i64` at offset `0` (`emit_store`, matching the class-field
8-byte-stride convention used everywhere else in this file)
- for each trailing arg (compile-time-known position `i`), `compile_expr` it and
`emit_store` it at the *static* offset `8 * (i + 1)` — this part needs no dynamic
address arithmetic, since `i` is a Rust-level loop variable over the AST at
codegen time, not a wasm runtime value
- push the resulting pointer as the final actual wasm call argument
**`for v in nums` codegen** (new branch in `compile_stmt`'s `Stmt::For` handling,
alongside the existing `matches!(b.op, ast::BinOp::Range)` branch — selected by the
checker-computed type of `f.iter` being `TVariadic`, not by the iterable's AST
shape, since the iterable is just a `Var` reference to the param, not a literal
range expression):
- load the variadic pointer into a scratch local (`ptr`)
- load `count` from `[ptr + 0]` (`I64Load`) into a scratch local
- loop an index `i` from `0` to `count` (same loop-structure codegen already used
for range `for`, just with a runtime bound instead of a constant/expression one —
range `for` already supports a non-constant upper bound today, e.g. `for i in 0..n`
where `n` is a variable, so this reuses that existing loop-bound machinery, not new
loop-control codegen)
- each iteration, compute the element's runtime address: `addr = ptr + 8 + i * 8`
(`i32.add`/`i32.mul` on the loop index, converted from the `i64` loop-counter
convention used by range `for` to `i32` for pointer arithmetic — check how range
`for`'s existing loop counter width interacts with this and keep it consistent,
adjusting with `i32.wrap_i64` if the existing loop counter is `i64`), then load the
element from that computed address (offset `0` in the `MemArg`, since the offset
is now baked into `addr` itself rather than being a `MemArg` immediate — this is
the one genuinely new pattern in this feature, since every other load/store in
this file uses a compile-time-constant `MemArg.offset`)
- bind the loop var to the loaded element value for the body's execution, exactly
like the existing range `for`'s loop var binding
## Testing
- Checker tests (`plum-checker/tests/checker_tests.rs`): a variadic call with 0
args passes; with 3 args of the right type passes; with a wrong-typed trailing
arg is an error; declaring two variadic params is an error; declaring a variadic
param not last is an error; `for v in nums` (variadic) type-checks and binds `v`
to the element type; `for v, i in nums` (variadic, two loop vars) is an error.
- Codegen tests (`plum-wasm-codegen/tests/codegen_tests.rs`), each compiled and run
via `run_main`:
- `sumAll(nums: ...Int) -> Int` (the target example) called with 0 args returns 0
- called with 3 args returns their sum
- a variadic function with one fixed prefix param plus variadic
(`combine(prefix: Int, rest: ...Int) -> Int`) correctly separates the two
- `examples/functions.plum`'s `sumAll` is updated from `todo` to a real
implementation and covered by `plum-checker/tests/examples_test.rs` /
`plum-wasm-codegen/tests/examples_test.rs`'s existing per-example compile
(and, since it now has real behavior, a runtime assertion) checks.
## README
Once this lands, update the "Known gaps" bullet that currently mentions `List`'s
methods being blocked on variadic support — `List`'s methods themselves stay
`todo` (out of scope here), but the bullet's phrasing changes from "blocked on
variadic-parameter support" to reflect that variadic parameters now exist as a
language feature and `List`'s own methods are the remaining, separate work.