plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/plans/2026-07-24-bracket-generics-syntax.md
# Bracket Generics Syntax Migration 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:** Migrate Plum's generic-type syntax from parenthesized/lowercase (`type Foo(a) =`, `Option(a)`) to bracketed/uppercase (`type Foo[T] =`, `Option[T]`) across the grammar, parser, checker, stdlib, and examples — a pure syntax migration with no semantic changes.
**Architecture:** Bottom-up through the compiler pipeline: grammar (tree-sitter) → AST/parser (`plum-core`) → monomorphization convention (`plum-checker`) → downstream `.name`-reader call sites (`plum-checker`/`plum-wasm-codegen`) → Rust test fixtures → `.plum` source files (stdlib, examples). Each layer is verified before moving to the next, since later layers depend on earlier ones compiling/parsing correctly.
**Tech Stack:** tree-sitter (JS grammar + generated Rust/C parser), Rust (`plum-core`, `plum-checker`, `plum-wasm-codegen`), Cargo workspace tests.
## Global Constraints
- Spec: `docs/superpowers/specs/2026-07-24-bracket-generics-syntax-design.md`
- `generic` becomes any single uppercase ASCII letter (`/[A-Z]/`); `type_identifier` becomes 2+ characters (`/[A-Z][a-zA-Z0-9]+/`) — single-letter type names are now permanently illegal.
- Declaration order: generics-with-bounds first, implements-list second — `type List[T: Stringable](Stringable) =`.
- Every generic-type appearance uses brackets: declarations, field types, return types, enum variant payloads (`| Some[T]`). Parens stay for value-level constructor/call argument lists and trait "implements" lists.
- Method receiver annotation (`get<List>(self, ...)`) is untouched — it's an unrelated mechanism.
- `is_generic_param_name` (the sole convention-detection function, in `plum-checker/src/monomorphize.rs`) flips from lowercase to uppercase; this is the only place the letter-case convention is defined.
- `ReturnType` (a separate, narrower AST shape than `Type`) is removed; every `returns` field becomes `Option<ast::Type>`, fixing the pre-existing `Type`/`ReturnType` asymmetry as part of this migration.
- The untracked plan `docs/superpowers/plans/2026-07-24-list-methods.md` is written in the OLD syntax and is **not** touched by this plan — it is blocked/stale and needs its own rebase before anyone executes it. Do not edit it here.
- Run `cargo test --workspace` after every task from Task 2 onward — expect it to start failing once the grammar changes land (Task 1), and to be fully green again only after Task 5.
- Letter conventions used in stdlib rewrites (Task 6): `List`/`Node` → `T` (map's callback output → `U`); `Map`/`Pair` → `K`, `V` (map's callback output pair → `X`, `Y`); `Option` → `T`; `Result` → `T` (Ok), `E` (Err); `examples/types.plum`'s `Box` → `T`, `Comparable` → `T`.
---
### Task 1: Grammar changes and corpus tests (`tooling/tree-sitter-plum`)
**Files:**
- Modify: `tooling/tree-sitter-plum/grammar.js`
- Modify: `tooling/tree-sitter-plum/test/corpus/type.txt`
- Modify: `tooling/tree-sitter-plum/test/corpus/trait.txt`
- Modify: `tooling/tree-sitter-plum/test/corpus/enum.txt`
- Modify: `tooling/tree-sitter-plum/test/corpus/function.txt`
**Interfaces:**
- Consumes: nothing (this is the bottom of the pipeline).
- Produces: a regenerated tree-sitter parser where `generic` is `/[A-Z]/` (any single uppercase letter, node kind `"generic"`), `type_identifier` is `/[A-Z][a-zA-Z0-9]+/` (2+ chars), `generics` declarations use `[...]`, `class`/`trait` generics come before implements, `enum_field` payloads use `[...]`, and `return_type` no longer exists as its own rule — return positions parse as plain `$.type` (node kind `"type"`, not `"return_type"`).
- [ ] **Step 1: Edit `grammar.js`'s generic/type-identifier tokens and the `generics` bracket**
In `tooling/tree-sitter-plum/grammar.js`, replace:
```js
inline: ($) => [$.generic_type, $.generic],
```
with:
```js
inline: ($) => [$.generic_type],
```
(`$.generic` is now a genuine terminal token — a leaf rule can't meaningfully be "inlined," since there's no substructure to hoist. `$.generic_type` stays inlined so `generics`' children remain flat, matching the existing parser convention.)
Replace:
```js
generics: ($) => seq("(", commaSep1($.generic_type), ")"),
```
with:
```js
generics: ($) => seq("[", commaSep1($.generic_type), "]"),
```
Replace:
```js
type: ($) =>
choice(
seq(
$.type_identifier,
field(
"generics",
optional(
choice(
seq("[", commaSep1($.type), "]"),
seq("(", commaSep1($.type), ")"),
),
),
),
),
$.generic,
),
```
with:
```js
type: ($) =>
choice(
seq(
$.type_identifier,
field(
"generics",
optional(seq("[", commaSep1($.type), "]")),
),
),
$.generic,
),
```
- [ ] **Step 2: Swap `class`'s field order (generics before implements) and drop `return_type`**
Replace:
```js
class: ($) =>
seq(
"type",
field("name", $.type_identifier),
field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
field("generics", optional($.generics)),
"=",
$._indent,
field("fields", optional(repeat(alias($.class_field, $.field)))),
$._dedent,
),
```
with:
```js
class: ($) =>
seq(
"type",
field("name", $.type_identifier),
field("generics", optional($.generics)),
field("implements", optional(seq("(", commaSep1($.type_identifier), ")"))),
"=",
$._indent,
field("fields", optional(repeat(alias($.class_field, $.field)))),
$._dedent,
),
```
Replace:
```js
trait_field: ($) =>
seq(
field("name", $.fn_identifier),
field("params", seq("(", optional(commaSep1(choice($.self, $.param))), ")")),
field("returns", optional(seq("->", $.return_type))),
),
```
with:
```js
trait_field: ($) =>
seq(
field("name", $.fn_identifier),
field("params", seq("(", optional(commaSep1(choice($.self, $.param))), ")")),
field("returns", optional(seq("->", $.type))),
),
```
Delete this rule entirely (return positions now parse as plain `$.type`):
```js
return_type: ($) =>
seq($.type_identifier, field("generics", optional($.generics))),
```
Replace, in `enum_field`:
```js
enum_field: ($) =>
seq(
"|",
field("name", $.type_identifier),
field("parameters", optional(seq("(", commaSep1(choice($.type_identifier, $.generic)), ")"))),
),
```
with:
```js
enum_field: ($) =>
seq(
"|",
field("name", $.type_identifier),
field("parameters", optional(seq("[", commaSep1(choice($.type_identifier, $.generic)), "]"))),
),
```
Replace, in `fn`:
```js
field("returns", optional(seq("->", $.return_type))),
```
with:
```js
field("returns", optional(seq("->", $.type))),
```
- [ ] **Step 3: Replace the hardcoded 4-letter `generic` rule with a single regex token**
Replace:
```js
generic: ($) => choice($.a, $.b, $.c, $.d), // single letter
a: (_) => token("a"),
b: (_) => token("b"),
c: (_) => token("c"),
d: (_) => token("d"),
```
with:
```js
generic: (_) => /[A-Z]/, // any single uppercase letter — reserved, illegal as a type_identifier
```
Replace:
```js
type_identifier: (_) => /[A-Z][a-zA-Z0-9]*/, // capital case
```
with:
```js
type_identifier: (_) => /[A-Z][a-zA-Z0-9]+/, // capital case, 2+ chars (single uppercase letters are reserved for `generic`)
```
- [ ] **Step 4: Regenerate the parser**
Run: `cd tooling/tree-sitter-plum && npx tree-sitter generate`
Expected: succeeds with no grammar conflicts reported. If tree-sitter reports a conflict between `generic` and `type_identifier`, stop and report BLOCKED — it would mean the 2-char minimum on `type_identifier` didn't fully disambiguate the two tokens, contradicting this plan's core assumption from the design spec.
- [ ] **Step 5: Update `test/corpus/type.txt`**
Replace the entire file contents with:
```
================================================================================
type
================================================================================
type Dog =
name: Str
age: B
type Cat(Stringable) =
name: Str
age: Int
init<Cat>(name: Str) -> Cat =
Cat(name: name, age: 0)
withName<Cat>(name: Str) -> Cat =
Cat(name: name, age: 0)
withAge<Cat>(age: Int) -> Cat =
Cat(name: "", age: age)
toStr<Cat>() -> Str =
"Cat({self.name}, {self.age})"
--------------------------------------------------------------------------------
(source
(class
(type_identifier)
(field
(var_identifier)
(type
(type_identifier)))
(field
(var_identifier)
(type
(generic))))
(class
(type_identifier)
(type_identifier)
(field
(var_identifier)
(type
(type_identifier)))
(field
(var_identifier)
(type
(type_identifier))))
(fn
(fn_identifier)
(type
(type_identifier))
(param
(var_identifier)
(type
(type_identifier)))
(type
(type_identifier))
(body
(expression
(primary_expression
(class_call
(type_identifier)
(class_argument_list
(var_identifier)
(expression
(primary_expression
(var_identifier)))
(var_identifier)
(expression
(primary_expression
(integer)))))))))
(fn
(fn_identifier)
(type
(type_identifier))
(param
(var_identifier)
(type
(type_identifier)))
(type
(type_identifier))
(body
(expression
(primary_expression
(class_call
(type_identifier)
(class_argument_list
(var_identifier)
(expression
(primary_expression
(var_identifier)))
(var_identifier)
(expression
(primary_expression
(integer)))))))))
(fn
(fn_identifier)
(type
(type_identifier))
(param
(var_identifier)
(type
(type_identifier)))
(type
(type_identifier))
(body
(expression
(primary_expression
(class_call
(type_identifier)
(class_argument_list
(var_identifier)
(expression
(primary_expression
(string
(string_start)
(string_end))))
(var_identifier)
(expression
(primary_expression
(var_identifier)))))))))
(fn
(fn_identifier)
(type
(type_identifier))
(type
(type_identifier))
(body
(expression
(primary_expression
(string
(string_start)
(string_content)
(interpolation
(primary_expression
(attribute
(primary_expression
(self))
(fn_identifier))))
(string_content)
(interpolation
(primary_expression
(attribute
(primary_expression
(self))
(fn_identifier))))
(string_content)
(string_end)))))))
```
- [ ] **Step 6: Update `test/corpus/trait.txt`**
This file has no generics and only plain `-> Str`/`-> Int` return types, so the only change is `return_type` → `type` in the expected tree. Replace the expected-tree section (everything after the `---` divider) with:
```
(source
(trait
(type_identifier)
(field
(fn_identifier)
(type
(type_identifier))))
(trait
(type_identifier)
(field
(fn_identifier)
(type
(type_identifier)))
(field
(fn_identifier)
(type
(type_identifier)))
(field
(fn_identifier))
(field
(fn_identifier)
(param
(var_identifier)
(type
(type_identifier)))
(type
(type_identifier)))))
```
(The source section above the divider is unchanged — no generics appear in this file's source.)
- [ ] **Step 7: Update `test/corpus/enum.txt`**
Replace the entire file contents with:
```
================================================================================
enum
================================================================================
enum Bool =
| True
| False
toStr<Bool>() -> Str =
"Bool"
--------------------------------------------------------------------------------
(source
(enum
(type_identifier)
(field
(type_identifier))
(field
(type_identifier)))
(fn
(fn_identifier)
(type
(type_identifier))
(type
(type_identifier))
(body
(expression
(primary_expression
(string
(string_start)
(string_content)
(string_end)))))))
================================================================================
enum - generic variant fields
================================================================================
enum Option =
| Some[T]
| None
--------------------------------------------------------------------------------
(source
(enum
(type_identifier)
(field
(type_identifier)
(generic))
(field
(type_identifier))))
```
- [ ] **Step 8: Update `test/corpus/function.txt`**
First, apply this mechanical substitution across the whole file — every remaining `(return_type` line (the ones NOT part of the "function - generics" test touched in the next step) becomes `(type`, with indentation untouched:
Run:
```bash
cd tooling/tree-sitter-plum/test/corpus
sed -i '' 's/(return_type/(type/' function.txt
```
Then hand-fix the two tests whose *source* text (not just the expected tree) also changes. Find the block starting `function - generics` and replace its source + expected tree:
```
================================================================================
function - generics
================================================================================
add(param: T, param2: List[U]) -> List[U] =
todo
--------------------------------------------------------------------------------
(source
(fn
(fn_identifier)
(param
(var_identifier)
(type
(generic)))
(param
(var_identifier)
(type
(type_identifier)
(type
(generic))))
(type
(type_identifier)
(type
(generic)))
(body
(todo))))
```
Then find the block starting `function - method with explicit self param` and replace its source + expected tree:
```
================================================================================
function - method with explicit self param
================================================================================
remove<List>(self, v: T) =
todo
--------------------------------------------------------------------------------
(source
(fn
(fn_identifier)
(type
(type_identifier))
(self)
(param
(var_identifier)
(type
(generic)))
(body
(todo))))
```
- [ ] **Step 9: Run the corpus tests**
Run: `cd tooling/tree-sitter-plum && npx tree-sitter test 2>&1 | tail -100`
Expected: all tests PASS. If any test other than `type`, `trait`, `enum`, `function` fails, inspect its diff — it likely means a `return_type` occurrence was missed elsewhere, or the sed substitution touched something unintended.
- [ ] **Step 10: Commit**
```bash
git add tooling/tree-sitter-plum/grammar.js tooling/tree-sitter-plum/src tooling/tree-sitter-plum/test/corpus/type.txt tooling/tree-sitter-plum/test/corpus/trait.txt tooling/tree-sitter-plum/test/corpus/enum.txt tooling/tree-sitter-plum/test/corpus/function.txt
git commit -m "feat(grammar): migrate generics to bracket syntax with uppercase letters"
```
(`tooling/tree-sitter-plum/src` covers the regenerated `parser.c`/`grammar.json`/etc. produced by Step 4 — include whatever `tree-sitter generate` wrote there.)
---
### Task 2: `plum-core` — AST and parser updates
**Files:**
- Modify: `plum-core/src/ast.rs`
- Modify: `plum-core/src/parser.rs`
**Interfaces:**
- Consumes: the regenerated grammar from Task 1 (node kinds `"generic"`, `"generics"`, `"type"` — no more `"return_type"`, `"a"`/`"b"`/`"c"`/`"d"`).
- Produces: `TraitMethod.returns: Option<Type>`, `Fn.returns: Option<Type>` (was `Option<ReturnType>`); `parse_class`, `parse_generics_field`, `parse_enum_variant`, `parse_trait_method`, `parse_fn` updated to match the new grammar shape; `parse_return_type` removed entirely.
- [ ] **Step 1: Remove `ReturnType` from `ast.rs`, retype `returns` fields**
In `plum-core/src/ast.rs`, replace:
```rust
#[derive(Debug, Clone, PartialEq)]
pub struct TraitMethod {
pub name: String,
pub params: Vec<Param>,
pub returns: Option<ReturnType>,
}
```
with:
```rust
#[derive(Debug, Clone, PartialEq)]
pub struct TraitMethod {
pub name: String,
pub params: Vec<Param>,
pub returns: Option<Type>,
}
```
Replace:
```rust
#[derive(Debug, Clone, PartialEq)]
pub struct Fn {
pub name: String,
/// Type parameter for method dispatch, e.g. `<Cat>` in `toStr<Cat>()`
pub type_param: Option<String>,
pub params: Vec<Param>,
pub returns: Option<ReturnType>,
pub body: FnBody,
}
```
with:
```rust
#[derive(Debug, Clone, PartialEq)]
pub struct Fn {
pub name: String,
/// Type parameter for method dispatch, e.g. `<Cat>` in `toStr<Cat>()`
pub type_param: Option<String>,
pub params: Vec<Param>,
pub returns: Option<Type>,
pub body: FnBody,
}
```
Delete this struct entirely (its shape is now identical to `Type`, which every `returns` field uses instead):
```rust
#[derive(Debug, Clone, PartialEq)]
pub struct ReturnType {
pub name: String,
pub generics: Vec<GenericParam>,
}
```
- [ ] **Step 2: Update `parse_class`'s implements-list derivation for the new field order**
In `plum-core/src/parser.rs`, replace:
```rust
fn parse_class(&self, node: Node) -> Class {
// class: "type" type_identifier ("(" type_identifier,* ")")? generics? "=" body
// Named children in order: type_identifier (name), type_identifier* (implements), field*
let mut cursor = node.walk();
let named: Vec<Node> = node.named_children(&mut cursor).collect();
let name = named.first().map(|n| self.text(*n)).unwrap_or_default();
// implements = type_identifiers that appear before any `field` node
let implements: Vec<String> = named[1..]
.iter()
.take_while(|n| n.kind() == "type_identifier")
.map(|n| self.text(*n))
.collect();
let generics = self.parse_generics_field(node);
let fields: Vec<Field> = named
.iter()
.filter(|n| n.kind() == "field")
.map(|n| self.parse_field(*n))
.collect();
Class { name, implements, generics, fields }
}
```
with:
```rust
fn parse_class(&self, node: Node) -> Class {
// class: "type" type_identifier generics? ("(" type_identifier,* ")")? "=" body
// Named children in order: type_identifier (name), generics? (declaration), type_identifier* (implements), field*
let mut cursor = node.walk();
let named: Vec<Node> = node.named_children(&mut cursor).collect();
let name = named.first().map(|n| self.text(*n)).unwrap_or_default();
// Skip the optional `generics` declaration node before looking for implements.
let after_generics = if named.get(1).map(|n| n.kind()) == Some("generics") { 2 } else { 1 };
// implements = type_identifiers that appear before any `field` node
let implements: Vec<String> = named[after_generics..]
.iter()
.take_while(|n| n.kind() == "type_identifier")
.map(|n| self.text(*n))
.collect();
let generics = self.parse_generics_field(node);
let fields: Vec<Field> = named
.iter()
.filter(|n| n.kind() == "field")
.map(|n| self.parse_field(*n))
.collect();
Class { name, implements, generics, fields }
}
```
- [ ] **Step 3: Update `parse_generics_field` and `parse_enum_variant` to match on `"generic"` instead of `"a"|"b"|"c"|"d"`**
Replace:
```rust
fn parse_generics_field(&self, node: Node) -> Vec<GenericParam> {
// generics: "(" generic_type,* ")" where generic_type: generic (":" sep1(type_identifier, "+"))?
//
// Both `generic_type` and `generic` are `inline`d in the grammar, so the
// `generics` node has NO `generic_type` children — its named children are
// the single-letter generic nodes (`a`/`b`/`c`/`d`) each optionally
// followed by their bound `type_identifier` nodes, all flattened together.
// Reconstruct each `GenericParam` by starting a new one at every generic
// letter and attaching any following `type_identifier`s as its bounds
// until the next generic letter.
let Some(generics_node) = self.children_of_kind(node, "generics").into_iter().next() else {
return Vec::new();
};
let mut cursor = generics_node.walk();
let mut params: Vec<GenericParam> = Vec::new();
for child in generics_node.named_children(&mut cursor) {
match child.kind() {
"a" | "b" | "c" | "d" => {
params.push(GenericParam { name: self.text(child), bounds: Vec::new() });
}
"type_identifier" => {
if let Some(last) = params.last_mut() {
last.bounds.push(self.text(child));
}
}
_ => {}
}
}
params
}
```
with:
```rust
fn parse_generics_field(&self, node: Node) -> Vec<GenericParam> {
// generics: "[" generic_type,* "]" where generic_type: generic (":" sep1(type_identifier, "+"))?
//
// `generic_type` is `inline`d in the grammar, so the `generics` node has NO
// `generic_type` children — its named children are the single-uppercase-letter
// `generic` nodes, each optionally followed by their bound `type_identifier`
// nodes, all flattened together. Reconstruct each `GenericParam` by starting a
// new one at every `generic` node and attaching any following
// `type_identifier`s as its bounds until the next `generic` node.
let Some(generics_node) = self.children_of_kind(node, "generics").into_iter().next() else {
return Vec::new();
};
let mut cursor = generics_node.walk();
let mut params: Vec<GenericParam> = Vec::new();
for child in generics_node.named_children(&mut cursor) {
match child.kind() {
"generic" => {
params.push(GenericParam { name: self.text(child), bounds: Vec::new() });
}
"type_identifier" => {
if let Some(last) = params.last_mut() {
last.bounds.push(self.text(child));
}
}
_ => {}
}
}
params
}
```
Replace:
```rust
fn parse_enum_variant(&self, node: Node) -> EnumVariant {
// enum_field (aliased to field): "|" type_identifier ("(" (type_identifier | generic),* ")")?
// named children: type_identifier (name), then each field type inside "()" — a
// `type_identifier` (concrete, e.g. `Int`) or an inlined generic letter node
// (`a`/`b`/`c`/`d`, since `generic` is inlined in the grammar).
let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
let fields: Vec<String> = (1..node.named_child_count())
.filter_map(|i| node.named_child(i as u32))
.filter(|n| matches!(n.kind(), "type_identifier" | "a" | "b" | "c" | "d"))
.map(|n| self.text(n))
.collect();
EnumVariant { name, fields }
}
```
with:
```rust
fn parse_enum_variant(&self, node: Node) -> EnumVariant {
// enum_field (aliased to field): "|" type_identifier ("[" (type_identifier | generic),* "]")?
// named children: type_identifier (name), then each field type inside "[]" — a
// `type_identifier` (concrete, e.g. `Int`) or a `generic` node (single uppercase letter).
let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
let fields: Vec<String> = (1..node.named_child_count())
.filter_map(|i| node.named_child(i as u32))
.filter(|n| matches!(n.kind(), "type_identifier" | "generic"))
.map(|n| self.text(n))
.collect();
EnumVariant { name, fields }
}
```
- [ ] **Step 4: Update `parse_trait_method` and `parse_fn` to read `returns` as a `type` node by field name; remove `parse_return_type`**
Replace:
```rust
fn parse_trait_method(&self, node: Node) -> TraitMethod {
// trait_field (aliased to field): fn_identifier "(" params ")" ("->" return_type)?
let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
let params = self.collect_params_from(node);
let returns = node
.named_children(&mut node.walk())
.find(|n| n.kind() == "return_type")
.map(|n| self.parse_return_type(n));
TraitMethod { name, params, returns }
}
```
with:
```rust
fn parse_trait_method(&self, node: Node) -> TraitMethod {
// trait_field (aliased to field): fn_identifier "(" params ")" ("->" type)?
let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
let params = self.collect_params_from(node);
let returns = node.child_by_field_name("returns").map(|n| self.parse_type(n));
TraitMethod { name, params, returns }
}
```
In `parse_fn`, replace:
```rust
let returns = named
.iter()
.find(|n| n.kind() == "return_type")
.map(|n| self.parse_return_type(*n));
// body is the last named child — it is either a `body` node (block)
// or an expression node when the body is a single expression.
let body = named.last().and_then(|last| {
match last.kind() {
// Skip non-body trailing nodes
"fn_identifier" | "type" | "param" | "self" | "return_type" => None,
"body" => Some(FnBody::Block(self.parse_block(*last))),
_ => {
let unwrapped = self.unwrap_expr_node(*last);
Some(FnBody::Expr(self.parse_expression(unwrapped)))
}
}
}).unwrap_or(FnBody::Block(Block { stmts: vec![] }));
```
with:
```rust
let returns = node.child_by_field_name("returns").map(|n| self.parse_type(n));
// body is the last named child — it is either a `body` node (block)
// or an expression node when the body is a single expression. The
// `<Cat>` receiver annotation and the `returns` type both have kind
// "type" now (return_type no longer exists as a separate node kind),
// but that's fine: neither can ever be the LAST named child when a
// body is present, since `body`/the trailing expression always comes
// after them in the grammar — so this match doesn't need to
// distinguish the two "type" cases from each other, only from `body`.
let body = named.last().and_then(|last| {
match last.kind() {
// Skip non-body trailing nodes
"fn_identifier" | "type" | "param" | "self" => None,
"body" => Some(FnBody::Block(self.parse_block(*last))),
_ => {
let unwrapped = self.unwrap_expr_node(*last);
Some(FnBody::Expr(self.parse_expression(unwrapped)))
}
}
}).unwrap_or(FnBody::Block(Block { stmts: vec![] }));
```
Delete `parse_return_type` entirely:
```rust
fn parse_return_type(&self, node: Node) -> ReturnType {
// return_type: type_identifier generics?
// named_child(0) = type_identifier
let name = node.named_child(0).map(|n| self.text(n)).unwrap_or_default();
let generics = self.parse_generics_field(node);
ReturnType { name, generics }
}
```
- [ ] **Step 5: Run `plum-core`'s tests**
Run: `cargo test -p plum-core 2>&1 | tail -60`
Expected: PASS. (`plum-core`'s own parser/formatter tests don't currently exercise generics syntax — see the design spec's survey — so this mainly confirms the crate still compiles and existing non-generic tests are unaffected.)
- [ ] **Step 6: Commit**
```bash
git add plum-core/src/ast.rs plum-core/src/parser.rs
git commit -m "feat(plum-core): parse bracket generics, drop ReturnType in favor of Type"
```
---
### Task 3: `plum-checker` and `plum-wasm-codegen` — uppercase convention and `ReturnType` cleanup
**Files:**
- Modify: `plum-checker/src/monomorphize.rs`
- Modify: `plum-checker/src/lib.rs`
- Modify: `plum-wasm-codegen/src/lib.rs`
**Interfaces:**
- Consumes: `Fn.returns: Option<Type>` / `TraitMethod.returns: Option<Type>` from Task 2.
- Produces: `is_generic_param_name` now recognizes single ASCII uppercase letters; every `ast::ReturnType` construction/reference becomes `ast::Type`.
- [ ] **Step 1: Flip `is_generic_param_name`'s case check**
In `plum-checker/src/monomorphize.rs`, replace:
```rust
/// A single lowercase letter (`a`, `b`, `c`, `d`, ...) is the grammar's only legal
/// spelling for a generic type parameter — this is how we recognize one, since
/// `ast::Fn` and `ast::Enum` (unlike `ast::Class`/`ast::Trait`) carry no explicit
/// generics declaration list.
pub fn is_generic_param_name(name: &str) -> bool {
let mut chars = name.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => c.is_ascii_lowercase(),
_ => false,
}
}
```
with:
```rust
/// A single uppercase letter (`T`, `U`, `K`, ...) is the grammar's only legal
/// spelling for a generic type parameter — this is how we recognize one, since
/// `ast::Fn` and `ast::Enum` (unlike `ast::Class`/`ast::Trait`) carry no explicit
/// generics declaration list.
pub fn is_generic_param_name(name: &str) -> bool {
let mut chars = name.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => c.is_ascii_uppercase(),
_ => false,
}
}
```
Also update the two doc comments in this file that describe the old lowercase convention:
Replace:
```rust
/// The generic parameter names implicitly introduced by a `Fn` — every distinct
/// single-lowercase-letter type name appearing in its params or return type, in
/// first-appearance order.
```
with:
```rust
/// The generic parameter names implicitly introduced by a `Fn` — every distinct
/// single-uppercase-letter type name appearing in its params or return type, in
/// first-appearance order.
```
Replace:
```rust
/// The generic parameter names implicitly introduced by an `Enum` — every distinct
/// single-lowercase-letter variant field type name, in first-appearance order.
```
with:
```rust
/// The generic parameter names implicitly introduced by an `Enum` — every distinct
/// single-uppercase-letter variant field type name, in first-appearance order.
```
- [ ] **Step 2: Replace `ast::ReturnType` construction sites with `ast::Type`**
In `plum-checker/src/monomorphize.rs`, replace:
```rust
returns: f.returns.as_ref().map(|r| {
let substituted = substitute_type(&ast::Type { name: r.name.clone(), generics: vec![] }, subst);
ast::ReturnType { name: substituted.name, generics: vec![] }
}),
```
with:
```rust
returns: f.returns.as_ref().map(|r| substitute_type(r, subst)),
```
Replace:
```rust
if needs {
f.returns = Some(ast::ReturnType { name: t.to_string(), generics: vec![] });
}
```
with:
```rust
if needs {
f.returns = Some(ast::Type { name: t.to_string(), generics: vec![] });
}
```
- [ ] **Step 3: Simplify the two `ast::Type` reconstructions in `plum-checker/src/lib.rs`**
`f.returns`/`r` is already `&ast::Type` after Task 2 — reconstructing a fresh `ast::Type` from its own fields is now redundant. Replace:
```rust
let ret = f.returns.as_ref()
.map(|r| plum_type_from_ast(&ast::Type { name: r.name.clone(), generics: vec![] }))
.unwrap_or(PlumType::TUnit);
```
with:
```rust
let ret = f.returns.as_ref()
.map(plum_type_from_ast)
.unwrap_or(PlumType::TUnit);
```
Replace:
```rust
let declared_ret = f.returns.as_ref()
.map(|r| {
let ast_ty = ast::Type { name: r.name.clone(), generics: vec![] };
plum_type_from_ast(&ast_ty)
})
.unwrap_or(PlumType::TUnit);
```
with:
```rust
let declared_ret = f.returns.as_ref()
.map(plum_type_from_ast)
.unwrap_or(PlumType::TUnit);
```
- [ ] **Step 4: Retype `ret_type_to_wasm` in `plum-wasm-codegen`**
In `plum-wasm-codegen/src/lib.rs`, replace:
```rust
fn ret_type_to_wasm(ret: Option<&ast::ReturnType>) -> Option<ValType> {
ret.and_then(|r| ast_type_to_wasm(&r.name))
}
```
with:
```rust
fn ret_type_to_wasm(ret: Option<&ast::Type>) -> Option<ValType> {
ret.and_then(|r| ast_type_to_wasm(&r.name))
}
```
(Its two call sites, `f.returns.as_ref()` at both usages, already produce `Option<&ast::Type>` after Task 2 — no call-site changes needed.)
- [ ] **Step 5: Build the workspace to confirm the Rust-level refactor compiles**
Run: `cargo build --workspace 2>&1 | tail -80`
Expected: builds clean, no type errors. (Tests are expected to start failing at this point — old-syntax `.plum` source strings embedded in test fixtures no longer parse under the Task-1 grammar. That's addressed in Tasks 4-5.)
- [ ] **Step 6: Commit**
```bash
git add plum-checker/src/monomorphize.rs plum-checker/src/lib.rs plum-wasm-codegen/src/lib.rs
git commit -m "feat(plum-checker,plum-wasm-codegen): recognize uppercase generic params, use Type instead of ReturnType"
```
---
### Task 4: Update `plum-checker` test fixtures
**Files:**
- Modify: `plum-checker/tests/checker_tests.rs`
- Modify: `plum-checker/tests/monomorphize_tests.rs`
**Interfaces:**
- Consumes: the Task 1-3 grammar/parser/checker changes.
- Produces: every embedded `.plum` source string in these two files rewritten to the new syntax, with assertions unchanged (this migration doesn't change behavior, so every currently-passing test must still pass with the same expected values).
- [ ] **Step 1: Run the checker test suite to see which tests fail on old syntax**
Run: `cargo test -p plum-checker 2>&1 | tail -150`
Expected: FAIL — a batch of tests fail because their embedded source strings use old syntax (`type Box(a) =`, `| Some(a)`, `-> a`, etc.) that no longer parses (single lowercase letters are no longer a valid `generic` or `type_identifier` token per Task 1).
- [ ] **Step 2: Rewrite every failing test's embedded source string using this mechanical rule**
For each failing test in `checker_tests.rs` and `monomorphize_tests.rs`, find every place a bare single lowercase letter (`a`, `b`, `c`, `d`) appears in a **type position** — i.e. immediately after `:` in a field/param declaration, as a bare function return type, inside a class's generic-declaration parens, or inside an enum variant's payload parens — and rewrite it using this fixed per-letter mapping, consistently within each individual test (do not reuse letters across unrelated tests):
- `a` → `T`
- `b` → `U`
- `c` → `V`
- `d` → `W`
And convert the enclosing syntax per Task 1's grammar: `type Foo(a) =` → `type Foo[T] =`, `type Foo(a, b) =` → `type Foo[T, U] =`, `Foo(a)` (type-argument usage, e.g. `Option(a)`) → `Foo[T]`, `| Some(a)` (enum variant payload) → `| Some[T]`, bare `-> a` (return type) → `-> T`.
Do **not** touch: variable/parameter *names* that happen to be single lowercase letters (e.g. `add(a: Int, b: Int) -> Int`, `bothTrue(a: Bool, b: Bool)`) — these are `var_identifier`s, unaffected by this migration, and must stay exactly as they are. Only letters appearing in **type position** (after the `:` or as a bare type name) are generics and need rewriting.
As a concrete worked example, in `checker_tests.rs` around line 322-323:
```rust
type Box(a) =
value: a
```
becomes:
```rust
type Box[T] =
value: T
```
and around line 356:
```rust
pair(first: a, second: b) -> Bool =
```
becomes:
```rust
pair(first: T, second: U) -> Bool =
```
(here `first`/`second` are param *names*, left untouched; `a`/`b` are the param *types*, rewritten.)
- [ ] **Step 3: Re-run until the checker suite passes**
Run: `cargo test -p plum-checker 2>&1 | tail -150`
Expected: iterate Step 2 against each remaining failure until this command shows all tests PASS, with the exact same assertions/expected values as before this migration (only source syntax changed, not behavior).
- [ ] **Step 4: Commit**
```bash
git add plum-checker/tests/checker_tests.rs plum-checker/tests/monomorphize_tests.rs
git commit -m "test(plum-checker): migrate test fixtures to bracket generics syntax"
```
---
### Task 5: Update `plum-wasm-codegen` test fixtures
**Files:**
- Modify: `plum-wasm-codegen/tests/codegen_tests.rs`
**Interfaces:**
- Consumes: the Task 1-3 grammar/parser/checker changes.
- Produces: every embedded `.plum` source string in this file rewritten to the new syntax; same assertions, same expected `run_main`/`run_main_str` results as before.
- [ ] **Step 1: Run the codegen test suite to see which tests fail on old syntax**
Run: `cargo test -p plum-wasm-codegen 2>&1 | tail -150`
Expected: FAIL — the same class of failures as Task 4, for `type Box(a) =`, `identity(value: a) -> a =`, and similar old-syntax fixtures (survey found occurrences around lines 856, 877, 891, 909, 1010, 1177, plus additional `Option(a)`/generic usages throughout).
- [ ] **Step 2: Rewrite every failing test's embedded source string using the same mechanical rule as Task 4**
Apply the identical rewrite rule from Task 4 Step 2 (bare single lowercase letters in type position → uppercase per the `a→T, b→U, c→V, d→W` mapping, consistently per-test; enclosing parens → brackets per Task 1's grammar; variable/parameter names left untouched).
As a concrete worked example, the `identity` pattern around lines 877/909/1177:
```rust
type Box(a) =
value: a
identity(value: a) -> a =
value
```
becomes:
```rust
type Box[T] =
value: T
identity(value: T) -> T =
value
```
- [ ] **Step 3: Re-run until the codegen suite passes**
Run: `cargo test -p plum-wasm-codegen 2>&1 | tail -150`
Expected: iterate Step 2 against each remaining failure until this command shows all tests PASS with unchanged expected values (e.g. `run_main` result assertions).
- [ ] **Step 4: Run the full workspace suite**
Run: `cargo test --workspace 2>&1 | tail -100`
Expected: all tests PASS across every crate (`tooling/tree-sitter-plum`'s corpus tests were already verified in Task 1 via `tree-sitter test`, which is a separate command from `cargo test`).
- [ ] **Step 5: Commit**
```bash
git add plum-wasm-codegen/tests/codegen_tests.rs
git commit -m "test(plum-wasm-codegen): migrate test fixtures to bracket generics syntax"
```
---
### Task 6: Rewrite the stdlib (`libs/std`)
**Files:**
- Modify: `libs/std/list.plum`
- Modify: `libs/std/map.plum`
- Modify: `libs/std/option.plum`
- Modify: `libs/std/result.plum`
**Interfaces:**
- Consumes: the new grammar (Task 1) — these files must parse under it.
- Produces: stdlib source using bracket/uppercase generics throughout, matching the `Node[T]` shape already present in `list.plum`.
- [ ] **Step 1: Rewrite `libs/std/option.plum`**
Replace the entire file contents with:
```
module std
enum Option =
| Some[T]
| None
```
- [ ] **Step 2: Rewrite `libs/std/result.plum`**
Replace the entire file contents with:
```
module std
enum Result =
| Ok[T]
| Err[E]
# checks whether the result is an Ok value
isOk<Result>(self) -> Bool =
match self
Ok(_) =>
True
Err(_) =>
False
```
- [ ] **Step 3: Rewrite `libs/std/map.plum`**
Replace the entire file contents with:
```
module std
# A Pair is a grouping of a key with a value
type Pair[K, V] =
key: K
val: V
# A Map is a data structure describing a contiguous section of an array stored separately from the slice variable itself.
# A Map is not an array. A slice describes a piece of an array.
type Map[K, V] =
items: List[Pair[K, V]]
init<Map>(self, kvs: ...Pair) -> Map =
Map().add(kvs)
# adds the specified elements to the start of the list
add<Map>(self, kvs: ...Pair) =
self.items.add(kvs)
# gets a value from the Map using key k
get<Map>(self, k: K) -> Option[V] =
for p in self.items
if p.key == k
return Some(p.val)
None
# puts a value into the Map
set<Map>(self, k: K, v: V) =
self.items.add(Pair(key: k, val: v))
# puts a value into the Map if its not already present
putIfAbsent<Map>(self, k: K, v: V) =
todo
map<Map>(self, cb: fn(Pair[K, V]) -> Pair[X, Y]) -> Map[X, Y] =
self.items.map(cb)
```
- [ ] **Step 4: Rewrite `libs/std/list.plum`**
Replace the entire file contents with:
```
module std
import std/option
# A node stores the data in a list and contains pointers to the previous and next sibling nodes
type Node[T] =
value: T
prev: Option[Node]
next: Option[Node]
# A list is a data structure describing a contiguous section of an array stored separately from the slice variable itself.
# It contains the pointers to the start and end nodes (head, tail) and maintains the size as well
type List[T: Stringable](Stringable) =
head: Option[Node]
tail: Option[Node]
size: Int
makeList(values: ...T) -> List =
List(None, None, 0).add(values)
# gets the element at i'th index of the list
get<List>(self, i: Int) -> Option[T] =
current = self.head
index = 0
while current != None
match current
Some(node) =>
if index == i
return Some(node.value)
current = node.next
index = index + 1
None =>
break
None
# sets the element at i'th index of the list
set<List>(self, i: Int, v: T) -> Option[T] =
todo
# returns the no of elements in the list
length<List>(self) -> Int =
self.size
# adds the specified elements to the start of the list
add<List>(self, values: ...T) =
todo
# removes the element at i'th index of the list
removeAt<List>(self, i: Int) =
todo
# removes the element v from list
remove<List>(self, v: T) =
todo
# removes all objects from this list
clear<List>(self) =
todo
# returns a new list with the elements in reverse order.
reverse<List>(self, v: fn(T) -> Bool) -> List =
todo
# returns a new list with the elements sorted by sorter
sort<List>(self, sorter: fn(T) -> Bool) -> List =
todo
# returns an item and index in the list if the item is is equal to search item
find<List>(self, search: T) -> Option[T] =
todo
# returns the index of an item in the list if present and comparable otherwise None
contains<List>(self, v: T) -> Bool =
todo
# calls f for each elem in the list
each<List>(self, cb: fn(T)) -> Unit =
current = self.head
while current != None
match current
Some(node) =>
cb(node.value)
current = node.next
None =>
break
# returns a list made up of b elements for each elem in the list
map<List>(self, cb: fn(T) -> U) -> List[U] =
nl = List()
current = self.head
while current != None
match current
Some(node) =>
item = cb(node.value)
nl.add(item)
current = node.next
None =>
break
nl
# returns a new list with each element flat-mapped
flatMap<List>(self) =
todo
# returns a new list with the elements that matched the predicate
retain<List>(self, predicate: fn(T) -> T) -> List =
todo
# returns a new list with the elements that matched the predicate removed
reject<List>(self, predicate: fn(T) -> T) -> List =
todo
# returns true if any element in the list satisfies the predicate
any<List>(self, predicate: fn(T) -> Bool) -> Bool =
todo
# returns true if all of the elements in the list satisfies the predicate
every<List>(self, predicate: fn(T) -> Bool) -> Bool =
todo
# returns the accumulated value of all the elements in the list
reduce<List>(self, acc: U, cb: fn(T) -> T) -> Option[U] =
todo
# returns the first element in the list
first<List>(self) -> Option[T] =
match self.head
Some(node) =>
Some(node.value)
None =>
None
# returns the last element in the list
last<List>(self) -> Option[T] =
match self.tail
Some(node) =>
Some(node.value)
None =>
None
# returns a list containing the first n elements of the given list
sublist<List>(self, start: Int, end: Int) -> List =
todo
# returns a list containing the first n elements of the given list
take<List>(self, n: Int) -> List =
todo
# returns a list containing the first n elements of the given list
skip<List>(self, n: Int) -> List =
todo
# returns a list containing the first n elements of the given list
drop<List>(self, n: Int) -> List =
todo
# returns a new list with some of the elements taken randomly
sample<List>(self) =
todo
# returns a new list with all elements shuffled
shuffle<List>(self) =
todo
# returns a new list with all elements grouped by adjacent pairs
partition<List>(self) =
todo
# returns a new list with all elements grouped into chunks
chunk<List>(self) =
todo
# returns a new list with all elements grouped
groupBy<List>(self) =
todo
join<List>(self, sep: Str = ",") -> Str =
res = Buffer()
self.each(|v|
res.write(v.toStr())
res.write(sep)
)
res.toStr()
```
(This is a straight letter-case/bracket rewrite of the file's pre-existing content — no method bodies change, including the still-`todo` ones. `Node[T]`'s already-migrated shape on disk is preserved as-is.)
- [ ] **Step 5: Confirm these files aren't exercised by any Rust test (so this step can't be verified by `cargo test`)**
Run: `grep -rn "libs/std" plum-checker/tests plum-wasm-codegen/tests plum-core/tests 2>/dev/null`
Expected: no output (or only unrelated matches) — confirming, per the design spec's survey, that no existing Rust test currently loads these files directly, so `cargo test --workspace` passing in Task 5 is unaffected by this task, and there is no automated check for these `.plum` files' correctness beyond `plum-cli` manually loading them (out of scope here — see Global Constraints on the blocked `list-methods` plan, which is what will eventually exercise `list.plum` end-to-end).
- [ ] **Step 6: Commit**
```bash
git add libs/std/option.plum libs/std/result.plum libs/std/map.plum libs/std/list.plum
git commit -m "feat(libs/std): migrate stdlib generics to bracket syntax"
```
---
### Task 7: Rewrite `examples/types.plum`
**Files:**
- Modify: `examples/types.plum`
**Interfaces:**
- Consumes: the new grammar (Task 1).
- Produces: the example file using bracket/uppercase generics for its one generic class and one generic trait; its non-generic declarations (`Point`, `Named(Stringable)`, `Shape`, `Color`, `Option`) are untouched.
- [ ] **Step 1: Rewrite the file**
Replace the entire file contents with:
```
type Point =
x: Int
y: Int
type Named(Stringable) =
name: Str
type Box[T] =
value: T
trait Shape =
area() -> Float
perimeter() -> Float
trait Comparable[T: Ord] =
compareTo(other: T) -> Int
enum Color =
| Red
| Green
| Blue
enum Option =
| Some(Int)
| None
makeIntBox() -> Box =
Box(value: 5)
makeStrBox() -> Box =
Box(value: "x")
```
- [ ] **Step 2: Confirm this file isn't parsed by any automated test**
Run: `grep -rn "examples/types" plum-checker/tests plum-wasm-codegen/tests plum-core/tests plum-cli 2>/dev/null`
Expected: no output — same reasoning as Task 6 Step 5; this file exists as a hand-written reference example, not something `cargo test` exercises.
- [ ] **Step 3: Commit**
```bash
git add examples/types.plum
git commit -m "feat(examples): migrate types.plum generics to bracket syntax"
```
---
### Task 8: Final full-workspace verification
**Files:** none (verification only).
**Interfaces:**
- Consumes: everything from Tasks 1-7.
- Produces: confirmation that the migration is complete and the workspace is green end-to-end.
- [ ] **Step 1: Run the full Rust test suite**
Run: `cargo test --workspace 2>&1 | tail -100`
Expected: all tests PASS.
- [ ] **Step 2: Run the tree-sitter corpus suite**
Run: `cd tooling/tree-sitter-plum && npx tree-sitter test 2>&1 | tail -60`
Expected: all tests PASS.
- [ ] **Step 3: Confirm no old-syntax generics remain in tracked `.plum` files**
Run: `grep -rn '([a-d])\|([a-d],\|(Stringable)([a-d]\|| Some(a)\|| Ok(a)\|| Err(b)' libs/std examples 2>/dev/null`
Expected: no output. (This is a narrow sanity grep for the exact old patterns this plan rewrote — not exhaustive old-syntax detection, since `a`/`b`/`c`/`d` as ordinary variable names elsewhere are legitimate and would false-positive on a broader pattern.)
- [ ] **Step 4: No commit needed**
This task is verification-only; if Steps 1-3 all pass, the migration is complete and every prior task's commit already captured the corresponding changes.