plum

#treesitter#compiler#wasm

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.