plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
README.md
| 61ab95d | 1 | # 👾 Plum |
| c8e4165 | 2 | |
| 61ab95d | 3 | A statically typed, imperative programming language with algebraic data types, inspired by Rust and Gleam. |
| fa31ba4 | 4 | |
| 900c685 | 5 | - Built on a [tree-sitter](plum-tooling/tree-sitter-plum) grammar, with syntax highlighting for Helix and VSCode out of the box (`plum editor helix` / `plum editor vscode`) |
| 61ab95d | 6 | - Compiles to WebAssembly today, with plans to target amd64, arm64, and riscv64 via QBE (C-ABI compatible) |
| fa31ba4 | 7 | |
| fa31ba4 | 8 | ## Requirements |
| 61ab95d | 9 | |
| fa31ba4 | 10 | ```sh |
| fa31ba4 | 11 | node >= 23.1.0 |
| fa31ba4 | 12 | npm >= 10.9.0 |
| fa31ba4 | 13 | qbe >= 1.2 |
| fa31ba4 | 14 | clang >= 16.0.0 |
| 222801d | 15 | ``` |
| 222801d | 16 | |
| 2236941 | 17 | ## Installing the `plum` CLI |
| 2236941 | 18 | |
| 2236941 | 19 | ```sh |
| 2236941 | 20 | cargo install --path plum-cli |
| 2236941 | 21 | ``` |
| 2236941 | 22 | |
| 2236941 | 23 | 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. |
| 2236941 | 24 | |
| 61ab95d | 25 | ## Layout and comments |
| 222801d | 26 | |
| 61ab95d | 27 | Blocks are indentation-sensitive (2 spaces), like Python — there's no `{ }` for grouping statements. `#` starts a line comment. |
| 222801d | 28 | |
| 222801d | 29 | ```plum |
| 222801d | 30 | # this is a comment |
| 0fe3528 | 31 | fun main() = |
| 222801d | 32 | x = 1 |
| 222801d | 33 | if x > 0 |
| 222801d | 34 | x = x + 1 |
| 222801d | 35 | ``` |
| 222801d | 36 | |
| 61ab95d | 37 | ## Naming conventions |
| 222801d | 38 | |
| 61ab95d | 39 | | Case | Used for | |
| 61ab95d | 40 | |---|---| |
| 61ab95d | 41 | | `PascalCase` | types, traits, enums | |
| 61ab95d | 42 | | `camelCase` | functions, methods, fields | |
| 61ab95d | 43 | | `snake_case` | variables, params | |
| 61ab95d | 44 | | `SCREAMING_SNAKE_CASE` | top-level constants | |
| 61ab95d | 45 | | single uppercase letter (`T`, `K`, `V`) | generic type parameters | |
| 222801d | 46 | |
| 61ab95d | 47 | ## Modules and imports |
| 222801d | 48 | |
| 222801d | 49 | ```plum |
| 222801d | 50 | module basics |
| 222801d | 51 | |
| 222801d | 52 | import std/io |
| 222801d | 53 | ``` |
| 222801d | 54 | |
| 61ab95d | 55 | ## Constants |
| 222801d | 56 | |
| 222801d | 57 | ```plum |
| 222801d | 58 | MAX_RETRIES = 3 |
| 222801d | 59 | PI = 3.14159 |
| 222801d | 60 | GREETING = "hello" |
| 222801d | 61 | ``` |
| 222801d | 62 | |
| 61ab95d | 63 | ## Literals |
| 222801d | 64 | |
| 222801d | 65 | ```plum |
| 222801d | 66 | dec = 42 |
| 222801d | 67 | hex = 0xFF |
| 222801d | 68 | bin = 0b1010 |
| 5e995b7 | 69 | big = 1_000_000 |
| 222801d | 70 | flt = 3.14 |
| 5e995b7 | 71 | flt2 = 12.0f |
| 222801d | 72 | exp = 6.022e23 |
| 222801d | 73 | name = "plum" |
| 222801d | 74 | escaped = "line one\nline two\ttabbed \"quoted\"" |
| 222801d | 75 | yes = True |
| 222801d | 76 | no = False |
| 222801d | 77 | ``` |
| 222801d | 78 | |
| 61ab95d | 79 | ## Operators |
| 222801d | 80 | |
| 222801d | 81 | From tightest to loosest binding: |
| 222801d | 82 | |
| 222801d | 83 | | Precedence | Operators | Notes | |
| 222801d | 84 | |---|---|---| |
| 43e5250 | 85 | | highest | `.` `?.` | attribute access / method call, safe navigation | |
| 61ab95d | 86 | | | `+` `-` | unary | |
| 222801d | 87 | | | `*` `/` `%` | | |
| 222801d | 88 | | | `+` `-` | | |
| 222801d | 89 | | | `<<` `>>` | | |
| 222801d | 90 | | | `^` | | |
| 222801d | 91 | | | `&` | | |
| 222801d | 92 | | | `\|` | | |
| 222801d | 93 | | | `<` `<=` `==` `!=` `>=` `>` `<>` | comparisons | |
| 222801d | 94 | | | `!` | boolean not | |
| 222801d | 95 | | | `&&` | | |
| 222801d | 96 | | | `\|\|` | | |
| 222801d | 97 | | lowest | `? :` | ternary | |
| 222801d | 98 | |
| 61ab95d | 99 | 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): |
| 222801d | 100 | |
| 222801d | 101 | ```plum |
| 5e995b7 | 102 | grouped = {1 + 2} * {3 - 1} |
| 222801d | 103 | picked = cmp ? sum : bits |
| 222801d | 104 | negated = -sum |
| 222801d | 105 | inverted = !cmp |
| 222801d | 106 | ``` |
| 222801d | 107 | |
| 61ab95d | 108 | ## Variables and assignment |
| 222801d | 109 | |
| 222801d | 110 | ```plum |
| 5e995b7 | 111 | x := 1 |
| 5e995b7 | 112 | # declares a new binding — errors if `x` is already in scope |
| 5e995b7 | 113 | a, b := 1, 2 |
| 5e995b7 | 114 | # multiple targets, positionally paired with multiple values |
| 5e995b7 | 115 | x = 2 |
| 5e995b7 | 116 | # reassigns an existing binding — type-checked against x's declared type |
| 222801d | 117 | ``` |
| 222801d | 118 | |
| 61ab95d | 119 | ## Control flow |
| 222801d | 120 | |
| 222801d | 121 | ```plum |
| 222801d | 122 | if n < 0 |
| 222801d | 123 | return "negative" |
| 222801d | 124 | else if n == 0 |
| 222801d | 125 | return "zero" |
| 222801d | 126 | else |
| 222801d | 127 | return "positive" |
| 222801d | 128 | |
| cc77764 | 129 | i := start |
| 222801d | 130 | while i > 0 |
| 222801d | 131 | i = i - 1 |
| 61ab95d | 132 | |
| cc77764 | 133 | for i := range limit |
| 222801d | 134 | if i == 3 |
| 222801d | 135 | continue |
| 222801d | 136 | if i == 8 |
| 222801d | 137 | break |
| 222801d | 138 | total = total + i |
| 222801d | 139 | |
| 61ab95d | 140 | assert n > 0 # traps at runtime if false |
| 61ab95d | 141 | todo # traps at runtime — marks a body as not yet implemented |
| 61ab95d | 142 | ``` |
| 222801d | 143 | |
| 61ab95d | 144 | ## Functions |
| 222801d | 145 | |
| 222801d | 146 | ```plum |
| 0fe3528 | 147 | fun addInts(a: Int, b: Int) -> Int = |
| 222801d | 148 | a + b |
| 222801d | 149 | |
| 5e995b7 | 150 | fun greet() = # no return type => Unit |
| 222801d | 151 | todo |
| 222801d | 152 | |
| 0fe3528 | 153 | fun withDefault(a: Int, step: Int = 1) -> Int = |
| 222801d | 154 | a + step |
| 222801d | 155 | |
| 5e995b7 | 156 | fun sumAll(nums: ...Int) -> Int = # variadic param |
| 222801d | 157 | todo |
| 222801d | 158 | ``` |
| 222801d | 159 | |
| 61ab95d | 160 | ## Testing |
| a271f34 | 161 | |
| 61ab95d | 162 | Zig-style `test` blocks are a native language feature, compiled and run only by `plum test`: |
| a271f34 | 163 | |
| a271f34 | 164 | ```plum |
| a271f34 | 165 | fun add(a: Int, b: Int) -> Int = |
| a271f34 | 166 | a + b |
| a271f34 | 167 | |
| a271f34 | 168 | test "add works" |
| a9a0147 | 169 | assert add(1, 2) == 3 |
| a271f34 | 170 | ``` |
| a271f34 | 171 | |
| a271f34 | 172 | ```sh |
| 984357b | 173 | $ plum test plum-examples/testing.plum |
| 61ab95d | 174 | └─ add works ✔ |
| a9a0147 | 175 | |
| 61ab95d | 176 | 1 passed, 0 failed |
| a271f34 | 177 | ``` |
| a271f34 | 178 | |
| 61ab95d | 179 | ## Types: records, traits, enums |
| 222801d | 180 | |
| 5f2f962 | 181 | 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: |
| 5f2f962 | 182 | |
| 222801d | 183 | ```plum |
| 5f2f962 | 184 | enum Point = |
| 5f2f962 | 185 | | Point(x: Int, y: Int) |
| 222801d | 186 | |
| 5f2f962 | 187 | enum Named(ToStr) = # implements ToStr |
| 5f2f962 | 188 | | Named(name: Str) |
| 222801d | 189 | |
| 222801d | 190 | trait Shape = |
| 222801d | 191 | area() -> Float |
| 222801d | 192 | perimeter() -> Float |
| 222801d | 193 | |
| 222801d | 194 | enum Color = |
| 222801d | 195 | | Red |
| 222801d | 196 | | Green |
| 222801d | 197 | | Blue |
| 222801d | 198 | |
| 222801d | 199 | enum Option = |
| 287b97c | 200 | | Some(Int) # unnamed positional payload — Some(5) |
| 222801d | 201 | | None |
| 3a2119e | 202 | |
| 3a2119e | 203 | enum Shape = |
| 5e995b7 | 204 | | Circle(radius: Int) # named payload fields — Circle(radius: 5) or Circle(5) |
| 61ab95d | 205 | | Square(x: Int, y: Int) |
| cc77764 | 206 | |
| 61ab95d | 207 | enum Step(n: Int) = # a shared field on every variant ... |
| 61ab95d | 208 | | ReadMin(10) # ... each variant supplies its own value for it |
| cc77764 | 209 | | ReadMax(20) |
| 222801d | 210 | ``` |
| 222801d | 211 | |
| 61ab95d | 212 | ## Generics |
| 222801d | 213 | |
| 222801d | 214 | ```plum |
| 5f2f962 | 215 | enum Box[T] = |
| 5f2f962 | 216 | | Box(value: T) |
| 222801d | 217 | |
| 5e995b7 | 218 | trait Comparable[T: Ord] = # bounded generic param |
| cc77764 | 219 | compareTo(other: T) -> Int |
| 222801d | 220 | |
| 61ab95d | 221 | fun wrap(value: T) -> Bool = |
| 5e995b7 | 222 | True |
| 222801d | 223 | ``` |
| 222801d | 224 | |
| 61ab95d | 225 | 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. |
| 222801d | 226 | |
| 61ab95d | 227 | ## Closures |
| 6341c74 | 228 | |
| 6341c74 | 229 | ```plum |
| 0fe3528 | 230 | fun each(cb: fn(Int) -> Int) -> Int = |
| 6341c74 | 231 | cb(5) |
| 6341c74 | 232 | |
| 0fe3528 | 233 | fun useCapturingClosure() -> Int = |
| 5e995b7 | 234 | offset := 100 |
| 5e995b7 | 235 | cb := |v| |
| 6341c74 | 236 | v + offset |
| 6341c74 | 237 | cb(5) |
| 6341c74 | 238 | |
| 0fe3528 | 239 | fun main() -> Int = |
| 6341c74 | 240 | each(|v| v * 3) + useCapturingClosure() |
| 6341c74 | 241 | ``` |
| 6341c74 | 242 | |
| 61ab95d | 243 | 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. |
| 6341c74 | 244 | |
| 61ab95d | 245 | ## `self`, field access, and methods |
| 6341c74 | 246 | |
| 5f2f962 | 247 | A `fun` declared indented directly inside an `enum` body is a method, with an implicit `self`: |
| 222801d | 248 | |
| 222801d | 249 | ```plum |
| 5f2f962 | 250 | enum Cat = |
| 5f2f962 | 251 | | Cat(name: Str, age: Int) |
| 41e0859 | 252 | fun getAge(self) -> Int = |
| 41e0859 | 253 | self.age |
| 41e0859 | 254 | fun birthday(self) -> Int = |
| 41e0859 | 255 | self.age + 1 |
| 222801d | 256 | |
| 0fe3528 | 257 | fun main() -> Int = |
| 5e995b7 | 258 | c := Cat(name: "Whiskers", age: 3) |
| 5e995b7 | 259 | a := c.getAge() |
| 5e995b7 | 260 | w := Wrapper(inner: c, tag: 1) |
| 61ab95d | 261 | w.inner.age # chained field access |
| 222801d | 262 | ``` |
| 222801d | 263 | |
| 61ab95d | 264 | ## Constructing values |
| 222801d | 265 | |
| 222801d | 266 | ```plum |
| 222801d | 267 | Cat(name: "Whiskers", age: 3) |
| 222801d | 268 | ``` |
| 222801d | 269 | |
| 5f2f962 | 270 | Construct a record-shaped enum value by calling its name with `field: value` pairs (any order; every field required). |
| 5f2f962 | 271 | |
| 5f2f962 | 272 | 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: |
| 5f2f962 | 273 | |
| 5f2f962 | 274 | ```plum |
| 5f2f962 | 275 | older := Cat(..c, age: c.age + 1) |
| 5f2f962 | 276 | ``` |
| 222801d | 277 | |
| 61ab95d | 278 | ## `match` |
| 222801d | 279 | |
| 222801d | 280 | ```plum |
| 222801d | 281 | match n |
| 61ab95d | 282 | 0 => 1 |
| 5e995b7 | 283 | x => |
| 61ab95d | 284 | if x < 0 |
| 5e995b7 | 285 | -1 |
| 5e995b7 | 286 | else |
| 5e995b7 | 287 | 2 |
| 222801d | 288 | |
| 222801d | 289 | match opt |
| 5e995b7 | 290 | Some(v) => v |
| 5e995b7 | 291 | None => 0 |
| cc77764 | 292 | |
| cc77764 | 293 | match book |
| 61ab95d | 294 | FantasyBook(title, _, hasMythicalCreatures) when hasMythicalCreatures => |
| 61ab95d | 295 | "Fantasy book \"{title}\" features mythical creatures" |
| 5e995b7 | 296 | FantasyBook(title, _, _) => "Fantasy book \"{title}\" has no mythical creatures" |
| 222801d | 297 | ``` |
| 222801d | 298 | |
| 61ab95d | 299 | 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. |
| 222801d | 300 | |
| 61ab95d | 301 | ## String interpolation |
| 222801d | 302 | |
| 222801d | 303 | ```plum |
| 0fe3528 | 304 | fun greet(name: Str) -> Str = |
| 222801d | 305 | "Hello, {name}!" |
| 222801d | 306 | ``` |
| 222801d | 307 | |
| 61ab95d | 308 | `{expr}` interpolates a variable, literal, attribute access, `&&`/`||`/comparison, ternary, or a closure-taking method call. |
| 222801d | 309 | |
| 61ab95d | 310 | ## `extern` functions and printing |
| a271f34 | 311 | |
| a271f34 | 312 | ```plum |
| a271f34 | 313 | extern fun printLn(s: Str) |
| a271f34 | 314 | ``` |
| a271f34 | 315 | |
| 984357b | 316 | `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`. |
| a271f34 | 317 | |
| 61ab95d | 318 | ```sh |
| 984357b | 319 | $ plum run plum-examples/io.plum |
| a271f34 | 320 | hello from plum |
| a271f34 | 321 | ``` |
| a271f34 | 322 | |
| af1776b | 323 | ## Error propagation |
| af1776b | 324 | |
| af1776b | 325 | `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 `?`: |
| af1776b | 326 | |
| af1776b | 327 | ```plum |
| af1776b | 328 | fun sumTwo(a: Str, b: Str) -> Result[Int, Str] = |
| af1776b | 329 | x := parsePositive(a)? |
| af1776b | 330 | y := parsePositive(b)? |
| af1776b | 331 | return Ok(x + y) |
| af1776b | 332 | ``` |
| af1776b | 333 | |
| ec5336f | 334 | `left ?: right` (elvis) is the plain-expression counterpart — no early return, just a fallback value if `left` is `Err`/`None`: |
| ec5336f | 335 | |
| ec5336f | 336 | ```plum |
| ec5336f | 337 | fun sumOrZero(a: Str, b: Str) -> Int = |
| ec5336f | 338 | x := parsePositive(a) ?: 0 |
| ec5336f | 339 | y := parsePositive(b) ?: 0 |
| ec5336f | 340 | x + y |
| ec5336f | 341 | ``` |
| ec5336f | 342 | |
| 43e5250 | 343 | `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: |
| 43e5250 | 344 | |
| 43e5250 | 345 | ```plum |
| 43e5250 | 346 | fun cityName(person: Option[Person]) -> Option[Str] = |
| 43e5250 | 347 | person?.city?.name |
| 43e5250 | 348 | ``` |
| 43e5250 | 349 | |
| 43e5250 | 350 | It's sugar for `obj.map(|v| v.field)` — nothing more than that; chain it as many times as needed. |
| 43e5250 | 351 | |
| 61ab95d | 352 | ## Standard library highlights |
| 222801d | 353 | |
| 61ab95d | 354 | - **`Bool`** (`import std/Bool`) — ordinary `enum Bool = | True | False` |
| 61ab95d | 355 | - **`Str`** (`import std/Str`) — a byte array with a codepoint-aware layer for Unicode correctness: `runeLength`, `runeAt`, `codePointAt`, `codePointToStr` |
| 61ab95d | 356 | - **`Byte`** / **`[]Byte`** (`import std/Byte`) — a scalar unsigned 8-bit value and a fixed-length mutable byte slice |
| 61ab95d | 357 | - **`Buffer`** (`import std/Buffer`) — a growable, mutable byte sequence for building up a `Str`, amortized O(n) writes |
| 61ab95d | 358 | - **`Array[T]`** (`import std/Array`) — a generic, growable array (reference types only) |
| 61ab95d | 359 | - **`Map[K: Hashable, V]`** (`import std/Map`) — a real bucketed hash table |
| 61ab95d | 360 | - **`List[T]`** (`import std/List`) — a singly-linked list with `get`/`add`/`set`/`removeAt`/`remove`/`each`/`map`/`reduce`/`sort`/`join`/`chunk`/`partition` |
| 222801d | 361 | |
| 984357b | 362 | See [`plum-examples/`](plum-examples) for a runnable file per feature above, and [`plum-std/`](plum-std) for the standard library source. |