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