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:
$ cargo build --release # produces target/release/lira
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.
$ lira run hello.li # type-check, compile, and execute $ lira check hello.li # type-check only, no output $ lira compile hello.li # emit bytecode → hello.lic $ lira ast hello.li # dump the parsed AST as JSON $ lira disasm hello.lic # disassemble compiled bytecode
Hello, Lira
Execution starts at fn main(). println is a
VM built-in — no import needed — and strings interpolate any expression
with $${…}.
// 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:
$ lira run hello.li
Hello, world!
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.
// `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 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.
// 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.
// `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 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 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.
// 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).
// 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:
$ lira check program.li 7:5: Cannot assign to immutable variable: total 12:13: Undefined variable: y
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.