plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


README.md
# 👾 Plum Programming Language

- A statically typed, imperative programming language with ADT's (Algebraic Data Types) inspired by rust, gleam
- The compiler is built upon the tree-sitter parser so has out of the box syntax highlighting support for helix and zed editor
- Plans to be compiled to amd64,arm64, and riscv64 using QBE maintaining C-ABI compatibility


## Requirements
```sh
node >= 23.1.0
npm >= 10.9.0
qbe >= 1.2
clang >= 16.0.0
```

## Language Syntax

This section documents the syntax currently implemented by the [tree-sitter grammar](tooling/tree-sitter-plum/grammar.js), [`plum-checker`](plum-checker), and [`plum-wasm-codegen`](plum-wasm-codegen). Every construct below has a runnable, verified example in [`examples/`](examples) — the two test suites [`plum-checker/tests/examples_test.rs`](plum-checker/tests/examples_test.rs) and [`plum-wasm-codegen/tests/examples_test.rs`](plum-wasm-codegen/tests/examples_test.rs) parse, type-check, and (where noted) compile-and-execute every file in that directory, so this documentation can't silently drift out of sync with what actually works.

> The [docs website](website/src/content/docs) describes a considerably more elaborate *future* syntax (`fn`, `record`, arrow return types, `{}` blocks, generic `[T]` params). That syntax isn't implemented yet — everything below is what the compiler accepts **today**.

### Table of contents

