👾 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
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, plum-checker, and plum-wasm-codegen. Every construct below has a runnable, verified example in examples/ — the two test suites plum-checker/tests/examples_test.rs and 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 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
- Naming conventions
- Modules and imports
- Constants
- Literals
- Operators
- Variables and assignment
- Control flow
- Functions
- Types: records, traits, enums
- Generics
self, field access, and methods- Constructing values
match- String interpolation
externfunctions and printing- Known gaps
Layout and comments
Blocks are indentation-sensitive (2 spaces), like Python — there's no { } for grouping statements. A # starts a line comment.
# 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
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
MAX_RETRIES = 3
PI = 3.14159
GREETING = "hello"
Top-level only, SCREAMING_SNAKE_CASE, one expression.
Literals
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/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:
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.
Variables and assignment
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
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.
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
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.
Types: records, traits, enums
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.
Generics
Generic type parameters are single letters only: a, b, c, d.
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 and makeIntBox/makeStrBox in 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/functions.plum.
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: 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.
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:
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.
Constructing values
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
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.
String interpolation
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.
extern functions and printing
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 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
Floatvalue in a string (Str/Int/Boolinterpolation, 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 actualList/Mapstill 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 byList/Node's own linked-list shape) doesn't survive monomorphization —plumTypeFromAstdrops 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 aplum-checkergenerics gap, not a codegen one — the underlying wasm-gc struct/array machinery it would need is fully implemented and tested (seeplum-wasm-codegen/tests/codegen_tests.rs'slistAddSetRemoveAtRemoveClearReverseAllWorkCorrectlyandremovingEveryNodeInALoopLeavesAnEmptyCorrectlyFunctioningList, which portList's methods onto an equivalent non-generic shape to prove it). Separately,List's ownjoinmethod (andMap) reference aBuffertype and trait-bounded dispatch (Stringable) that don't exist yet —plum-checkerdoesn't process trait declarations at all currently