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
64b1d3c 1
# Design: variadic parameters (`values: ...a`)
64b1d3c 2
64b1d3c 3
## Problem
64b1d3c 4
64b1d3c 5
`fn(..., values: ...a)` parses today (`ast::ParamType::Variadic(Type)`), but every
64b1d3c 6
downstream stage — checker, `monomorphize.rs`, codegen — treats a `Variadic(t)`
64b1d3c 7
param identically to a plain fixed-arity `Type(t)` param: arity checks require
64b1d3c 8
`args.len() == params.len()` exactly, and the param is bound as a single `t`-typed
64b1d3c 9
value, not a collection. There is no way to actually call a variadic function with
64b1d3c 10
0, 1, or many arguments, or to do anything with the collected values inside the
64b1d3c 11
body. `examples/functions.plum`'s own `sumAll(nums: ...Int) -> Int = todo` has never
64b1d3c 12
been implemented because there's nothing to implement it with.
64b1d3c 13
64b1d3c 14
This is the second of the two remaining "Known gaps" follow-ups (the first,
64b1d3c 15
field/attribute assignment, is already done); it's a prerequisite for wiring up
64b1d3c 16
`libs/std/list.plum`'s `add`/`init` methods, which is explicitly **out of scope**
64b1d3c 17
for this cycle — this spec covers only making variadic parameters real as a
64b1d3c 18
language feature, with `examples/functions.plum`'s `sumAll` as the target sanity
64b1d3c 19
check.
64b1d3c 20
64b1d3c 21
## Scope
64b1d3c 22
64b1d3c 23
In scope:
64b1d3c 24
- Call-site arity: a variadic param accepts 0 or more trailing arguments.
64b1d3c 25
- Type-checking: each trailing argument unifies against the variadic's declared
64b1d3c 26
  element type.
64b1d3c 27
- The one supported operation on a variadic param inside the function body:
64b1d3c 28
  `for v in nums` (direct iteration, binding `v` to each element in call order).
64b1d3c 29
- `sumAll(nums: ...Int) -> Int` (from `examples/functions.plum`) implemented and
64b1d3c 30
  tested end-to-end (parse → typecheck → compile → run).
64b1d3c 31
64b1d3c 32
Out of scope (tracked separately):
64b1d3c 33
- Indexing syntax (`values[i]`) — no grammar for `[...]` expressions exists at all
64b1d3c 34
  today; not needed since direct iteration covers the target use case.
64b1d3c 35
- A `.length()` builtin or any other method on a variadic param.
64b1d3c 36
- `libs/std/list.plum`'s `add`/`init`/etc. — those need this feature plus separate
64b1d3c 37
  work once it lands.
64b1d3c 38
- Passing an existing collection where variadic args are expected (i.e. no
64b1d3c 39
  "spread" call syntax); every call site must list its trailing args literally.
64b1d3c 40
- Variadic params anywhere but the last position (grammar already allows only one
64b1d3c 41
  `variadic_type` per param list positionally by convention; the checker will
64b1d3c 42
  enforce it's declared last and that there's at most one).
