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 mutmakes them mutable - Standard library in namespaces:
array,string,regex,math,json,http,time,fs,io,os - Modules with their own top level,
exportandimport a.b as m;, relativeimport ..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:
"""...""", rawr"""...""" - String interpolation:
"x = {x}, sum = {a + b}" - 64-bit integers and floats, both of type
number:7 / 2is3.5,7 % 2is1, 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,
mapandsetkeep their keys in insertion order; amaporsetkey may be any hashable value bytes: immutable binary data, with literalsb"\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.nameas sugar forobj["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 selfmethods set:struct Point { x, y = 0 },Point { ...p, x: 1 } - Methods in
implblocks, 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 andfor, 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 withis:v is Point,catch e is KeyError,p is Point => - Error handling with
try/catchandthrow, also as an expression:x ?? throw E { ... } return,breakandcontinueas expressions:let v = x ?? return nil;,row?.id ?? continue- Two loops only:
whileandfor x in ...over arrays, strings, objects and ranges (a..b) breakandcontinuein bothif/else,whileandforbodies 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
- Crafting Interpreters by Robert Nystrom
- Lox Programming Language
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 namemutably); a struct type’s name is an error, test the type withis; - the name of a constant,
LIMITorm.LIMIT, matches its value, as the literal would; as a bare key in{ LIMIT }, or aftermut, it still binds; name is Tand_ is Ttest the value is aT, a struct type or a built-in one likestringornil,_ is T | Uone of them, the same test asv is Tandcatch e is T;- literals: numbers, strings,
true,false,nil, compared as by==; p | qmatches if either does;Color.Whitematches 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,resttaking 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 argumentsValueError— the right type but a bad value (step 0,[].pop())IndexError— an index out of boundsKeyError— a missing property or struct fieldNameError— an undefined variableIOError— a file system call or anhttprequest failing; it also haspathandcode(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
fib.rn— Fibonacci numbers, recursionbubble-sort.rn— loops and in-place array updateslinked-list.rn— a linked list built from objects
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(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 aValueError.insert(i, v)— insertsvbefore indexiin place (xs.len()appends).remove(i)— removes elementiin place and returns it. A negativeicounts from the end; one out of bounds is anIndexError.append(v1, ...)— a new array with the values appended. It copies, so building an array with it in a loop is quadratic; usepushfor 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)— whetherfis truthy for all or any element, stopping once that is known.find(f)— the first element for which it is, ornil;findIndex(f)— its index, or-1.contains(item),indexOf(item)— by==, so arrays and objects compare by content;indexOfis-1if 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 keyk.get(k[, default])— the value ofk, ordefault(nilif not given) when missing;obj[k]andobj.kare aKeyErrorinstead.keys(),values(),entries()— new arrays of the keys, the values, or[key, value]pairs, in the order the keys were added, asforwalks them.delete(k)— removeskin 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 ofchars, at both ends, the start or the end.removePrefix(p),removeSuffix(s)— the string withoutpat its start orsat 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 characteri(default 0);string.fromCharCode(c, ...)— the string of those code points.padStart(width[, pad]),padEnd(width[, pad])— the string filled towidthcharacters withpad(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.
%vand%sshow a value asprintdoes,%qas inside an array;%d %x %X %o %btake integers,%f %e %E %g %Gnumbers;%%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, %y | year, two-digit year |
%m, %d, %e | month, day, day padded with a space |
%j | day of the year |
%H, %I, %p | hour, 12-hour hour, AM/PM |
%M, %S | minute, second |
%f | microseconds; parsing takes any fraction |
%b, %B | month name, short and long |
%a, %A, %u | weekday name, short and long; weekday from 1 |
%z, %:z | offset +0200; +02:00, Z for UTC |
%Z | zone abbreviation; parsing takes only UTC and GMT |
%s | seconds 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\nor\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)— aFileInfo { name, size, isDir, modified, mode },modifiedin seconds liketime.now().list(dir)— the entries asFileInfos, 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\nor\r\n, ornilat the end;read(n)— bytes, at mostnand at least one, ornilat 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\nor\r\n, ornilat the end.readText()— the rest of stdin.write(v)— writesvto stdout asprintdoes, 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
ifand 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.xfinds the field by pointer, with no hashing or string comparison; - setting a field copies the struct, as structs are values; but a
mut selfmethod that never lets its struct go (no passing, returning, capturing or binding it, and calling only methods that keepselftoo) copies it once, on its first write, and then sets fields in place. Alet mutvariable ormutparameter 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 selfmethod called on such a variable changes the variable’s own struct in place, unless atryis open in the calling function, which must find the struct unchanged after an error; - a
consthas 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-inover an array walks a snapshot, whose buffer a loop that runs to its end hands on to the next; printoutput 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.