Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rune

Rune is a dynamically typed interpreted programming language inspired by Lox and built following concepts from the book Crafting Interpreters by Robert Nystrom. The interpreter is implemented in Go: the resolved AST is compiled to register bytecode run by a VM, and the whole thing ships as a single statically linked binary.

struct Point { x, y = 0 }

impl Point {
  fun len(self) { return math.sqrt(self.x * self.x + self.y * self.y); }
}

let points = [Point { x: 3, y: 4 }, Point { x: 1 }];
for i, p in points {
    print("{i}: {p} has length {p.len()}");
}
// 0: Point {x: 3, y: 4} has length 5
// 1: Point {x: 1, y: 0} has length 1

A Taste

Classifying the lines of an access log, with a struct, destructuring, pattern matching and a map:

struct Request { method, path, status, ms }

fun parse(line) {
    let [method, path, status, ms] = line.split(" ");
    return Request { method, path, status: int(status), ms: number(ms) };
}

fun kind(r) {
    return match r {
        Request { status } if status >= 500 => "error",
        Request { ms } if ms > 100 => "slow",
        _ => "ok",
    };
}

let log = """
    GET /users 200 12.5
    POST /orders 201 340
    GET /health 503 1
    GET /users 200 8
    """;

let mut counts = map();
for line in log.split("\n") {
    let k = kind(parse(line));
    counts[k] = counts.get(k, 0) + 1;
}
print(counts);    // map {"ok": 2, "slow": 1, "error": 1}

New here? Start with Getting Started, then read the Language chapters in order.

Features

  • Dynamically typed language with a simple syntax
  • Supports expressions, statements, variables, and functions
  • Variables are immutable by default (let); let mut makes them mutable
  • Standard library in namespaces: array, string, regex, math, json, http, time, fs, io, os
  • Modules with their own top level, export and import a.b as m;, relative import ..lib.x;
  • Recursive descent parser; the resolved AST is compiled to register bytecode run by a VM
  • Parser recovers from syntax errors and reports all of them in one run
  • String escapes: \n, \t, \r, \", \\, \{, \}; raw strings take neither: r"\d{3}"
  • Multi-line strings, dedented, with quotes inside: """...""", raw r"""..."""
  • String interpolation: "x = {x}, sum = {a + b}"
  • 64-bit integers and floats, both of type number: 7 / 2 is 3.5, 7 % 2 is 1, and overflow is an error
  • Number literals with exponents, hex, octal, binary and _: 1e3, 0xff, 0o17, 0b1010, 1_000_000
  • Bitwise operators on integers: &, |, ^, ~, <<, >>
  • Objects, map and set keep their keys in insertion order; a map or set key may be any hashable value
  • bytes: immutable binary data, with literals b"\x00\xff"
  • Streams: files and HTTP bodies read line by line or in chunks, for line in fs.open(path)
  • Object property access with obj.name as sugar for obj["name"]; shorthand { ip, hits } for { ip: ip, hits: hits }, computed keys { [name]: value }
  • Structs with required and default fields, values whose fields only their mut self methods set: struct Point { x, y = 0 }, Point { ...p, x: 1 }
  • Methods in impl blocks, on structs and built-in types: p.len(), Point.origin(), fun move(mut self); a function in a property is called as (o.f)()
  • Destructuring of objects, structs and arrays, in let, parameters and for, with defaults: let { x, y = 0 } = p;, let [head, ...tail] = xs;
  • Nil-safe access and defaults: resp?.data?.items ?? []
  • Conditional expressions: n > 0 ? "pos" : "neg"
  • Spread and rest: [...a, ...b], {...o, x: 1}, Point { ...p, x: 1 }, f(...args), fun log(level, ...parts)
  • Strings compare with <, <=, >, >= and index by character: "héllo"[1] is "é"; a negative index counts from the end: xs[-1]
  • Pattern matching with match, as an expression or a statement, on literals, types, structs, objects and arrays: [cmd, ...args] =>
  • Logical operators &&, || and !, type tests with is: v is Point, catch e is KeyError, p is Point =>
  • Error handling with try/catch and throw, also as an expression: x ?? throw E { ... }
  • return, break and continue as expressions: let v = x ?? return nil;, row?.id ?? continue
  • Two loops only: while and for x in ... over arrays, strings, objects and ranges (a..b)
  • break and continue in both
  • if/else, while and for bodies are blocks, or a bare statement starting with a name or keyword: if v < 0 return;, for x in xs out.push(x);
  • Anonymous functions: fun (x) { return x * 2; }, or arrow functions: x => x * 2
  • Default parameters, evaluated at each call: fun greet(name, greeting = "hi")
  • Modulo % and compound assignment: +=, -=, *=, /=, %=
  • Increment and decrement, prefix and postfix: ++i, i++, --i, i-- (on variables, array elements and properties)
  • Cross-platform, compiles to a single binary

References

Getting Started

Installation

Download the archive for your platform from the releases page. Builds exist for Linux, macOS and Windows, on amd64 and arm64. Each archive holds a single rune binary; put it somewhere on your PATH:

# macOS on Apple silicon; swap the version and platform for yours
curl -LO https://github.com/kryptamine/rune/releases/download/v0.1.0/rune-0.1.0-darwin-arm64.tar.gz
tar -xzf rune-0.1.0-darwin-arm64.tar.gz
sudo mv rune-0.1.0-darwin-arm64/rune /usr/local/bin/
rune version

checksums.txt on the same page lists the SHA-256 sum of every archive.

From source

Building needs Go 1.27+. Clone the repository and run:

make build

This produces the rune binary in the project directory.

Your First Program

Save this as hello.rn:

let name = "world";
print("hello, {name}!");

and run it:

$ rune run hello.rn
hello, world!

Arguments after the file go to the program’s main:

fun main(args) {
    let name = args.len() > 0 ? args[0] : "world";
    print("hello, {name}!");
}
$ rune run greet.rn ann
hello, ann!

make run builds and runs program.rn, a scratch file ignored by git.

Interpreter Commands

  • run — runs the program in the file.
  • evaluate — evaluates a single expression in the file and prints its value.
  • tokenize — prints the tokens of the file.
  • disasm — prints the bytecode the file compiles to.
  • fmt — prints the file formatted; fmt -w <path>... rewrites files and directories in place, fmt --check <path>... lists the unformatted ones and exits 1.
  • version — prints the interpreter’s version.
$ echo '(2 + 3) * 4' > expr.rn
$ rune evaluate expr.rn
20

Errors and Exit Codes

Errors show the offending source line with a caret under the column. A runtime error also lists the calls it unwound through:

fun total(order) {
    return order.price * order.qty;
}

let orders = [{ price: 10, qty: 2 }, 5];
print(orders.map(total));
[line: 2] TypeError: Cannot read property 'price' of number.
    2 |     return order.price * order.qty;
      |                  ^
  at total (line 2)
  at array.map (native)
  at <script> (line 6)

The parser recovers from a syntax error and reports all of them in one run:

