Language tour

A fast tour of Oska, one idea at a time. Every program on this page is checked by oskac when the documentation is tested, and the output shown is what oskac run prints. The full rules are in the design notes. The words in bold are the project's glossary terms.

Functions, bindings, and blocks

A function states the type of every parameter and of its result. Nothing is inferred across a function boundary. The last expression of a block is its value, and return leaves early.

Bindings are immutable. let binds a value, and var makes a binding that can be assigned to. There is no ;: a line break ends a statement.

fn add(a: Int, b: Int) -> Int {
    a + b
}

fn clamp_to_ten(n: Int) -> Int {
    if n > 10 {
        return 10
    }
    n
}

fn main(io: Stdout) -> Result<(), IoError> {
    let sum = add(3, 4)
    var best = clamp_to_ten(sum)
    best = best + 1
    io.println("best is {best}")?
    Ok(())
}
best is 8

Things to notice:

Structs, methods, and mutation

A struct is built with call syntax. Fields declared by name must all be named, in declaration order.

A method is declared in an impl block with self as its first parameter. A mutating method declares var self. It can be called only on a var binding, and no other parameter can be mutated.

struct Counter { count: Int, step: Int }

impl Counter {
    fn current(self) -> Int {
        self.count
    }

    fn tick(var self) {
        self.count = self.count + self.step
    }
}

fn main(io: Stdout) -> Result<(), IoError> {
    var counter = Counter(count: 0, step: 5)
    counter.tick()
    counter.tick()
    let now = counter.current()
    io.println("count is {now}")?
    Ok(())
}
count is 10

Values are copied when they are shared and then changed, so assigning a var never changes another binding. There are no references to mutate through and no shared mutable state.

Enums and match

An enum's variants can carry data. Variants are declared with positional fields, Rect(Int, Int), or named fields, Circle { radius: Int }. They are built and matched with call syntax.

match must be exhaustive. Each arm sits on its own line, with no commas. An arm after a catch-all arm is an error because it is unreachable.

enum Shape {
    Circle { radius: Int },
    Rect(Int, Int),
    Empty,
}

fn area(shape: Shape) -> Int {
    match shape {
        Shape.Circle(radius: r) => 3 * r * r
        Shape.Rect(w, h) => w * h
        Shape.Empty => 0
    }
}

fn main(io: Stdout) -> Result<(), IoError> {
    let a = area(Shape.Rect(3, 4))
    let b = area(Shape.Circle(radius: 2))
    io.println("{a} {b}")?
    Ok(())
}
12 12

Capitalization is enforced: types, variants, and traits start with an uppercase letter, and everything else starts with a lowercase letter. In a pattern, a bare uppercase name is always a fieldless variant, so a misspelled variant is an error instead of a silent catch-all (E0250).

Option, Result, and ?

There is no null. An absent value is an Option<T>, and a failure is a Result<T, E>. Neither can be ignored. ? returns the None or the Err early, from a function that returns the same kind.

fn first_number(text: String) -> Option<Int> {
    let parts = text.split(",")
    let first = parts.get(0)?
    first.parse_int()
}

fn describe(text: String) -> String {
    match first_number(text) {
        Some(n) => "found {n}"
        None => "nothing"
    }
}

fn main(io: Stdout) -> Result<(), IoError> {
    io.println(describe("42,7"))?
    io.println(describe("x,7"))?
    Ok(())
}
found 42
nothing

To cross from one kind to the other, convert explicitly, for example with .ok_or(err)?. A ? on a Result whose error type differs from the function's needs an impl From<E1> for E2; the capabilities example has one.

Loops, ranges, lists, and closures

for x in xs walks a List or a range. a..b excludes b. There is no loop keyword; write while true. break and continue work as usual.

fn sum_to(n: Int) -> Int {
    var total = 0
    for i in 0..n {
        total = total + i
    }
    total
}

fn main(io: Stdout) -> Result<(), IoError> {
    var numbers: List<Int> = List.new()
    numbers.push(1)
    numbers.push(2)
    numbers.push(3)
    let doubled = numbers.map(|x| x * 2)
    let first = doubled.get(0).unwrap_or(0)
    let count = doubled.len()
    let total = sum_to(5)
    io.println("{first} {count} {total}")?
    Ok(())
}
2 3 10

List.get returns an Option. List.at panics with P0002 when the index is out of bounds, because that is a bug. A closure is written |x| expr or |x| { ... }. A closure that captures a capability has the type cap fn; see capabilities.

Generics and traits

