Learn

The Zeno guide

Everything in the language, a step at a time. Every sample on this page was run with zeno run, and what is under it is exactly what Zeno printed, error messages included. If you know Python or C, you can read it in twenty minutes.

01

Install

Zeno is built from source. You need Rust, and a C compiler if you want zeno build. For zeno build --llvm you also need clang 15 or later. There is nothing else to install.

Terminal
git clone https://github.com/partham20/zeno-lang
cd zeno-lang
cargo build --release
./target/release/zeno run examples/hello.zeno

The rest of this guide writes zeno for that program. Put target/release on your PATH, or use cargo run -- in its place.

02

Your first program

A program is a file of statements, run from the top. There is no main, nothing to import for printing, and no semicolons: a line break ends a statement.

hello.zeno
print("Hello, Zeno!")
output
Hello, Zeno!
03

Variables

A variable says once whether it can change. let is set once. var can change, and a plain = updates it. const is fixed when the program is built. A type goes after the name, and only where you want one.

basics.zeno
let name = "Zeno"            # let: set once
var count = 3                # var: can change
count = count + 1            # plain = updates it
const LIMIT = 10             # const: fixed when the program is built

let ports: list[int] = [80, 443, 8080]   # a type, only where you want one
print(name, count, LIMIT, ports)
output
Zeno 4 10 [80, 443, 8080]
04

A misspelt name is an error

Because = only updates, assigning to a name that was never declared cannot quietly make a new variable. The checker stops before anything runs, and says what to write.

typo.zeno
var count = 3
cuont = count + 1
output
error: `cuont` is not declared. Create it with `let` or `var` first
 --> typo.zeno:2:1
  |
2 | cuont = count + 1
  | ^

1 error found. Nothing was run.
05

Numbers

/ always gives a float and // divides and rounds down, so you can see which one you meant. Comparisons chain as they do in maths. An int and a float can be mixed in arithmetic; nothing else converts by itself.

