Guide

Getting started

Install the CLI, write your first program, and work up through the core building blocks — values, functions, control flow, strings, arrays, structs, enums, Result, and a first taste of concurrency.

Lira is a statically-checked language that compiles to bytecode and runs on a small virtual machine. You write .li source, the checker proves it sound, and the unified lira CLI runs it. This page is a progressive tour: each section builds on the last, and every snippet is a real file in the repository, verified to run before this site is built.

Install & the CLI

Lira builds from source with Cargo. From a clone of the repository, build the release binary once:

A single binary, lira, drives the whole toolchain. The two commands you reach for first are run and check; the rest expose each stage of the pipeline for when you want to look inside.

Hello, Lira

Execution starts at fn main(). println is a VM built-in — no import needed — and strings interpolate any expression with $${…}.

hello.li
// Your first Lira program. `fn main()` is the entry point;
// `println` is a built-in, so no import is needed.
fn main() {
    let name = "world"
    println("Hello, ${name}!")
}

Run it:

Values and bindings

Bind a value with let for an immutable binding, or var when you need to reassign it later; const is a compile-time constant. Local bindings infer their type from the initializer — you only annotate where there is nothing to infer from, such as function parameters and return types.

values.li
// `let` binds an immutable value; `var` binds one you can reassign.
// `const` is a compile-time constant. Local bindings infer their type
// from the initializer — you annotate only where there's nothing to
// infer from, like function parameters and return types.
const MAX_RETRIES = 3

fn main() {
    let name = "Lira"      // inferred string
    let answer = 42        // inferred int
    let ratio = 0.5        // inferred float
    let ready = true       // inferred bool

    var count = 0          // var: reassignable
    count = count + 1
    count = count + 1

    println("${name}: answer=${answer} ratio=${ratio} ready=${ready}")
    println("count=${count} of ${MAX_RETRIES}")
}

Functions

Functions annotate each parameter and, if they return a value, the return type after ->. A function with no -> T returns nothing. Inside a body, let bindings infer their type from the value.

functions.li
// Functions annotate their parameters and return type.
// Inside a body, `let` bindings infer their type from the value.
fn add(a: int, b: int) -> int {
    return a + b
}

// A function with no `-> T` returns nothing.
fn announce(label: string, value: int) {
    println("${label} = ${value}")
}

fn main() {
    let sum = add(2, 3)        // inferred int
    announce("sum", sum)
    announce("doubled", add(sum, sum))
}

Named arguments & defaults

A parameter may declare a default value. Callers can pass arguments positionally, or by name with name: value in any order — and skip any parameter that has a default. Named arguments make call sites self-documenting and let defaults stand in for the rest.

named_args.li
// Parameters may declare a default value. Callers can pass arguments
// positionally, or by name (`name: value`) in any order — and skip any
// parameter that has a default.
fn greet(name: string, greeting: string = "Hello", excited: bool = false) -> string {
    let mark = if excited { "!" } else { "." }
    return "${greeting}, ${name}${mark}"
}

fn main() {
    println(greet("Ada"))                              // both defaults
    println(greet("Grace", greeting: "Hi"))            // override one
    println(greet("Lin", greeting: "Hey", excited: true))
    println(greet("Bob", excited: true))               // skip the middle default
}

Control flow

if is an expression: each branch yields a value, so you can bind the whole thing to a let. while, for…in, and the unbounded loop  (which you leave with break) drive iteration. And match — the workhorse — is an expression too: each arm yields a value and the whole match evaluates to the arm that fits.

control.li
// `if` is an expression: each branch yields a value, so you can bind
// the whole thing to a `let`. `while`, `for`, and `loop` drive iteration,
// and `match` (covered below) is also an expression.
fn classify(n: int) -> string {
    return match n {
        0 => "zero",
        _ => if n > 0 { "positive" } else { "negative" }
    }
}

fn main() {
    // if-as-expression
    let parity = if 7 % 2 == 0 { "even" } else { "odd" }
    println("7 is ${parity}")

    // while
    var i = 0
    while i < 3 {
        println("tick ${i}")
        i = i + 1
    }

    // loop + break (an unbounded loop you exit explicitly)
    var n = 1
    loop {
        n = n * 2
        if n > 20 { break }
    }
    println("first power of two over 20 is ${n}")

    println(classify(7))
    println(classify(0))
    println(classify(-2))
}

match is far richer than this — literals, bindings, guards, and enforced exhaustiveness all live on the pattern matching page.

Strings & interpolation