Traits are explicitly implemented interfaces. They can provide default methods. There are no trait objects, so all dispatch is static, and there is no inheritance or overloading. Bounds are written inline.

trait Describe {
    fn describe(self) -> String

    fn shout(self) -> String {
        "LOUD"
    }
}

struct Dog { name: String }

impl Describe for Dog {
    fn describe(self) -> String {
        "a dog"
    }
}

fn pick<T>(a: T, b: T, first: Bool) -> T {
    if first { a } else { b }
}

fn same<T: Eq>(a: T, b: T) -> Bool {
    a == b
}

fn main(io: Stdout) -> Result<(), IoError> {
    let rex = Dog(name: "Rex")
    io.println(rex.describe())?
    io.println(rex.shout())?
    let chosen = pick(1, 2, false)
    io.println("chosen {chosen}")?
    if same(chosen, 2) {
        io.println("it is two")?
    }
    Ok(())
}
a dog
LOUD
chosen 2
it is two

A generic function is checked once, against its bounds. fn same<T: Eq> can use == but not +.

Interpolating a value needs Display, which you implement for your own types. This block is a fragment, not a complete program, because Dog is declared elsewhere:

impl Display for Dog {
    fn to_string(self) -> String {
        let n = self.name
        "dog {n}"
    }
}

Complete, with the declaration beside it:

struct Dog { name: String }

impl Display for Dog {
    fn to_string(self) -> String {
        let n = self.name
        "dog {n}"
    }
}

fn main(io: Stdout) -> Result<(), IoError> {
    let rex = Dog(name: "Rex")
    io.println("hello {rex}")?
    Ok(())
}
hello dog Rex

The let is needed because a {...} hole that starts with self is not resolved in this release (E0221): copy the field into a local first. Bool has no Display, so a Bool cannot be interpolated (E0265).

Text

Strings are UTF-8. len() counts characters (Unicode code points), and byte_len() counts bytes. The prelude's text methods are lines, len, byte_len, trim, split, contains, starts_with, ends_with, replace, join, chars, slice, repeat, and parse_int. + works only on numbers, so build strings by interpolation.

Text also carries a trust flag: text from outside the program is an untrusted value. That is why a plain String parameter is a structural position. See untrusted data and secrets.

Contracts

requires and ensures clauses sit between a function's return type and its body, one per line. ensures |r| ... binds the return value. A contract may call only pure functions.

fn halve(n: Int) -> Int
    requires n >= 0
    ensures |r| r <= n
{
    n / 2
}

Contracts are checked at runtime, in every build, including --release. A false requires stops the program with P0006, "precondition violated": it points at the call, because the caller broke the contract. A false ensures stops it with P0007, "postcondition violated": it points at the clause, and the message names the return that broke it, because the function broke its promise. Both quote the condition, exit with code 101, and fail only their own test under oskac test. ensures is checked on every way out of the function: falling off the end, an early return, and a ? that returns an Err or None. r is that value.

A contract must be pure, and it must be a plain condition:

A trait method's contract lives on the trait and binds every impl, whether the call goes through the trait, a qualified call such as Shape.area(x), or a direct x.area(). A method of impl Trait for Type declares no contract of its own (E0218); move the clause to the trait. main declares none either (E0219), because nothing calls it. A method of an inherent impl can declare contracts.

trait Shape {
    fn scale(self, k: Int) -> Int
        requires k > 0
        ensures |r| r >= 0
}

struct Square { side: Int }

impl Shape for Square {
    fn scale(self, k: Int) -> Int {
        self.side * k
    }
}

Concurrency

Tasks run inside a scope |s| { ... } block. s.spawn(|| work()) starts a task and returns a handle, and join() returns its result. A scope does not end until all its tasks have finished, and the scope handle cannot escape the block. There is no async or await.

fn main(io: Stdout) -> Result<(), IoError> {
    let total = scope |s| {
        let a = s.spawn(|| 20)
        let b = s.spawn(|| 22)
        a.join() + b.join()
    }
    io.println("{total}")?
    Ok(())
}
42

In this release, tasks run one at a time in the order they were spawned, so every run gives the same result.

Modules and std

One file is one module, and items are private unless marked pub. use names a path with . as the separator, and only std.* modules can be imported so far. There are no glob imports. The modules are std.sql, std.proc, and std.html. Only std.html can run so far: the interpreter cannot run std.sql or std.proc yet, so oskac run refuses a program that imports one (E0371).

Tests

A test "name" { ... } item is a top-level test with the built-in value t. See testing.

What is not there yet

These are gaps in this release, so do not write programs that depend on them: