A programming language, built from scratch

Reads like plain English. Checked before it runs.

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.

readings.zeno
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))
}
zeno run readings.zeno
21.5 °C
no reading
22.0 °C
0dependencies. The whole compiler is plain Rust and one C file.
4ways to run a program: interpreter, bytecode VM, C and LLVM.
300tests, near enough. Each program must print the same on every engine.
0bytes of heap in a firmware build. Memory is one fixed pool.
1/2

Two rules shape the syntax

Most languages inherit their punctuation. Zeno asks each mark to justify itself, and keeps the ones a reader already knows.

Elsewhere
if (!ready && (retries > 3 || timed_out)) {
    throw new Error("gave up");
}
Zeno
if not ready and (retries > 3 or timed_out) {
    raise Timeout("gave up")
}

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 ?.

2. Punctuation has to earn its place

A mark stays only if it tells the reader something the line does not already say.

Kept

{ }
You always see where a block ends, and a stray space cannot change what the code does.
( ) [ ] ,
Calls, lists and items. Everyone already reads these.
: ->
A name is separated from its type at a glance.
#
A comment. One character, hard to miss.
& | ^ ~ << >>
Bit operators, written the way every datasheet writes a register.

Dropped

;
The line break already ends the statement.
: after if
The brace already opens the block.
( ) around conditions
if x > 0 { needs no help.
&& || !
and, or and not read better.
Truthiness
A condition is true or false. An empty list is not quietly "false".

Types are optional

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.

No surprise none

A value can be none only if its type says or none, and you must check before you use it.

Errors are checked

A function says what it raises. try and except read as they do in Python, and cost little enough for a microcontroller.

Enums carry data

Each case can hold its own values, and a match has to cover every case before the program runs.

Numbers do not wrap by accident

Arithmetic overflow is an error. A u8 is one byte, and wrapping is something you ask for with u8.wrap(n).

Units are types

500 ms and 3.3 V are quantities. A function that wants milliseconds will not take a bare number.

Every design decision, with its reason

1/4

A tour, with the compiler's answers

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.

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]

The whole language in the guide

1/8

One job, four ways

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]
Typed, around a class
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))
No types written
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))
Around functions
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))
The short way
let kept = [r for r in readings if -40.0 <= r <= 85.0]
print(sum(kept) / len(kept))

Fifteen whole programs to read

1/16

The same language, on a microcontroller

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.

signal.zeno
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.

zeno build --no-collector --memory examples/buffer.zeno
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.

How the firmware build works

1/32

Inside the compiler

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.

Memory, three ways

A small mark-and-sweep collector by default. owned values freed where their owner ends. Or no collector at all, on a fixed pool.

Every stage is documented

Each part of the compiler has a walkthrough that ends in things to try, from the lexer to the LLVM backend.

Every decision has a reason

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.

Walk through every stage of the compiler

1/64

One language, every job

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.

Zeno 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.

The full roadmap

1/128

Run it yourself

You need Rust to build Zeno, and a C compiler for zeno build. There is nothing else to install.

Terminal
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.

Install and the first steps