- [Layout and comments](#layout-and-comments)
- [Naming conventions](#naming-conventions)
- [Modules and imports](#modules-and-imports)
- [Constants](#constants)
- [Literals](#literals)
- [Operators](#operators)
- [Variables and assignment](#variables-and-assignment)
- [Control flow](#control-flow)
- [Functions](#functions)
- [Types: records, traits, enums](#types-records-traits-enums)
- [Generics](#generics)
- [`self`, field access, and methods](#self-field-access-and-methods)
- [Constructing values](#constructing-values)
- [`match`](#match)
- [String interpolation](#string-interpolation)
- [`extern` functions and printing](#extern-functions-and-printing)
- [Known gaps](#known-gaps)

### Layout and comments

Blocks are indentation-sensitive (2 spaces), like Python — there's no `{ }` for grouping statements. A `#` starts a line comment.

```plum
# this is a comment
fun main() =
  x = 1
  if x > 0
    x = x + 1
```

### Naming conventions

The lexer enforces case by construct — using the wrong case for a position is a parse error, not just a style nit:

| Token | Case | Used for |
|---|---|---|
| `type_identifier` | `PascalCase` | type/trait/enum names, generic bounds |
| `fn_identifier` | `camelCase` | function/method names, `.field`/`.method()` member names |
| `var_identifier` | `snake_case` | variables, params, fields (also accepts camelCase — see below) |
| `const_identifier` | `SCREAMING_SNAKE_CASE` | top-level constants |
| `mod_identifier` | `snake_case`, single word | `module` declarations only (no `/`) |

`var_identifier`'s regex is deliberately a superset of `fn_identifier`'s (it allows both `_` and mixed case) so that a bare function call like `factorial(x)` — an all-lowercase, no-underscore name — isn't ambiguous between "a variable" and "a function" at the lexer level.

### Modules and imports

```plum
module basics

import std/io
```

`module` takes exactly one identifier (no path segments). `import` takes a `/`-separated path; there's no module resolution yet — it's parsed but not loaded.

### Constants

```plum
MAX_RETRIES = 3
PI = 3.14159
GREETING = "hello"
```

Top-level only, `SCREAMING_SNAKE_CASE`, one expression.

### Literals

```plum
dec = 42
hex = 0xFF
bin = 0b1010
big = 1_000_000       # underscores allowed as digit separators

flt = 3.14
flt2 = 12.0f          # trailing f/F suffix
exp = 6.022e23

name = "plum"
empty = ""
escaped = "line one\nline two\ttabbed \"quoted\""

yes = True
no = False
```

`True`/`False` are built into the type checker/codegen as `Bool`'s two variants — you don't need to declare `enum Bool` yourself to use them.

Full example: [`examples/basics.plum`](examples/basics.plum), [`examples/strings.plum`](examples/strings.plum).

### Operators

From tightest to loosest binding:

| Precedence | Operators | Notes |
|---|---|---|
| highest | `.` | attribute access / method call |
| | unary `+` `-` | |
| | `*` `/` `%` | |
| | `+` `-` | |
| | `<<` `>>` | |
| | `^` | |
| | `&` | |
| | `\|` | |
| | `<` `<=` `==` `!=` `>=` `>` `<>` | comparisons |
| | `!` | boolean not |
| | `&&` | |
| | `\|\|` | |
| | `..` | range (e.g. `for i in 0..5`) |
| lowest | `? :` | ternary |

`!` binds **looser** than comparisons — `!a == b` means `!(a == b)`, not `(!a) == b`. Whenever you mix categories (especially with `!`), group explicitly rather than relying on memorized precedence:

```plum
grouped = {1 + 2} * {3 - 1}    # {expr} groups, like (expr) in most languages
picked = cmp ? sum : bits
negated = -sum
inverted = !cmp
```

Note: this language uses `{ }` for grouping a sub-expression, not `( )` — parens are reserved for call/constructor argument lists.

Full example: [`examples/basics.plum`](examples/basics.plum).

### Variables and assignment

```plum
x = 1
a, b = 1, 2          # multiple targets, positionally paired with multiple values
```

Assignment introduces a binding if `x` isn't already in scope, or updates it otherwise — there's no separate `let`/`var` keyword.

### Control flow

```plum
if n < 0
  return "negative"
else if n == 0
  return "zero"
else
  return "positive"

while i > 0
  i = i - 1

for i in 0..limit
  if i == 3
    continue
  if i == 8
    break
  total = total + i

assert n > 0    # traps at runtime if false
todo            # traps at runtime — marks a body as not yet implemented
```

`for x in <range>` only accepts a range expression (`a..b`) or another primary expression on the right — there's no iterator protocol yet.

Full example: [`examples/control_flow.plum`](examples/control_flow.plum).

### Functions

```plum
fun addInts(a: Int, b: Int) -> Int =
  a + b

fun greet() =                        # no return type => Unit
  todo

fun withDefault(a: Int, step: Int = 1) -> Int =
  a + step

fun sumAll(nums: ...Int) -> Int =    # variadic param
  todo
```

The body always follows `=` on the next, indented line (`= expr` on the same line is grammatically accepted too, but every example and test in this repo uses the indented form — prefer it for consistency).

Full example: [`examples/functions.plum`](examples/functions.plum).

### Types: records, traits, enums

```plum
type Point =
  x: Int
  y: Int

type Named(Stringable) =    # implements Stringable
  name: Str

trait Shape =
  area() -> Float
  perimeter() -> Float

enum Color =
  | Red
  | Green
  | Blue

enum Option =
  | Some(Int)              # variant with payload
  | None
```

Full example: [`examples/types.plum`](examples/types.plum).

### Generics

Generic type parameters are single letters only: `a`, `b`, `c`, `d`.

```plum
type Box(a) =
  value: a

trait Comparable(a: Ord) =    # bounded generic param
  compareTo(other: a) -> Int

fun wrap(value: a) -> Bool =      # generic param type
  True
```

Generic **arguments** (instantiating a generic type) accept either bracket or paren syntax: `List[Int]` and `List(Int)` both parse. User-defined generics (classes, their methods, free functions, and enums) are monomorphized: each concrete-type-argument combination actually used in the program gets its own specialized, fully-concrete copy, which then type-checks and compiles to wasm through the normal, unmodified pipeline. See `useWrap`/`usePair` in [`examples/functions.plum`](examples/functions.plum) and `makeIntBox`/`makeStrBox` in [`examples/types.plum`](examples/types.plum) for real instantiation sites. Generic enums support any number of concrete instantiations coexisting in one program (variant names are mangled per instantiation, e.g. `Some` -> `Some$Int`/`Some$Str`, internally — invisible to user code). One narrower residual limitation: a payload-free variant (e.g. `None`) used as a bare value *outside* of a `match` pattern can't be disambiguated between multiple concrete instantiations of its enum from that expression alone; constructing via a payload-carrying sibling (`Some(5)`) and matching (`Some(v) => ...`, `None => ...`) is fully supported and is the overwhelmingly common usage pattern.

Full example: [`examples/types.plum`](examples/types.plum), [`examples/functions.plum`](examples/functions.plum).

### Closures

```plum
fun each(cb: fn(Int) -> Int) -> Int =
  cb(5)

fun useCapturingClosure() -> Int =
  offset = 100
  cb = |v|
    v + offset
  cb(5)

fun main() -> Int =
  each(|v| v * 3) + useCapturingClosure()
```

A closure literal is `|params| body`: zero or more bare parameter names between pipes, followed by an expression or an indented block body. A parameter's type is inferred from context (e.g. the declared type of the `fn(...)` slot it's passed into) rather than annotated inline.

A `fn(...)` / `fn(...) -> T` type annotates a closure-typed parameter or field: `fn(Int) -> Int` is a function from one `Int` to an `Int`; parameter types are positional only (no names). Closures compile to a `{table_index, env_pointer}` pair and are called via `call_indirect` through a function table, so a `fn(...)`-typed value can hold a closure literal, a plain top-level named function (`each(double)` — compiled as a zero-capture "trampoline" closure), or a closure nested inside another closure's body.

Capture is **snapshot-by-value**: any outer variable a closure body references is copied into the closure's environment at the moment the closure literal is evaluated, not re-read live later. Reassigning the captured variable afterward does not change what the closure sees.

Closures parse and compile in two shapes: passed directly as a call argument (`each(|v| v * 3)`), or assigned to a local first and called or passed on later (`cb = |v|\n  v + offset` followed by `cb(5)` or `each(cb)`). Both support a multi-line indented body.

Full example: [`examples/closures.plum`](examples/closures.plum).

### `self`, field access, and methods

A `fun` declared indented directly inside a `type`/`enum` body is a method on that type, with an implicit `self`:

```plum
type Cat =
  name: Str
  age: Int

  fun getAge(self) -> Int =
    self.age

  fun birthday(self) -> Int =
    self.age + 1

fun main() -> Int =
  c = Cat(name: "Whiskers", age: 3)
  a = c.getAge()        # method call
  w = Wrapper(inner: c, tag: 1)
  w.inner.age            # chained field access
```

Methods are dispatched by declared receiver type, not by name alone — two types can each define a method with the same name without colliding.

Full example: [`examples/methods.plum`](examples/methods.plum).

### Constructing values

```plum
Cat(name: "Whiskers", age: 3)
```

Construct a `type` value by calling its name with `field: value` pairs (any order; every field must be provided).

### `match`

```plum
match n
  0 => "zero"       # inline body
  1 =>
    "one"           # indented block body — both forms are accepted
  _ =>
    "many"

match b
  True =>
    1
  False =>
    0

match opt
  Some(v) =>
    v
  None =>
    0
```

A case body can be a single inline expression right after `=>`, or an indented block — pick whichever reads better for that arm. Patterns can be: integer/float/string literals, a bare identifier (binds a new local to the subject's value), a bare capitalized tag (`True`, `False`, or any declared `enum` variant with no payload — compared, not bound), a constructor pattern (`Some(v)`, binding its argument — sub-patterns can themselves be constructor patterns, e.g. `Wrap(Some(v))`), or `_` (wildcard). `match a, b, ...` against multiple comma-separated subjects is also supported — each case supplies one pattern per subject, and every position must match for that case to apply.

Full example: [`examples/match.plum`](examples/match.plum).

### String interpolation

```plum
fun greet(name: Str) -> Str =
  "Hello, {name}!"
```

`{expr}` inside a string interpolates a single primary expression (a variable, literal, attribute access, etc). Parses, type-checks, and compiles for `Str`, `Int`, and `Bool` values; interpolating a `Float` reports a clear error (see below).

Full example: [`examples/strings.plum`](examples/strings.plum).

### `extern` functions and printing

```plum
extern fun printLn(s: Str)
```

`extern fun` declares a function with no Plum body at all — no `=`, no `todo` — backed instead by a host-provided wasm import. wasm has no built-in notion of stdout, so this is how `printLn` exists: `plum-checker` requires an `extern` fn to have no body and no receiver (it can't be a method), and `plum-wasm-codegen` compiles it to a genuine wasm import (`plum::printLn`) instead of a normal function body. Both `plum run` (JIT-executes immediately under an embedded `wasmtime`) and `plum build` (AOT-compiles to wasm and links it into a standalone executable via the `plum-runtime` crate) supply this import; a bare `.wasm` file run through an unmodified `wasmtime` CLI will fail to instantiate for lack of it. `libs/std/os.plum` declares `printLn` for use via `import std/os`; [`examples/io.plum`](examples/io.plum) declares an equivalent local copy (avoiding the import, so it stays runnable by the single-file test harnesses in `plum-checker`/`plum-wasm-codegen`) and is the smallest example to run for a smoke test:

```
$ plum run examples/io.plum
hello from plum
hello, world!
```

### Known gaps

Some things parse and type-check but don't compile to wasm yet — `plum-wasm-codegen` reports a clear error rather than silently producing wrong code:

- interpolating a `Float` value in a string (`Str`/`Int`/`Bool` interpolation, and plain non-interpolated literals, all compile) — correct decimal formatting of a float is a substantial separate undertaking (something like Grisu/Ryu), scoped out for now
- `libs/std`'s actual `List`/`Map` still don't compile — `List`'s methods (`get`/`length`/`add`/`set`/`removeAt`/`remove`/`clear`/`reverse`/`each`/`map`) are all implemented now, but a class or enum-variant field declared with a concrete instantiation of another generic type (`Node.next: Option[Node]`, needed by `List`/`Node`'s own linked-list shape) doesn't survive monomorphization — `plumTypeFromAst` drops generic type arguments when building field types, so once the referenced generic type is specialized (and its unspecialized original removed), the field is left pointing at a name that no longer exists. This is a `plum-checker` generics gap, not a codegen one — the underlying wasm-gc struct/array machinery it would need is fully implemented and tested (see `plum-wasm-codegen/tests/codegen_tests.rs`'s `listAddSetRemoveAtRemoveClearReverseAllWorkCorrectly` and `removingEveryNodeInALoopLeavesAnEmptyCorrectlyFunctioningList`, which port `List`'s methods onto an equivalent non-generic shape to prove it). Separately, `List`'s own `join` method (and `Map`) reference a `Buffer` type and trait-bounded dispatch (`Stringable`) that don't exist yet — `plum-checker` doesn't process trait declarations at all currently