Plum

Tutorial

From `plum new` to a program with tests, in twenty minutes.

This walks from nothing to a program you would recognise as real. Every snippet is a complete program. Copy it into main.plum and run it.

If you have not installed Plum yet:

curl -fsSL https://raw.githubusercontent.com/bradcypert/plum/main/install.sh | sh

You also need clang on your PATH. Plum shells out to it to assemble and link; that is the whole toolchain.

1. A project

plum new hello
plum run hello

A project is a directory, not a manifest file. There is nothing to configure. plum new writes one main.plum, and that is the project.

2. Functions, and types you mostly do not write

let double (n: Int): Int = n * 2

let main (): Unit = println(double(21).to_string())
42

Parameter and return types are annotations you write on purpose; everything inside is inferred. let total = double(21) needs no type.

A function body is an expression, not a block of statements, which is why there is no return. { ... } is itself an expression whose value is its last line:

let describe (n: Int): String = {
    let label = if n > 0 { "positive" } else { "not positive" };
    label.concat(": ").concat(n.to_string())
}

let main (): Unit = println(describe(7))
positive: 7

3. Structs

struct Point { x: Int, y: Int }

let shifted (p: Point) (by: Int): Point = Point { x: p.x + by, y: p.y + by }

let main (): Unit = {
    let p = Point { x: 1, y: 2 };
    let q = shifted(p, 10);
    println(q.x.to_string().concat(", ").concat(q.y.to_string()))
}
11, 12

Values are immutable. shifted builds a new Point rather than changing one, and p is still {1, 2} afterwards.

To change one field of a big struct, use functional update. ..base fills in the rest:

struct Config { host: String, port: Int, verbose: Bool }

let main (): Unit = {
    let base = Config { host: "localhost", port: 80, verbose: false };
    let dev = Config { port: 8080, ..base };
    println(dev.host.concat(":").concat(dev.port.to_string()))
}
localhost:8080

4. Enums, and matches that cannot forget a case

enum Shape {
    Circle(Int),
    Rect(Int, Int),
}

let area (s: Shape): Int = match s {
    Circle(r) => 3 * r * r,
    Rect(w, h) => w * h,
}

let main (): Unit = println(area(Rect(3, 4)).to_string())
12

Delete the Rect arm and the compiler stops you:

error: match is not exhaustive — missing variant(s): Rect

That is the point of enums here. A new variant added a year later becomes a compile error in every place that has to care, instead of a bug you find in production.

5. There is no null

A value that might be missing is an Option[T], and the only way to read it is to handle both cases.

let main (): Unit = {
    let found = Array.find([1, 2, 3], |n| n > 2);
    let text = match found {
        Some(n) => "found ".concat(n.to_string()),
        None => "nothing",
    };
    println(text)
}
found 3

An operation that can fail is a Result[T, E]:

let main (): Unit = {
    match String.parse_int("41") {
        Ok(n) => println((n + 1).to_string()),
        Err(e) => println("bad number: ".concat(e)),
    }
}
42

When you genuinely do not care, unwrap_or takes a fallback:

let main (): Unit =
    println(String.parse_int("nope").unwrap_or(0).to_string())
0

6. Arrays, and the method rule

let main (): Unit = {
    let xs = [1, 2, 3, 4];
    let doubled = xs.map(|n| n * 2);
    println(doubled.to_string());
    println(Array.fold(doubled, 0, |acc, n| acc + n).to_string())
}
[2, 4, 6, 8]
20

xs.map(f) and Array.map(xs, f) are the same call. A method is a namespaced function whose first parameter is the receiver, so anything named Array.<name> is a method on arrays, String.<name> is a method on strings, and a function you write yourself is no different:

struct Point { x: Int, y: Int }

let Point.magnitude_squared (p: Point): Int = p.x * p.x + p.y * p.y

let main (): Unit = println(Point { x: 3, y: 4 }.magnitude_squared().to_string())
25

7. Getting it wrong

Type errors point at the source and say what they wanted:

let main (): Unit = println(1 + "one")
error: '+': Int != String

Integer overflow is checked, not wrapped. A program that overflows stops rather than quietly continuing with a wrong number. Division by zero stops too.

8. Tests

Any zero-argument function named test_* is a test. plum new writes one for you.

let double (n: Int): Int = n * 2

let main (): Unit = println(double(21).to_string())

let test_double (): Unit = assert_eq(double(21), 42)
let test_double_zero (): Unit = assert_eq(double(0), 0)
plum test hello
running 2 tests
test test_double ... ok
test test_double_zero ... ok

test result: ok. 2 passed; 0 failed

Each test runs in its own process, so one that crashes does not take the rest of the run with it.

9. A binary

plum build hello -o hello/out
./hello/out

run and build compile the same way and differ only in whether the binary is kept. There is no interpreter, so nothing can behave one way under one and differently under the other.

Where to go next

Generated from TUTORIAL.md in the repository.