String literals interpolate any expression with $${…} and concatenate with +. Importing std.strings adds method-style helpers like to_upper and contains. To keep a literal dollar-brace, escape it as \$${}.

strings.li
// Strings interpolate any expression with ${...}, concatenate with +,
// and the std.strings module adds methods like to_upper and contains.
import std.strings

fn main() {
    let name = "Lira"
    let version = 7

    println("Welcome to ${name}, v${version}")
    println("2 + 2 = ${2 + 2}")

    // Concatenate with +.
    let shout = name + "!"
    println(shout)

    // Methods from std.strings.
    println(name.to_upper())
    println("${name} contains 'ir': ${name.contains("ir")}")

    // \${ stays a literal dollar-brace.
    println("a literal \${placeholder}")
}

Arrays

Arrays are ordered and homogeneous. Index with [i], ask for len(...), grow them with push(...), and walk them with for…in.

arrays.li
// Arrays are ordered, homogeneous, and grow with push. Index with [i],
// ask for len(...), and walk them with for-in.
fn main() {
    let nums = [4, 8, 15, 16, 23, 42]

    println("first=${nums[0]} last=${nums[5]} len=${len(nums)}")

    // Sum with for-in.
    var total = 0
    for n in nums {
        total = total + n
    }
    println("sum=${total}")

    // Build one up with a `var` binding and push.
    var doubled = []
    for n in nums {
        push(doubled, n * 2)
    }
    println("doubled first=${doubled[0]} len=${len(doubled)}")
}

Structs, enums & Result

Structs group named fields; construct one with Name { field: value } and read fields back with dot access. Enums model a fixed set of cases, some of which carry data, and match reads them back exhaustively — the checker rejects a match that forgets a case. Result<T, E> is the idiom for fallible work, and the ? operator returns early on an Err so the happy path stays flat.

data.li
// Structs hold named fields; enums model a fixed set of cases, some of
// which carry data. `match` reads them back, and the checker enforces
// that every case is handled.
struct User {
    name: string,
    age: int,
}

enum Plan {
    Free,
    Pro,
    Team(int),   // number of seats
}

fn price(p: Plan) -> int {
    return match p {
        Plan::Free => 0,
        Plan::Pro => 12,
        Plan::Team(seats) => seats * 9,
    }
}

// Result<T, E> is the idiom for fallible work; `?` returns early on Err.
fn parse_age(raw: int) -> Result<int, string> {
    if raw < 0 {
        return Result::Err("age cannot be negative")
    }
    return Result::Ok(raw)
}

fn make_user(name: string, raw: int) -> Result<User, string> {
    let age = parse_age(raw)?        // propagates the Err if there is one
    return Result::Ok(User { name: name, age: age })
}

fn main() {
    match make_user("Ada", 36) {
        Result::Ok(user) => println("${user.name} is ${user.age}"),
        Result::Err(e) => println("error: ${e}"),
    }

    println("Free=${price(Plan::Free)} Pro=${price(Plan::Pro)} Team(5)=${price(Plan::Team(5))}")
}

A taste of concurrency

Concurrency is one of Lira's signature features. spawn runs a function on a lightweight fiber — a green thread scheduled by the VM — and channels carry typed values between fibers. A select arm binds a received value (using recv as a bare expression yields an ok-bool, so the select arm is how you capture the value itself).

concurrency.li
// A taste of concurrency: `spawn` runs a function on a lightweight fiber,
// channels carry typed values between fibers, and a `select` arm binds
// a received value. (recv(ch) as a bare expression yields an ok-bool, so
// the select arm is how you capture the value itself.)
fn square(out: Channel<int>, n: int) {
    send(out, n * n)
}

fn main() {
    let ch = chan(1)         // buffered channel holding one int

    spawn square(ch, 9)      // runs on its own fiber

    select {
        v = <-ch => println("9 squared is ${v}")
    }
}

That is the whole shape in miniature. Fibers, buffered vs. unbuffered channels, select multiplexing, mutexes, semaphores, and wait groups all get the full treatment on the concurrency page.

When something is wrong

The checker runs before any bytecode is generated and reports errors with a line:column location. These are the compiler's real messages — for example, assigning to a let binding or using a name that was never defined:

Because lira check never produces output on success, it is the fast inner loop: fix until it is silent, then lira run.

Where to go next

From here, three areas go deeper: concurrency with fibers, channels, and select; the type system with generics, optionals, and Result; and pattern matching with enforced exhaustiveness. The standard library covers the everyday work in between, and the examples gallery collects real programs you can read end to end.