64b1d3c 43
64b1d3c 44
## Type representation
64b1d3c 45
64b1d3c 46
Add one variant to `plum-checker/src/types.rs`'s `PlumType`:
64b1d3c 47
64b1d3c 48
```rust
64b1d3c 49
pub enum PlumType {
64b1d3c 50
    // ...existing variants...
64b1d3c 51
    /// The type of a variadic parameter, e.g. `...Int` -> `TVariadic(TInt)`.
64b1d3c 52
    /// Appears in exactly two places: as the trailing entry of a `TFun`'s
64b1d3c 53
    /// param-types list (for call-site arity/type checking), and as the type
64b1d3c 54
    /// bound to the param's name inside the function body. Its only legal use
64b1d3c 55
    /// inside a body is as a `for` loop's iterable; using it any other way
64b1d3c 56
    /// (returning it, passing it to another call, unifying it against a
64b1d3c 57
    /// concrete type) is a type error by construction — no other match arm
64b1d3c 58
    /// in `unify` or `infer_expr` handles it.
64b1d3c 59
    TVariadic(Box<PlumType>),
64b1d3c 60
}
64b1d3c 61
```
64b1d3c 62
64b1d3c 63
`Display` renders it as `...{inner}` (e.g. `...Int`), matching the source syntax,
64b1d3c 64
for error messages.
64b1d3c 65
64b1d3c 66
Every place that currently converts `ast::ParamType::Variadic(t)` to a `PlumType`
64b1d3c 67
by unwrapping straight to `plum_type_from_ast(t)` (there are several — in
64b1d3c 68
`plum-checker/src/lib.rs`'s method/function signature building, `monomorphize.rs`,
64b1d3c 69
and `plum-wasm-codegen/src/lib.rs`'s param-type resolution helpers) instead wraps it:
64b1d3c 70
`PlumType::TVariadic(Box::new(plum_type_from_ast(t)))`.
64b1d3c 71
64b1d3c 72
## Checker changes
64b1d3c 73
64b1d3c 74
**Call-site arity + unification** (`plum-checker/src/lib.rs`'s `infer_expr` for
64b1d3c 75
`Expr::FnCall`, and the analogous `ClassCall`/method-call arms if they can ever
64b1d3c 76
target a variadic-param method — in practice today only free functions and
64b1d3c 77
`name<Receiver>(...)` methods can declare `...a`, both go through the same
64b1d3c 78
`TFun(param_types, ret)` shape): if `param_types.last()` is `TVariadic(elem)`,
64b1d3c 79
require `args.len() >= param_types.len() - 1`; unify the fixed prefix positionally
64b1d3c 80
as today; unify every remaining (trailing) arg against `elem`. If `param_types` is
64b1d3c 81
empty this can't happen (a variadic-only function has `param_types.len() == 1`,
64b1d3c 82
the variadic entry itself, so `args.len() >= 0` always holds — any number of args
64b1d3c 83
including zero is valid).
64b1d3c 84
64b1d3c 85
**Declaring a variadic param**: when building a function/method's `TFun` signature
64b1d3c 86
from its `ast::Param` list, error if more than one param is `ParamType::Variadic`,
64b1d3c 87
or if a `Variadic` param isn't the last one in the list. This is a new validation,
64b1d3c 88
not currently enforced anywhere (today it's moot since variadic isn't handled
64b1d3c 89
specially at all).
64b1d3c 90
64b1d3c 91
**`for` loop typing** (`check_stmt`'s `Stmt::For` arm, `plum-checker/src/lib.rs`):
64b1d3c 92
today it unconditionally binds every loop var to `TInt` without even checking the
64b1d3c 93
iterable's shape. Change it to: infer the iterable's type; if it's
64b1d3c 94
`TVariadic(elem)`, require exactly one loop var (`for v, i in nums` over a variadic
64b1d3c 95
is a checker error — multi-var iteration isn't defined for this type) and bind
64b1d3c 96
that var to `elem`; otherwise (the existing range case, `a..b`) keep today's
64b1d3c 97
behavior of binding every loop var to `TInt` unconditionally, unchanged.
64b1d3c 98
64b1d3c 99
## Codegen changes
64b1d3c 100
64b1d3c 101
**Callee side**: a variadic param compiles to a single `i32` local — a pointer,
64b1d3c 102
exactly like a class-instance param — carrying the calling convention used for all
64b1d3c 103
of "unmodeled/pointer" types today (`ast_type_to_wasm`'s catch-all `Some(ValType::I32)`
64b1d3c 104
arm). No new callee-side representation needed beyond wherever param `PlumType`s are
64b1d3c 105
resolved for local typing (those sites already need the `TVariadic` unwrap from the
64b1d3c 106
Type representation section above; the *wasm width* of a `TVariadic`-typed local is
64b1d3c 107
always `ValType::I32`, same as any other pointer).
64b1d3c 108
64b1d3c 109
**Caller side** (the call-compiling code path used for `Expr::FnCall`, e.g. around
64b1d3c 110
`plum-wasm-codegen/src/lib.rs`'s call-compilation logic): when the callee's last
64b1d3c 111
param is variadic, compile the fixed-prefix args normally (pushed as ordinary wasm
64b1d3c 112
call args), then build a length-prefixed buffer for the trailing args:
64b1d3c 113
64b1d3c 114
- `count = trailing_args.len()` (known at compile time — every call site lists its
64b1d3c 115
  variadic args literally per this spec's scope)
64b1d3c 116
- bump-allocate `8 * (count + 1)` bytes: `result = bump_global; bump_global += 8 * (count + 1)`
64b1d3c 117
  (same inline bump-and-increment pattern already used for `ClassCall`/class-literal
64b1d3c 118
  construction — no new allocation helper function needed, this is a handful of
64b1d3c 119
  instructions inlined at the call site, matching how class construction already
64b1d3c 120
  works)
64b1d3c 121
- store `count` as an `i64` at offset `0` (`emit_store`, matching the class-field
64b1d3c 122
  8-byte-stride convention used everywhere else in this file)
64b1d3c 123
- for each trailing arg (compile-time-known position `i`), `compile_expr` it and
64b1d3c 124
  `emit_store` it at the *static* offset `8 * (i + 1)` — this part needs no dynamic
64b1d3c 125
  address arithmetic, since `i` is a Rust-level loop variable over the AST at
64b1d3c 126
  codegen time, not a wasm runtime value
64b1d3c 127
- push the resulting pointer as the final actual wasm call argument
64b1d3c 128
64b1d3c 129
**`for v in nums` codegen** (new branch in `compile_stmt`'s `Stmt::For` handling,
64b1d3c 130
alongside the existing `matches!(b.op, ast::BinOp::Range)` branch — selected by the
64b1d3c 131
checker-computed type of `f.iter` being `TVariadic`, not by the iterable's AST
64b1d3c 132
shape, since the iterable is just a `Var` reference to the param, not a literal
64b1d3c 133
range expression):
64b1d3c 134
64b1d3c 135
- load the variadic pointer into a scratch local (`ptr`)
64b1d3c 136
- load `count` from `[ptr + 0]` (`I64Load`) into a scratch local
64b1d3c 137
- loop an index `i` from `0` to `count` (same loop-structure codegen already used
64b1d3c 138
  for range `for`, just with a runtime bound instead of a constant/expression one —
64b1d3c 139
  range `for` already supports a non-constant upper bound today, e.g. `for i in 0..n`
64b1d3c 140
  where `n` is a variable, so this reuses that existing loop-bound machinery, not new
64b1d3c 141
  loop-control codegen)
64b1d3c 142
- each iteration, compute the element's runtime address: `addr = ptr + 8 + i * 8`
64b1d3c 143
  (`i32.add`/`i32.mul` on the loop index, converted from the `i64` loop-counter
64b1d3c 144
  convention used by range `for` to `i32` for pointer arithmetic — check how range
64b1d3c 145
  `for`'s existing loop counter width interacts with this and keep it consistent,
64b1d3c 146
  adjusting with `i32.wrap_i64` if the existing loop counter is `i64`), then load the
64b1d3c 147
  element from that computed address (offset `0` in the `MemArg`, since the offset
64b1d3c 148
  is now baked into `addr` itself rather than being a `MemArg` immediate — this is
64b1d3c 149
  the one genuinely new pattern in this feature, since every other load/store in
64b1d3c 150
  this file uses a compile-time-constant `MemArg.offset`)
64b1d3c 151
- bind the loop var to the loaded element value for the body's execution, exactly
64b1d3c 152
  like the existing range `for`'s loop var binding
64b1d3c 153
64b1d3c 154
## Testing
64b1d3c 155
64b1d3c 156
- Checker tests (`plum-checker/tests/checker_tests.rs`): a variadic call with 0
64b1d3c 157
  args passes; with 3 args of the right type passes; with a wrong-typed trailing
64b1d3c 158
  arg is an error; declaring two variadic params is an error; declaring a variadic
64b1d3c 159
  param not last is an error; `for v in nums` (variadic) type-checks and binds `v`
64b1d3c 160
  to the element type; `for v, i in nums` (variadic, two loop vars) is an error.
64b1d3c 161
- Codegen tests (`plum-wasm-codegen/tests/codegen_tests.rs`), each compiled and run
64b1d3c 162
  via `run_main`:
64b1d3c 163
  - `sumAll(nums: ...Int) -> Int` (the target example) called with 0 args returns 0
64b1d3c 164
  - called with 3 args returns their sum
64b1d3c 165
  - a variadic function with one fixed prefix param plus variadic
64b1d3c 166
    (`combine(prefix: Int, rest: ...Int) -> Int`) correctly separates the two
64b1d3c 167
  - `examples/functions.plum`'s `sumAll` is updated from `todo` to a real
64b1d3c 168
    implementation and covered by `plum-checker/tests/examples_test.rs` /
64b1d3c 169
    `plum-wasm-codegen/tests/examples_test.rs`'s existing per-example compile
64b1d3c 170
    (and, since it now has real behavior, a runtime assertion) checks.
64b1d3c 171
64b1d3c 172
## README
64b1d3c 173
64b1d3c 174
Once this lands, update the "Known gaps" bullet that currently mentions `List`'s
64b1d3c 175
methods being blocked on variadic support — `List`'s methods themselves stay
64b1d3c 176
`todo` (out of scope here), but the bullet's phrasing changes from "blocked on
64b1d3c 177
variadic-parameter support" to reflect that variadic parameters now exist as a
64b1d3c 178
language feature and `List`'s own methods are the remaining, separate work.