let x = (1 + ;
print(x)
[line: 1] Error at ';': Expect expression.
    1 | let x = (1 + ;
      |              ^
[line: 2] Error at end: Expect ';' after expression.

run exits with 65 on a syntax or compile error, 70 on an uncaught runtime error, and otherwise with 0 or the code main returns.

Values

A value is one of nil, a boolean, a number, a string, an array, an object, a function, a map or set, bytes, a struct or a stream. type names it:

print(type(nil));              // nil
print(type(42));               // number
print(type("ann"));            // string
print(type([1, 2]));           // array
print(type({ name: "ann" }));  // object
print(type(x => x * 2));       // function

Type Tests

v is T tests that v is of type T: a struct or enum type, or one of the built-in nil, boolean, number, string, array, object, function, map, set, bytes, stream, struct and enum, which are what type says of a struct type and an enum type themselves. It has the precedence of ==; the same test is name is T in match and catch e is T.

struct User { name }

print(42 is number);                  // true
print("42" is number);                // false
print([1, 2] is array);               // true
print(User { name: "ann" } is User);  // true
print(User { name: "ann" } is object); // false
print(nil is nil);                    // true
print(User is struct);                // true

A variable named like a built-in type hides it, and testing against anything but a type is a TypeError:

let string = "text";
print("a" is string); // TypeError: Right operand of 'is' must be a type, got string.

Truthiness

Falsy values are nil, false, 0 and ""; everything else, including empty arrays and objects, is truthy.

for v in [nil, false, 0, "", [], {}, "0"] {
    print("%q is %s".format(v, v ? "truthy" : "falsy"));
}
// nil is falsy
// false is falsy
// 0 is falsy
// "" is falsy
// [] is truthy
// {} is truthy
// "0" is truthy

So || gives a fallback for an empty string, and an empty array needs .len():

let user = { name: "ann", nickname: "" };
print(user.nickname || user.name);   // ann

let cart = [];
if cart { print("has a cart"); }     // has a cart
if cart.len() == 0 { print("cart is empty"); }  // cart is empty

Equality

== compares numbers (an integer with a float too), strings, bytes, booleans and nil by value, functions and streams by identity, and arrays, objects, maps and sets structurally (element by element, cycles included), so [1, 2] == [1, 2] is true. contains and indexOf use the same equality. Values of different types are never equal: 1 == "1" is false.

print(1 == 1.0);                                    // true
print([1, [2, 3]] == [1, [2, 3]]);                  // true
print({ role: "admin", tags: ["ops"] } == { tags: ["ops"], role: "admin" }); // true
print(1 == "1");                                    // false
print(nil == false);                                // false

let seen = [{ id: 1 }, { id: 2 }];
print(seen.contains({ id: 2 }));                    // true
print(seen.indexOf({ id: 2 }));                     // 1

let validate = order => order.qty > 0;
let check = validate;
print(check == validate);                           // true
print((order => order.qty > 0) == validate);        // false

Printing

print shows strings inside arrays and objects quoted, so ["1", 1] and [1, 1] look different; a string printed on its own is written as is. Objects print their keys in the order they were added.

print("1");                          // 1
print(["1", 1]);                     // ["1", 1]
print([nil, true, 2.5]);             // [nil, true, 2.5]
print({ name: "ann", age: 31 });     // {name: "ann", age: 31}

Numbers

A number is a 64-bit integer or a float; type says "number" for both and math.isInt tells them apart. A literal without a fraction or an exponent is an integer; 0x, 0o and 0b prefix hex, octal and binary ones, and _ may separate digits. A decimal integer literal past math.MAX_INT is a compile error; a prefixed one may take all 64 bits, read as two’s complement, so 0xFFFF_FFFF_FFFF_FFFF is -1.

print(1_000_000);    // 1000000
print(0xff);         // 255
print(0o17);         // 15
print(0b1010);       // 10
print(0x8000_0000_0000_0000 == math.MIN_INT); // true
print(1e3);          // 1000
print(2.5e-3);       // 0.0025
print(math.isInt(42));    // true
print(math.isInt(1e3));   // false

Arithmetic

+, -, * and % on two integers give an integer, and an integer that would overflow is a ValueError; with a float they give a float. / always gives a float: 6 / 2 is 3, a float, and math.idiv(7, 2) is the integer 3. A float with an integer value prints without a fraction, and one outside [1e-6, 1e21) with an exponent: 1e+21, 1e-7.

let price = 19.99;
let qty = 3;
print(qty * 2);                // 6
print(price * qty);            // 59.97
print(10 / 4);                 // 2.5
print(math.isInt(10 / 5));     // false

let items = 17;
let perPage = 5;
print(math.idiv(items, perPage));       // 3
print(items % perPage);                 // 2
print(math.ceil(items / perPage));      // 4

% takes the sign of its left operand, and an integer % 0 is a ValueError. A float divided by zero is inf, -inf or nan.

print(-7 % 3);     // -1
print(7 / 0);      // inf
print(math.MAX_INT);        // 9223372036854775807
print(math.MAX_INT + 1);    // ValueError: Integer overflow.
print(7 % 0);               // ValueError: Integer modulo by zero.

Integers compare exactly with floats, so 1 == 1.0, and 9007199254740993 == 9007199254740992.0 is false.

print(1 == 1.0);                                   // true
print(9007199254740993 == 9007199254740992.0);     // false
print(0.1 + 0.2);                                  // 0.30000000000000004

Bitwise Operators

The bitwise operators &, |, ^, ~, <<, >> and >>> take integers, or floats with an integer value, and give an integer; >> keeps the sign and >>> shifts in zeros, as on 64 unsigned bits. A shift never overflows: bits shifted out are lost, one by 64 or more gives 0 (or -1 for >> of a negative), and a negative one is a ValueError. They bind tighter than comparisons and looser than +, with | loosest, then ^, & and the shifts: 1 + 2 & 3 is 3, and flags & MASK == 0 needs no parentheses. Each has a compound assignment: &=, |=, ^=, <<=, >>=, >>>=.

const READ = 0b001;
const WRITE = 0b010;
const EXEC = 0b100;

let mut perms = READ | WRITE;
print(perms);                     // 3
print(perms & WRITE == WRITE);    // true
perms &= ~WRITE;
perms |= EXEC;
print(perms);                     // 5
print(1 << 10);                   // 1024
print(-16 >> 2);                  // -4
print(-16 >>> 60);                // 15
print(1.5 & 1);     // TypeError: Operands must be integers.

Where Integers Come From

Lengths, indices, int, ranges of integers and JSON numbers without a fraction or an exponent are integers; math.floor, ceil and round give integers when one holds the result.

print(math.isInt("abc".len()));              // true
print(math.isInt(math.floor(3.7)));         // true
print(math.isInt(json.parse("2")));         // true
print(math.isInt(json.parse("2.0")));       // false
print(int(-3.9));                           // -3

Strings

Strings are immutable UTF-8 text. + joins two strings; anything else must be converted first, with string or interpolation. Methods such as split and upper are in String Methods.

let ticket = 42;
print("ticket #" + string(ticket));    // ticket #42
print("ticket #" + ticket);         // TypeError: Operands must be two numbers, two strings or two bytes.

A string indexes by character, a negative index from the end, s.len() counts characters, and <, <=, >, >= compare strings by their characters’ code points:

print("héllo"[1]);       // é
print("héllo"[-1]);      // o
print("héllo".len());    // 5
print("ann" < "bob");    // true
print("Zed" < "ann");    // true

Escapes

\n, \t, \r, \", \\, \{ and \}:

print("say \"hi\"");             // say "hi"
print("C:\\temp");               // C:\temp
print("shipped\npending");
// shipped
// pending

Interpolation

Any expression in {} inside a string literal is evaluated and inserted as print would show it; \{ is a literal brace.

let user = { name: "ann", roles: ["admin", "ops"] };
let total = 59.97;

print("hi {user.name}");                                  // hi ann
print("roles: {user.roles}");                             // roles: ["admin", "ops"]
print("{user.roles.len()} roles, total {total.fixed(1)}"); // 2 roles, total 60.0
print("order is {total > 50 ? "big" : "small"}");         // order is big
print("set \{name} in the template");                     // set {name} in the template

So a { meant literally, as in JSON, needs \{ in a plain string. A raw string is simpler:

json.parse("{\"ok\": true}");                  // Error: Unexpected character: \
print(json.parse("\{\"ok\": true}").ok);        // true
let body = r"""{"id": 7, "tags": ["new"]}""";
print(json.parse(body).id);                     // 7

Raw Strings

A raw string, r"...", is taken as written: no escapes and no interpolation, so it suits regular expressions and Windows paths. It cannot hold "; in a pattern write it \x22, or use r"""...""".

print(r"C:\temp\{x}");                              // C:\temp\{x}
print(regex.test(r"^\d{3}-\d{4}$", "555-1234"));    // true

Multi-line Strings

"""...""" is a string that may hold " and span lines, with escapes and interpolation; r"""...""" is its raw form. When the opening quotes end their line and the closing ones start theirs, those two newlines are dropped and the whitespace before the closing quotes is taken off every line; a line indented less, unless blank, is an error.

let user = "ann";
print("""say "hi" to {user}""");    // say "hi" to ann

let title = "Orders";
let page = """
    <h1 class="title">{title}</h1>
      <p>indented by two</p>
    """;
print(page);
// <h1 class="title">Orders</h1>
//   <p>indented by two</p>

let css = r"""
    a { color: red; }
    """;
print(css);    // a { color: red; }
let text = """
    shipped
  pending
    """;    // Error: Insufficient indentation in multi-line string.

Variables

let declares an immutable variable, let mut a mutable one. Assigning to an immutable variable (=, +=, ++, …) is a compile-time error. An immutable variable must be initialized; let mut x; starts as nil.

let name = "ann";
let mut visits = 0;
visits += 1;
visits++;
print("{name}: {visits}");    // ann: 2

let mut lastSeen;
print(lastSeen);              // nil
let name = "ann";
name = "bob";    // Error at 'name': Cannot assign to immutable variable 'name'.
let limit;       // Error at 'limit': Immutable variable must be initialized.

Immutable Arrays and Objects

Through an immutable variable the array, object or struct it holds cannot be changed either: index and property assignment (a[0] = 1, o.x += 1, a[0]++, also nested like o.xs[0] = 1) and calls of the in-place methods (push, pop, sort, map.set, set.add, delete, clear, …) and of mut self methods are compile errors. Taking an in-place method as a value is a runtime TypeError. Methods that return a new value (append, map, …) work as usual.

let config = { db: { hosts: ["db1"] } };
config.db.hosts[0] = "db2";    // Error at 'config': Cannot mutate immutable variable 'config'.
let scores = [70, 95];
print(scores.append(80));       // [70, 95, 80]
print(scores);                  // [70, 95]
scores.push(80);                // Error at 'scores': Cannot mutate immutable variable 'scores'.
let mut scores = [70, 95];
scores.push(80);
print(scores);                  // [70, 95, 80]

Parameters and Loop Variables

Function parameters, for-in variables, catch variables and functions are immutable too. Parameters and for-in variables are made mutable with mut:

fun addTag(mut tags, tag) {
    tags.push(tag);
}

let mut tags = ["new"];
addTag(tags, "sale");
print(tags);    // ["new", "sale"]

let mut grid = [[0, 0], [0, 0]];
for i, mut row in grid { row[i] = 1; }
print(grid);    // [[1, 0], [0, 1]]
fun retry(attempts) {
    attempts -= 1;    // Error at 'attempts': Cannot assign to immutable variable 'attempts'.
}

The check is static and follows the variable, not the value: an array held by an immutable variable still changes when it is mutated through another, mutable reference (let mut b = a; or a mut parameter).

let scores = [70, 95];
let mut alias = scores;
alias.push(80);
print(scores);    // [70, 95, 80]

Constants

const names a value known before the program runs: a number, string, true, false or nil, written as a literal or computed from other constants with operators, or an array or object of such values. A constant costs nothing to read: each use is the value itself. Constants are declared at the top level only, can be used before their declaration, as a struct field’s default, and exported.

const KB = 1 << 10;
const LIMIT = 4 * KB;
const UNIT = "bytes";
const SIZES = [KB, LIMIT, [0, -KB]];
const UNITS = { KB, MB: KB * KB, limits: { body: 8 * KB } };

print("{LIMIT} {UNIT}");    // 4096 bytes
print(SIZES);               // [1024, 4096, [0, -1024]]
print(UNITS.limits.body);   // 8192
let size = 4;
const LIMIT = size * 2;    // Error at 'LIMIT': Constant 'LIMIT' must be a constant: a literal, a const or operators on them, an enum variant, or an array, object, map() or set() of them.
const LIMIT = 10;
LIMIT = 20;    // Error at 'LIMIT': Cannot assign to constant 'LIMIT'.

A constant array or object is one value, shared by every read. Like one held by a let it cannot be changed through its name, but still changes through a mutable reference to it. Spreads and computed keys are not constant.

In a pattern a constant’s name tests for its value; an array or object constant cannot be one.

Scopes

Redeclaring a variable in the same scope is an error, at the top level too. A new scope may shadow a name with a different mutability. Built-ins are not declarations, so a program may still redefine them.

let total = 10;
{
    let mut total = 0;
    total += 5;
    print(total);    // 5
}
print(total);        // 10

let type = 3;        // shadows the built-in type
print(type);         // 3
let total = 10;
let total = 20;    // Error at 'total': Variable with this name already declared in this scope.

Discarding with _

_ declares nothing: in let, a parameter, a for-in or catch variable or a pattern it takes a value and drops it, so it may appear any number of times. It cannot be read, assigned or exported.

fun second(_, b, _) { return b; }
print(second(1, 2, 3));    // 2

let _ = 1;
let _ = 2;
for _, x in ["a"] { print(x); }    // a
let _ = 5;
print(_);    // Error at '_': Cannot read '_'.

Conditionals

if and else

The condition needs no parentheses; it is tested for truthiness.

let temp = 22;

if temp > 30 {
    print("hot");
} else if temp > 15 {
    print("mild");
} else {
    print("cold");
}
// mild

A body is a block, or a bare statement starting with a name or keyword, which suits early returns:

fun classify(age) {
    if age < 0 return "invalid";
    if age < 18 return "minor";
    return "adult";
}

print(classify(-1));    // invalid
print(classify(30));    // adult

&&, || and !

&& and || stop as soon as the result is known and give one of their operands, not a boolean; ! negates. ! binds tightly, like -, so !a == b is (!a) == b; write !(a == b) or a != b.

let user = nil;
if !user { print("guest"); }       // guest
print(!(1 == 2));                  // true
print(!1 == 2);                    // false

print(nil || "default");           // default
print(0 || 5);                     // 5
print("ann" && "admin");           // admin

Conditional Expressions

c ? a : b is a if c is truthy, else b; only the chosen branch runs. It binds looser than ranges and ??, tighter than assignment, and nests to the right: a ? b : c ? d : e is a ? b : (c ? d : e).

let stock = 3;
let label = stock > 10 ? "in stock" : stock > 0 ? "low" : "sold out";
print(label);    // low

let cart = ["tea", "milk"];
print("{cart.len()} item{cart.len() == 1 ? "" : "s"}");    // 2 items

let parity = n => n % 2 == 0 ? "even" : "odd";
print(parity(7));    // odd

Loops

There are two loops: for x in ... and while. There is no C-style for: count with a range.

for-in

for x in arr { ... } visits array elements, string characters or object keys, in the order they were added. With two variables you also get the index, or the key and the value.

for i, name in ["ann", "bob"] {
    print("{i + 1}. {name}");
}
// 1. ann
// 2. bob

let stock = { pears: 0, apples: 5 };
for fruit, count in stock {
    print("{fruit}: {count}");
}
// pears: 0
// apples: 5

for ch in "hé!" { print(ch); }
// h
// é
// !

It also walks maps and sets, bytes, struct fields and streams.

break and continue

continue skips to the next iteration, break leaves the loop; both work in for and while.

let orders = [{ id: 1, total: 30 }, { id: 2, total: 0 }, { id: 3, total: 120 }];

for order in orders {
    if order.total == 0 continue;
    if order.total > 100 {
        print("big order {order.id}, stopping");
        break;
    }
    print("order {order.id}: {order.total}");
}
// order 1: 30
// big order 3, stopping

A body is a block, or a bare statement starting with a name or keyword: if order.total == 0 continue;, for x in xs out.push(x);.

Ranges

Count with a..b (end excluded) or a..=b (end included), optionally step n; a for-in walks it without building the array. Outside a loop a range is an array.

for i in 1..4 { print(i); }              // 1 2 3
for i in 1..=3 { print(i); }             // 1 2 3
for i in 10..0 step -3 { print(i); }     // 10 7 4 1
for x in 0..1 step 0.25 { print(x); }    // 0 0.25 0.5 0.75

print(1..4);            // [1, 2, 3]
print(0..10 step 5);    // [0, 5]

What a Loop Sees

Array elements are taken when the loop starts, so pushing to the array inside the loop does not extend it. Each iteration has its own variable, so closures capture the current element.

let mut queue = [1, 2];
for x in queue { queue.push(x * 10); }
print(queue);    // [1, 2, 10, 20]

let mut handlers = [];
for name in ["save", "load"] {
    handlers.push(() => "clicked " + name);
}
print(handlers[0]());    // clicked save
print(handlers[1]());    // clicked load

while

Anything else is a while:

let mut n = 27;
let mut steps = 0;
while n != 1 {
    n = n % 2 == 0 ? math.idiv(n, 2) : 3 * n + 1;
    steps++;
}
print(steps);    // 111

Functions

fun name(params) { ... } declares a function. return gives its value; a function that ends without one returns nil.

fun total(items) {
    let mut sum = 0;
    for item in items { sum += item.price * item.qty; }
    return sum;
}

print(total([{ price: 10, qty: 2 }, { price: 5, qty: 1 }])); // 25

A function captures the variables around it, and keeps them after the enclosing call returns:

fun counter() {
    let mut n = 0;
    return () => { n += 1; return n; };
}

let next = counter();
next();
print(next()); // 2

Calling with the wrong number of arguments is an ArgumentError:

fun area(w, h) { return w * h; }
area(3); // ArgumentError: Expected 2 arguments but got 1.

Anonymous Functions

fun without a name is an expression, so functions can be passed around inline.

let prices = [120, 45, 300];
print(prices.map(fun (p) { return p * 2; })); // [240, 90, 600]

Arrow functions are shorthand: an expression body is returned, a block body works like fun’s.

let add = (a, b) => a + b;
let double = x => x * 2;
let log = msg => { print("[log] " + msg); };

print(add(2, 3));  // 5
print(double(21)); // 42
log("started");    // [log] started

A single plain parameter needs no parentheses. Parameters may be mut or patterns:

let users = [{ name: "ann", age: 31 }, { name: "bob", age: 17 }];

print(users.map(({ name }) => name));                    // ["ann", "bob"]
print(users.filter(u => u.age >= 18).map(u => u.name));  // ["ann"]

let bump = (mut n) => { n += 1; return n; };
print(bump(1)); // 2

A body in braces is an object when it looks like one: () => { a: 1 } and x => { x } return an object.

let origin = () => { x: 0, y: 0 };
let wrap = name => { name };

print(origin());     // {x: 0, y: 0}
print(wrap("ann"));  // {name: "ann"}

Default Parameters

A parameter may have a default, name = expression, taken when the call passes no argument for it.

fun connect(host, port = 5432) {
    return "{host}:{port}";
}

print(connect("db.local"));       // db.local:5432
print(connect("db.local", 6379)); // db.local:6379

The default is evaluated at each such call, after the parameters before it, which it may use, so a mutable default is a new value every call. An explicit nil is an argument and gets no default.

fun greet(name, greeting = "hi", punct = greeting == "hi" ? "!" : ".") {
    return "{greeting} {name}{punct}";
}

print(greet("ann"));          // hi ann!
print(greet("ann", "hello")); // hello ann.
print(greet("ann", nil));     // nil ann.

fun tag(item, mut tags = []) { tags.push(item); return tags; }
print(tag("a")); // ["a"]
print(tag("b")); // ["b"]

Every parameter after one with a default needs one too, except a rest parameter; self and a rest parameter cannot have one.

fun f(a = 1, b) {} // Error at 'b': A parameter without a default cannot follow one with a default.

Arrow functions take defaults too:

let inc = (n, by = 1) => n + by;
print(inc(5));     // 6
print(inc(5, 10)); // 15

Spread and Rest

Spread

... spreads an array into an array literal or into a call’s arguments:

let morning = ["standup", "review"];
let evening = ["deploy"];
print([...morning, "lunch", ...evening]); // ["standup", "review", "lunch", "deploy"]

let scores = [72, 95, 88];
print(math.max(...scores)); // 95

Both also take a set, in its order, a map as [key, value] pairs and bytes as integers: [...tags], f(...counts), [...b"hi"] is [104, 105].

An object or a struct spreads into an object literal. Keys are applied in order and later ones win, so put the defaults first to override them, or last to fill in only what is missing:

let defaults = { timeout: 30, retries: 3 };

print({ ...defaults, timeout: 5 }); // {timeout: 5, retries: 3}
print({ timeout: 5, ...defaults }); // {timeout: 30, retries: 3}

struct Point { x, y = 0 }
let p = Point { x: 1, y: 2 };
print({ ...p, z: 3 });              // {x: 1, y: 2, z: 3}

A struct literal takes a struct of its own type, or an object of some of its fields, which makes an updated copy (see Structs):

struct Point { x, y = 0 }
let p = Point { x: 1, y: 2 };
print(Point { ...p, y: 5 }); // Point {x: 1, y: 5}

Spreading anything else is a TypeError:

print([..."abc"]); // TypeError: Cannot spread string into an array.

Rest Parameters

A last parameter ...name (or ...mut name) takes the arguments after the others as an array, possibly empty.

fun log(level, ...parts) {
    print("[{level}] " + parts.join(" "));
}

log("info", "started", "in", 3, "ms"); // [info] started in 3 ms

let count = (...args) => args.len();
print(count(1, 2, 3)); // 3
print(count());        // 0

Rest and spread go together to forward arguments:

fun sum(...nums) { return nums.reduce((a, b) => a + b, 0); }

let xs = [1, 2, 3];
print(sum(...xs, 10)); // 16

Fewer arguments than the parameters before the rest is an ArgumentError:

fun log(level, ...parts) {}
log(); // ArgumentError: Expected at least 1 argument but got 0.

Nil Safety

Optional Chaining

a?.b, a?.[i] and a?.(args) are nil when a is, and the rest of the chain is skipped: a?.b.c is nil for a nil a, without reading .c.

let user = { name: "ann", profile: nil };

print(user.profile?.city);         // nil
print(user.profile?.address.city); // nil

A missing property, field or element read with ?. is nil too, so o?.name never fails on an object or struct:

let users = [{ name: "ann" }];

print(users?.[0]?.name); // ann
print(users?.[5]?.name); // nil
print(users[0]?.email);  // nil

Without ?. the same read is an error:

let user = { name: "ann" };
print(user.email); // KeyError: Undefined property 'email'.

f?.(args) calls f only if it is not nil, which suits optional callbacks:

let onDone = nil;
let notify = msg => "sent " + msg;

print(onDone?.("ok")); // nil
print(notify?.("ok")); // sent ok

A value that has no properties at all is still a TypeError:

print(5?.x); // TypeError: Cannot read property 'x' of number.

A chain with ?. cannot be assigned to:

let mut user = { profile: nil };
user?.profile = {}; // Error at '=': Invalid assignment target.

Default Values

a ?? b is a unless it is nil, then b, which runs only then.

let resp = json.parse(r"""{"data": {"items": [1, 2]}}""");
let empty = json.parse("\{}");

print(resp?.data?.items ?? []);  // [1, 2]
print(empty?.data?.items ?? []); // []

Unlike ||, it keeps false, 0 and "", so a setting that is explicitly off stays off:

let opts = { retries: 0, verbose: false };

print(opts.retries ?? 3);      // 0
print(opts.retries || 3);      // 3
print(opts.verbose ?? true);   // false
print(opts?.timeout ?? 30);    // 30

The right side is evaluated only when needed, and ?? chains:

fun loadDefault() { print("loading"); return 1; }

print(5 ?? loadDefault());    // 5
print(nil ?? nil ?? "last");  // last

?? binds looser than || and tighter than ranges and ? :.

Leaving Early

return, break and continue are also expressions, like throw, so a missing value can end the function or the iteration right where it is read. return takes a value unless ;, ,, : or a closing bracket follows.

fun port(cfg) {
    let p = cfg?.port ?? return 80;
    return int(p);
}

print(port({ port: "8080" }));  // 8080
print(port(nil));               // 80

let mut ids = [];
for row in [{ id: 1 }, {}, { id: 3 }] {
    ids.push(row?.id ?? continue);
}
print(ids);                     // [1, 3]

They fit in ? : and match arms too: n < 10 ? n + 1 : break, 0 => return "zero",.

Destructuring

A pattern takes a value apart into variables. It works in let, in function parameters and in for-in variables.

Objects and Structs

An object pattern binds properties of an object, or fields of a struct, to variables of the same name:

let user = { name: "ann", age: 31 };
let { name, age } = user;

print(name); // ann
print(age);  // 31

key: name binds under another name; a string key needs one. mut x and key: mut name make a variable mutable, let mut { ... } makes them all mutable.

let user = { name: "ann", age: 31, "full name": "Ann Lee" };
let { name: login, "full name": fullName, age: mut years } = user;
years += 1;

print(login);    // ann
print(fullName); // Ann Lee
print(years);    // 32

In a parameter, a pattern takes the fields it needs, from an object or a struct alike:

struct Point { x, y = 0 }

fun length({ x, y }) {
    return x * x + y * y;
}

print(length(Point { x: 3, y: 4 })); // 25
print(length({ x: 1, y: 1 }));       // 2

In for-in the pattern takes the value, not the key:

let pages = { "/home": { hits: 10 }, "/about": { hits: 3 } };

for path, { hits } in pages {
    print("{path}: {hits}");
}
// /home: 10
// /about: 3

...name takes the other properties, or fields, as a new object and must come last:

struct User { name, role = "member", age = 31 }

let { id, ...data } = { id: 7, title: "Hello", draft: true };
print(id);   // 7
print(data); // {title: "Hello", draft: true}

let { name, ...rest } = User { name: "ann" };
print(rest); // {role: "member", age: 31}

A missing property or field is a runtime error, as with obj.name:

let { email } = { name: "ann" }; // KeyError: Undefined property 'email'.

Arrays

An array pattern takes elements by position: _ skips one, ...name takes the rest as a new array and must come last. The array may be longer than the pattern.

let [first, _, third, ...rest] = ["mon", "tue", "wed", "thu", "fri"];

print(first); // mon
print(third); // wed
print(rest);  // ["thu", "fri"]

It shines in parameters, for pairs and tuples:

let env = { HOME: "/home/ann", PORT: "8080" };
print(env.entries().map(([k, v]) => k + "=" + v)); // ["HOME=/home/ann", "PORT=8080"]

fun swap([a, b]) { return [b, a]; }
print(swap([1, 2])); // [2, 1]

let mut [lo, hi] = [1, 9];
lo = 2;
print([lo, hi]); // [2, 9]

A shorter array is an IndexError, anything but an array a TypeError:

let [host, port] = ["localhost"]; // IndexError: Expected at least 2 elements but got 1.
let [a, b] = "ab"; // TypeError: Cannot destructure string as an array.

Defaults

A variable may have a default, name = expression, taken when the property, field or element is missing, not when it is nil. It is evaluated only then, and may use the variables before it.

let opts = { title: "Blog", root: nil };
let { title, theme = "light", root = ".", depth = root == "." ? 0 : 1 } = opts;

print(title); // Blog
print(theme); // light
print(root);  // nil
print(depth); // 1
let [src = "content", out = "site"] = ["posts"];

print(src); // posts
print(out); // site

fun page({ title, root = "." }) { return "{title} at {root}"; }

print(page({ title: "Home" }));                // Home at .
print(page({ title: "Docs", root: "/docs" })); // Docs at /docs

In an array pattern every element after one with a default needs one too; _ and ...rest, in either pattern, take none.

let [a = 1, b] = [1, 2]; // Error at 'b': An element without a default cannot follow one with a default.

Pattern Matching

match tries its arms in order and runs the first whose pattern matches.

fun status(code) {
    return match code {
        200 | 201 | 204 => "ok",
        301 | 302 => "redirect",
        404 => "not found",
        c if c >= 500 => "server error {c}",
        _ => "unknown",
    };
}

print(status(201)); // ok
print(status(404)); // not found
print(status(503)); // server error 503
print(status(418)); // unknown

An arm is pattern => expression, ended by , or ;, pattern => { block }, or a bare if, while, for or try statement. return, break, continue and throw are expressions, so pattern => return x, works. As after an arrow function’s =>, braces that look like an object are one: 1 => { a: 1 } and 1 => { x } give an object. A guard, pattern if condition, adds a test after the pattern matches.

Patterns

  • _ matches anything;
  • a name binds the value (mut name mutably); a struct type’s name is an error, test the type with is;
  • the name of a constant, LIMIT or m.LIMIT, matches its value, as the literal would; as a bare key in { LIMIT }, or after mut, it still binds;
  • name is T and _ is T test the value is a T, a struct type or a built-in one like string or nil, _ is T | U one of them, the same test as v is T and catch e is T;
  • literals: numbers, strings, true, false, nil, compared as by ==;
  • p | q matches if either does;
  • Color.White matches that enum variant;
  • Point { x, y: 0 } tests the type, then matches the listed fields;
  • { key, "other key": p } matches an object, or a struct, that has the keys; other keys are allowed and a missing one is no match;
  • [p, q] matches an array of exactly that many elements, [p, ...rest] one of at least as many, rest taking the others as a new array (..._ ignores them).

Struct patterns test the type and the fields at once:

struct Point { x, y = 0 }

fun describe(v) {
    return match v {
        nil => "nothing",
        Point { x: 0, y: 0 } => "origin",
        Point { x: 0, y } => "on the y axis at {y}",
        Point { x, y } if x == y => "diagonal",
        _ is Point => "a point",
        _ => "other",
    };
}

print(describe(nil));                  // nothing
print(describe(Point { x: 0 }));       // origin
print(describe(Point { x: 0, y: 5 })); // on the y axis at 5
print(describe(Point { x: 2, y: 2 })); // diagonal
print(describe(Point { x: 1, y: 2 })); // a point
print(describe(42));                   // other

Object patterns pick data apart by shape, which makes small interpreters and event handlers short:

fun eval(e) {
    return match e {
        { op: "+", a, b } => eval(a) + eval(b),
        { op: "*", a, b } => eval(a) * eval(b),
        { op: "neg", a } => -eval(a),
        n => n,
    };
}

print(eval({ op: "+", a: 1, b: { op: "*", a: 2, b: 3 } })); // 7
let event = { type: "click", "target id": "save" };

match event {
    { type: "click", "target id": id } => print("clicked " + id); // clicked save
    { type: "key", key } => print("pressed " + key);
    _ => {}
}

Array patterns suit tuples, command lines and entries() pairs:

fun run(args) {
    return match args {
        [] => "usage: tool <command>",
        ["add", name] => "adding " + name,
        ["rm", first, ...more] => "removing {first} and {more.len()} more",
        [cmd, ..._] => "unknown command " + cmd,
    };
}

print(run([]));                   // usage: tool <command>
print(run(["add", "milk"]));      // adding milk
print(run(["rm", "a", "b"]));     // removing a and 1 more
print(run(["ls", "-l"]));         // unknown command ls

for pair in map({ ok: 3, failed: 0 }).entries() {
    print(match pair { [k, 0] => "{k}: none", [k, n] => "{k}: {n}" });
}
// ok: 3
// failed: none

Anything but an array, a string included, is no match.

A bare struct name would bind, so it is an error:

struct Point { x, y = 0 }
match 1 { Point => 1, _ => 2 } // Error at 'Point': 'Point' is a struct type; test it with '_ is Point'.

Alternatives cannot bind:

match 1 { 1 | x => 1, _ => 2 } // Error at 'x': Cannot bind variables in '|' patterns.

Expression or Statement

As an expression its value is the arm’s; a block or statement arm gives nil. No arm matching is a ValueError:

let count = 3;
let label = match count {
    0 => "empty",
    1 => "one item",
    n => "{n} items",
};
print(label); // 3 items

print(match 7 { 1 => "a" }); // ValueError: No match arm for 7.

At the start of a statement it is a statement: values are dropped, and no arm matching is a ValueError all the same, so add _ => {} to ignore the rest. An arm may return from the enclosing function:

fun handle(cmd) {
    match cmd {
        "quit" => return "bye";
        "help" => print("usage: add | list | quit");
        _ => {}
    }
    return "continue";
}

print(handle("help")); // continue
print(handle("quit")); // bye
print(handle("save")); // continue

The first call prints usage: add | list | quit before continue.

Details

The value is evaluated once. It may be a struct literal, match Point { x: 1 } { ... }, except an empty one, which needs parentheses: match (Empty {}) { ... }.

An arm after one that matches everything is an error:

match 1 { _ => 1, 2 => 2 } // Error at '2': Unreachable match arm.

Error Handling

try and catch

Runtime errors, including those from built-ins like http.get(), are caught with try/catch:

fun parsePort(s) {
    try {
        return int(s);
    } catch e {
        print("bad port: " + e.message);
        return 80;
    }
}

print(parsePort("8080")); // 8080
print(parsePort("http")); // 80

The second call first prints bad port: int() cannot parse "http" as an integer in base 10.

The catch variable is optional:

let mut queue = [];
try { queue.pop(); } catch { print("queue is empty"); } // queue is empty

Error Types

An error raised by the language is a struct of one of the built-in error types, each with fields { message, line = nil, cause = nil }:

  • TypeError — a value of the wrong type (1 + nil, calling a number)
  • ArgumentError — the wrong number of arguments
  • ValueError — the right type but a bad value (step 0, [].pop())
  • IndexError — an index out of bounds
  • KeyError — a missing property or struct field
  • NameError — an undefined variable
  • IOError — a file system call or an http request failing; it also has path and code (see fs)
try {
    [].pop();
} catch e {
    print(type(e));   // ValueError
    print(e.message); // array.pop() got an empty array.
    print(e.line);    // 2
}

A stack overflow is fatal and cannot be caught, and os.exit is not an error at all: try does not stop it.

Catching by Type

catch e is T | U catches only the types it names, the same test as v is T. Clauses are tried in order, and an error none of them catches goes on up with its trace intact. A clause without types catches everything and must come last.

let config = { port: 8080 };

fun get(key) {
    try {
        return config[key];
    } catch e is KeyError {
        return nil;
    }
}

print(get("port")); // 8080
print(get("host")); // nil
fun load(raw) {
    try {
        return json.parse(raw).port + 1;
    } catch e is KeyError | IndexError {
        return "missing: " + e.message;
    } catch e is ValueError {
        return "invalid JSON";
    } catch e {
        return type(e);
    }
}

print(load(r"""{"port": 80}"""));   // 81
print(load("\{}"));                 // missing: Undefined property 'port'.
print(load("not json"));            // invalid JSON
print(load(r"""{"port": "80"}""")); // TypeError

throw

throw raises a struct instance, built-in or your own; catch receives it unchanged, with all its fields.

struct AgeError { message, value }

fun parseAge(v) {
    if v < 0 throw AgeError { message: "negative age", value: v };
    return v;
}

try {
    parseAge(-1);
} catch e is AgeError {
    print(e.value); // -1
}

An uncaught throw stops the program like any runtime error, reported as Name: message when the struct has a string message:

struct AgeError { message, value }
throw AgeError { message: "negative age", value: -1 }; // AgeError: negative age

throw is also an expression, whose operand extends as far as it can, so it fits where a value is expected: after ??, in ? :, or in a match arm.

struct ConfigError { message, key }

fun require(cfg, key) {
    return cfg?.[key] ?? throw ConfigError { message: "missing " + key, key };
}

fun positive(n) {
    return n > 0 ? n : throw ValueError { message: "not positive: {n}" };
}

let cfg = { port: 8080 };
print(require(cfg, "port")); // 8080

try { require(cfg, "host"); } catch e is ConfigError { print(e.message); } // missing host
try { positive(-2); } catch e is ValueError { print(e.message); }          // not positive: -2

Wrap a lower-level error in cause to keep its details:

fun readConfig(raw) {
    try {
        return json.parse(raw);
    } catch e {
        throw ValueError { message: "bad config", cause: e };
    }
}

try {
    readConfig("port=80");
} catch e {
    print(e.message);       // bad config
    print(type(e.cause));   // ValueError
}

Throwing anything but a struct is a TypeError:

throw "oops"; // TypeError: Can only throw struct instances.

Arrays

An array is an ordered list of values of any type. xs[i] reads element i, counting from 0, and xs.len() is the number of elements.

let langs = ["go", "rust", "rune"];
print(langs[0]);     // go
print(langs.len());  // 3

let row = [1, 2.5, "three", true, nil, [4]];
print(row);          // [1, 2.5, "three", true, nil, [4]]

let grid = [[1, 2], [3, 4]];
print(grid[1][0]);   // 3

An index must be an integer from -xs.len() to xs.len() - 1; a negative one counts from the end, as in strings, bytes, slice and remove. One out of bounds is an IndexError, for reading and writing alike. To grow an array, push to it.

let langs = ["go", "rust", "rune"];
print(langs[-1]); // rune
print(langs[3]);  // IndexError: Index out of bounds: 3 of 3

Writing an element needs a mutable variable (see Variables):

let mut scores = [70, 85, 90];
scores[0] = 75;
scores[1] += 5;
scores.push(100);
print(scores); // [75, 90, 90, 100]

for x in xs walks the elements, for i, x in xs the indices too. The elements are taken when the loop starts, so pushing inside the loop does not extend it.

let todo = ["write docs", "fix bug"];
for i, task in todo {
    print("{i + 1}. {task}");
}
// 1. write docs
// 2. fix bug

== compares arrays element by element:

print([1, [2, 3]] == [1, [2, 3]]); // true
print([1, 2] == [2, 1]);           // false

Arrays are references: assigning an array or passing it to a function does not copy it, so push, pop and index assignment are visible through every reference. [...xs] or xs.slice(0) makes a copy.

let a = [1, 2];
let mut b = a;
b.push(3);
print(a); // [1, 2, 3]

let mut c = [...a];
c.push(4);
print(a); // [1, 2, 3]
print(c); // [1, 2, 3, 4]

slice, map, filter, sort, join and the rest are array methods.

let prices = [12, 5, 30, 8];
print(prices.slice(1, 3));               // [5, 30]
print(prices.filter(p => p > 10));       // [12, 30]
print(prices.reduce((a, b) => a + b));   // 55

Map and Set

A map holds values by key, a set unique values. Unlike an object, whose keys are strings, their keys may be any hashable value; like an object, they keep keys in the order they were first added.

let mut stock = map();
stock["apple"] = 3;
stock["pear"] = 0;
print(stock["apple"]);   // 3
print(stock);            // map {"apple": 3, "pear": 0}

let mut visitors = set();
visitors.add("ann");
visitors.add("bob");
visitors.add("ann");
print(visitors);         // set {"ann", "bob"}
print(visitors.len());   // 2

Making one

map() makes an empty map and map(src) one from an object, a struct, another map or an array of [key, value] pairs; set() and set(array) likewise.

print(map({ host: "localhost", port: 8080 }));  // map {"host": "localhost", "port": 8080}
print(map([[200, "OK"], [404, "Not Found"]]));  // map {200: "OK", 404: "Not Found"}
print(set(["a", "b", "a"]));                    // set {"a", "b"}

Keys

A key is any hashable value: nil, a boolean, a number (1 and 1.0 are one key; NaN is none), a string, bytes, a function, a struct type, or a struct whose fields are hashable, compared by value. A struct makes a natural composite key:

struct Pos { x, y }

let mut grid = map();
grid[Pos { x: 0, y: 1 }] = "wall";
print(grid[Pos { x: 0, y: 1 }]);                // wall
print(grid.get(Pos { x: 9, y: 9 }, "floor"));   // floor

An array, object, map or set is not hashable, as it can change:

let mut seen = map();
seen[[1, 2]] = true; // TypeError: Cannot use an array as a key.

Reading and writing

m[k] reads a key and a missing one is a KeyError; m?.[k] gives nil instead. m[k] = v sets one, like an array element, so m must be mutable.

let mut prices = map({ apple: 1.5 });
print(prices?.["kiwi"]); // nil
print(prices["kiwi"]);   // KeyError: Key not found: "kiwi".

Map methods: get(k[, default]), set(k, v), has(k), delete(k) (whether it was there), clear(), and keys(), values(), entries(), new arrays. Like an array’s in-place methods, set and clear return nil.

let mut stock = map();
stock.set("apple", 3);
stock.set("pear", 0);
stock.set("plum", 7);

print(stock.get("kiwi"));     // nil
print(stock.get("kiwi", 0));  // 0
print(stock.has("pear"));     // true
print(stock.delete("pear"));  // true
print(stock.delete("pear"));  // false
print(stock.keys());          // ["apple", "plum"]
print(stock.values());        // [3, 7]
print(stock.entries());       // [["apple", 3], ["plum", 7]]

A set has add(x), contains(x), delete, clear, values(), and union(s), intersection(s), difference(s), new sets. As everywhere, has tests a key (of an object or a map) and contains a value (of an array, a string or a set).

let backend = set(["ann", "bob", "eve"]);
let frontend = set(["bob", "kim"]);

print(backend.contains("eve"));          // true
print(backend.union(frontend));          // set {"ann", "bob", "eve", "kim"}
print(backend.intersection(frontend));   // set {"bob"}
print(backend.difference(frontend));     // set {"ann", "eve"}

Iterating

m.len() counts the entries; for k, v in m walks a map’s keys and values, for k in m its keys, and for x in s or for i, x in s a set, always in insertion order.

let stock = map({ apple: 3, plum: 7 });
for name, n in stock {
    print("{name}: {n}");
}
// apple: 3
// plum: 7

A loop may set existing keys and delete them, but adding one is a ValueError; walk m.keys() to change it freely.

let mut counts = map({ a: 1 });
for k in counts {
    counts[k + "!"] = 0; // ValueError: Cannot add to a map while iterating over it.
}
let mut counts = map({ a: 1, b: 2 });
for k in counts.keys() {
    counts[k + "!"] = 0;
}
print(counts); // map {"a": 1, "b": 2, "a!": 0, "b!": 0}

Equality and conversion

== compares entries in any order. [...s] spreads a set into an array; json.stringify writes a set as an array and a map of string keys as an object, in order. Both are references, like arrays.

print(map({ a: 1, b: 2 }) == map({ b: 2, a: 1 })); // true

let ids = set([3, 1, 2]);
print([...ids]);                      // [3, 1, 2]
print(json.stringify(ids));           // [3,1,2]
print(json.stringify(map({ a: 1 }))); // {"a":1}

Bytes

bytes are immutable binary data: a b"..." literal, which takes \xNN escapes and no interpolation, or bytes(v) of a string (its UTF-8), an array of integers from 0 to 255, or bytes. b[i] is the integer byte, b.len() the size; + joins and < compares bytes, == by content, and for i, x in b walks them. Bytes are hashable, and print as a literal.

let header = b"\x89PNG";
print(header);           // b"\x89PNG"
print(header[0]);        // 137
print(header.len());     // 4
print(header.hex());     // 89504e47

print(bytes("é"));                // b"\xc3\xa9"
print(bytes([104, 105]));         // b"hi"
print(b"hi" + b"!");              // b"hi!"
print(b"hi" == bytes("hi"));      // true

for i, x in b"hi" {
    print("{i}: {x}");
}
// 0: 104
// 1: 105

A byte cannot be changed; build new bytes instead:

let mut buf = b"ab";
buf[0] = 120; // TypeError: Bytes are immutable.

The bytes namespace

Besides bytes(v) itself, alloc(n[, fill]) (n bytes of fill, default 0), fromHex(s) and fromBase64(s):

print(bytes.alloc(3));             // b"\x00\x00\x00"
print(bytes.alloc(2, 255));        // b"\xff\xff"
print(bytes.fromHex("cafe"));      // b"\xca\xfe"
print(bytes.fromBase64("aGk="));   // b"hi"

Methods

text() (the bytes as UTF-8 text, as a stream’s readText() reads it; anything else is a ValueError), toArray(), slice(start[, end]) (sharing the data), indexOf(sub) (-1 if missing), concat(b, ...), hex() and base64().

let data = b"hello";
print(data.text());                  // hello
print(data.toArray());               // [104, 101, 108, 108, 111]
print(data.slice(1, 3));             // b"el"
print(data.indexOf(b"ll"));          // 2
print(data.concat(b", ", b"world")); // b"hello, world"
print(data.base64());                // aGVsbG8=
print(b"\xff".text()); // ValueError: bytes.text() got bytes that are not valid UTF-8.

Structs

A struct is a named record with a fixed set of fields. Fields without a default are required. Structs are declared at the top level only and can be used before their declaration.

struct User { name, role = "member", tags = [] }

let ann = User { name: "ann" };
print(ann);          // User {name: "ann", role: "member", tags: []}
print(ann.name);     // ann
print(ann["role"]);  // member

let name = "bob";
print(User { name, role: "admin" }); // User {name: "bob", role: "admin", tags: []}

User { name } is shorthand for User { name: name }.

A default is a constant: a literal or operators on literals (60 * 60), a const, a variant of an enum of the file, an array or object of them, map() or set(). Each struct made gets its own copy of an array, object, map or set, so tags = [] is never shared:

struct User { name, tags = [] }

let mut ann = User { name: "ann" };
let bob = User { name: "bob" };
ann.tags.push("admin");
print(ann.tags); // ["admin"]
print(bob.tags); // []

A field that does not exist, or a required one left out, is a compile error in a literal:

struct User { name, role = "member" }
User { name: "ann", email: "ann@example.com" }; // Error at 'email': User has no field 'email'.
struct User { name, role = "member" }
User { role: "admin" }; // Error at 'User': Missing field 'name' of User.

Reading a field that does not exist is a runtime error:

struct User { name, role = "member" }
let ann = User { name: "ann" };
print(ann.email); // KeyError: User has no field 'email'.

Setting fields

Fields are read-only outside the struct’s own mutating methods: only self.x = ... (or self["x"], +=, ++, …) in a mut self method sets one. Anywhere else setting a field of a struct, through a variable, an element or another struct’s field, is a runtime error. To change a struct from outside, call one of its mut self methods or make an updated copy with spread.

struct User { name, role = "member" }
impl User {
  fun promote(mut self, role) { self.role = role; }
}

let mut ann = User { name: "ann" };
ann.promote("admin");
print(ann); // User {name: "ann", role: "admin"}

let mut team = [User { name: "bob" }];
team[0].promote("lead");
print(team[0].role); // lead
struct User { name, role = "member" }
let mut ann = User { name: "ann" };
ann.role = "admin"; // TypeError: Cannot set field 'role' of User outside its mut self methods.

A struct held in another struct’s field is changed by the outer struct’s methods, calling the inner one’s: self.pos.move(1) in a method of the outer struct; self.pos.x = 1 there, or acc.pos.move(1) outside it, is an error.

The rule is shallow: an array, object, map or set in a field is not part of the struct, so ann.tags.push("x") changes it in place.

Updating with spread

A literal may spread a struct of the same type, or an object of some of its fields, as T { ...t, x: 1 } does an update without changing t. Items apply in order, later ones winning, and a field none of them gives takes its default; one with no default, a key that is not a field, or a struct of another type is a runtime error.

struct Config { host, port = 8080, debug = false }

let base = Config { host: "localhost" };
print(Config { ...base, debug: true });  // Config {host: "localhost", port: 8080, debug: true}
print(Config { debug: true, ...base });  // Config {host: "localhost", port: 8080, debug: false}
print(Config { ...{ host: "example.com" } }); // Config {host: "example.com", port: 8080, debug: false}
print(base);                             // Config {host: "localhost", port: 8080, debug: false}

Converting

Calling a struct type converts to it. From an object the keys must be exactly the fields, the defaulted ones optional; a struct of the same type is returned as is, and any other value is an error. There are no implicit conversions, and none between struct types. This is how data from json.parse becomes a struct:

struct Config { host, port = 8080 }

let cfg = Config(json.parse(r"""{"host": "example.com"}"""));
print(cfg);      // Config {host: "example.com", port: 8080}
print(cfg.port); // 8080
struct Config { host, port = 8080 }
Config({ host: "example.com", timeout: 5 }); // KeyError: Config has no field 'timeout'.
struct Config { host, port = 8080 }
Config({ port: 80 }); // KeyError: Missing field 'host' of Config.

Type checks, equality, iteration

v is User is true only for a User, never for an object; see Type Tests.

== compares structs field by field and only if they are of the same struct, so a struct never equals an object. for k, v in s walks the fields in declaration order. object(s) copies a struct into a new object, object(s).len() its number of fields and type(s) its name.

struct User { name, role = "member" }
let ann = User { name: "ann" };

print(ann is User);                            // true
print({ name: "ann" } is User);                // false
print(ann == User { name: "ann" });            // true
print(ann == { name: "ann", role: "member" }); // false

for field, value in ann {
    print("{field}: {value}");
}
// name: ann
// role: member

print(object(ann)); // {name: "ann", role: "member"}
print(object(ann).len()); // 2
print(type(ann));   // User
print(type(User));  // struct

Methods

An impl block adds methods to a struct declared in the same file. A method taking self first is called on an instance, self being the instance; one without is static and called on the type.

struct Money { cents, currency = "USD" }

impl Money {
  fun of(amount, currency = "USD") {
    return Money { cents: math.round(amount * 100), currency };
  }
  fun plus(self, other) {
    return Money { ...self, cents: self.cents + other.cents };
  }
  fun format(self) {
    return "%.2f %s".format(self.cents / 100, self.currency);
  }
}

let price = Money.of(12.5);
let total = price.plus(Money.of(0.99));
print(total.format()); // 13.49 USD
print(price.format()); // 12.50 USD

Impl blocks are top-level only, may come before the struct and there may be several; a method cannot share a name with a field or another method of the type. A static method is not reachable from an instance:

struct Money { cents }
impl Money {
  fun zero() { return Money { cents: 0 }; }
}
Money.zero().zero(); // TypeError: 'zero' is a static method of Money; call it as Money.zero().

Mutating methods

mut self makes a mutating method, which can set the fields of self (self.x += 1, self["x"] = 1), or self as a whole, as a mutable variable can. Without mut, self is immutable. These methods are the only place a struct’s fields are set; see Setting fields.

struct Account { owner, balance = 0 }

impl Account {
  fun deposit(mut self, amount) {
    self.balance += amount;
  }
  fun withdraw(mut self, amount) {
    self.balance -= amount;
    if self.balance < 0 throw ValueError { message: "insufficient funds" };
  }
}

let mut acc = Account { owner: "ann" };
acc.deposit(100);
acc.withdraw(30);
print(acc.balance); // 70

Structs are values, so the method works on a copy, which replaces the receiver when it returns; nothing else holding the old struct sees its fields change, and if the method throws, the receiver keeps its old value.

struct Account { owner, balance = 0 }
impl Account {
  fun deposit(mut self, amount) { self.balance += amount; }
  fun withdraw(mut self, amount) {
    self.balance -= amount;
    if self.balance < 0 throw ValueError { message: "insufficient funds" };
  }
}

let mut acc = Account { owner: "ann", balance: 50 };
let before = acc;
acc.deposit(10);
print(acc.balance);    // 60
print(before.balance); // 50

try {
    acc.withdraw(1000);
} catch e {
    print(e.message);  // insufficient funds
}
print(acc.balance);    // 60

The copy replaces the receiver only if nothing changed the receiver while the method ran, such as a closure over self the method calls; that change would be lost, so it is a ValueError instead:

struct Parser { i = 0 }
impl Parser {
  fun next(mut self) { self.i++; }
  fun twice(mut self, f) { f(); self.next(); }
  fun run(mut self) { self.twice(() => self.next()); }
}

let mut p = Parser {};
p.run(); // ValueError: 'self' changed while its mut method 'twice' ran; that change would be lost.

Call the closure outside the mutating call, or have the method take what it needs as arguments.

The copy is shallow: an array, object, map or set in a field is shared with the old struct, so changing it in place (self.items.push(x)) shows through both. Assign a new one (self.items = self.items.append(x)) to keep the old struct as it was.

struct Cart { items = [] }
impl Cart {
  fun add(mut self, x) { self.items.push(x); }
  fun with(mut self, x) { self.items = self.items.append(x); }
}

let mut cart = Cart {};
let before = cart;
cart.add("tea");
print(before.items); // ["tea"]

let snapshot = cart;
cart.with("milk");
print(snapshot.items); // ["tea"]
print(cart.items);     // ["tea", "milk"]

The receiver must be a mutable variable, or an element, property or field of a value one holds; through an immutable variable, or on a struct literal or conversion, the call is a compile error. A struct in a field of another struct is such a receiver only in the outer struct’s own mut self methods, as self.pos.move(1); see Setting fields. On another value it is a runtime error, or a compile error when no method of that name but a mutating one exists.

struct Account { owner, balance = 0 }
impl Account {
  fun deposit(mut self, amount) { self.balance += amount; }
}

let mut accounts = [Account { owner: "ann" }, Account { owner: "bob" }];
accounts[1].deposit(25);
print(accounts[1]); // Account {owner: "bob", balance: 25}
struct Account { owner, balance = 0 }
impl Account {
  fun deposit(mut self, amount) { self.balance += amount; }
}

let acc = Account { owner: "ann" };
acc.deposit(10); // Error at 'acc': Cannot mutate immutable variable 'acc'.
struct Account { owner, balance = 0 }
impl Account {
  fun deposit(mut self, amount) { self.balance += amount; }
}

Account { owner: "ann" }.deposit(10); // Error at 'deposit': Cannot call mutating method 'deposit' of Account: the receiver is not a mutable variable, element or property.

Methods as values

acc.format without a call is the method bound to acc, a function taking the rest of the arguments; Money.format is the plain function, taking the instance first, so prices.map(Money.format) works. A mutating method cannot be taken as a value.

struct Money { cents }
impl Money {
  fun format(self) { return "$%.2f".format(self.cents / 100); }
}

let prices = [Money { cents: 499 }, Money { cents: 1250 }];
print(prices.map(Money.format)); // ["$4.99", "$12.50"]

let show = prices[0].format;
print(show()); // $4.99

Functions in fields and properties

x.f(args) always calls a method. A function held in a field or property is called as (x.f)(args), or x["f"](args):

struct Button { label, onClick }
let save = Button { label: "save", onClick: () => "saved" };
print((save.onClick)()); // saved

let handlers = { greet: name => "hi " + name };
print((handlers.greet)("ann"));  // hi ann
print(handlers["greet"]("bob")); // hi bob
let handlers = { greet: name => "hi " + name };
handlers.greet("ann"); // TypeError: object has no method 'greet'; call the function in its property as (x.greet)().

Members of a namespace, as in math.floor(x), are no methods and are called as they are.

Methods on built-in types

impl also takes the built-in types string, number, boolean, array, object, function, map, set, bytes and stream. Their methods must take self, not mut self, and are seen by the whole program; a method of the program wins over a built-in one. Built-in structs, such as IOError, take no methods.

impl string {
  fun shout(self) { return self.upper() + "!"; }
}

impl array {
  fun sum(self) { return self.reduce((a, b) => a + b, 0); }
}

print("hi".shout());      // HI!
print([3, 4, 5].sum());   // 12

Enums

An enum is a type with a fixed set of named values, its variants. A variant is not a number or a string: it equals only itself, prints as Type.Variant and is tested with is. Enums are declared at the top level only and can be used before their declaration.

enum Color { White, Black }

let c = Color.White;
print(c);                // Color.White
print(c is Color);       // true
print(c == Color.Black); // false
print(type(c));          // Color
print(type(Color));      // enum
print(Color is enum);    // true

A name that is not a variant is a compile error:

enum Color { White, Black }
print(Color.Red); // Error at 'Red': Color has no variant 'Red'.

Ordinals and conversion

Variants are numbered from 0 in declaration order. int(v) is a variant’s number and v.name its name; calling the enum converts back, from the number or from the name. Color.values() lists the variants in order.

enum Dir { North, East, South, West }

print(int(Dir.South));         // 2
print(Dir.South.name);         // South
print(Dir(3));                 // Dir.West
print(Dir("East"));            // Dir.East
print(Dir.values());           // [Dir.North, Dir.East, Dir.South, Dir.West]
print(Dir((int(Dir.West) + 1) % 4)); // Dir.North
enum Dir { North, East, South, West }
Dir(4); // ValueError: Dir has no variant 4.

json.stringify writes a variant as its name, so Dir(name) reads it back. Variants are hashable, as map keys and set elements.

enum Dir { North, East }

let saved = json.stringify({ facing: Dir.East });
print(saved);                             // {"facing":"East"}
print(Dir(json.parse(saved).facing));     // Dir.East

Matching

A variant is a pattern:

enum Piece { Pawn, Knight, Bishop, Rook, Queen, King }

fun worth(p) {
    return match p {
        Piece.Pawn => 1,
        Piece.Knight | Piece.Bishop => 3,
        Piece.Rook => 5,
        Piece.Queen => 9,
        Piece.King => 0,
    };
}

print(worth(Piece.Bishop)); // 3

Methods

impl adds methods, as for a struct; self is the variant. A variant has no fields to change, so methods cannot take mut self.

enum Side { White, Black }

impl Side {
    fun other(self) {
        return self == Side.White ? Side.Black : Side.White;
    }
    fun parse(s) {
        return s == "w" ? Side.White : Side.Black;
    }
}

print(Side.White.other());              // Side.Black
print(Side.parse("b"));                 // Side.Black
print(Side.values().map(Side.other));   // [Side.Black, Side.White]

An enum is no struct, so Color { } is an error. A variant cannot be named values or name, nor can a method taking self.

Modules

A program can be split into files. Each file has its own top level: names it declares are private unless marked export (fun, let, const, struct, enum), and another file reaches them only through the module, as m.name.

A small shop, in three files:

shop/
├── main.rn
├── data/prices.txt
└── lib/
    ├── cart.rn
    └── money.rn
// lib/money.rn
export struct Money { cents, currency = "USD" }
export struct CurrencyError { message }

export fun format(m) {
    return "{m.cents / 100} {m.currency}";
}

export fun add(a, b) {
    check(a, b);
    return Money { cents: a.cents + b.cents, currency: a.currency };
}

fun check(a, b) {
    if a.currency != b.currency throw CurrencyError { message: "{a.currency} vs {b.currency}" };
}
// lib/cart.rn
import .money;

export let mut items = [];

export fun add(name, cents) {
    items.push({ name, price: money.Money { cents } });
}

export fun total() {
    return items.reduce((sum, it) => money.add(sum, it.price), money.Money { cents: 0 });
}
// main.rn
import lib.cart;
import lib.money as m;

cart.add("book", 1250);
cart.add("pen", 199);

print(cart.items.len());       // 2
print(m.format(cart.total())); // 14.49 USD

let eur = m.Money { cents: 500, currency: "EUR" };
print(eur is m.Money);         // true

try {
    m.add(cart.total(), eur);
} catch e is m.CurrencyError {
    print("cannot add: " + e.message); // cannot add: USD vs EUR
}

rune run shop/main.rn runs it from any directory. Two modules may declare the same name: cart.add and money.add do not clash.

Import Paths

import a.b; loads a/b.rn, named from the directory of the file passed to rune, and binds it as b; import a.b as m; binds it as m.

Leading dots make it relative to the importing file: import .money; is money.rn next to it, import ..c.d; is c/d.rn in its parent directory, one more dot per level up. The program’s directory cannot be left:

import ...shared.util; // Error at '...': Module '...shared.util' is outside the program's directory.

Imports come first in a file:

print("start");
import lib.money; // Error at 'import': Imports must come before other statements.

module.dir and module.file

module.dir is the directory of the file it is in, and module.file its path, both absolute. A program uses them to find the files it comes with wherever it is run from:

// main.rn
let prices = fs.readText(fs.join(module.dir, "data", "prices.txt"));
print(prices.trim()); // book 12.50

module is a namespace of each file’s own: in lib/cart.rn, module.dir is the lib directory.

What a Module Allows

Members are bound when the program is compiled, so a mistake is caught before anything runs. A member the module does not export is an error:

import lib.money;
money.check(1, 2); // Error at 'check': Module 'money' has no export 'check'.

A module is not a value:

import lib.money;
print(money); // Error at 'money': Module 'money' is not a value; use its members, as in money.name.

A module’s variables cannot be assigned from outside:

import lib.cart;
cart.items = []; // Error at 'items': Cannot assign to a member of module 'cart'.

The array or object a let mut export holds may be changed, as cart.items.push(...) would; one held by an immutable let may not:

// lib/consts.rn
export let tags = ["sale"];
import lib.consts;
consts.tags.push("new"); // Error at 'tags': Cannot mutate immutable variable 'consts.tags'.

Importing a module shadows a built-in namespace of the same name in that file, so import lib.math; hides math.floor. Import it as another name to keep both:

// lib/math.rn
export fun floor(x) { return "mine"; }
import lib.math as mymath;

print(mymath.floor(1.5)); // mine
print(math.floor(1.5));   // 1

Running Order

Each file runs once, after the files it imports. An import cycle is an error:

// lib/a.rn
import .b;
export let x = 1;
// lib/b.rn
import .a;
export let y = 1;
import lib.a; // Error at '.': Import cycle: lib/a.rn -> lib/b.rn -> lib/a.rn.

An imported file may only declare things at its top level (fun, let, struct, impl); statements such as print(...) belong in the entry file:

// lib/noisy.rn
print("loaded"); // Error at 'print': Only declarations can be at the top level of a module.

main

If the entry file declares fun main, it runs after the top level. If it takes a parameter, that is the program’s arguments, an array of strings. It returns nil to exit with 0, or an exit code from 0 to 255.

// greet.rn
fun main(args) {
    if args.len() == 0 {
        io.eprint("usage: greet <name>...");
        return 2;
    }

    for name in args {
        print("hi " + name);
    }
}
$ rune run greet.rn ann bob
hi ann
hi bob
$ rune run greet.rn
usage: greet <name>...
$ echo $?
2

Arguments are always strings; convert them yourself:

// repeat.rn
fun main([word, times = "1"]) {
    print(word.repeat(int(times)));
}
$ rune run repeat.rn ab 3
ababab

The top level runs first, so main sees everything declared there. Without main the top level is the whole program.

let greeting = "hello";

fun main() {
    print(greeting); // hello
}

main takes at most one parameter. Returning an integer out of range is a ValueError, and anything else but nil or an integer a TypeError:

fun main() { return 256; } // ValueError: main must return nil or an exit code from 0 to 255, got 256.
fun main() { return "ok"; } // TypeError: main must return nil or an exit code from 0 to 255, got "ok".

To exit from deeper in the program, use os.exit(code).

Examples

Complete programs live in examples/. Each one runs as is from the repository root.

Real-world programs

blog/ — static blog generator

Markdown posts with front matter to an HTML site. Split into modules (site/, md/), finds its content with module.dir, renders pages with """ templates, parses Markdown with regex.

rune run examples/blog/main.rn                    # build the bundled posts into a temp dir
rune run examples/blog/main.rn examples/blog/content site --drafts

todo.rn — task tracker

Tasks with tags, priorities and due dates, kept in a JSON file. Shows fun main with arguments, structs with impl, a Priority enum read back from JSON by name, match on commands, time for dates, and writes the file atomically with fs.rename. Tasks live in $TODO_FILE, or ~/.todo.json.

rune run examples/todo.rn                                  # demo session against a temp file
rune run examples/todo.rn add "pay rent" +home !high due:fri
rune run examples/todo.rn list +home --overdue

validate.rn — config validator

Checks a gateway config against a schema written as data: one struct per rule kind (Str, Duration, List, Tagged…), matched with struct patterns. Every problem is reported with its path (routes[2].auth.type), unknown keys get a “did you mean”, and checks across fields run on the normalized config with ?. and ??. Old config versions are upgraded first by migrations that reshape objects with destructuring and spread.

rune run examples/validate.rn                         # a good, a legacy and a broken config
rune run examples/validate.rn check gateway.json      # exit 1 on any error
rune run examples/validate.rn migrate gateway.json -w # upgrade the file in place
rune run examples/validate.rn normalize gateway.json  # defaults filled, durations in ms

query/ — SQL over CSV

SELECT with joins, WHERE, GROUP BY, HAVING, ORDER BY, LIMIT and EXPLAIN over the CSV files of a directory, split into modules: parser, ast, expr, plan, aggregate, table, output. The parser builds a tree of structs; the planner compiles every expression to a closure over a row array, runs each WHERE conjunct as soon as its tables are there, turns ON a = b into a hash join on a map, and groups with aggregates kept in slots. Errors point at the query with a caret and a “did you mean”. The demo generates 100k orders and times each query.

rune run examples/query/main.rn                              # generate a dataset, run and time queries
rune run examples/query/main.rn "SELECT * FROM users LIMIT 5" -d data -f json
rune run examples/query/main.rn repl -d data                 # one query per line from stdin

log-report.rn — access log report

Per-endpoint latency percentiles, status classes, top clients and slow requests. Reads the log line by line with a stream, groups with map, writes JSON.

rune run examples/log-report.rn                 # generate a sample log and analyze it
rune run examples/log-report.rn access.log -j   # your own log, plus a JSON report

chess.rn — chess engine

Move generation on a 10×12 mailbox board, perft, alpha-beta search with quiescence, a transposition table on Zobrist keys and iterative deepening. Moves are packed into integers with bitwise operators; the position is a struct changed in place by mut self methods make and unmake; table entries record their bound as an enum.

rune run examples/chess.rn                     # perft check, a mate puzzle, self-play
rune run examples/chess.rn perft 4             # leaf nodes per root move
rune run examples/chess.rn go -t 3             # best move from the start position
rune run examples/chess.rn play black -t 2     # play against the engine

calc.rn — calculator

Tokenizer, Pratt parser and evaluator with variables and functions. A good read for structs, an enum of token kinds matched inside struct patterns, match on AST nodes and throw for parse errors.

rune run examples/calc.rn        # demo
rune run examples/calc.rn repl   # read lines from stdin

shop.rn — REST client with auth

A client for the DummyJSON API: logs in, sends a Bearer token, refreshes it on a 401 and retries. http.request with JSON bodies, a Client struct whose mut self methods keep the tokens, a typed ApiError, JWT claims decoded with bytes.fromBase64, a URL encoder over bytes, tables with format.

rune run examples/shop.rn                 # demo user, search "phone"
rune run examples/shop.rn "apple watch"
SHOP_USER=michaelw SHOP_PASS=michaelwpass rune run examples/shop.rn

fetch.rn — HTTP and JSON

Fetches a URL, parses the JSON and handles network errors with try.

rune run examples/fetch.rn

Basics

Built-in Functions

print, type, object, string, bytes, number, int, map and set are global; the name of a type converts to it, as string(v), number(v) or map(v) do, and string and bytes also have members, such as string.fromCharCode and bytes.fromHex. The operations on a value are its methods, as xs.map(f) or s.split(","); everything else lives in a namespace, used as math.floor(x).

print

print(v) writes v to stdout followed by a newline: a string as it is, anything else as it would appear in an array. io.write, io.eprint and io.ewrite do the same without a newline or to stderr.

print("hi");             // hi
print([1, "two", nil]);  // [1, "two", nil]
["a", "b"].map(print);
// a
// b

type

type(value) is "nil", "boolean", "number", "string", "array", "object", "function", "map", "set", "bytes", "stream", the name of a value’s struct or enum, "struct" for a struct type, "enum" for an enum type, or "namespace".

struct User { name }

print(type(nil));                  // nil
print(type(1.5));                  // number
print(type([1]));                  // array
print(type(x => x));               // function
print(type(User { name: "ann" })); // User
print(type(User));                 // struct
print(type(math));                 // namespace

object and string

object() makes an empty object and object(src) a new one from what map(src) takes, with string keys: an object, the fields of a struct, a map or an array of [key, value] pairs. string(v) is v as print shows it.

struct User { name, role = "guest" }

let u = User { name: "ann" };
print(object(u));                       // {name: "ann", role: "guest"}
print(object([["id", 7], ["ok", true]])); // {id: 7, ok: true}
print(string(u));                       // User {name: "ann", role: "guest"}
print(string([1, "two"]));              // [1, "two"]
print("items: " + string(3));           // items: 3
print([1, 2].map(string));              // ["1", "2"]
object(map([[1, "one"]])); // TypeError: object() expects string keys, got 1.

number and int

number(v) is a number as is, or a string holding a number literal, optionally negated and surrounded by spaces ("42", " -2.5e1 ", "0xff", "1_000"). It is an integer unless it has a fraction or an exponent or, in decimal, int64 cannot hold it; anything else, ".5", "+1" or "inf" included, is a ValueError.

int(v[, base]) is a number truncated toward zero (one past the integers is a ValueError), or a string parsed as an integer in base, 2 to 36, default 10.

print(number("42"));         // 42
print(number(" -2.5e1 "));   // -25
print(number("0xff"));       // 255
print(number("1_000"));      // 1000
print(int(3.9));             // 3
print(int(-3.9));            // -3
print(int("42"));            // 42
print(int("ff", 16));        // 255
print(int("0755", 8));       // 493
print(["10", "2"].map(int)); // [10, 2]
number("12 apples"); // ValueError: number() cannot parse "12 apples" as a number.
int("12.5"); // ValueError: int() cannot parse "12.5" as an integer in base 10.

Namespaces

A namespace is an immutable struct of its members. Unless a program declares a variable of its name, ns.member is bound when the program is compiled: it costs nothing at run time, and a misspelled member is a compile error.

math.flor(1); // Error at 'flor': math has no member 'flor'.

Built-ins are immutable, as let variables are:

math.PI = 3; // Error at 'math': Cannot mutate immutable variable 'math'.

Array Methods

Methods marked in place change the array, so it must be held by a mutable variable, and return nil, unless they take an element out; the others return a new value and leave the array as it is.

Adding and removing

  • push(v1, ...) — appends in place (amortized O(1) each).
  • pop() — removes the last element in place and returns it; an empty array is a ValueError.
  • insert(i, v) — inserts v before index i in place (xs.len() appends).
  • remove(i) — removes element i in place and returns it. A negative i counts from the end; one out of bounds is an IndexError.
  • append(v1, ...) — a new array with the values appended. It copies, so building an array with it in a loop is quadratic; use push for that.
let mut queue = ["build", "test"];
queue.push("deploy");
print(queue.pop());                 // deploy
queue.insert(0, "lint");
print(queue);                       // ["lint", "build", "test"]
print(queue.remove(-1));            // test
print(queue.append("release"));     // ["lint", "build", "release"]
print(queue);                       // ["lint", "build"]
let mut empty = [];
empty.pop(); // ValueError: array.pop() got an empty array.

Transforming

map(f), filter(f) and reduce(f[, init]) return new values; f may be any function, built-ins included. Without init, reduce starts from the first element.

map, filter, every, some, find and findIndex also pass the index when f requires two arguments, so map(int), whose base is optional, takes the element alone.

let orders = [
    { id: 1, total: 30 },
    { id: 2, total: 120 },
    { id: 3, total: 75 },
];

print(orders.map(o => o.total));                         // [30, 120, 75]
print(orders.filter(o => o.total > 50).map(o => o.id));  // [2, 3]
print(orders.reduce((sum, o) => sum + o.total, 0));      // 225
print([3, 1, 2].reduce((a, b) => a + b));                // 6
print(["10", "2"].map(int));                             // [10, 2]
print(["a", "b"].map((x, i) => "{i}:{x}"));              // ["0:a", "1:b"]
print([5, 6, 7].filter((_, i) => i % 2 == 0));           // [5, 7]

Sorting

sort([cmp]) sorts in place, stably; reverse() and the rest return a new array. Without cmp the elements must be all numbers or all strings; with it, cmp(a, b) < 0 puts a first.

let mut scores = [5, 3, 10, 1];
scores.sort();
print(scores);                            // [1, 3, 5, 10]

let mut fruits = ["pear", "Apple", "fig"];
fruits.sort();
print(fruits);                            // ["Apple", "fig", "pear"]
fruits.sort((a, b) => a.len() - b.len());
print(fruits);                            // ["fig", "pear", "Apple"]

let mut people = [{ name: "bob", age: 25 }, { name: "ann", age: 31 }];
people.sort((a, b) => b.age - a.age);
print(people.map(({ name }) => name));    // ["ann", "bob"]
let mut mixed = [1, "a"];
mixed.sort(); // TypeError: array.sort() without a comparator needs all numbers or all strings.

Searching

  • every(f), some(f) — whether f is truthy for all or any element, stopping once that is known.
  • find(f) — the first element for which it is, or nil; findIndex(f) — its index, or -1.
  • contains(item), indexOf(item) — by ==, so arrays and objects compare by content; indexOf is -1 if missing.
let users = [
    { name: "ann", admin: true },
    { name: "bob", admin: false },
];

print(users.some(u => u.admin));                // true
print(users.every(u => u.admin));               // false
print(users.find(u => !u.admin));               // {name: "bob", admin: false}
print(users.find(u => u.name == "eve"));        // nil
print(users.findIndex(u => u.name == "bob"));   // 1
print([10, 20, 30].contains(20));               // true
print([10, 20, 30].indexOf(99));                // -1
print([[1, 2], [3, 4]].indexOf([3, 4]));        // 1

Slicing and joining

slice(start[, end]) (negative bounds count from the end), reverse(), repeat(n) and join(sep) return new values. repeat makes an array of a given size; its elements are shared, not copied, so [[]].repeat(3) holds one array three times.

let xs = [10, 20, 30, 40, 50];
print(xs.slice(1, 3));   // [20, 30]
print(xs.slice(-2));     // [40, 50]
print(xs.reverse());     // [50, 40, 30, 20, 10]
print([0].repeat(3));    // [0, 0, 0]
print(xs);               // [10, 20, 30, 40, 50]
print(["a", "b", "c"].join(", ")); // a, b, c

Object Methods

A key in brackets is computed: its value, a string, is the key.

fun counter(name) { return { [name]: 0, [name + "Max"]: 10 }; }
print(counter("retries")); // {retries: 0, retriesMax: 10}

let fields = ["host", "port"].map(f => ({ [f]: nil }));
print(fields);              // [{host: nil}, {port: nil}]
print({ [1]: "one" }); // TypeError: Object properties must be accessed with string keys.
  • has(k) — whether the object has the key k.
  • get(k[, default]) — the value of k, or default (nil if not given) when missing; obj[k] and obj.k are a KeyError instead.
  • keys(), values(), entries() — new arrays of the keys, the values, or [key, value] pairs, in the order the keys were added, as for walks them.
  • delete(k) — removes k in place and returns whether it was there.
let mut config = { port: 8080, host: "localhost", debug: false };

print(config.has("port"));             // true
print(config.get("user"));             // nil
print(config.get("user", "admin"));    // admin
print(config.keys());                  // ["port", "host", "debug"]
print(config.values());                // [8080, "localhost", false]
print(config.entries());               // [["port", 8080], ["host", "localhost"], ["debug", false]]
print(config.delete("debug"));         // true
print(config.delete("debug"));         // false
print(config);                         // {port: 8080, host: "localhost"}

entries() pairs well with array destructuring:

let prices = { apple: 1.2, pear: 0.8 };
for [fruit, price] in prices.entries() {
    print("{fruit}: {price}");
}
// apple: 1.2
// pear: 0.8

Reading o.k gives an own property of that name first; a call o.k() always calls the method:

let stats = { keys: 3 };
print(stats.keys);          // 3
print(stats.keys());        // ["keys"]

String and Number Methods

Strings are immutable: every method returns a new value. Indices and lengths count characters, not bytes.

Splitting and case

split(sep) ("" splits into characters), upper(), lower(), startsWith(p), endsWith(p).

let row = "2026-11-12,ann,checkout,42.50";
print(row.split(","));               // ["2026-11-12", "ann", "checkout", "42.50"]
print("abc".split(""));              // ["a", "b", "c"]
print("Ann".upper());                // ANN
print("Ann".lower());                // ann
print("report.csv".endsWith(".csv")); // true
print("https://x.io".startsWith("https://")); // true

Searching and replacing

replace(old, new) replaces all occurrences; repeat(n), and slice, reverse, contains, indexOf work as for arrays.

print("2026-11-12".replace("-", "/"));   // 2026/11/12
print("=".repeat(10));                   // ==========
print("héllo".slice(1, 3));              // él
print("héllo".slice(-2));                // lo
print("héllo".reverse());                // olléh
print("hello world".contains("wor"));    // true
print("héllo".indexOf("l"));             // 2
print("hello".indexOf("z"));             // -1

Trimming

  • trim([chars]), trimStart([chars]), trimEnd([chars]) — the string without whitespace, or any of the characters of chars, at both ends, the start or the end.
  • removePrefix(p), removeSuffix(s) — the string without p at its start or s at its end, if there.
print("  ann  ".trim() + "|");            // ann|
print("  ann  ".trimStart() + "|");       // ann  |
print("  ann  ".trimEnd() + "|");         //   ann|
print("--title--".trim("-"));             // title
print("/usr/local/".trim("/"));           // usr/local
print("v1.2.3".removePrefix("v"));        // 1.2.3
print("report.csv".removeSuffix(".csv")); // report
print("report.csv".removePrefix("x"));    // report.csv

Characters and padding

  • charCode([i]) — the code point of character i (default 0); string.fromCharCode(c, ...) — the string of those code points.
  • padStart(width[, pad]), padEnd(width[, pad]) — the string filled to width characters with pad (default " "), repeated and cut.
print("A".charCode());                 // 65
print("héllo".charCode(1));            // 233
print(string.fromCharCode(72, 105));   // Hi
print("7".padStart(3, "0"));           // 007
print("ann".padEnd(6) + "|");          // ann   |
print("ab".padStart(7, "xy"));         // xyxyxab

format

format(...) is printf, the string being the format: %[flags][width][.precision]verb, flags -+ 0#. A width or precision of * takes the next argument, so a column can fit its widest value; a negative width pads on the right.

  • %v and %s show a value as print does, %q as inside an array;
  • %d %x %X %o %b take integers, %f %e %E %g %G numbers;
  • %% is %.

Widths count characters; a missing or extra argument is an ArgumentError.

print("%s has %d items".format("cart", 3));        // cart has 3 items
print("%-6s|%5.2f|%03d".format("ab", 3.14159, 7)); // ab    | 3.14|007
print("%v and %q".format(["a"], "a"));             // ["a"] and "a"
print("%x %X %o %b".format(255, 255, 8, 5));       // ff FF 10 101
print("%+d %e".format(5, 12345.678));              // +5 1.234568e+04
print("100%%".format());                           // 100%

A table, columns aligned:

let items = [["coffee", 2, 3.5], ["bagel", 1, 2.25]];
for [name, qty, price] in items {
    print("%-8s %2d x %5.2f".format(name, qty, price));
}
// coffee    2 x  3.50
// bagel     1 x  2.25
let rows = [["GET /users", 12], ["POST /orders/batch", 340]];
let width = rows.reduce((w, [path]) => math.max(w, path.len()), 0);
for [path, ms] in rows {
    print("%-*s %4dms".format(width, path, ms));
}
// GET /users           12ms
// POST /orders/batch  340ms
"%d".format("7"); // TypeError: string.format() expects an integer as argument 1, got string.

Numbers

A number has fixed(digits), with 0 to 100 decimals.

print(3.14159.fixed(2));  // 3.14
print(2.fixed(2));        // 2.00
print((19.999).fixed(0)); // 20

regex

Patterns are RE2 syntax (no backreferences; matching is linear in the input). A bad pattern is a ValueError. Indices count characters.

Write patterns as raw strings, r"...", so \d and {3} are taken as written; in a plain string \ is an escape and { starts interpolation.

test

test(re, s) — whether re matches anywhere in s.

print(regex.test(r"^\d{3}-\d{4}$", "555-1234"));  // true
print(regex.test(r"^\d{3}-\d{4}$", "555-12"));    // false

find and findAll

find(re, s) is the first Match { text, index, groups, named }, or nil: groups holds each group’s text, nil if it did not take part, and named the named ones ((?P<name>...)). findAll(re, s[, n]) is all matches, at most n.

let line = "GET /api/users 200 12ms";
let m = regex.find(r"(\w+) (\S+) (\d+)", line);
print(m.text);     // GET /api/users 200
print(m.index);    // 0
print(m.groups);   // ["GET", "/api/users", "200"]

let [method, path, status] = m.groups;
print("{method} {path} -> {status}"); // GET /api/users -> 200

let d = regex.find(r"(?P<year>\d{4})-(?P<month>\d\d)", "released 2026-11");
print(d.named);          // {year: "2026", month: "11"}
print(d.index);          // 9

print(regex.find(r"\d+", "no digits"));            // nil
print(regex.find(r"(cat)|(dog)", "dog").groups);   // [nil, "dog"]
let ids = regex.findAll(r"\d+", "ids: 7, 42, 108");
print(ids.map(m => m.text));                                   // ["7", "42", "108"]
print(regex.findAll(r"\d+", "ids: 7, 42, 108", 2).map(m => m.text)); // ["7", "42"]

replace

replace(re, s, with) replaces every match; with is a string, where $1 or ${name} is a group, or a function from a Match to a string.

print(regex.replace(r"\s+", "a  b   c", " "));                // a b c
print(regex.replace(r"(\w+)@(\w+)", "ann@mail", r"$2: $1"));  // mail: ann
print(regex.replace(r"(?P<user>\w+)@(?P<host>\w+)", "ann@mail", r"${host}: ${user}")); // mail: ann
print(regex.replace(r"\d+", "3 apples, 12 pears", m => string(int(m.text) * 2))); // 6 apples, 24 pears

split and escape

split(re, s[, n]) is the parts between matches, at most n; escape(s) is s with the special characters quoted, to match it literally.

print(regex.split(r"\s*,\s*", "a , b,c ,  d"));  // ["a", "b", "c", "d"]
print(regex.split(r",", "a,b,c,d", 2));          // ["a", "b,c,d"]
print(regex.escape("1.5*2?"));                   // 1\.5\*2\?
print(regex.test(regex.escape("1.5"), "105"));   // false
regex.test("(", "x"); // ValueError: regex.test() bad pattern: missing closing ): `(`.

math

Rounding

floor(x), ceil(x) and round(x) give integers when one holds the result; round takes halves away from zero. idiv(a, b) is integer division, toward zero.

print(math.floor(3.7));    // 3
print(math.floor(-3.2));   // -4
print(math.ceil(3.2));     // 4
print(math.round(2.5));    // 3
print(math.round(-2.5));   // -3
print(7 / 2);              // 3.5
print(math.idiv(7, 2));    // 3
print(math.idiv(-7, 2));   // -3

Arithmetic

abs(x), sqrt(x), pow(x, y), min(a, ...) and max(a, ...), which return the argument itself, and isInt(x), whether x is an integer.

print(math.abs(-7));           // 7
print(math.sqrt(16));          // 4
print(math.sqrt(2));           // 1.4142135623730951
print(math.pow(2, 10));        // 1024
print(math.min(3, 1, 2));      // 1
print(math.max(...[4, 9, 2])); // 9
print(math.isInt(7));          // true
print(math.isInt(7.5));        // false
print(math.isInt(6 / 2));      // false

Random numbers

random() is a float in [0, 1).

let die = math.floor(math.random() * 6) + 1;
print(die >= 1 && die <= 6);   // true

let colors = ["red", "green", "blue"];
let pick = colors[math.floor(math.random() * colors.len())];
print(colors.contains(pick));   // true

Constants

print(math.PI);        // 3.141592653589793
print(math.INF);       // inf
print(-math.INF);      // -inf
print(math.MAX_INT);   // 9223372036854775807
print(math.MIN_INT);   // -9223372036854775808

json and http

json.parse

json.parse(s) parses JSON: an object, an array or a primitive. Objects become objects, null becomes nil, and a number without a fraction or an exponent is an integer. Malformed input is a ValueError.

JSON is full of " and {, so write it in a raw string, r"""...""".

let user = json.parse(r"""{"name": "ann", "tags": ["admin"], "age": 31, "manager": null}""");
print(user);            // {name: "ann", tags: ["admin"], age: 31, manager: nil}
print(user.tags[0]);    // admin
print(user.manager);    // nil
print(json.parse("[1, 2.5]"));  // [1, 2.5]
print(json.parse("42"));        // 42

Calling a struct type checks the shape of what was parsed:

struct Order { id, total, items = [] }

let order = Order(json.parse(r"""{"id": 8, "total": 5}"""));
print(order);   // Order {id: 8, total: 5, items: []}
json.parse("not json"); // ValueError: json.parse() could not parse JSON: invalid character 'o' in literal null (expecting 'u')

json.stringify

json.stringify(v[, indent]) encodes nil, booleans, numbers, strings, arrays, objects (keys in order), structs (fields in order), sets (as arrays) and maps of string keys (as objects, in order), compactly unless indent, a number of spaces or a string of spaces and tabs, at most 10, puts each element on its own line. A function is a TypeError, a non-finite number or a cycle a ValueError.

struct Order { id, total, items = [] }

print(json.stringify({ name: "ann", active: true, manager: nil }));
// {"name":"ann","active":true,"manager":null}
print(json.stringify(Order { id: 7, total: 19.99 }));
// {"id":7,"total":19.99,"items":[]}
print(json.stringify(set(["a", "b"])));   // ["a","b"]
print(json.stringify("say \"hi\""));      // "say \"hi\""
print(json.stringify({ name: "ann", tags: ["admin"] }, 2));
// {
//   "name": "ann",
//   "tags": [
//     "admin"
//   ]
// }
json.stringify({ onClick: () => nil }); // TypeError: json.stringify() cannot encode function.

http.get

http.get(url) fetches a URL and returns the body as a string; a network error or a status other than 200 is an IOError.

let todos = json.parse(http.get("https://api.example.com/todos")).todos;
print(todos.filter(t => !t.completed).map(t => t.todo));   // ["buy milk"]
try {
    http.get("https://api.example.com/missing");
} catch e is IOError {
    print(e.message);   // http.get() could not fetch https://api.example.com/missing: status code 404
}

http.request

http.request(method, url[, opts]) sends a request and returns HttpResponse { status, headers, body }, header names lowercased and repeated ones joined with , . Any status is a response; a network error or a timeout is an IOError.

opts is an HttpOptions { headers, body, timeout, stream }, or an object converted to one: headers an object of strings, body a string or bytes, timeout seconds (default 30).

let r = http.request("POST", "https://api.example.com/users", {
    headers: { "Content-Type": "application/json" },
    body: json.stringify({ name: "ann" }),
    timeout: 5,
});

print(r.status);                  // 201
print(r.headers["location"]);     // /users/42
print(json.parse(r.body).id);     // 42

if r.status != 201 throw IOError { message: "status {r.status}" };
let r = http.request("GET", "https://api.example.com/missing");
print(r.status);   // 404
print(json.parse(r.body).error);   // not found

With stream: true, body is a Stream to read and close, and the timeout covers only getting the response, so a large download is never held whole:

let r = http.request("GET", "https://api.example.com/export.csv", { stream: true });
for i, line in r.body {
    if i == 0 continue;
    let [id, total] = line.split(",");
    print("order {id}: {total}");
}
r.body.close();
// order 1: 30
// order 2: 120

time

A time is a number of seconds since 1970-01-01 UTC. A time zone is "local" (the default), "UTC" or an IANA name, "Europe/Kyiv"; an unknown one is a ValueError.

The examples use a fixed moment, Thursday 2026-11-12 14:30 UTC:

let t = 1794493800;
print(time.format(t, nil, "UTC"));   // 2026-11-12T14:30:00Z

now

now() is the current time, with sub-second precision. Subtract two to measure a duration:

let start = time.now();
let mut sum = 0;
for i in 0..100000 { sum += i; }
let ms = (time.now() - start) * 1000;
print(ms < 1000);   // true

date and make

date([t[, zone]]) is t (default now) on the calendar of zone: a Date { year, month = 1, day = 1, hour = 0, minute = 0, second = 0, weekday, yearDay, zone = "local" }, weekday from 1, Monday, to 7.

let t = 1794493800;
let d = time.date(t, "UTC");
print(d);
// Date {year: 2026, month: 11, day: 12, hour: 14, minute: 30, second: 0, weekday: 4, yearDay: 316, zone: "UTC"}
print("{d.day}.{d.month}.{d.year}");     // 12.11.2026
print(time.date(t, "Europe/Kyiv").hour); // 16

make(d[, zone]) is the time of the Date d, or an object converted to one, in zone or else d.zone. Fields past their range carry over, day: 32 into the next month; weekday and yearDay are ignored. A whole second gives an integer.

print(time.make({ year: 2026, month: 11, day: 12, hour: 14, minute: 30, zone: "UTC" })); // 1794493800

let newYear = time.make({ year: 2026, month: 12, day: 32, zone: "UTC" });
print(time.format(newYear, "%Y-%m-%d", "UTC"));   // 2027-01-01

Date arithmetic is a make of a changed Date, so it keeps the wall clock, across DST too:

let d = time.date(1794493800, "UTC");
let tomorrow = time.make(Date { ...d, day: d.day + 1 });
let nextMonth = time.make(Date { ...d, month: d.month + 1 });
print(time.format(tomorrow, "%a %d %b %H:%M", "UTC"));  // Fri 13 Nov 14:30
print(time.format(nextMonth, "%Y-%m-%d", "UTC"));       // 2026-12-12

format

format(t[, layout[, zone]]) is t as a string, layout defaulting to ISO 8601, "%Y-%m-%dT%H:%M:%S%:z".

let t = 1794493800;
print(time.format(t, "%Y-%m-%d %H:%M", "UTC"));                // 2026-11-12 14:30
print(time.format(t, "%a %d %b %Y, %I:%M %p", "Europe/Kyiv")); // Thu 12 Nov 2026, 04:30 PM
print(time.format(t, nil, "Europe/Kyiv"));                     // 2026-11-12T16:30:00+02:00
print(time.format(t, "%A, %B %e", "UTC"));                     // Thursday, November 12
print(time.format(t + 0.25, "%H:%M:%S.%f", "UTC"));            // 14:30:00.250000

parse

parse(s[, layout[, zone]]) is the time s holds in layout, read in zone unless it has an offset; anything else, or a date that does not exist, is a ValueError.

print(time.parse("2026-11-12", "%Y-%m-%d", "UTC"));       // 1794441600
print(time.parse("2026-11-12T16:30:00+02:00"));           // 1794493800
print(time.parse("12/Nov/2026:14:30:00 +0000", "%d/%b/%Y:%H:%M:%S %z")); // 1794493800
time.parse("2026-02-30", "%Y-%m-%d", "UTC"); // ValueError: time.parse() cannot parse "2026-02-30" as "%Y-%m-%d": 2026-02-30 is not a valid date.

Layouts

%Y, %yyear, two-digit year
%m, %d, %emonth, day, day padded with a space
%jday of the year
%H, %I, %phour, 12-hour hour, AM/PM
%M, %Sminute, second
%fmicroseconds; parsing takes any fraction
%b, %Bmonth name, short and long
%a, %A, %uweekday name, short and long; weekday from 1
%z, %:zoffset +0200; +02:00, Z for UTC
%Zzone abbreviation; parsing takes only UTC and GMT
%sseconds since the epoch
%%%

Parsing ignores case in names; a weekday it reads is not checked against the date.

fs

Files are read and written whole, as strings; relative paths are taken from the current directory, not the script’s (use module.dir for those). For large files see Streams.

The examples work in a fresh temporary directory:

let dir = fs.tempDir();
let notes = fs.join(dir, "notes", "todo.txt");
fs.mkdir(fs.dir(notes));
fs.write(notes, "buy milk\nwrite docs\n");
fs.append(notes, "ship v1\n");

print(fs.lines(notes));                 // ["buy milk", "write docs", "ship v1"]
print(fs.readText(notes).len());        // 28
print(fs.readBytes(notes).slice(0, 3)); // b"buy"
fs.removeAll(dir);

Reading and writing

  • readText(path) — the contents as a string; lines(path) — its lines without \n or \r\n; readBytes(path) — the contents as bytes.
  • write(path, data) (creates or truncates), append(path, data), of a string or bytes.
  • open(path[, mode]) — a Stream of the file: "r" (default) to read, "w" to write (creating or truncating), "a" to append.
let path = fs.join(fs.tempDir(), "settings.json");
fs.write(path, json.stringify({ theme: "dark", fontSize: 14 }));
let settings = json.parse(fs.readText(path));
print(settings.theme);   // dark
fs.removeAll(fs.dir(path));

Files and directories

  • exists(path); stat(path) — a FileInfo { name, size, isDir, modified, mode }, modified in seconds like time.now().
  • list(dir) — the entries as FileInfos, sorted by name.
  • mkdir(path) (with parents; an existing directory is fine), remove(path) (a file or an empty directory), removeAll(path), rename(from, to), copy(from, to).
  • cwd(); tempDir() — a new directory, for the program to remove.
let dir = fs.tempDir();
fs.write(fs.join(dir, "draft.md"), "# Hello\n");
fs.copy(fs.join(dir, "draft.md"), fs.join(dir, "backup.md"));
fs.rename(fs.join(dir, "draft.md"), fs.join(dir, "post.md"));
fs.mkdir(fs.join(dir, "images"));

for f in fs.list(dir) {
    print("{f.name} {f.isDir ? "dir" : f.size}");
}
// backup.md 8
// images dir
// post.md 8

let info = fs.stat(fs.join(dir, "post.md"));
print("%o".format(info.mode));              // 644
print(fs.exists(fs.join(dir, "draft.md"))); // false

fs.remove(fs.join(dir, "backup.md"));
fs.removeAll(dir);
print(fs.exists(dir));                      // false

Paths

join(a, ...), base(p), dir(p), ext(p) and stem(p), the base without its extension.

let p = "content/posts/hello.md";
print(fs.join("content", "posts", "hello.md")); // content/posts/hello.md
print(fs.base(p));   // hello.md
print(fs.dir(p));    // content/posts
print(fs.ext(p));    // .md
print(fs.stem(p));   // hello
print(fs.join("site", fs.stem(p) + ".html"));   // site/hello.html
print(fs.join("a", "../b", "./c"));             // b/c

Errors

A failure is an IOError with path and code: "notFound", "exists", "permission", "isDir", "notDir", "notEmpty", or nil for anything else.

try {
    fs.readText("missing.txt");
} catch e is IOError {
    print(e.code);     // notFound
    print(e.path);     // missing.txt
    print(e.message);  // fs.readText() missing.txt: no such file or directory.
}

A missing file is often not an error:

fun loadTodos(path) {
    try {
        return fs.lines(path);
    } catch e is IOError {
        if e.code != "notFound" throw e;
        return [];
    }
}

print(loadTodos("todo.txt"));   // []

Streams

A stream is an open file from fs.open, or a response body from http.request with stream: true. It reads and writes a piece at a time, so a large file is never held whole.

Reading

  • readLine() — the next line without \n or \r\n, or nil at the end;
  • read(n) — bytes, at most n and at least one, or nil at the end;
  • readBytes() — the rest as bytes; readText() — the rest as a string.

for line in s (or for i, line in s) reads lines up to the end.

let path = fs.join(fs.tempDir(), "access.log");
fs.write(path, "GET /a 200\nPOST /b 500\nGET /c 404\n");

let log = fs.open(path);
print(log.readLine());   // GET /a 200
print(log.read(4));      // b"POST"
print(log.readLine());   //  /b 500
print("%q".format(log.readText())); // "GET /c 404\n"
print(log.readLine());   // nil
log.close();

let mut errors = 0;
for line in fs.open(path) {
    if !line.endsWith(" 200") errors++;
}
print(errors);           // 2

Writing

write(data) writes a string or bytes, buffered; flush() writes the buffer out. close() flushes and closes the stream; closing again does nothing, and streams the program leaves open are closed when it ends.

let dir = fs.tempDir();
let src = fs.join(dir, "words.txt");
let dst = fs.join(dir, "upper.txt");
fs.write(src, "alpha\nbeta\n");

let out = fs.open(dst, "w");
for line in fs.open(src) {
    out.write(line.upper() + "\n");
}
out.close();
print(fs.lines(dst));   // ["ALPHA", "BETA"]

let log = fs.open(dst, "a");
log.write("GAMMA\n");
log.flush();
print(fs.lines(dst));   // ["ALPHA", "BETA", "GAMMA"]
log.close();

Errors

A failure, reading a closed stream included, is an IOError.

let s = fs.open(fs.join(fs.tempDir(), "data.txt"), "w");
s.close();
try {
    s.readLine();
} catch e is IOError {
    print(e.message.endsWith("stream is closed."));   // true
}

io

  • readLine() — the next line of stdin without \n or \r\n, or nil at the end.
  • readText() — the rest of stdin.
  • write(v) — writes v to stdout as print does, without a newline.
  • eprint(v), ewrite(v) — the same to stderr, with and without a newline.

Reading stdin or writing stderr first flushes what was printed.

A filter that sums the numbers it is given, one per line, and warns about the lines that are not numbers:

// sum.rn
let mut total = 0;
let mut line = io.readLine();
while line != nil {
    try {
        total += number(line);
    } catch e is ValueError {
        io.eprint("skipping '{line}'");
    }
    line = io.readLine();
}
io.write("total: ");
print(total);
$ printf '10\n20\nabc\n12\n' | rune run sum.rn
skipping 'abc'
total: 42

readText() takes all of stdin at once, for input that is not line by line:

// count.rn
let words = regex.split(r"\s+", io.readText().trim());
print("{words.len()} words");
$ echo "the quick brown fox" | rune run count.rn
4 words

os

Environment

env(name) is the variable’s value, or nil if unset; setEnv(name, v) and unsetEnv(name) change it, and environ() is all of it as an object. platform is "darwin", "linux", "windows", …

let port = int(os.env("PORT") ?? "8080");
print(port);                    // 8080

os.setEnv("APP_MODE", "test");
print(os.env("APP_MODE"));      // test
os.unsetEnv("APP_MODE");
print(os.env("APP_MODE"));      // nil

print(os.environ().has("PATH")); // true
let shell = os.platform == "windows" ? "cmd" : "sh";
print(shell);                   // sh

exec

exec(cmd[, args[, opts]]) runs cmd with the array of string args and waits; it returns ExecResult { code, stdout, stderr }. A non-zero exit is not an error; a command not found is an IOError with code notFound.

opts is an ExecOptions { input, dir, env }, or an object converted to one: input is stdin, dir the working directory, env an object added to the current environment; all are optional.

let r = os.exec("echo", ["hello", "world"]);
print(r);   // ExecResult {code: 0, stdout: "hello world\n", stderr: ""}

let up = os.exec("tr", ["a-z", "A-Z"], { input: "shout\n" });
print(up.stdout.trim());   // SHOUT

let greet = os.exec("sh", ["-c", "echo $GREETING"], { env: { GREETING: "hi" } });
print(greet.stdout.trim()); // hi

let ls = os.exec("ls", ["/no/such/dir"]);
print(ls.code);             // 1

try {
    os.exec("no-such-command");
} catch e is IOError {
    print(e.code);          // notFound
}

exit

exit([code]) exits with code, 0 to 255, default 0, at once; try does not catch it. To exit from the end of the program, return the code from main.

// status.rn
let r = os.exec("git", ["status", "--short"]);
if r.code != 0 {
    io.eprint(r.stderr.trim());
    os.exit(r.code);
}
io.write(r.stdout);
$ rune run status.rn; echo "exit $?"    # outside a git repository
fatal: not a git repository (or any of the parent directories): .git
exit 128

Performance

Before running, the resolved AST is compiled to bytecode for a register machine, in the style of Lua. In particular:

  • every local, top-level variables included, lives in a register of its function’s frame; a variable some nested function captures is kept in a heap cell that the closures share;
  • a call pushes a frame onto one shared register stack, so it allocates nothing;
  • comparisons in if and loop conditions jump directly; constant operands and literal expressions are folded; loops test their condition at the bottom;
  • a value is two words: numbers, booleans and strings are stored inline and never allocate;
  • a struct is one allocation of its fields; the compiler interns field names, so p.x finds the field by pointer, with no hashing or string comparison;
  • setting a field copies the struct, as structs are values; but a mut self method that never lets its struct go (no passing, returning, capturing or binding it, and calling only methods that keep self too) copies it once, on its first write, and then sets fields in place. A let mut variable or mut parameter no function captures does the same, copying again only after a read that lets the struct go; one just given a struct literal needs no copy at all;
  • a mut self method called on such a variable changes the variable’s own struct in place, unless a try is open in the calling function, which must find the struct unchanged after an error;
  • a const has no variable: reads of it fold like the literal, in every file;
  • a map or a set is an open-addressing table over the values themselves, in insertion order;
  • a for-in over an array walks a snapshot, whose buffer a loop that runs to its end hands on to the next;
  • print output is buffered and flushed when the program ends (also on a runtime error).

Reading the bytecode

rune disasm file.rn prints the bytecode of each function: an opcode and its operands, registers or indices of constants.

// countdown.rn
fun countdown(n) {
    let mut left = n;
    while left > 0 {
        left -= 1;
    }
    return 60 * 1000;
}
$ rune disasm countdown.rn
<script>: arity 1, 2 registers, 0 upvalues
    0  LOADK         1 0 0
    1  CLOSURE       1 0 0
    2  RETURNNIL     0 0 0

countdown: arity 1, 3 registers, 0 upvalues
    0  MOVE          1 0 0
    1  JMP           0 0 3
    2  SUBK          1 1 0
    3  JMPCMPK       1 1 2  x=2 y=true
    4  LOADK         2 0 2
    5  RETURN        2 0 0
    6  RETURNNIL     0 0 0

n is register 0 and left register 1. The loop jumps to its condition at the bottom (1), where JMPCMPK compares left with the constant 0 and jumps back to the body (2) while it holds, with no boolean made in between; left -= 1 is a single SUBK with a constant operand, and 60 * 1000 was folded to 60000 at compile time (4).

Garbage collection

rune lets the heap grow to five times the live data before collecting (GOGC=400), which matters most on a single CPU. Set GOGC to override it.

Benchmarks

make bench

Running Tests

make test                # all tests
make test filter=arrays  # scripts under test/arrays
make update              # rewrite golden files after an intended change
make cover               # coverage
make fuzz FUZZTIME=1m    # fuzz the whole pipeline

Each test/**/*.rn script runs in-process, in parallel, with a 5s timeout. Directives in comments:

  • // expect: <line> — next line of stdout;
  • // [line: N] Error... — compile error, in order; without [line: N] it is the comment’s line;
  • // expect runtime error: [line: N] <message> — uncaught runtime error.

foo.stderr next to foo.rn holds the full error report (snippet, caret, trace); a missing file means empty stderr. testdata/<command>/*.rn are run through the CLI command (tokenize, evaluate, run, disasm, fmt) and compared with .golden files: exit code, stdout and stderr.