plum

#treesitter#compiler#wasm

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

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


👾 Plum

A statically typed, imperative programming language with algebraic data types, inspired by Rust and Gleam.

  • Built on a tree-sitter grammar, with syntax highlighting for Helix and VSCode out of the box (plum editor helix / plum editor vscode)
  • Compiles to WebAssembly today, with plans to target amd64, arm64, and riscv64 via QBE (C-ABI compatible)

Requirements

node >= 23.1.0
npm >= 10.9.0
qbe >= 1.2
clang >= 16.0.0

Installing the plum CLI

cargo install --path plum-cli

Installs a release build as plum in ~/.cargo/bin, which cargo puts on your PATH for you — no sudo//usr/local/bin needed. Re-run this after pulling changes to the compiler; it doesn't auto-update. Use cargo run -p plum-cli -- instead while actively developing the compiler itself.

Layout and comments

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

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

Naming conventions

Case Used for
PascalCase types, traits, enums
camelCase functions, methods, fields
snake_case variables, params
SCREAMING_SNAKE_CASE top-level constants
single uppercase letter (T, K, V) generic type parameters

Modules and imports

module basics

import std/io

Constants

MAX_RETRIES = 3
PI = 3.14159
GREETING = "hello"

Literals

dec = 42
hex = 0xFF
bin = 0b1010
big = 1_000_000
flt = 3.14
flt2 = 12.0f
exp = 6.022e23
name = "plum"
escaped = "line one\nline two\ttabbed \"quoted\""
yes = True
no = False

Operators

From tightest to loosest binding:

Precedence Operators Notes
highest . ?. attribute access / method call, safe navigation
+ - unary
* / %
+ -
<< >>
^
&
|
< <= == != >= > <> comparisons
! boolean not
&&
||
lowest ? : ternary

There's no range operator — for i := range n is the only way to iterate a numeric range. { } is used to group a sub-expression (not ( ), which is reserved for calls):

grouped = {1 + 2} * {3 - 1}
picked = cmp ? sum : bits
negated = -sum
inverted = !cmp

Variables and assignment

x := 1
# declares a new binding — errors if `x` is already in scope
a, b := 1, 2
# multiple targets, positionally paired with multiple values
x = 2
# reassigns an existing binding — type-checked against x's declared type

Control flow

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

i := start
while i > 0
  i = i - 1

for i := range 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

Functions

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

Testing

Zig-style test blocks are a native language feature, compiled and run only by plum test:

fun add(a: Int, b: Int) -> Int =
  a + b

test "add works"
  assert add(1, 2) == 3
$ plum test plum-examples/testing.plum
└─ add works ✔

1 passed, 0 failed

Types: records, traits, enums

There's a single declaration form, enum — a record type is just a single-variant enum whose one variant shares the enum's own name:

enum Point =
  | Point(x: Int, y: Int)

enum Named(ToStr) = # implements ToStr
  | Named(name: Str)

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

enum Color =
  | Red
  | Green
  | Blue

enum Option =
  | Some(Int) # unnamed positional payload — Some(5)
  | None

enum Shape =
  | Circle(radius: Int) # named payload fields — Circle(radius: 5) or Circle(5)
  | Square(x: Int, y: Int)

enum Step(n: Int) = # a shared field on every variant ...
  | ReadMin(10) # ... each variant supplies its own value for it
  | ReadMax(20)

Generics

enum Box[T] =
  | Box(value: T)

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

fun wrap(value: T) -> Bool =
  True

Generic types, methods, and free functions are monomorphized: each concrete instantiation used in the program gets its own specialized copy. List[Int] and List(Int) both parse as generic arguments.

Closures

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. Capture is snapshot-by-value: a closure copies the outer variables it references at creation time, not re-read live later. A fn(...) -> T type annotates a closure-typed parameter or field.

self, field access, and methods

A fun declared indented directly inside an enum body is a method, with an implicit self:

enum Cat =
  | 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()
  w := Wrapper(inner: c, tag: 1)
  w.inner.age # chained field access

Constructing values

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

Construct a record-shaped enum value by calling its name with field: value pairs (any order; every field required).

Fields are mutable (c.age = c.age + 1), and a Gleam-style spread updates a copy from an existing value, overriding just the fields you name:

older := Cat(..c, age: c.age + 1)

match

match n
  0 => 1
  x =>
    if x < 0
      -1
    else
      2

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

match book
  FantasyBook(title, _, hasMythicalCreatures) when hasMythicalCreatures =>
    "Fantasy book \"{title}\" features mythical creatures"
  FantasyBook(title, _, _) => "Fantasy book \"{title}\" has no mythical creatures"

Patterns can be literals, a bare identifier (binds), a bare capitalized tag (compared), a constructor pattern (Some(v), binding its argument), or _ (wildcard). match a, b, ... supports multiple subjects, and a case can carry a when <expr> guard.

String interpolation

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

{expr} interpolates a variable, literal, attribute access, &&/||/comparison, ternary, or a closure-taking method call.

extern functions and printing

extern fun printLn(s: Str)

extern fun declares a function backed by a host-provided wasm import, with no Plum body. plum-std/Os.plum declares printLn for use via import std/os.

$ plum run plum-examples/io.plum
hello from plum

Error propagation

expr? unwraps a Result's Ok or an Option's Some, or exits the enclosing function early with the Err/None value as-is otherwise — Rust's ?:

fun sumTwo(a: Str, b: Str) -> Result[Int, Str] =
  x := parsePositive(a)?
  y := parsePositive(b)?
  return Ok(x + y)

left ?: right (elvis) is the plain-expression counterpart — no early return, just a fallback value if left is Err/None:

fun sumOrZero(a: Str, b: Str) -> Int =
  x := parsePositive(a) ?: 0
  y := parsePositive(b) ?: 0
  x + y

obj?.field / obj?.method(args) (safe navigation, Groovy/Kotlin-style) reaches into a Result/Option value without unwrapping it — Some/Ok maps the field or method access over the inner value and re-wraps it, None/Err passes through untouched:

fun cityName(person: Option[Person]) -> Option[Str] =
  person?.city?.name

It's sugar for obj.map(|v| v.field) — nothing more than that; chain it as many times as needed.

Standard library highlights

  • Bool (import std/Bool) — ordinary enum Bool = | True | False
  • Str (import std/Str) — a byte array with a codepoint-aware layer for Unicode correctness: runeLength, runeAt, codePointAt, codePointToStr
  • Byte / []Byte (import std/Byte) — a scalar unsigned 8-bit value and a fixed-length mutable byte slice
  • Buffer (import std/Buffer) — a growable, mutable byte sequence for building up a Str, amortized O(n) writes
  • Array[T] (import std/Array) — a generic, growable array (reference types only)
  • Map[K: Hashable, V] (import std/Map) — a real bucketed hash table
  • List[T] (import std/List) — a singly-linked list with get/add/set/removeAt/remove/each/map/reduce/sort/join/chunk/partition

See plum-examples/ for a runnable file per feature above, and plum-std/ for the standard library source.