1. Words over symbols
Every keyword is a word you can read aloud: function, and, or, not, match, case, raise. There is no fn, &&, => or ?.
A programming language, built from scratch
Zeno is one small language for a quick script and for a microcontroller. Keywords are whole words, punctuation is kept only where it helps, and the compiler finds the mistake and says what to write instead.
enum Reading {
Value(celsius: float)
Gap
}
function describe(reading: Reading) -> str {
match reading {
case Value(c) { return f"{c:.1f} °C" }
case Gap { return "no reading" }
}
}
for reading in [Value(21.5), Gap, Value(22.04)] {
print(describe(reading))
}
21.5 °C
no reading
22.0 °C
Most languages inherit their punctuation. Zeno asks each mark to justify itself, and keeps the ones a reader already knows.
if (!ready && (retries > 3 || timed_out)) {
throw new Error("gave up");
}
if not ready and (retries > 3 or timed_out) {
raise Timeout("gave up")
}
Every keyword is a word you can read aloud: function, and, or, not, match, case, raise. There is no fn, &&, => or ?.
A mark stays only if it tells the reader something the line does not already say.
{ }( ) [ ] ,: ->#& | ^ ~ << >>;: after if( ) around conditionsif x > 0 { needs no help.&& || !and, or and not read better.true or false. An empty list is not quietly "false".Write function area(w: float, h: float) -> float, or just function area(w, h). Start loose and add types as the program grows. Where they are left out, the checker works them out when it can.
noneA value can be none only if its type says or none, and you must check before you use it.
A function says what it raises. try and except read as they do in Python, and cost little enough for a microcontroller.
Each case can hold its own values, and a match has to cover every case before the program runs.
Arithmetic overflow is an error. A u8 is one byte, and wrapping is something you ask for with u8.wrap(n).
500 ms and 3.3 V are quantities. A function that wants milliseconds will not take a bare number.
Each sample below was run with zeno run. What you see under it is exactly what Zeno printed, error messages included.
A variable says once whether it can change. let is set once, var changes, and a plain = updates. Assigning to a misspelt name is an error, never a new variable.
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]Types are written where you want them and left out where you do not. Functions are values: pass them around, return them, or write one in place.
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]This program forgets one case. Zeno refuses to run it, names the case, and says how to fix it.
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.A function declares what it raises. An error passes up until a try catches it. Comparisons chain, as they do in maths.
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 rangeA value that may be missing has to be checked first. The first print skips the check, so nothing runs.
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.A number can carry its unit. Passing a bare 20 where milliseconds are wanted is caught before the program runs.
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.u8, u16, u32, i8, i16 and i32 have a size. Arithmetic gives a full int, u8(n) checks that n fits, and u8.wrap(n) cuts on purpose.
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
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
| ^Write a class or function once, with T standing for a type. The call never says what T is. Each use is checked, and compiled as if written by hand for that type.
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 readyAn owned value has one owner and is freed where that owner's block ends. borrow lends it for a call. Use it after giving it away, and the checker points at the line.
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.A sensor gives five readings. Throw away the ones out of range and average the rest. Zeno does not make you pick a style, and every version prints 21.5.
let readings = [21.5, 22.0, -99.0, 21.0, 150.0]
class Sensor {
low: float
high: float
function average(self, readings: list[float]) -> float {
var total: float = 0.0
var count: int = 0
for reading in readings {
if self.low <= reading <= self.high {
total += reading
count += 1
}
}
return total / count
}
}
print(Sensor(-40.0, 85.0).average(readings))function average(readings, low, high) {
var total = 0.0
var count = 0
for reading in readings {
if low <= reading <= high {
total += reading
count += 1
}
}
return total / count
}
print(average(readings, -40.0, 85.0))function in_range(low: float, high: float) {
return function(reading: float) -> bool {
return low <= reading <= high
}
}
function add(total: float, reading: float) -> float {
return total + reading
}
let valid = readings.filter(in_range(-40.0, 85.0))
print(valid.reduce(add, 0.0) / len(valid))let kept = [r for r in readings if -40.0 <= r <= 85.0]
print(sum(kept) / len(kept))Firmware is where Zeno started. The program below is a traffic light written as a state machine. On a computer the board is simulated, and each line of the log is what a logic analyser clipped to that pin would show.
from board import pin, led
from time import sleep
enum Light {
Red
Green
Amber
}
let red = pin(2)
let amber = pin(3)
let green = pin(4)
function hold(light: Light) -> ms {
match light {
case Red { return 40 ms }
case Green { return 30 ms }
case Amber { return 10 ms }
}
}
var light = Red
for step in range(7) {
show(light) # old lamp off, new lamp on
led.toggle() # a heartbeat
sleep(hold(light))
light = after(light)
}
pin 2 on
led on
The simulated board, slowed down 25 times. The excerpt leaves out show and after; the whole program is in examples/signal.zeno.
zeno build --firmware
Errors become return values that every caller tests. The C that comes out has no setjmp.
zeno build --no-collector
For a program where every value has an owner. The garbage collector is left out of the build altogether.
A fixed memory pool
No malloc and no free. Lists and objects live in one array whose size you choose when the C is compiled.
A real board is four C functions
A compiled program meets its board through four functions, so real hardware can be put underneath the same source.
memory: 1691 blocks made (2646563 bytes), at most 13336 bytes in use at once, no collector, 0 blocks left at the end
pool: 25384 bytes taken, in a pool of 32768
That program makes 2.6 MB of values over its run and never holds more than 13 KB, because each one is freed at a line you can point to.
Zeno is written in Rust with no dependencies, so every stage is there to read. One front end feeds four engines, and the tests hold them to the same output.
none, errors, ownershipzeno runWalks the syntax tree. The reference for what a program means.zeno run --vmA stack machine, two to three times as fast.zeno buildPortable C, for any chip with a C compiler.zeno build --llvmTextual LLVM IR, built with clang. No LLVM library needed.A small mark-and-sweep collector by default. owned values freed where their owner ends. Or no collector at all, on a fixed pool.
Each part of the compiler has a walkthrough that ends in things to try, from the lexer to the LLVM backend.
One document records each design choice, why it was made and what was turned down. If you disagree, you can see what you are disagreeing with.
The goal is that anything you would reach for C, C++, Python, JavaScript, Java or Rust to do, you can do in Zeno. That is a long road. This is where it stands.
unsafe { }, raw pointers, memory-mapped registersNextasync and awaitPlannedZeno is young and the syntax can still change. It is a language to learn from and experiment with, and not yet one to ship a product on.
You need Rust to build Zeno, and a C compiler for zeno build. There is nothing else to install.
git clone https://github.com/partham20/zeno-lang
cd zeno-lang
cargo run -- run examples/tour.zeno
zeno run file.zenoCheck a program, then run it.zeno run --vm file.zenoThe same, on the bytecode VM.zeno check --strict file.zenoEvery type must be known, as firmware needs.zeno build file.zenoCompile to C, then to a program.zeno build --llvm file.zenoCompile through LLVM IR and clang.zeno bytecode file.zenoShow the VM's instructions, one per line.