Hands-on: the eight-step ladder
Eight small steps, each adding one idea of the language. Every step is a short .kl program you can run on this page: press "Run" or "Tests" under the example, edit the code and run it again. The interpreter runs in the browser, so there is nothing to install, and each step links to the chapter that covers its idea in depth.
Step 1. Saying something, with no authority at all
File hello.kl:
use std::log;
fn main() -> u8 !{log} {
log::info("hello, kola");
0
}Hover over the highlighted pieces for explanations. Break it: change the effect row from !{log} to !{}, which promises that the function does nothing at all, and check it again:
error[card §6]: `main` performs `log` (call to `std::log::info`) but does not declare it
--> hello.kl:4:5
|
4 | log::info("hello, kola");
| ^^^^^^^^^^^^^^^^^^^^^^^^
= help: add `log` to the effect clause, or hand the work to a caller that has itAn effect is a kind of thing a function does besides computing its result: log writes to the log, io touches the outside world, alloc allocates memory. The effect row lists them, and !{} means none at all. This is the idea of the language in miniature: a signature is a claim, and the compiler does not let it be false. An effect row cannot drift out of date the way a comment can, because it is checked at every call boundary. Learn more: Quick start.
Step 2. A function and the first test
File area.kl. Tests live next to the code, in the same file:
fn area(w: u32, h: u32) -> u64 !{} {
widen[u64](w) * widen[u64](h)
}
test area_works {
assert area(3, 4) == 12;
assert area(0, 100) == 0;
}u32 and u64 are unsigned integers of 32 and 64 bits. The product of two 32-bit numbers can need up to 64 bits, which is why the function widens both before multiplying. Break it: replace 12 with 13 to see what a failing test looks like. Then drop the widen calls and multiply w * h directly. The result is a u32 where the signature promises a u64, and the compiler refuses to guess which conversion you meant:
error[card §2]: mixed integer widths `u64` and `u32` (no implicit conversions — widen[u64](x) to go wider, wrap[u32](x) to keep the low bits, narrow[u32](x) -> Option to check the fit)Learn more: Basics.
Step 3. Enums and match
File lights.kl:
enum Light { Red, Yellow, Green }
fn seconds_to_wait(l: Light) -> u64 !{} {
match l {
Light::Red => 30,
Light::Yellow => 3,
Light::Green => 0,
}
}
test lights {
assert seconds_to_wait(Light::Red) == 30;
assert seconds_to_wait(Light::Green) == 0;
}Break it: delete the arm Light::Yellow => 3,. A match in kola must cover every variant:
error[card §1]: match is not exhaustive: missing Yellow
--> lights.kl:4:5
|
4 | match l {
| ^^^^^^^^^
= help: match is exhaustive in kola; add the missing arms or `_`A forgotten variant is a compile error, not a surprise in production. Learn more: Basics.
Step 4. Allocating memory: a region and a Vec
File memory.kl. kola has no malloc and no free. Memory comes from a region, and the whole region is released at its closing brace:
use std::log;
fn total(xs: &[u64]) -> u64 !{} {
let mut s: u64 = 0;
for i in 0..xs.len {
s = s +% xs[i];
}
s
}
fn main() -> u8 !{log, alloc(heap)} {
region r {
let v: &r mut Vec[u64] = alloc in r mut Vec::new();
let mut i: u64 = 1;
while i <= 10 {
v.push(i);
i = i + 1;
}
log::info(f"sum 1..=10 = {total(v)}");
}
0
}
test totals {
let a = [2u64, 3u64, 5u64];
assert total(&a) == 10;
}The f in front of f"sum 1..=10 = {total(v)}" makes the literal interpolate: each {...} hole holds an expression. A plain "..." never interpolates.
Try it: add a test that opens a region of its own and puts a buffer in it, test peak { region r { let buf = alloc in r [0u8; 4096]; assert buf.len == 4096; } }, then press "Tests". On an installed kola, run it with kola test --sim memory.kl, and the simulator reports how much memory each region actually used:
memory.kl: test peak ... ok
region r: 4096 bytes region-charged high-water (arena-faithful)How much memory a component needs is something you measure in a test rather than discover when the operating system kills the process for running out of memory. And main still takes no sys: allocating in a plain region needs the alloc(heap) effect, not a capability. Only a region with its own budget (region r from small) needs one. Learn more: Memory: regions and references.
Step 5. A resource: a value you cannot lose
File tempfile.kl. Anything that other languages handle with "don't forget to close it" (a file, a socket, a transaction) is declared in kola as a resource:
use std::log;
resource struct TempFile { writes: u32 }
impl TempFile {
pub fn create() -> TempFile !{} {
TempFile { writes: 0 }
}
pub fn write(self: &mut TempFile) !{} {
self.writes = self.writes + 1;
}
pub fn cleanup(self: TempFile) !{log} {
log::info(f"removed after {self.writes} writes");
let TempFile { writes: _ } = self;
}
}
fn main() -> u8 !{log} {
let mut f = TempFile::create();
f.write();
f.write();
f.cleanup();
0
}Break it: delete the line f.cleanup();:
error[card §2]: resource `f` is not consumed on this path
--> tempfile.kl:19:5
|
19 | let mut f = TempFile::create();
| ^^^
= help: no implicit destructors: move it on, return it, destructure it, or pass it to a consuming fnIn C, forgetting fclose leaks a file descriptor, the small number the operating system uses to track an open file; a long-running server that leaks them eventually cannot open any more. In kola a forgotten close is a compile error rather than a leak. Learn more: Resources and linearity.
Step 6. Capabilities: a sandbox made of three functions
File sandbox.kl. This step gives each function exactly as much access to the world as it needs:
use std::log;
enum Verdict { Clean, Suspicious }
// Parses untrusted bytes. Receives no capability and declares no effect.
pub fn scan[r](payload: &r [u8]) -> Verdict !{} {
let mut zeros = 0;
for i in 0..payload.len {
if payload[i] == 0 { zeros = zeros + 1; }
}
if zeros * 4 > payload.len { Verdict::Suspicious } else { Verdict::Clean }
}
// The only function that speaks. Logging is an effect, not a
// capability, so even this one holds no authority over the world.
fn report(v: Verdict) !{log} {
match v {
Verdict::Clean => log::info("payload: clean"),
Verdict::Suspicious => log::info("payload: suspicious"),
}
}
fn main() -> u8 !{log, alloc(heap)} {
region r {
let good = alloc in r [1u8, 2u8, 3u8, 4u8];
let bad = alloc in r [0u8, 0u8, 7u8, 0u8];
report(scan(good));
report(scan(bad));
}
0
}
test clean_payload_passes {
region r {
let p = alloc in r [1u8, 2u8, 3u8, 4u8];
assert scan(p) == Verdict::Clean;
}
}
test zero_heavy_payload_flagged {
region r {
let p = alloc in r [0u8, 0u8, 7u8, 0u8];
assert scan(p) == Verdict::Suspicious;
}
}Break it: make scan say something. Add log::info(f"scanning {payload.len} bytes"); as the first line of its body:
error[card §6]: `scan` performs `log` (call to `std::log::info`) but does not declare it
--> sandbox.kl:7:5
|
7 | log::info(f"scanning {payload.len} bytes");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= help: add `log` to the effect clause, or hand the work to a caller that has itNow try something with real reach. Replace that line with let t = clock.now();:
error[card §1]: unknown name `clock`
--> sandbox.kl:7:13
|
7 | let t = clock.now();
| ^^^^^kola has no globals, so there is no clock to reach for. The only way to get one is as a parameter. Add clock: &Clock to the parameter list and the next wall appears, because reading a clock is the io effect and the effect row !{} forbids it:
error[card §6]: `scan` performs `io` (Clock::now) but does not declare itWidening the row to !{io} would mean every caller passes a Clock and declares io too, all the way up to main, visibly in every signature on the way. So to audit scan you read its signature, and that stays true if the body is rewritten tomorrow, generated by a model, or grows to a thousand lines. Learn more: Effects and capabilities.
Step 7. Contracts and reproducible randomness
File contracts.kl:
fn pick(xs: &[u64], i: usize) -> u64 !{}
req i < xs.len
{
xs[i]
}
fn dice(rng: &mut Rng) -> u64 !{}
ens ret >= 1, ret <= 6
{
rng.weak_u64() % 6 + 1
}
test picking {
let xs = [10u64, 20u64, 30u64];
assert pick(&xs, 2) == 30;
}
test dice_is_fair_enough(mut sim: Sim) {
let mut i = 0;
while i < 100 {
let d = dice(&mut sim.rng);
assert d >= 1;
assert d <= 6;
i = i + 1;
}
}Break it: change the call in the first test to pick(&xs, 5):
contracts.kl: test picking ... FAILED (seed 1802464353)
error[card §8]: req violated calling `pick`
--> contracts.kl:2:9
|
2 | req i < xs.len
| ^^^^^^^^^^
= help: contracts are always enforced: proven statically or trapped at runtime
contracts.kl: test dice_is_fair_enough ... ok
1 passed, 1 failedThe error points at the contract you violated. The seed beside the failure is the starting number for the test's pseudo-random generator: run the test again with that seed and every "random" value comes out the same, so the failure reproduces exactly. pick never read out of bounds, because the check ran before the body. The correct call pick(&xs, 2) costs nothing at run time: the compiler proved it and removed the check. What can be proven disappears, what cannot is enforced, and neither can be switched off.
On an installed kola, run kola test --sim --seed 7 contracts.kl twice. The two runs match exactly, because all randomness flows from the seed. Learn more: Contracts, traps, and tests.
Step 8. Finale: a binary frame parser
File frame.kl. This step puts the pieces together: a struct holding a reference, a region, Result and the standard library. The frame format is one tag byte, the payload length as a big-endian u16, then the payload. Big-endian means the most significant byte comes first, the usual order in network protocols:
use std::encoding::bytes;
// Frame: [ tag:u8 | len:u16 be | payload:len bytes ]
enum FrameErr { Short, LengthMismatch }
struct Frame[r] { tag: u8, payload: &r [u8] }
fn parse[r](b: &r [u8]) -> Result[Frame[r], FrameErr] !{} {
if b.len < 3 { return Err(FrameErr::Short); }
let len = bytes::rd16(b, 1);
if widen[usize](len) + 3 != b.len { return Err(FrameErr::LengthMismatch); }
Ok(Frame { tag: b[0], payload: b[3..] })
}
test parses_good_frame {
region r {
let b = alloc in r [7u8, 0u8, 2u8, 170u8, 187u8];
match parse(b) {
Ok(f) => {
assert f.tag == 7;
assert f.payload.len == 2;
assert f.payload[0] == 170;
}
Err(_) => { assert false; }
}
}
}
test rejects_short {
region r {
let b = alloc in r [7u8, 0u8];
assert parse(b) == Err(FrameErr::Short);
}
}
test rejects_length_mismatch {
region r {
let b = alloc in r [7u8, 0u8, 9u8, 170u8];
assert parse(b) == Err(FrameErr::LengthMismatch);
}
}Exercise: add a FrameErr::BadTag variant, reject frames whose tag is 0, and write a test for it. If any match on FrameErr names its variants one by one, it stops compiling the moment you add the new one, which is step 3's exhaustiveness at work.
The finished parser is a pure function holding no capability: the sandbox from step 6, arrived at by construction. Most real kola code has this shape, with hostile bytes in, plain data out, and the capabilities kept at the edge of the program where a reviewer sees them all at once.
The module is std::encoding::bytes, not std::bytes; guess wrong and the compiler tells you where it lives. kola help std-index lists every public item of the standard library.