👾 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) — ordinaryenum Bool = | True | FalseStr(import std/Str) — a byte array with a codepoint-aware layer for Unicode correctness:runeLength,runeAt,codePointAt,codePointToStrByte/[]Byte(import std/Byte) — a scalar unsigned 8-bit value and a fixed-length mutable byte sliceBuffer(import std/Buffer) — a growable, mutable byte sequence for building up aStr, amortized O(n) writesArray[T](import std/Array) — a generic, growable array (reference types only)Map[K: Hashable, V](import std/Map) — a real bucketed hash tableList[T](import std/List) — a singly-linked list withget/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.