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
| 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. |