plum

#treesitter#compiler#wasm

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

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


👾 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

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 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