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.
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.
git clone https://github.com/partham20/zeno-lang
cd zeno-lang
cargo build --release
./target/release/zeno run examples/hello.zenoThe rest of this guide writes zeno for that program. Put target/release on your PATH, or use cargo run -- in its place.
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.
print("Hello, Zeno!")Hello, Zeno!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.
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)Zeno 4 10 [80, 443, 8080]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.
var count = 3
cuont = count + 1error: `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.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.
print(7 / 2, 7 // 2, 7 % 2, 2 ** 10)
print(1 < 2 < 3, 0 <= 5 < 4)
print(0xFF, 0b1010, 1 + 2.5)3.5 3 1 1024
true false
255 10 3.5Overflow 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.
var big = 9223372036854775807
big = big + 1error: uncaught OverflowError: the result is too big for an int
--> overflow.zeno:2:7
|
2 | big = big + 1
| ^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.
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)hot
tick 0
tick 1
tick 2
done, left = 0Conditions 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.
let items = [1, 2]
if items {
print("not empty")
}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.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) { }.
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])7.0 6
Hello, Achilles. Hello, tortoise!
[3, 6, 9] [0, 4, 16]Functions that remember
A function written inside another keeps the variables it can see, and can change them.
function counter() {
var count = 0
return function() {
count += 1
return count
}
}
let next_id = counter()
print(next_id(), next_id(), next_id())1 2 3Lists 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.
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}")[80, 443, 8080] 3 80
[0, 2, 4, 6, 8]
[0, 20, 40, 60, 80]
[6, 8]
["a", "b", "c"] ZENO 3.14Classes
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.
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))Point(x=3.0, y=4.0) 5.0Enums 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.
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)))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.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.
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
}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.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.
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}")
}listening on 8080
error: port out of rangeUnits
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.
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))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.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.
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 byte300 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
| ^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.
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"]))72 3.3 V readyOwned 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.
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)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.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.
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)pin 5 on
led on
led off
led on
pin 5 off
took at least 30 ms: trueThe 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.