Basics: values, functions, data
kola's syntax is close to Rust's, but the language is much smaller and spells out everything that matters. The parts covered here look familiar from any C-like language, and no knowledge of Rust is assumed. Every example is a complete file you can check with kola check or run with kola test.
Dissecting main
Here is a main that does two things. Hover over the highlighted pieces:
use std::log;
fn main(sys: Sys) -> u8 !{io, log} {
log::info("hello, kola");
let t = sys.clock.now();
log::info(f"booted at {t}");
0
}Every function has the form fn name(parameters) -> Type !{effects} { body }. The effect row !{...} is the part C has no counterpart for: a list of the kinds of things the function is allowed to do beyond computing its result, such as allocating memory, touching the outside world or writing to the log. The compiler checks that the body does nothing outside the list. A function that returns nothing omits -> Type; its result is unit, (), the counterpart of C's void, which you rarely write yourself.
main returns u8 or nothing, and takes either no parameter or one Sys by value. The returned number is the exit status: when a program ends, the operating system hands this small number to whoever started it (a shell, a script, a build tool), and by convention 0 means success and anything else means failure. A shell keeps it in $?. Sys is the bundle of capabilities, the values a program needs in order to reach the clock, files, the network and the rest of the world; without one, a function has no way to do any of those things. A computed exit code is narrowed explicitly: narrow[u8](n) returns an Option, so your code decides what an error count of 256 should report. Option[T] is kola's type for a value that may be absent: it is either Some(x) or None, and there is no null.
Variables and integers
let declares an immutable binding (a variable that cannot be reassigned) and let mut a mutable one. The fixed-width integer types are i8 to i64 (signed) and u8 to u64 plus u128 (unsigned). There are also bool, char, f64 (below) and unit (). Two types are deliberately missing: i128 and isize.
Integer literals come in four bases: decimal 42, hex 0x2A, octal 0o644 and binary 0b1010. An underscore between digits only groups them: 1_000_000. A suffix fixes the type, as in 3u64, and the value must fit (32768i16 is a compile error). You need a suffix where nothing else determines the type, as in [1u64, 2u64] or 1u32 << n.
The width of an integer type is the number of bits it occupies; it decides the range of values the type can hold (u8 holds 0 to 255). An unsuffixed literal takes its width from its surroundings: an annotation, a parameter type, the other operand, or an index position (which means usize). If nothing fixes the width, it is u64, or i64 if a negative value ever enters the picture (let mut c = 0; c = -1; makes c an i64).
This catches people: an unpinned counter that starts at 0 is a u64, and let mut c = 0; c = c - 1; traps, because - on an unsigned type is checked like every other operator. Annotate i64 where a value can go negative.
const MODE: u32 = 0o644;
const FLAGS: u8 = 0b1010_0101;
test literal_forms {
assert 42u32 == 0x2Au32; // decimal and hex
assert MODE == 420u32; // octal: a Unix permission mask
assert FLAGS == 165u8; // binary
assert 1_000_000u32 == 1000000u32; // `_` only groups digits
assert 0x5741_4C00u32 == 1463897088u32;
}There are no character or byte literals such as 'v': write 118u8 with a comment. Byte strings do exist, as you will see below.
usize is the type of sizes and indices: .len and the i in xs[i] have this type. On a 64-bit machine it is as wide as u64, but it is a distinct type, so a count of elements cannot be mixed up with ordinary data by accident.
Conversions: why there is no as
In C and Rust, as does two different things with one spelling. It widens harmlessly (u32 to u64), and it narrows by silently dropping bits (300 as u8 is 44). kola has three words instead, and each one says which you meant:
widen[u64](x)widens. It is always lossless and returns a plain number.narrow[u8](x)narrows exactly. The value might not fit, so it returnsOption[u8]and you decide what to do withNone.wrap[u8](x)truncates on purpose: it keeps the low 8 bits ofxand reinterprets them in the target type, and it never traps. This is C's implicitinttoint16_t, written out, as used in hashing or the final fold of a checksum. The target may not be wider than the source; equal width is allowed, and that is how you reinterpretu16asi16.
test conversions {
let x: u32 = 300;
assert widen[u64](x) == 300u64; // always fits
assert narrow[u8](x) == None; // 300 does not fit in a u8
assert narrow[u8](255u32) == Some(255u8);
assert wrap[u8](x) == 44u8; // keep the low 8 bits, on purpose
assert wrap[i16](0x18000i32) == -32768i16; // same width, reinterpreted
}There are no implicit conversions at all. That is more verbose, but in code where an overflow is a security hole (packet lengths, indices) every conversion is visible, and a reader can tell "this may fail" from "this deliberately drops bits" without leaving the line.
Arithmetic: a trap instead of silent overflow
In C, signed overflow is undefined behaviour: the standard says nothing about what happens, so the compiler may assume it never occurs and the program can misbehave in ways that are hard to trace. Unsigned overflow silently wraps around, so 255 + 1 in a byte becomes 0. kola does neither. Ordinary arithmetic + - * / % traps on overflow and on division by zero: the program stops at that point, the same way every time, instead of continuing with a wrong number (Contracts, traps, and tests explains how to contain one). A shift by at least the operand's width also traps, and unary -x is exactly 0 - x, so it traps on the type's minimum.
Signed division truncates toward zero, and % takes the sign of the dividend: -7 / 2 is -3 and -7 % 2 is -1. The one signed division that overflows, MIN / -1, traps.
Sometimes wrapping is the point of the algorithm: hashes, checksums, cryptography, ring counters. For those there are separate operators, +%, -% and *% (there is no wrapping / or %). The FNV-1a hash relies on its multiplication overflowing:
fn fnv1a(bytes: &[u8]) -> u64 !{} {
let mut h: u64 = 14695981039346656037;
for i in 0..bytes.len {
h = (h ^ widen[u64](bytes[i])) *% 1099511628211;
}
h
}
test fnv1a_known_value {
assert fnv1a(&b"kola") == 119015361278554776;
}The bitwise operators & | ^ << >> work as in Rust, and unary ~ is the complement. ~x is defined as x ^ all-ones of x's width, so it is allowed wherever integer ^ is, signed types included, and it can never trap (~MIN is MAX).
test bitwise_and_wrapping {
assert ~0b1u8 == 0b11111110u8; // ~x is x ^ all-ones
assert ~0u32 == 4294967295u32;
assert (0b1010u8 & 0b0110u8) == 0b0010u8;
assert (1u32 << 5) == 32u32;
assert 250u8 +% 10u8 == 4u8; // wrapping add
assert 0u8 -% 1u8 == 255u8;
assert -7i64 / 2 == -3; // truncates toward zero
assert -7i64 % 2 == -1; // sign of the dividend
}f64: deterministic floating point
kola has one floating-point type, f64: the 64-bit IEEE 754 format that C calls double and Python calls float. Literals look like 1.5, 2.5e-3 and 1f64. You get + - * /, unary -, comparisons and f64::sqrt. Every operation rounds to nearest and gives bit-identical results on every backend. Dividing by 0.0 gives ±Inf, but an operation that produces a NaN ("not a number", the result of something like 0.0 / 0.0) traps. Conversions to and from integers are explicit.
test f64_basics {
let s = 0.1 + 0.2;
assert s == 0.30000000000000004; // binary64, as everywhere else
assert f64::sqrt(9.0) == 3.0;
assert f64::of_i64(3) / 2.0 == 1.5; // explicit, like every conversion
assert f64::to_i64(2.9) == Some(2i64); // None for NaN and out-of-range values
assert f64::to_str(1.5) == "1.5";
}f64::to_str allocates its result, by the same rule as an f-literal (below). There are no transcendental functions such as sin or exp: those come from libm, the C maths library, whose results differ in the last bits between platforms. f64 is not allowed in a ct fn (Constant time).
Blocks and pure functions
A block { ... } is an expression whose value is its last expression without a trailing semicolon. if/else is an expression too:
fn describe(n: u64) -> &heap str !{} {
if n == 0 { "zero" } else if n < 10 { "small" } else { "big" }
}
test describe_sizes {
assert describe(0) == "zero";
assert describe(3) == "small";
}describe and fnv1a are both marked !{}, which makes them pure. A pure function is trivial to test, safe to call from anywhere, and a reviewer knows without reading the body that it does not touch the network or consume memory. Impure functions are fine; their effects are declared in the signature, and the compiler checks the declaration: marking a function that logs as !{} is a compile error.
Strings
A plain string literal "..." is text. Nothing in it is interpolated (braces are just braces), and it is static data, stored inside the executable itself: it allocates nothing and has type &heap str, a reference into the built-in region heap that lives as long as the program. A region is kola's unit of memory management: a block of memory that is handed out piece by piece and then freed all at once, instead of one malloc/free pair per object. Memory: regions and references explains what that means. A str is immutable UTF-8. s.len is its length in bytes, and s.bytes is a view of the same bytes as a &[u8], without copying. The only way back from bytes to a str is the validating utf8::to_str from std::text::utf8, which returns an Option.
To build text from values, put an f in front of the literal. Each {...} hole in an f-literal holds one expression whose value can be rendered:
use std::log;
struct Point { x: i64, y: i64 }
fn show(n: u64, p: Point) !{log} {
log::info(f"n={n} doubled={n * 2} point=({p.x},{p.y})");
}
test interpolation {
show(3, Point { x: 4, y: -2 }); // logs: n=3 doubled=6 point=(4,-2)
}An f-literal renders at run time, so the result has to live somewhere. Passed straight to a parameter that only reads it, as in log::info(f"..."), it renders into a temporary buffer on the stack, which disappears when the call returns, and allocates nothing. Bound with let, it creates a string, which goes into the region its destination names (let s: &r str = f"...", or a -> &r str return type) or, failing that, into the innermost open region block. If there is neither, the compiler refuses the code rather than quietly allocate for the program's whole lifetime. Writing let s: &heap str = ... says you meant exactly that, and costs the alloc(heap) effect. Inside a test, an f-literal with nowhere else to go lands in heap.
A few more rules of the literal syntax:
{{and}}inside anf-literal write a single brace. In a plain literal they are refused, since a plain literal takes braces as written.A hole must have a value to render: a hole of type
()is refused, and so is a hole that is a bare number, such asf"{0}".The escapes are
\n \t \r \0 \\ \"and no others.\0is an ordinary character, so"a\0b".lenis 3.A raw literal,
r"..."orr#"..."#, is exactly the text between its delimiters: no escapes and no interpolation. It is the convenient spelling for JSON, shell commands and C snippets.
Byte strings
Wire protocols are bytes, not text, so kola has a second literal for them: b"GET /". It is shorthand for a fixed array [u8; N], where N is the number of bytes. The bytes are static program data, so &b"..." has type &heap [u8]: you can store and return it, and there is no &mut form. This is what you pass to an API that takes bytes, such as a hash function or a capability lookup by name.
The content is printable ASCII plus a small set of escapes: \n \t \r \\ \" \0 \xNN. Any other raw byte is a compile error that suggests the \xNN spelling. Byte strings have no interpolation holes.
test byte_strings {
let tag = b"GET /"; // a [u8; 5] of static program data
assert tag.len == 5;
assert tag[0] == 71u8; // 'G': there are no char literals
let line = &b"ok\r\n"; // &heap [u8]: storable, returnable, never &mut
assert line.len == 4;
assert line[3] == 10u8; // '\n'
assert (&b"\x00\xFF")[1] == 255u8;
}Loops: for, while, loop
kola has no iterator chains (.map().filter()); you write an ordinary loop, of one of three kinds.
for walks a range a..b (excluding b) or a slice, and nothing else. Over a slice, the loop variable is each element in turn:
fn count_even(xs: &[u64]) -> usize !{} {
let mut n = 0;
for x in xs {
if x % 2 == 0 { n = n + 1; }
}
n
}
test counting {
let xs = [1u64, 2u64, 3u64, 4u64];
assert count_even(&xs) == 2;
}while is for loops whose step is irregular or whose condition is more than a counter:
fn sum_while(xs: &[u64]) -> u64 !{} {
let mut s: u64 = 0;
let mut i: usize = 0;
while i < xs.len {
s = s +% xs[i];
i = i + 1;
}
s
}loop runs until a break, for when the exit condition sits in the middle of the logic:
fn pow2_at_least(n: u64) -> u64 !{}
req n <= 9223372036854775808
{
let mut p: u64 = 1;
loop {
if p >= n { break; }
p = p * 2;
}
p
}
test powers {
assert pow2_at_least(9) == 16;
assert pow2_at_least(1) == 1;
}The line req n <= ... is a precondition; without it, p * 2 could overflow. Contracts are the subject of Contracts, traps, and tests. An early exit from a function is an ordinary return.
References in a signature
In count_even(xs: &[u64]) the ampersand makes the parameter a reference: the function sees the caller's array (count_even(&a)) without copying it or taking ownership. A slice &[u64] has .len, indexing and sub-slices xs[a..b]. Under the hood a reference is a pointer, but there is no dereference operator * and no pointer arithmetic; a reference is used like the value. Memory: regions and references explains &r, a reference with a region name.
Structs, enums, match
struct Point { x: i64, y: i64 }
enum Shape {
Dot,
Rect(u32, u32),
}
fn shape_area(s: Shape) -> u64 !{} {
match s {
Shape::Dot => 0,
Shape::Rect(w, h) => widen[u64](w) * widen[u64](h),
}
}
test areas {
assert shape_area(Shape::Rect(2, 5)) == 10;
assert shape_area(Shape::Dot) == 0;
let p = Point { x: 1, y: 2 };
assert p.x + p.y == 3;
}There are no constructors. You create a struct value by listing all its fields, Point { x: 1, y: 2 }, and read a field with a dot, p.x. Fields have no visibility of their own: a field is readable wherever its struct type is, so you never write pub on one.
A pattern is a shape that a value is matched against; when it matches, the names in the shape are bound to the corresponding parts. Patterns appear in match and in let, and when a variable has the same name as a field there is a shorthand:
struct Point { x: i64, y: i64 }
test shorthand_both_ways {
let x: i64 = 1;
let y: i64 = 2;
let p = Point { x, y }; // shorthand: `x` means `x: x`
let q = Point { x: x, y: y }; // the same without the shorthand
assert p == q;
let Point { x: px, y: py } = p; // destructure into px and py
assert px + py == 3;
let Point { x, y } = q; // with the shorthand: fields land in x and y
assert x + y == 3;
}An enum variant can carry data, as Rect(u32, u32) does, and match unpacks it: in the arm Shape::Rect(w, h), the names w and h are bound to the variant's contents. A match must cover every variant, otherwise:
error[card §1]: match is not exhaustive: missing Yellow
= help: match is exhaustive in kola; add the missing arms or `_`A catch-all arm _ => ... covers everything else. A variant without data is always written with its enum's name, Shape::Dot: a bare Dot in a pattern would read as a fresh binding that matches anything, so it is refused.
Learn one more pattern rule early, because Rust and C habits disagree about it. A bare identifier in a pattern that names a visible integer const compares against the constant's value instead of binding a new variable. That is Rust's rule, and it makes token dispatch read naturally:
const TOK_INT: u8 = 1u8;
const TOK_STR: u8 = 2u8;
fn classify(op: u8) -> &heap str !{} {
match op {
TOK_INT => "int", // compares against the constant's value
TOK_STR => "str",
_ => "unknown", // a const arm is not a catch-all
}
}
test const_patterns_compare_by_value {
assert classify(1u8) == "int";
assert classify(2u8) == "str";
assert classify(9u8) == "unknown";
}A const arm is a literal arm, so a match made only of const arms still needs a _. And a pattern never consults local variables: a let that reuses the constant's name elsewhere in the function does not change what the arm compares against.
Tuples
A tuple is an anonymous, fixed group of values, (a, b), with its type written the same way: (u64, u64). It suits a function that returns two things where a named struct would be noise. Take it apart with a let pattern, or use .0 and .1:
fn min_max(xs: &[u64]) -> (u64, u64) !{} req xs.len > 0 {
let mut lo = xs[0];
let mut hi = xs[0];
for i in 0..xs.len {
if xs[i] < lo { lo = xs[i]; }
if xs[i] > hi { hi = xs[i]; }
}
(lo, hi)
}
test tuples {
let xs = [5u64, 2u64, 9u64];
let (lo, hi) = min_max(&xs); // destructure the pair
assert lo == 2;
assert hi == 9;
let p = (1u32, true);
assert p.0 == 1u32; // or reach an element by position
assert p.1;
}Equality is by value
Point and Shape are data types: they copy freely, and == compares them field by field. References compare by what they point to, never by address:
struct Point { x: i64, y: i64 }
test equality_is_by_value {
let a = Point { x: 1, y: 2 };
let b = Point { x: 1, y: 2 };
assert a == b; // two variables, one value
region r {
let ra = alloc in r Point { x: 1, y: 2 };
let rb = alloc in r Point { x: 1, y: 2 };
assert ra == rb; // two references: the values are compared
}
}Sequences compare element by element too: a slice, a fixed array and a Vec holding the same elements are equal, so you never need a hand-written byte-comparison loop. (== is not constant-time; comparing secrets belongs in a ct fn.)
The other kind of value is the linear resource, declared with resource struct, which cannot be copied at all; see Resources and linearity.
Generics
Type parameters go in square brackets; kola does not use <...> anywhere. The bound T: data means that T must be copyable:
fn first[T: data](xs: &[T]) -> Option[T] !{} {
if xs.len == 0 { None } else { Some(xs[0]) }
}
test generic_first {
let xs = [1u64, 2u64, 3u64];
assert first(&xs) == Some(1u64); // T = u64, inferred
let e = [0u64; 0];
assert first(&e) == None;
}A generic function is monomorphized: the compiler produces a specialized copy for each type it is used with, as if you had written first_u64 by hand. The program never has to look up at run time which version to call, so a generic function is as fast as a hand-written one.
The same brackets can carry a compile-time value. [const N: usize] binds N as a usize that you can use both as an array length ([T; N], including arithmetic such as [T; 2*N]) and as an ordinary number in the body. A const parameter's name is uppercase, like a type parameter's. Each distinct N gets its own copy, and N is inferred from the arguments:
fn sum_fixed[const N: usize](a: &[u64; N]) -> u64 !{} {
let mut s: u64 = 0;
for i in 0..N {
s = s +% a[i];
}
s
}
test const_generics {
let a = [1u64, 2u64, 3u64];
assert sum_fixed(&a) == 6; // N = 3, inferred from the argument
let b = [10u64, 20u64];
assert sum_fixed(&b) == 30; // N = 2: a separate copy
}Square brackets carry a third kind of parameter, a region, written in lowercase (fn f[r](x: &r T)). Regions are the subject of the next chapter. Generics covers all three kinds in full, including generic impl blocks and explicit instantiation.
Tests from day one
A test block lives in the same file as the code it tests and is both a check and a runnable usage example. Most examples above carry one:
kola test basics.klkola test runs every test on the interpreter and through the C backend and compares the results; --interp runs the interpreter alone, which is faster while you work.