numbers.zeno
print(7 / 2, 7 // 2, 7 % 2, 2 ** 10)
print(1 < 2 < 3, 0 <= 5 < 4)
print(0xFF, 0b1010, 1 + 2.5)
output
3.5 3 1 1024
true false
255 10 3.5
06

Overflow is an error

Whole-number arithmetic is checked. A sum that does not fit is an error you can catch, never a silent wrap to a negative number.

overflow.zeno
var big = 9223372036854775807
big = big + 1
output
error: uncaught OverflowError: the result is too big for an int
 --> overflow.zeno:2:7
  |
2 | big = big + 1
  |       ^
07

Choices and loops

if, else if, for and while need no brackets around the condition and no colon after it. The brace opens the block, and the block always has braces, so a stray space can never change what the code does.

control.zeno
let temperature = 31

if temperature > 30 {
    print("hot")
} else if temperature > 15 {
    print("mild")
} else {
    print("cold")
}

for i in range(3) {
    print("tick", i)
}

var left = 3
while left > 0 {
    left -= 1
}
print("done, left =", left)
output
hot
tick 0
tick 1
tick 2
done, left = 0
08

Conditions are true or false

There is no truthiness. An empty list, zero and empty text are not quietly false: write what you mean, such as len(items) > 0.

truthy.zeno
let items = [1, 2]
if items {
    print("not empty")
}
output
error: a condition must be true or false, but this is list[int]
 --> truthy.zeno:2:4
  |
2 | if items {
  |    ^

1 error found. Nothing was run.
09

Functions

A function is declared with the word function. Types on parameters and the result are optional. Arguments can be named, parameters can have defaults, and a function with no name is written in place with function(n) { }.

functions.zeno
function area(width: float, height: float) -> float {
    return width * height
}

function loose_area(width, height) {     # the same, with no types written
    return width * height
}

function greet(who: str, excited: bool = false) -> str {
    if excited {
        return f"Hello, {who}!"
    }
    return f"Hello, {who}."
}

let triple = function(n) { return n * 3 }   # functions are values
print(area(2.0, 3.5), loose_area(2, 3))
print(greet("Achilles"), greet("tortoise", excited=true))
print([1, 2, 3].map(triple), [n * n for n in range(6) if n % 2 == 0])
output
7.0 6
Hello, Achilles. Hello, tortoise!
[3, 6, 9] [0, 4, 16]
10

Functions that remember

A function written inside another keeps the variables it can see, and can change them.

closures.zeno
function counter() {
    var count = 0
    return function() {
        count += 1
        return count
    }
}

let next_id = counter()
print(next_id(), next_id(), next_id())
output
1 2 3
11

Lists and text

Lists are written with [ ], and have append, map, filter and reduce. Comprehensions read like the sentence they stand for. Formatted text needs the f prefix, so a brace in ordinary text is just a brace.

lists.zeno
var ports = [80, 443]
ports.append(8080)
print(ports, len(ports), ports[0])

let evens = [n for n in range(10) if n % 2 == 0]
print(evens)
print(evens.map(function(n) { return n * 10 }))
print(evens.filter(function(n) { return n > 4 }))
print("a,b,c".split(","), "zeno".upper(), f"{3.14159:.2f}")
output
[80, 443, 8080] 3 80
[0, 2, 4, 6, 8]
[0, 20, 40, 60, 80]
[6, 8]
["a", "b", "c"] ZENO 3.14
12

Classes

A class lists its fields at the top and gets a constructor for free, which takes the fields in order or by name. Methods name self explicitly.

classes.zeno
from math import sqrt

class Point {
    x: float
    y: float

    function distance_to(self, other: Point) -> float {
        return sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
    }
}

let origin = Point(0.0, 0.0)
let here = Point(x=3.0, y=4.0)
print(here, origin.distance_to(here))
output
Point(x=3.0, y=4.0) 5.0
13

Enums and match

Each case of an enum can carry its own values, and match takes them apart. A match must handle every case. This one forgets Dot, so nothing runs.

shapes.zeno
enum Shape {
    Circle(radius: float)
    Rect(width: float, height: float)
    Dot
}

function area(shape: Shape) -> float {
    match shape {
        case Circle(r) { return 3.14159 * r * r }
        case Rect(w, h) { return w * h }
    }
}

print(area(Rect(2.0, 3.0)))
output
error: this `match` does not handle Dot. Add a `case` for it, or a final `case _`
 --> shapes.zeno:8:5
  |
8 |     match shape {
  |     ^

1 error found. Nothing was run.
14

Values that may be missing

A value can be none only if its type says or none. Before you use it, you check it with is not none, and inside that check it is the plain type.

users.zeno
function find_user(id: int) -> str or none {
    if id == 1 {
        return "partha"
    }
    return none
}

let user = find_user(1)
print(user.upper())              # not allowed: it might be none

if user is not none {
    print(user.upper())          # allowed inside this check
}
output
error: this is str or none. Check it with `is not none` before using `.upper`
 --> users.zeno:9:7
  |
9 | print(user.upper())              # not allowed: it might be none
  |       ^

1 error found. Nothing was run.
15

Errors

try, except and raise read as they do in Python. A function that can fail says so with raises, and the checker makes sure the error is handled or passed on. Any class can be raised, so an error can carry data.

ports.zeno
function parse_port(text: str) -> int raises ValueError {
    let port = int(text)             # int() may raise; it passes up
    if not 1 <= port <= 65535 {      # comparisons chain, as in maths
        raise ValueError("port out of range")
    }
    return port
}

try {
    print("listening on", parse_port("8080"))
    print("listening on", parse_port("99999"))
} except ValueError as err {
    print(f"error: {err}")
}
output
listening on 8080
error: port out of range
16

Units

A number can carry a unit: 500 ms, 3.3 V. The unit is part of the type, so a function that wants milliseconds will not take a bare number.

delay.zeno
from time import sleep

function blink_for(delay: ms) -> str {
    sleep(delay)
    return f"waited {delay}"
}

print(blink_for(20 ms))
print(3.3 V > 2.9 V)
print(blink_for(20))
output
error: `delay` of `blink_for` should be a quantity in ms, but this is int
  --> delay.zeno:10:17
   |
10 | print(blink_for(20))
   |                 ^

1 error found. Nothing was run.
17

Numbers with a size

u8, u16, u32, i8, i16 and i32 have a size, and &, |, ^, ~, << and >> work on their bits. Arithmetic gives a full int, u8(n) checks that n fits, and u8.wrap(n) keeps the low eight bits on purpose. A u8 field is one byte in a compiled object.

packet.zeno
class Header {
    sensor: u8          # one byte in the compiled struct
    value: u16          # two bytes
}

let total = 200 + 100            # arithmetic gives a full int: nothing wraps by accident
print(total, u8.wrap(total))     # wrapping is something you ask for
let flags = 0b1010 << 4 | 0x03   # the operators every datasheet uses
print(f"{flags:02x}", Header(sensor=3, value=u16(64000)))
print(u8(total))                 # 300 does not fit in a byte
output
300 44
a3 Header(sensor=3, value=64000)
error: uncaught OverflowError: 300 does not fit in a u8 (0 to 255)
  --> packet.zeno:10:7
   |
10 | print(u8(total))                 # 300 does not fit in a byte
   |       ^
18

Generics

class Stack[T] and function largest[T] are written once, with T standing for a type. A call never says what T is: the checker works it out, and the compilers make one copy for each type used, so a Stack[u8] is as small as one written for bytes by hand.

stack.zeno
class Stack[T] {
    items: list[T]

    function push(self, item: T) {
        self.items.append(item)
    }

    function top(self) -> T or none {
        if len(self.items) == 0 {
            return none
        }
        return self.items[len(self.items) - 1]
    }
}

function largest[T](items: list[T]) -> T {
    var best = items[0]
    for item in items {
        if item > best {
            best = item
        }
    }
    return best
}

let bytes: Stack[u8] = Stack([])
bytes.push(u8(72))
print(bytes.top(), largest([3.1 V, 3.3 V, 2.9 V]), largest(["boot", "ready"]))
output
72 3.3 V ready
19

Owned values

Most values are shared, and a garbage collector frees them. A value marked owned has one owner instead, and is freed the moment its owner's block ends. A borrow parameter only looks. Giving the value away empties the old name, and the checker points at any later use.

frame.zeno
owned class Frame {
    channel: int
    samples: owned list[int]
}

function capture(channel: int) -> Frame {
    let samples: owned list[int] = [n * channel for n in range(4)]
    return Frame(channel, samples)       # samples moves into the frame
}

function average(samples: borrow list[int]) -> int {   # only looks
    return sum(samples) // len(samples)
}

function send(frame: Frame) -> int {     # takes the frame over, and frees it
    return len(frame.samples)
}

let frame = capture(7)
print(average(frame.samples))
print(send(frame))
print(frame.channel)
output
error: `frame` was given away on line 21 and holds nothing now. Lend it instead (a `borrow` parameter), or use it before it goes
  --> frame.zeno:22:7
   |
22 | print(frame.channel)
   |       ^

1 error found. Nothing was run.
20

Pins and time

from board import led, pin and from time import sleep, now give a program pins and a clock. On a computer the board is simulated and prints each pin as it changes. A compiled program meets a real board through four C functions.

board.zeno
from board import led, pin
from time import sleep, now

let start = now()
let fan = pin(5)
fan.on()
for i in range(3) {
    led.toggle()
    sleep(10 ms)
}
fan.off()
print("took at least 30 ms:", now() - start >= 30 ms)
output
pin 5 on
led on
led off
led on
pin 5 off
took at least 30 ms: true
∞

The commands

One program, zeno, does everything. Every command checks the program first, and nothing runs if the check fails.

zeno run file.zeno
Check it, then run it with the interpreter.
zeno run --vm file.zeno
The same, compiled to bytecode and run by the VM.
zeno check file.zeno
Only look for mistakes.
zeno check --strict file.zeno
Every type must be known, as firmware needs.
zeno build file.zeno
Compile to C, then to a program.
zeno build --llvm file.zeno
Compile to LLVM IR, then to a program with clang.
zeno build --firmware
Errors as return values, no setjmp.
zeno build --no-collector
No garbage collector and no heap: one fixed pool.
zeno build --memory
The program reports its memory use as it ends.
zeno bytecode · parse · tokens
Look inside: the instructions, the syntax tree, the tokens.

Where next