Nikaia Language Specification

Part I: The Language Core Version: 0.0.1343 (Draft) Date: 2026-11-03


Chapter 1: Introduction and Principles

1.1. What Nikaia is

Nikaia is a statically typed systems programming language. Its properties are predictable execution, memory safety and concise source. Memory management, concurrency constraints and representation details are enforced by the compiler. The compiler translates Nikaia source to Rust and drives the Rust toolchain, which is called the backend throughout this specification.

Four weights decide a question about the language (ADR-258). The special case carries the cost, not the common one. The compiler works for the developer, not the developer for the compiler: what can be worked out is not written. The common case has zero overhead: it costs what the same program costs written by hand in the backend. The language is consistent with itself: a rule reads the same wherever its shape appears.

1.2. One language, and the build options

There is one Nikaia. The same source compiles for every target and under every build option, and the resulting program prints the same bytes. A build option decides how a program is built, never what it computes.

A build decides how, never what.

The build options are named below. Each lives under [build] in nikaia.toml; --target and --user-parallelism override the first two for a single build. The specification does not state their number. Statements run in the order they are written; a program that wants work to run beside the code after it writes async (8.1.2).

target — the machine

target names the machine a program is built for. The target decides what the standard library offers on it (Part III 17.2) and what a panic does: the stack unwinds where the machine unwinds, and the program traps where the machine traps (Part III, Appendix A). The targets are x86_64-linux, aarch64-linux, wasm32-unknown, aarch64-android, x86_64-android, armv7-android, and every triple rustc --print target-list prints, written as Rust writes it; the default is the machine the compiler runs on. A target without an operating system (a -none or -uefi triple) is refused.

user_parallelism — whether user code may run concurrently

user_parallelism is a permission, not a thread count.

The option applies to user code only. The compiler and the runtime may use additional threads for internal work, provided that no user code executes concurrently on them. Such work does not change a byte of what the program prints. How many threads serve a yes is the runtime’s decision.

The compiler may be concurrent even when the program is not.

The re-entrancy check — whether a broken rule is noticed

Taking a lock while a lock is held is refused with NK2203 when the program is compiled (Part II 12.3). The re-entrancy check is a build option that decides whether the program also carries the runtime check that notices such a nesting if one occurs. It is on by default and may be declined.

For every program that obeys the nesting rule, both builds behave identically. The check cannot fire in a correct compiler. If it fires, the compiler has a defect; without the check, the same defect appears as a silent hang.

The key is reentrancy-check in [build], and it takes yes or no. It has no command-line override. Declining it removes the check from the crossing shape of std only; the single-threaded shape keeps its own borrow check.

1.3. The prelude

Some names need no use. They are these, and there are no others:

Nothing in the prelude does I/O but printing, and nothing in it pauses. The prelude is not cut down on a machine with no filesystem (Part III 17.2). HashMap is outside it: a program writes collections::HashMap after use std::collections.

The compiler decides the shape of a SharedMut[T] per value. A program writes SharedMut(0) with no import (6.2, Part II 12.2).

eprint and eprintln, like panic and assert, are diagnosis; what they do depends on the target, and the compiler decides it. access_all and update_all take the locks in one order however the program wrote them, so a deadlock cycle cannot form. Bytes is one shared buffer: what fs::read hands back, passed on by a count. panic ends the program with the program’s own words, at the Nikaia line.

Everything else in the standard library is reached the way a package is reached: use std::fs at the top of the file, and fs::read_to_string(path, fs::Root::Anywhere) where it is used (9.1). A name that lives in a std module is refused without its prefix — NK1117 for a function, NK1135 for a type — and a prefix is refused without its use, each with the line to add. A name on this list written with a std module in front of it, such as io::println, is refused too (NK1163).


Chapter 2: Variables and Data Types

A comment begins with // and runs to the end of the line, or begins with /* and runs to the matching */. A block comment may span lines and may stand anywhere whitespace may stand. Block comments nest: /* a /* b */ c */ is one comment. An unclosed /* is reported where it opened. A block comment is a comment wherever it stands, including /** … */ and /*! … */.

A run of /// lines immediately before an item is that item’s documentation. An item is a fn, a struct, an enum, a trait, a field or a variant. Anywhere else /// is an ordinary comment. An ordinary comment standing between the run and the item does not end it; a statement does. A doc comment is prose: the compiler reads no directive, no @param and no link out of it. The compiler does not copy it into nikaia.contracts (Part III, 13.5; ADR-251 D2).

2.1. Variables and Assignment

A variable is a named storage location that holds a value. A variable is declared with let.

Immutability A variable is immutable unless it is declared otherwise. Once a value is assigned to an immutable name, the name cannot be assigned again.

let x = 10
// x = 20  <-- This would cause a Compiler Error

Mutability A variable that may change is declared mutable with the keyword mut.

let mut y = 10
y = 20     // This is allowed

Deep immutability: immutable let promises that the name is not assigned again; a SharedMut it holds can still change through another owner. immutable promises more: neither the name nor anything reachable from its value ever changes (ADR-389). A value whose type reaches, through fields, elements, variants or captures, a SharedMut, a Locked, a channel end or another handle to storage changeable through another owner is refused (NK1331). Nothing is moved out of it and no mut self method is called on it. A task may read it, captured or handed over, without a copy or an owner count (8.3).

fn main() {
    immutable limits = load_limits()
    let a = async fn { check(limits) }   // no copy, no count
}

Where an item stands, immutable X = f() is evaluated on first access, exactly once, even when several tasks reach it together; every later access reads the same value. Nobody can change it, so it is not global state. Its initializer may not throw; a value that may be missing is a T?. A cycle among item-level immutables is refused (NK1331). A value known at build time is a comptime (Part II, 10.2).

immutable DUMP_DIR: String? = env::var("NIKAIA_DUMP_ACCEPTED")

Taking a tuple apart. let (a, b) = pair() binds each name to the part at its position. The names may nest as the tuple does, let (a, (b, c)) = (1, (2, 3)): a let takes a name, _, or a parenthesised list of these, as deep as it is written. A written type on a tuple is refused (NK1136), because one type cannot say which name it is about. A variant is refused (NK1259): it may not be the one the value holds, and taking it apart is a match’s. A parameter takes the same pattern, in a function, fn dist((x, y): (f64, f64)) -> f64, with the type of the whole after it, and in a lambda, fn((name, v)) { … }. It is still one parameter: callers, the function’s type and its count of arguments see one tuple. mut before it belongs to the whole, so the parts are changeable; without it they are views. An option, which a call names, is never a pattern.

Reserved words

A reserved word means one thing wherever it appears. A name may not be a reserved word. The reserved words are:

as        async     break     catch     comptime  continue  dsl       else
enum      extern    false     fn        for       grammar   if        immutable
impl      in        let       match     mut       null      pub       ref
return    select    self      spawn     struct    sync      test      throw
throws    trait     true      unsafe    use       while     with

A name that is a reserved word does not parse, and the parse error names the word and says it is reserved.

ref is reserved for the pointer an extern "C" declaration writes (Part III 15.1); anywhere else it is refused (6.5).

comptime is reserved for its construct: a binding where an item stands or a statement inside a function body (Part II, 10.2). It promises a time of evaluation, not constancy.

_ is not a name; it is the ignore pattern. It stands where a name would be bound and says that the value is ignored on purpose: a position of a destructured tuple (let (name, _) = pair()), a parameter a shape dictates (fn handle(event: Event, _: Context), fn(_, value) { … }), and a part of a match pattern ((0, _), Shape::Circle(_)). The arm taken when nothing else matched is else, not _ (3.4). let _ = expr is refused: a call made for its effect is written as the call, and a resource is closed by name. _ is never a value. What _ ignores is not moved, so a let over a place stays a view of it (6.5).

with is reserved for its construct: a copy of a value with named fields changed, p with { x: 1 } (4.2).

extern and unsafe are reserved for their constructs: an extern block, and the unsafe { … } a call into one is written in (Part III 15.1).

select is reserved for its construct: the block that races several waits and takes the first to finish (Part II, 12.4).

break and continue are reserved for their constructs (3.3).

async is reserved for its construct: the prefix that starts a task (8.1.2).

spawn stays reserved until the move to async(detached) is built (8.2): spawn fn { … } is still accepted and means async(detached) fn { … }, and the commit that removes the form releases the word (ADR-async-5 D13).

test is reserved for its construct: a test "name" { … } block of the package it stands in (Part III 14.1).

loop, const, macro and quote are ordinary names. A declared loop or quote is a name like any other. Where nothing declares one of them, the refusal every undeclared name meets carries a hint: loop { … } is answered with write while true, const X = … with write comptime, and macro and quote with Nikaia has no macros.

seq is an ordinary name.

keep and from are not reserved; each means its construct in one position (ADR-views-3 D13). keep is the mark only before a parameter or self in a parameter list (6.5), from the source only after a result type (6.6); both are names everywhere else.

Three further rules about the list:

2.2. Primitive Data Types

Nikaia provides basic types to represent simple values.

A number may carry a digit separator, a radix prefix or a scale, and nothing else. 1_000_000, 0xFF, 0b1010 and 0o17 are the four forms, beside the exponent above. A decimal whole number may end in a scale: K, M, G, T and P are 10³ to 10¹⁵, Ki, Mi, Gi, Ti and Pi are 2¹⁰ to 2⁵⁰ (ADR-322). 1G is 1000000000 and 4Gi is 4294967296. A radix-prefixed number and a float take no scale. An underscore stands between digits and nowhere else. A float takes the separator (1_000.5) and no prefix. A digit the radix does not have (0b1210, 0o19) is refused. A leading underscore is not one of the four forms: _000 is a name.

The radix is a spelling, and a literal is a value. 0xFF is 255, and it takes the first type that holds it exactly as 255 does. So let mask = 0xFF is an i32 and let mask: u8 = 0xFF is a u8. The width is never read off the digits. The separator and the scale are not part of the value: 1_000 is 1000 and 1K is 1000 to the checker, to the ledger and to a diagnostic’s text.

There is no type suffix. 1i64 is a number beside a name, and the name beside it is refused with NK1117 because nothing declares it (Part III, C.3). A word that stands on its own is read as a name, so assert c is refused the same way.

A number too wide for a u64 is refused (Part III C.1), and one above an i64 is a u64’s: let basis: u64 = 0xcbf29ce484222325.

These are the integer types a program writes: int, and the five fixed widths i64, i32, u8, u64 and u32. A fixed width is written where the width is the point; neither is a warning anywhere. The specification does not offer others: a written usize, isize, i8, i16, u16, i128 or u128 is refused with NK1135, and so is str, outside an extern "C" declaration (Part III 15.1). A length is an i64: for i in 0..<xs.len() gives an i64, xs[i] takes one, and neither conversion is written, because the compiler emits both. A negative index reports as an access out of bounds (Part III, A.2).

An integer that does not fit aborts, at every build. An i32 holds what an i32 holds. An arithmetic result that does not fit is an inconsistent program state, like an index past the end of a list or a division by zero (Part III, A.2), and the program stops. The rule is the same at both values of user_parallelism and in every build (1.2).

Three cases abort although the code looks harmless:

Where a program means to wrap or to stop at the limit, it says so by name.

let h = h.wrapping_mul(31).wrapping_add(c)        // a hash, wrapping on purpose
let volume = level.saturating_add(increase)       // stops at the maximum
let n = a + b                                     // aborts if it does not fit

The names are wrapping_add, wrapping_sub, wrapping_mul, wrapping_div, wrapping_neg, wrapping_abs, wrapping_shl, wrapping_shr, and the same names with saturating_, except that there is no saturating_shl and no saturating_shr. They exist for i32, i64, u8, u32 and u64; an unsigned type has no _neg and no _abs. There is no operator for either.

The bits of an integer are reached with operators (ADR-285): &, |, ^, << and >>, and !, which on an integer flips every bit. Both sides are one integer type, except a shift’s count, which may be any. A shift loses the bits it moves out of its type (a shift of numbers alone takes the type that holds its exact value, 2.4), and a count as large as the type is wide aborts; >> fills with zeros on an unsigned type and with the sign on a signed one. They bind tighter than a comparison, and one written beside a comparison is written in parentheses (NK1197), because C reads a & mask == 0 the other way:

fn fnv(key: String) -> u64 {
    let mut hash: u64 = 0xcbf29ce484222325
    for b in key.bytes() {
        hash ^= b as u64
        hash = hash.wrapping_mul(0x100000001b3)
    }
    return hash
}

fn main() {
    let h = fnv("hello")
    println(f"{h >> 32} {(h & 0xff) == 0x0b}")
}

On a bool they are refused (NK1198): && and || join two bools.

A rotation puts the bits it moves out back in at the other end: x.rotate_left(n) and x.rotate_right(n) exist for u8, u32, u64, i32 and i64. The count n may be of any integer type and is taken modulo the type’s width, so no count aborts: w.rotate_right(-1) is w.rotate_left(1). x.low_bits(n) is x with only its n lowest bits kept, for u8, u32 and u64: none where n <= 0, all where n is at least the width; no n aborts.

One integer type becomes another without as where no value can be lost (ADR-285 D32). Two integer types have a common type when one type holds every value of both:

  u8 u32 u64 i32 i64
u8 u8 u32 u64 i32 i64
u32   u32 u64 i64 i64
u64     u64 none none
i32       i32 i64
i64         i64
fn area(x: i32, y: i32) -> i64 {
    x * y                       // computed in i64
}

let total = count + offset      // u32 + i64 is an i64
let n: u64 = xs.len()           // a length is never negative

A stated type reaches through +, -, *, /, % and a negation to the operands, each widened before the operation: x * y above is computed in i64, and does not stop where an i32 would. It does not reach into what a name was given, into a call’s arguments, a method’s receiver or an index. It stops at &, |, ^, !, << and >> and at the wrapping_, saturating_ and truncating_ names, whose result depends on the width: a computation of them in a narrower type, put where a wider one is stated, is refused (NK1215), and the program writes (a as i64) << 3 or (a << 3) as i64.

A value the compiler shows is not negative goes into an unsigned type without as: a length, and a value after if k >= 0 or assert k >= 0. Where nothing shows it, k as u64 is refused with NK1241, and the program writes k.checked_u64() or k.bits_as_u64().

Widening plays no part in deciding a type. A number without an annotation (2.4) and a type parameter (4.6) take the type their uses ask for, and two uses asking two types are refused (NK1200) though one would widen into the other. pick(a, b) for fn pick[T](a: T, b: T) over an i32 and an i64 is refused; pick(a as i64, b) is not.

A conversion is written as where it always fits, or where the compiler shows it does.

let average = (total as f64) / (count as f64)     // widening, always fits
let small = big as i32                            // only where `big` is shown to fit

A conversion that can lose a value - a wider integer to a narrower one, a signed one to an unsigned one or back, an f64 to an integer or to an f32 - is written as only where it is shown to fit. A constant that does not fit is refused with NK1116. A value the compiler proves within the destination’s range converts. Any other such as is refused with NK1241, and the program says by name what it means: the low digits, or whether it fits. This does not depend on a build option. An integer becomes a scalar by as only from a u8; from any other integer type, a variable or a literal alike, as scalar is refused with NK1241, and the program writes scalar::from(n) (2.2) or the character, '\u{8}'.

Where a program means to keep only the low digits, it says so by name, as it does for wrapping. The name carries the type it converts to:

let small = big.truncating_i32()                  // keep the low digits, on purpose
let floored = measurement.truncating_i32()        // a float, toward zero, clamped
let n = text.bytes.len().truncating_i32()         // a count that may not fit

The names are truncating_i32, truncating_i64, truncating_u8, truncating_u32 and truncating_u64. Each converts out of every integer type wider than its own, signed or unsigned, and out of an f64: the low bits of its width, read as its type. None converts out of a type of the same width. rounding_f32 is the nearest f32 out of an f64, and an infinity beyond the f32 range.

Where a program means to read the same bits with the other sign, it says that by name:

let word = b.u32_le(0).bits_as_i32()              // 0xFFFFFFFF is -1
let key = offset.bits_as_u64()                    // -1 is 18446744073709551615

bits_as_i32 converts out of a u32, bits_as_u32 out of an i32, bits_as_i64 out of a u64 and bits_as_u64 out of an i64, and out of nothing else. Every bit stays where it is; no value is checked. A number that fits both types keeps its value, and one that does not comes out on the other side of zero. A same-width truncating_ name is refused (NK1210), naming the bits_as_ one.

Where a program means to ask whether it fits, it says so by name too (ADR-315), and the answer is optional:

let sum = a.checked_add(b) ?? return null         // null where it does not fit
let small = big.checked_i32() ?? 0                // null where an i32 does not hold it

The arithmetic names are checked_add, checked_sub, checked_mul, checked_div, checked_rem, checked_neg and checked_abs, for the five types the wrapping names have and without _neg and _abs on an unsigned one; a division by zero is null too. The conversions are checked_i32, checked_i64, checked_u8, checked_u32, checked_u64 and checked_f32, out of an integer type that has values the destination lacks and out of an f64. A value that is not a number fits no integer; an infinity and a value that is not a number carry over into an f32.

Two conversions lose digits without anything failing:

An as names one of the types above and nothing else. n as u128 and n as usize are refused with NK1122, naming the type.

Where the backend wants a machine-width number, the compiler writes the conversion. A length comes back as an i64 and an index goes in as one. A count goes in as one too, so " ".repeat(indent) is written with no conversion. A negative count aborts with “a count cannot be negative”.

A literal that does not fit its type is a compile error, not an abort. let x: i32 = 3000000000 is refused with NK1116, and so is a sum of literals that cannot fit. NK1116 fires wherever a type stands beside the constant: an annotated let, a return against a declared result, or an argument whose parameter says what it takes.

The compile-time check reaches a sum. let b = a + 1, where a is a constant an annotation declared an i32, is folded and refused with no annotation on the let line: the operand’s declaration gives the arithmetic its type. + - * / % and a negation fold, through any number of immutable lets, in a magnitude and a sign (65 bits), each step held to the type a declared operand gives it, so that the message can name what the expression comes to. A mut local, a parameter, a for binding and a cast stop the fold. An expression that does not fold is never refused.

A division whose divisor is a constant zero is refused with NK1118. Every other division by zero is unrecoverable at run time and names its line (Part III A.2).

A literal that nothing constrains, let big = 3000000000 on its own, is not refused: it takes the first type that holds it (2.4).

2.3. Nullable Types (Null Safety)

A type is non-nullable unless it says otherwise. A variable of type String always holds a string and is never null. A type that admits the absence of a value is written with a trailing question mark ?.

let strictly_string: String = "Hello"
// strictly_string = null // Error!

let mut maybe_string: String? = null     // Valid
maybe_string = "World"             // Valid (`mut`, as in 2.1)

A literal is a view of text the program was compiled with (6.6), and it stays one wherever a String is wanted. Where the use only reads it, or keeps it in a position - a field, an annotated let, a return, an element of a list, an argument the callee keeps - into which only literals and other views flow, nothing is allocated: the position holds the view, and static text outlives every name. Where text of its own (an f"…", a .clone(), a String handed over that no view still reads, 6.6) flows into the same position, the literal is constructed there, as [1, 2] is a Vec where one is wanted. A literal in a String that is changed, or handed on to what takes text of its own (a mut or owning parameter, also through a match binding or a lambda’s last expression), is text of its own: let mut y: String = "hi" then y.push_str("!") needs no copy written. A view of text the program has is not a literal, and where it is kept .clone() makes the copy, written where it happens. A view in a published String slot that text of its own also flows into is refused without it (NK1106); any other String slot takes the view (2.2). .clone() is the one word for a copy, of text and of everything else: a copy of text is text of its own. .to_owned() is refused naming it (NK1189); .to_string() is the text form of a value, and the text form of text is the text itself: a view stays a view, and nothing is copied. On a plain text literal neither .to_string() nor .clone() is written: the literal is the text (NK1301).

T? lowers to the backend’s Option<T>, the mapping Part III 15.2 writes the other way round, and null is a reserved word (2.1) that lowers to None. The ? comes last, after the type’s arguments: Vec[i64]? is a nullable list and Vec[i64?] is a list of nullables. A tuple takes it too: (String, String)? is a pair that may be missing, and p?.0 is its first part as a nullable.

A plain value stands where a nullable one is wanted. That is the one widening the language has, and the compiler writes the constructor. There is no Some in Nikaia. The constructor is written wherever a plain value meets a nullable slot: an annotated let, an assignment, a return, a struct-literal field, and a call argument. Where the compiler cannot work out the value’s type, it writes a conversion instead, which is right whether or not the value is already nullable.

A test that proves a T? is not null makes it a T where the proof holds (ADR-382), for a local, a parameter, or a path of fields from one (p.name, self.cache):

if x != null && x > 3 { use(x + 1) }   // `x` is an i64 on the right and in the body
if x == null || x > 3 { … }            // and on the right of `||`
if x == null { return }                // and in the rest of the block
while x != null { x = next(x) }        // and in the body, up to the write

The proof holds in the operand after && (what the left proves true) and after || (what the left proves false), in the branch the condition leads into (the else branch gets what it proves false), in a while body, and in the rest of a block after an if whose branch leaves with return, break, continue or throw, and in the body of a match arm whose guard proves it ((x, _) if x != null => x; no other arm sees that proof). ! swaps true and false. Elsewhere the name is the T? it was declared. An assignment to the name after the test ends the proof there, and a loop inside the proven region that assigns it ends it for the whole loop; a use that needs a T after that is NK1102, naming the assignment.

A path of fields is proved like a name, at any depth. Its proof ends at an assignment to the path or to a path it is part of (p.name = …, p = …), at a mut hand-over of either (a mut self method called on it, a mut argument), and at a lambda or a task (an async form, a par_iter element, a select arm) that sees the root; a write to a sibling (p.age = 3) and a call that only reads keep it. A read through an index or a call (v[i].f, f().x) is not a path and proves nothing. A binding in a condition (if let v = x, if x is Some(v)) is refused: match binds the value an enum or a T? holds.

let m = null with nothing beside it is not refused by the compiler: let mut m = null followed by m = "hi" is a correct program. Where nothing ever says the type, the backend asks for the annotation.

2.4. Type Inference

Nikaia is statically typed: the type of every variable is known at compile time. A type is rarely written. The compiler uses type inference to deduce the type from the value.

let name = "Nikaia"  // Compiler knows this is a String - a view of static text
let count = 42       // an i32, because nothing here asks for anything else

A number takes the type its use asks for. Where nothing asks, it takes the first type that holds it — i32, and i64 where an i32 is too small:

let count = 42          // an i32: nothing asks, and an i32 holds it
let big = 3000000000    // an i64: nothing asks, and an i32 does not
let m = 3000000000      // also an i64 - here because the line below asks
println(f"{wide(m)}")   // fn wide(n: i64) -> i64
let small = 42
println(f"{wide(small)}")  // an i64 here: the use decides, and 42 holds in one

The use is asked first and the size second. So small may still become an i64, and big never has to be annotated to be one. An integer comptime is such a number whatever computed it, and each use may take it in its own type (Part II 10.2). A number too large for an i64 is refused unless a use asks for a u64.

A use is anything the function does with the name: the parameter it is handed to, a typed value it meets in an operation or a comparison, an annotated let, a field or a return it goes into, a value assigned to it or from it, and an index, which asks for an i64 only where nothing else asks. The compiler reads every use in the function and writes down the type it decides, among all five integer types. Two uses that ask for two types are refused (NK1200), with the annotation to write. Where no use asks, what the name is given decides: let mut n = 1 followed by n = 3000000000 is an i64, and a value it is given that the decided type does not hold is NK1116, naming the use.

A sum of numbers is a number, so the same rule decides it. So are a product and a shift: the value is computed exactly, and a shift loses bits only in a type a use asks for (2.2):

let c = 2000000000 + 2000000000   // an i64: nothing asks, and an i32 does not hold it
let small = 2 + 3                 // an i32, the same as any number that fits
let k = 1 << 40                   // an i64: 1099511627776
let top = 1 << 31                 // an i64: 2147483648
let bit: i32 = 1 << 31            // -2147483648: the i32 is asked for

A name is where the widening stops. A name already took a type, and arithmetic happens in the type of its operands where no wider type is stated for it (2.2):

let a = 2000000000        // an i32 - the first type that holds it
let c = a + a             // refused: this comes to 4000000000, which an i32 does not hold
let b: i64 = 2000000000   // say so, and the sum is an i64
let d = b + b             // 4000000000

The way out is the annotation on the third line.

The question is about the value, not the digits: -2147483648 is an i32. NK1116 answers the case where a type stands beside the literal (an annotated let, a return against a declared result, an argument whose parameter says what it takes) and is silent otherwise (Part III, C.4). An operation over names whose type is pinned is asked wherever it stands - in an f"…" hole, a condition, a list - and refused once, at the innermost operation that overflows. A constant is widened where an i64 holds it and refused with NK1116 where no type does or where a name pinned a narrower one. Two positions take their type from where they stand and are left alone: a sequence index and a repeat count, both counted by the machine.

2.5. Strings, Plain and Interpolated

There are two string literals, and the difference is one character at the front.

let name = "Nikaia"

println(f"hello, {name} - {name.to_uppercase()}")   // f"…" - the braces are code
println("hello, {name}")                              // "…"  - the braces are braces

"…" is text. A { in it is a brace and nothing else, so a program that writes JSON, CSS or a regular expression writes the braces as they are:

print("{}")               // prints {}
print("\\d{3}")            // a regular expression, written as one
print("{ margin: 0 }")    // a rule, not a hole

f"…" has code in it. Between { and } stands an expression: a name, a field, a call, an index. It is written as code is written anywhere else, so a string inside a hole has plain quotes: f"{greet("Ada")}". What follows a : inside a hole says how to write the value rather than which value. The first colon that is not inside a call or an index separates the two, so move(by: 1) in a hole keeps its own colon.

The format after the colon is a closed set, as in Rust and Python, in this order, each part optional: a fill character and an alignment, a 0, a width, a precision, a base.

written what it does
< > ^ left, right or centred in the width; a character before it is the fill ({s:*^9}), a space where none is written. Without one, a number stands right and text left
0 a number is filled with zeros after its sign: {n:05} is -0042 for -42
a width the least number of scalars written, as decimal digits: {s:>8}
. and digits a float’s digits after the point, rounded: {x:.2} is 3.14
x X b o an integer in hexadecimal (lower or upper case), binary or octal, with no prefix: {h:016x}

The width and the precision are literal digits. A base on a value that is not an integer, a precision on one that is not a float, a 0 on one that is not a number, and anything outside the table are refused (NK1332), and the message names the table.

A hole is written as its value’s text, and so is what a print call is handed. Text, a number, a bool, a scalar and an error (whose text is its message) have one. A list, a map, a set, a range, a tuple, a value behind a lock, and a struct or enum of the program that is not an error have none: f"{p}" and println(p) are refused alike (NK1201), and the help names the parts to write instead, such as {p.name}.

Inside an f"…", two braces stand for one: {{ is a literal { and }} a literal }. An escape is not a hole: the { in "\u{0041}" belongs to the escape. A } on its own is an error. A plain string needs none of that: it has no holes, so "{" is a brace and "{{" is two.

The f and the quote are one token. f"x" interpolates; f "x" is a variable named f beside a string. Whitespace never decides what a program means. An f"…" is not a constant: it cannot stand as a default, a match pattern or a grammar’s literal. Inside a hole, \" is refused - a hole is code, and a string in it has plain quotes.

A newline is an ordinary character in a literal. Nothing ends a literal but the closing ". A literal left unterminated runs on to the next " in the file or, if there is none, to the end of the file.

A \ introduces an escape, and the set is closed:

written what it is
\n \r \t newline, carriage return, tab
\0 the zero character
\\ \' \" the backslash and the two quotes, standing for themselves
\xNN two hexadecimal digits, naming a byte: below \x80 in text, any byte in a '…' that is a u8
\u{…} up to six hexadecimal digits, naming any scalar: \u{1F600}; a surrogate is refused

The same set holds in a '…', in an f"…" and in a template. A \ in front of anything else is refused (NK1184), naming the set; a literal backslash is written \\. A character above \x7F is \u{…}. The set is the backend’s, and a literal is written into the generated file as it stands.

The type follows the syntax and the use, never the contents. "…" is a view of static text and f"…" builds a String, whether or not it has a hole in it. Adding a brace to a piece of text cannot change its type. Where the use asks for an fs::Path, an f"…" builds one, and its holes may be a path, text or a number (Part III 17.1); a let without a type takes it from the binding’s first use. A path in a hole of an f"…" that is text is refused (NK1201): the text is written p.to_text() or p.display(). Handed to print or println, a path in a hole is written as its bytes are.

A template’s holes need no f. dsl html { <p>{name}</p> } eod (Part II) marks the construct as a place where code appears.

2.6. Text: Bytes, Scalars and Graphemes

Text is UTF-8 (ADR-320), and a position in it is a text::Pos (ADR-374). A text is read through a view that names what it counts:

view an element reads
s.bytes u8 s.bytes[i], s.bytes[a..<b], s.bytes.len(), s.bytes.find(…), s.bytes.iter(), for
s.scalars scalar take(n), skip(n), count(), for
s.graphemes String, a view of one grapheme take(n), skip(n), count(), for

s.bytes is a Bytes (Part I 6.6) over the text’s own buffer, borrowed: it costs no copy unless it is kept beyond the text. There is no s.len() and no s[i]. s.is_empty() needs no view. split, lines, trim, starts_with, ends_with, contains, == and find work on the text itself.

let preview = title.graphemes.take(10).collect()   // ten things a reader sees
let field = record.bytes[0..<8]                     // eight bytes of a fixed format
for c in name.scalars { if c == 'ä' { … } }

A position in a text is a text::Pos, a boundary in that one text. It comes from the text: s.start(), s.end(), a match, a move, a grammar run over the text. No number becomes one, and it takes no arithmetic; two positions of one text compare with < and ==. Below the language it is a byte offset.

A search gives a match. s.find(needle; from: text::Pos) -> text::Match? finds the first match at or after from (default s.start()), s.rfind(needle) -> text::Match? the last. A match has start and end, positions of s, and m.text(), the matched text. A needle is a text or one scalar, s.find(','), as the pattern of contains, starts_with, split and replace is (#877).

A pattern is searched with std::regex (ADR-regex-1). The dialect is RE2’s, as Rust’s regex crate writes it: no backreference, no lookaround, a search in time proportional to the text times the pattern. A match is the leftmost one, among those the one the pattern prefers. Text is matched by scalar, and \d, \w, \s are Unicode classes; (?:…) does not capture. A regular expression is a regex::Regex[E], E its engine, a type implementing regex::Engine: regex::Literal, regex::OnePass, regex::Dfa, regex::TaggedDfa, regex::BitNfa, regex::FlatNfa, or regex::Dynamic for a pattern compiled at run time. regex::compile(comptime pattern) -> regex::Regex[E] takes a pattern the build knows - a literal, a text composed of comptime values, asset("…") - and the build chooses E from it; a type written where the result is received is held to, and a pattern that does not parse stops the build (NK1244). regex::dynamic(pattern) -> regex::Regex[regex::Dynamic] throws regex::Error takes a pattern known only at run time. regex::Any holds a regular expression of any engine (re.any()). re.is_match(s) -> bool, re.find(s; from: text::Pos) -> text::Match?, re.find_all(s) -> Seq[text::Match] (not overlapping), re.captures(s; from: text::Pos) -> regex::Captures? with c.group(i) and c.named(name), each a text::Match? (group 0 the whole match), re.replace_all(s, with) -> String ($1, ${name} in with) and re.split(s) -> Seq[String]. Every position is a text::Pos of s. --comptime prints for each known pattern its engine and what the calls on it became.

A text is cut by name, at its own positions: s.before(p), s.after(p), s.between(p, q), and s.split_at(p) for the first two at once. Each is a view of s. A text takes no […]. between with q before p stops the program.

let m = line.find(";") ?? return
let name = line.before(m.start)
let rest = line.after(m.end)

A position moves only in a unit it names: p.forward(scalars: n), p.forward(graphemes: n), p.forward(bytes: n), and back with the same options. Each gives a text::Pos? of the same text, null past either end and, for bytes:, inside a scalar. Exactly one option is written (NK1285).

let s = "abcdefg"
let m = s.find("ab") ?? return
let q = m.end.forward(scalars: 4) ?? return
println(s.between(m.start, q))                    // abcdef

A number is reached through a view: s.before(p).bytes.len() is the byte offset of p, s.before(p).scalars.count() its scalar count.

A position cuts, moves and searches only the text it came from. The compiler keeps which text that is as it keeps a view’s sources (6.6), through parameters and results. A position of another text, or of a text chosen at run time, is refused (NK1214); so is a number, and the help names forward and the view. A position of row = t.between(k, e) is one of row, not of t. A cut at positions is not checked when the program runs.

A position is kept in a text::Cursor, never in a field (NK1284). A cursor is a text and a position in it: text::Cursor(t) stands at t.start(); c.at() is the position, c.go(p) sets it to one of the same text; c.until(needle) -> String? is the text from the position to the next needle and moves past it, or null without moving where none follows; c.rest() is the text from the position, c.text() the whole text.

let mut c = text::Cursor(t)
while true {
    let row = c.until("\n") ?? break
    let (name, temp) = row.split_once(";") ?? continue
    …
}

A Bytes is one run of bytes, whatever holds its memory: what it allocated, a Vec[u8] handed over with Bytes(v), which copies nothing, or a mapped file (fs::Mapped is a Bytes, Part III 17.2). It reads the same in each case and is handed on by a count. A Bytes parameter, lent, takes any of them, and a text’s s.bytes.

A Bytes is read as a run of bytes: b[i] -> u8, b[a..<b], b.len(), b.find(byte; from: i64) -> i64?, b.iter(), the bytes one at a time as a list’s iter gives its elements, b.starts_with(prefix) and b.ends_with(suffix), each a Bytes compared byte for byte, and b.text(), which checks UTF-8 and is NotText where it fails, and b.text_lossy(), an owned copy in which each run that is not UTF-8 is U+FFFD. An index outside stops the program, as a list’s does. A Bytes searches for a set of bytes (ADR-375): bytes::ByteSet([';', '\n']) is such a set, of any bytes, built once. A set written empty is refused (NK1287); one empty at run time finds nothing. b.find(set; from: i64) -> i64? is the first byte of the set at or after from, b.find_all(set; from: i64) -> Seq[i64] every one, rising, and b.split(set) -> Seq[Bytes] the pieces between them by text’s split rules: neighbouring bytes of the set give an empty piece, one at the end an empty last piece, an empty b one empty piece. b.find_all(set).count() counts without making a position. Each position find_all yields is at least from and below b.len(), and a proof may use it (Part III 14.2). find_all takes scan: bytes::Scan: Scan::ByBlock, the default, reads the next block when the hits of the last one are spent; Scan::AfterHit looks for each hit in the half block right after the one before it, which costs a compare a hit and is predicted where hits lie mostly within half a block of one another. Both yield the same positions. A word is read at a position, in the order its name says: b.u32_le(i) and b.u64_le(i) read 4 or 8 bytes from i, least significant first, as a u32 or a u64; b.u32_be(i) and b.u64_be(i) read them most significant first. Each reads what b[i..<i + n] reads and stops the program where that cut would. No read goes past the end: a loop that reads whole words up to its last row runs over a copy of the last bytes with zeros behind them, Bytes(tail) (ADR-320 D13). Vectors are std::simd’s (ADR-377): simd::Lanes[T] holds as many lanes as the build’s widest vector register (Lanes[T].count, a constant of the build), simd::Fixed[T, N] holds N lanes on every target, N a literal power of two from 2 to 64 (NK1297); T is u8, i8, u16, i16, u32, i32, u64 or i64; i8, u16 and i16 are lane types only. Both are made by simd::load(b, at), which reads what b[at..<at + count] reads and stops the program where that cut would, or by simd::splat(x), each taking its type from what receives it (NK1245 without one); v.count() is the lane count. They offer lane by lane wrapping_add, wrapping_sub, wrapping_mul, saturating_add, saturating_sub, min, max, & | ^ !, << and >> by a constant, simd::select(mask, a, b), the comparisons eq, ne, lt, le, gt, ge, which answer a simd::Mask[T] (a Fixed[T, N]’s is simd::Mask[T, N]), and the reductions wrapping_sum, min_lane and max_lane. + - * / % are not lane operations (NK1296); a lane shift’s count is a literal below the lane’s width (NK1299). Only a Fixed offers lane(i), checked as an index, simd::Fixed.from([…]), shuffle(of) by constant indices within its lanes (NK1298), dot_pairs(w), which multiplies lane by lane and adds each neighbouring pair into one lane of twice the width (u8 by i8 into i16, saturating, i16 by i16 into i32, wrapping), widen and narrow_saturating. A mask is read as positions: first() -> i64?, positions() -> Seq[i64] rising, count(), any(), all(), and combined with & | !; each position is at least 0 and below the lane count, and a proof may use it.

A '…' is a u8 or a scalar, by its use. One ASCII character is either; '\xNN' above \x7F is only a u8; one scalar that is not ASCII is only a scalar. A literal its uses do not type is a scalar. A let of one without an annotation is typed by its uses, as a number’s is (2.4): let c = ';' then b.find(c) is a u8, and uses that ask both are NK1200. A surrogate is refused (NK1184). Several scalars that normalise (NFC) to one are that one, with the warning NK1213; anything else is refused with NK1212, which hands over the text literal: "👍🏽".

A grapheme is the standard library’s. s.graphemes follows Unicode’s segmentation rules in the version of the std the program is built with. A grapheme is a view of text, compared with a text literal.

== compares bytes, and so does a hash. A precomposed and a decomposed ä differ. s.normalized(text::Form::Nfc) - or Nfd, Nfkc, Nfkd - makes a text that compares as its form says.

s.quoted() is the text as source writes it. It returns the text between double quotes, written exactly as Rust’s {:?} writes a string: \" and \\, \n, \t, \r and \0, \u{…} for the other control characters and for characters that do not print, and every other character, ä among them, as it is. Read as a literal (2.6), the result is the same text.


Chapter 3: Control Flow

Control flow is the order in which statements and calls are executed.

3.1. Expressions and Blocks

Nikaia is an expression-oriented language: almost every construct has a value. A block is a group of statements surrounded by curly braces { ... }. The last line of a block is the value of that block.

let result = {
    let a = 5
    let b = 10
    a + b  // Returns 15
}

A statement begins a line. Two statements on one line are a parse error: println!("hi") is not println and then !"hi", and let a = 1 2 is not let a = 1 and then 2. The first statement of a block may stand on the line of its { ({ return 0 }), and a jump’s trailing condition (return 0 if done) is part of its statement.

A block’s last line is the block’s value; return is the function’s. A return written at the end of a block that is itself a value (a match arm, 3.4; an if branch whose value is taken, 3.2; a catch handler, 7.1) leaves the enclosing function rather than handing that block a value. The one block that is its own function is a lambda, whose return leaves the lambda (5.3).

3.2. Conditional Logic (if / else)

The if expression tests a condition of type bool. Where the condition is true it evaluates the first block; otherwise it evaluates the else block. if is an expression, so its value may be bound to a variable.

let age = 18

let status = if age >= 18 {
    "Adult"
} else {
    "Minor"
}

After else, an if may stand where the block would stand: else if. A chain is one if inside another with the inner braces left out, so every rule of if holds at every link:

let grade = if score >= 90 {
    "A"
} else if score >= 80 {
    "B"
} else {
    "C"
}

A condition is an ordinary expression. Every expression the language has may stand there, with the same operators, the same precedence and the same associativity as anywhere else. &&, ||, !, ??, as, null, a tuple and a range are all conditions.

One rule concerns the brace. The { after the condition opens the body, so an expression that starts with a brace is parenthesised:

if p == (P { x: 1 }) {          // the parentheses say which `{` is which
    …
}

if (match n { 1 => 10, else => 20 }) > 15 {
    …
}

That is the whole of the difference between the head of an if, a while or a for and any other position. Every expression is reachable.

A jump may carry its condition after it (ADR-276). return, throw, break and continue take an if on their own line, and the line means if cond { jump }:

fn limit(speed: i64) -> i64 {
    return 250 if speed > 250
    return speed
}

fn first_even(numbers: Vec[i64]) -> i64 {
    for n in numbers {
        continue if n % 2 != 0
        return n
    }
    return 0 - 1
}

throw TooFast(speed) if speed > 250 reads the same way. Nothing but a jump is guarded like this, and an if that begins a line is a statement of its own. return if c { a } else { b } is still a return of an if expression.

3.3. Loops

A loop repeats code.

The while Loop A while loop repeats its body as long as its condition is true.

let mut count = 0
while count < 5 {
    println(f"Count is {count}")
    count += 1
}

The for Loop A for loop iterates over a sequence, such as a range of numbers or a list.

// Iterates from 0 to 4: `..<` stops before its end.
for i in 0..<5 {
    println(f"Index: {i}")
}

a..b includes its end and a..<b excludes it. The spelling is the same in a for, in a slice and in a pattern. a..=b is refused, naming the form that replaces it. A range is an ordinary expression: it may be given a name, passed, or indexed with. A range binds looser than every operator in it, so 0..<n - 1 is a range ending below n - 1, not a range with something subtracted from it.

A range is a value, and walking it does not use it up. A range kept in a name is walked as often as a program likes, from either end, and in steps: after let days = 0..<n, both for d in days and for d in days.step_by(7).rev() walk it, the second every seventh day counted back from the last. A sequence something produces — keys(), io::lines(), xs.iter().map(…) — is walked once, and a second walk is refused (NK2702, Part III C.3). Whether one can be walked from its back end is known to the compiler: a range, a list’s iter(), scalars and a map over any of them can; io::lines() cannot, and rev() on it is refused. A range in a list’s brackets reads a run of it, a view, whether the range is written there or kept in a name.

A for over a list lends it: the elements are looked at, and the list is still there when the loop is over. Changing the list inside the loop is refused with NK2305; what the loop wants to add is collected and added after it. A binding is a view of its element, so an element kept past the turn, text in a field for instance, is kept as a copy, .clone(); a number, a bool and a scalar are the element itself. A map is walked as pairs, for (key, value) in scores. A for head takes the pattern a let takes, nested tuples and _ included, for (name, (line, col)) in places, and no variant (NK1259). Taking the elements away is written, for x in xs.drain() (6.5). A ref written in front of the list is refused with NK1137 (6.5).

A for binding changes only with mut, and mut means what it means on a let (ADR-views-2). A change to a binding without it is refused with NK1139. mut stands in front of the whole head, for mut p in team, for mut (a, b) in pairs, as in front of a let’s, and marks every name in it. In front of one name of a tuple, for (k, mut v) in m or let (tx, mut rx) = …, it is not supported yet, NK1307: refused now, possible later, and the help writes mut in front of the whole pattern. Over a list or an array that the function may change, the binding is a view of the element, and a change reaches the list:

struct P { pub n: i64 }

fn main() {
    let mut v: Vec[P] = [P { n: 1 }, P { n: 2 }]
    for mut p in v {
        p.n = p.n + 1                // changes `v`
    }
    let mut counts = [1, 2, 3]
    for mut n in counts { n = n * 2 }   // `counts` is [2, 4, 6]
    for mut i in 0..<3 {
        i = i * i                    // a range's number is the turn's own
        println(f"{i}")
    }
}

Over a list the function may not change, mut is NK1139 at the list. Over elements the loop owns - xs.drain(), a range - the binding is the turn’s own, as let mut y = x is. A map’s key and a set’s element take no mut, NK1306: the key decides where the entry stands, so it is taken out and inserted again after the loop. A map’s values are not changed by a loop over the map yet (that needs the one-name mut above); a value is changed by key, m[k]. While the element is a view the body uses the list neither to change nor to read it (NK2305); what it needs of the list is read into a name before the loop. The view is given back whole at the end of each turn (6.5).

Leaving a loop early: break and continue

break leaves the loop. continue skips the rest of this turn and starts the next one. Both act on the innermost loop around them:

let mut first_even = 0 - 1
for n in numbers {
    if n % 2 != 0 {
        continue                 // not this one; take the next
    }
    first_even = n
    break                        // found it; stop looking
}
println(f"{first_even}")         // the loop is over, the program is not

A break leaves the loop and a return leaves the function. After the break above, the println runs.

Three rules govern them:

A loop can fail. Some sequences a for walks are read as it goes, such as standard input’s lines. Getting the next element can fail. When it does, the loop stops and the failure leaves the function, exactly as a failing call would (Chapter 7). The function declares it:

use std::io

fn tally() -> i64 throws {           // without `throws`: error[NK2701]
    let mut n = 0
    for line in io::lines() { n += 1 }
    return n
}

Nothing marks the loop, as nothing marks a call that can fail. The same rule applies at the end of a block, where a resource’s cleanup can fail and the function that owns it declares it (6.4).

Getting the next element can also pause, and nothing marks that either. A loop over standard input gives its thread up between lines. The library says which sequences are like that; a program writes for.

Most loops cannot fail. A range, a list, a map: nothing is read.

There is no third loop form. A loop that does not end on its own is written while true { … }, and it is left by a break:

let mut n = 0
while true {
    n += 1
    if n == 5 {
        break
    }
}

There is no loop keyword. break hands back nothing. A function whose last statement is a while true that no break leaves needs no return after it, whatever type it declares: it never reaches its end (ADR-276 D6).

3.4. Pattern Matching (match)

The match expression compares a value against a series of patterns. Every possible case is handled.

let value = 2

match value {
    1 => println("One"),
    2 => println("Two"),
    else => println("Something else"), // `else` is the arm taken when nothing above matched
}

A pattern is one of seven things, and each is read the way it is written:

pattern matches
else anything, and binds nothing: the arm taken when none above it matched. _ in this position is refused: it is the ignore pattern, which stands in a tuple position, as a parameter and as a part of a pattern, (0, _)
1, "text", true, 'n' that value
Op::Times that variant
Message::Write(text) that variant, binding what it carries
Message::Move { x, y } that variant, binding its fields by name: x is short for x: x
other anything, and binds it to that name
null over a T?, the value that is not set; every other arm of that match matches the T inside, so a bare name binds the T and 0 compares with it (ADR-352). In a tuple, a bare name at a T? position binds the T once the arms before it have taken null there: after (null, null) and (null, y), (x, _) binds x as the T (ADR-352 D1). Over a type that is never null it is refused with NK1259

The last two rows are one rule: a path with :: in it names a variant, and a bare name binds. Quit is a variant of Message, never on its own.

An enum with exactly one variant is written by its type’s name (ADR-347): Exit(c) is Exit::Code(c) and Pos { x, y } is Pos::At { x, y }, as a pattern and as a value. A bare name still binds, so a unit variant’s pattern is written long, or as else. The short form of an enum with more than one variant is refused with NK1252.

Eight more shapes:

pattern matches
(0, 0), (0, y) a tuple, position by position
(0, y) \| (y, 0) either alternative; every alternative binds the same set of names, and alternatives that bind different names are refused with NK1155
200..299 a range, inclusive at both ends. An exclusive one is written by moving the end; ..< is never written in a pattern
(x, y) if x == y a guard: the arm matches only where the condition holds, and the word is if
Event::Click(Point { x, .. }) a pattern inside a pattern, and .. for the fields this one does not name
Held::Precondition(..), Held::Precondition(c, ..) that variant, whatever values it carries, binding none (ADR-349); beside other parts, .. is the values they do not name, as in (a, ..). On a variant without values it is refused with NK1255
[], [x], [first, .., last], [h, ..rest] a Vec[T], an Array[T] or a view of either, element by element (ADR-355). .. is the elements not named and binds nothing; ..rest binds them as a view of the run. A pattern has at most one .. (NK1258), and a match with a list pattern needs else
Point { x: 0, y }, Move { x: px, .. } a field against a pattern: x: 0 matches where the field is 0, x: px binds the field to the name px, and x: Op::Times or x: (0, y) reaches inside it. It names every field, or ends with ..; one that leaves a field out otherwise is refused with NK1234

.. means two different things: in a range pattern it is the range, and in a struct pattern it is the rest of the fields, in a tuple’s, a variant’s or a list’s brackets the rest of its values. The position says which.

A match reads what it binds. Over a name the function owns, an arm that only reads a part it binds takes a view of it (6.5), and the name is whole after the match: it may be matched again, printed, or handed on. An arm that keeps the part — pushes it into a list, returns it, hands it back as the arm’s value, changes it — takes it, and the name is gone afterwards, as it is after any hand-over. A number, a bool and a scalar are copied either way.

Every case is covered. A match over an enum is complete when every variant is named (an or-pattern names each variant in it), and needs no else. A match over anything else needs an else, or a bare name, which catches everything; over a struct, a pattern of that struct whose every field is a binding, _ or left to .., Point { x, .. }, catches everything too. bool is the exception: true and false are two arms and a complete match. An incomplete match is refused with NK1151, which names what is missing: the variants, or else. A guarded arm covers nothing, so a match whose only catch-all carries an if is refused with NK1151. A match over a T? with a null arm is complete when an unguarded null arm or else covers null, and the other arms cover the T: null => … and n => … are a complete match. Some(…) and None are not patterns of a T? and are refused (NK2704 for Some(…), NK2705 for None), unless the subject’s type declares such a variant.

An arm’s body is an expression or a block. The expression may be a throw, a return, a break, a continue or a panic(…). Their type is never, so an arm that throws sits beside an arm that hands back a value, and the match is that value’s type:

match step {
    (Op::Times, n)  => { value = value * n }
    (Op::Divide, n) => { value = value / n }
}

3.5. Null Safety Operators

A member of a nullable type is reached through an operator that handles the null case.

// If find_user returns null, 'name' becomes null.
let name = repo.find_user(id)?.full_name

// A method is a member, so it is reached the same way.
let greeting = repo.find_user(id)?.greet("Hallo")

// If 'name' is null, "Guest" is assigned.
let display_name = name ?? "Guest"

// And a fallback may have a fallback.
let shown = nickname ?? name ?? "Guest"

The result of a ?. is a T?. Where the member is itself a T?, the result is flattened: a?.b?.c never reaches through a nullable of a nullable. A read of a map whose values are T? is flattened the same way (4.5). The compiler decides which case applies from the declared type.

?. or ?? after a type that cannot be absent is a warning. A type that is not T? always has a value: s?.len() is s.len(), with the member’s type and the warning NK1217; s ?? "b" is s, its fallback never runs, and the warning is NK1216.

After a list’s index they are refused. A list’s index is a T (4.5), so xs[i] ?? 0 over a Vec[i64] is NK1211 and users[i]?.name over a Vec[User] is NK1121: one past the end stops the program, and neither operator guards an index. Over a Vec[i64?] the index is an i64?, and ?? replaces a null element.

?. takes nothing. It reaches through a view of its receiver, so user is usable on the line after user?.name. What comes out is a copy where the member copies and a view of the receiver otherwise, as a field read is (6.6). A receiver that is a call’s result is a temporary: a field reached through it is taken out of it, and a method that hands back a view of it keeps it, to the end of the block, in a binding the compiler writes on the line before. Where that would change what runs (on the lazy side of ??, && or ||, in a match arm, after another call in the same statement) or where the view is the block’s value or the result, the line is refused, naming the let to write (ADR-278 D22).

?? takes its left side where the answer is kept, and lends it where the answer is only read (ADR-279 D5), as an argument is lent or handed over (6.6). println(user ?? "Guest") leaves user usable on the next line; let shown = user ?? "Guest" takes user, as let b = a takes a. The positions that only read are an argument the function only reads, an f"…" hole, a comparison of text, and the receiver of a method that only reads it. A value that is a word by its kind - a number, a bool, a scalar, an enum whose variants hold nothing - is copied at a read rather than lent; every other type is lent, at every size (ADR-279 D8).

A left side that is itself only borrowed, beside a fallback that jumps, is a view wherever it stands (ADR-279 D10): over a lent c: Contract, let s = c.signature ?? return false binds a view of the field. Nothing can take a part of a loan, and a return, throw, continue or break has no value of its own, so the answer can only be the left side, lent. Kept past the loan - returned, stored, pushed - the view is refused, and .clone() is the copy.

?. reaches a method. find(1)?.greet("Hallo") calls the method only where there is something to call it on; the arguments reach it, and the result is a T? like any other reach. It flattens where the method’s own result is already a T?. A method may pause and may fail, and a ?. on one carries both.

A ?. guards its own member and no more. Where the receiver is absent, the whole expression is null and what follows is never reached. A . written after the reach is reaching into a T?, and is refused where it is written with NK1125: T? is a type of its own (2.3), and a member of T is not a member of it. So a?.b.c is refused and a?.b?.c is the program.


Chapter 4: Data Structures

4.1. Structs (Custom Data Types)

A struct groups related values under a single name.

Visibility and Encapsulation Everything is private unless it says pub. That includes a struct and its fields.

// file: users.nika

// The Struct is public, but fields are private
pub struct User {
    username: String,
    email: String,
    is_active: bool,
}

A field may have a default, a build-time value written after its type (ADR-331 D5, ADR-318):

pub struct Page {
    pub size: i64 = 50,
    pub cursor: String?,
}

A struct literal may leave such a field out, and the field takes its default: Page { cursor: null } has a size of 50. A literal that leaves out a field without a default is refused with NK1234. The default is evaluated once, while the package that declares the struct is built.

A struct states its rule with an assert after its fields (ADR-369):

pub struct Piece {
    start: i64,
    end: i64,
    assert(0 <= start && start <= end; message: "a piece ends after it starts")
}

The claim reads the fields by name, and it is the type’s invariant. It holds after every construction - a literal, a with, the constructor’s result - at the end of every method that changes the value, and after every write to a field from outside such a method; inside a method it may be broken for a while. Each of these points is proved, or where the compiler cannot, refused or checked as the build says, as any assert is (Part III 14.2). Wherever a value of the type is read, the rule is a fact the compiler proves from. Where a struct with private fields writes no rule, the compiler may find one its package’s code always keeps, and uses it only as a fact.

4.2. Constructors and Instantiation

A struct literal Type { field: value } names fields, and a private field cannot be named from another package. A type another package constructs provides a public constructor.

The Anonymous Constructor (pub fn) An impl block may declare one function with no name. That function is called when the type name is invoked like a function: User(...).

// file: users.nika
impl User {
    // The Constructor
    // It accepts positional arguments (Subject Zone)
    pub fn(username: String, email: String) -> User {
        // We can access private fields here because we are inside the module
        return User {
            username: username,
            email: email,
            is_active: true, // Default logic handled internally
        }
    }
}

A struct literal is written with braces, and only with braces. User { username: name, email: address } is the literal. User(a, b) is a call, of the anonymous constructor or of anything else. Where the name is a type, Type(field: value) is refused with NK1146, naming the braces.

A type is constructed by its anonymous constructor, in std as in a .nika file: Vec(), String(), HashMap(), Stats(first). A written Type::new is refused with NK1149, in a call and as a value alike; the constructor handed over as a value is the same spelling, par_fold(M, Summary, …). The ledger writes Type::new, the name the lowering uses.

A copy with fields changed: with. with writes a copy of a value with named fields changed, without naming the rest:

let moved = p with { x: p.x + 1 }
let stats = old with { count: old.count + 1, sum: old.sum + t }

The braces are the struct literal’s, with its field list and its shorthand. with names top-level fields only: a field of a field is p with { pos: p.pos with { x: 1 } }. The fields not named are moved from p, never copied unseen. Where one of them is a text or a list and p is used afterwards, the refusal names the copy to write. Across a package, with names pub fields only, as a literal does.

A field the type does not have is NK1107 and a private one NK1110, both the literal’s own refusals. A field named twice is NK1172, here and in a plain literal. A with that names none is NK1174. NK1173 refuses the operand: an enum, a view (there is nothing to move out of one), a type with no fields, or a value whose type the compiler did not work out.

Usage Example Another package reaches User through its constructor.

// file: main.nika
use users

fn main() {
    // ERROR: Private Fields
    // Direct struct initialization is forbidden because fields are private.
    // let u = users::User { username: "A", email: "a@b.com", is_active: true }

    // OK: Public Factory Constructor
    // Calls the 'pub fn' defined in 'impl User'.
    // Note: Uses positional arguments as per Function Syntax.
    let u = users::User("Alice", "alice@example.com")
}

4.3. Why No Classes? (Data vs. Behavior)

In Java, C# or Python a class bundles three things: the data a value holds, the methods that work on it, and a place in a hierarchy it inherits from. Nikaia has no classes. Each of the three has its own construct:

A class gives Nikaia writes
fields a struct (4.1): what a value is
methods an impl block: what a value does
a base class, shared behaviour a trait (4.7): what several types can do, each in its own impl
a fixed family of subclasses an enum (4.4): one of a fixed set of variants
struct Position {
    x: int,
    y: int,
}

struct Player {
    name: String,
    at: Position,          // a part, not a parent
    health: int,
}

impl Player {
    fn hit(mut self, damage: int) {
        self.health = self.health - damage
    }
}

What goes wrong with classes, and what Nikaia does instead:

enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
}

fn area(s: Shape) -> f64 {
    return match s {
        Shape::Circle { r } => 3.14159 * r * r,
        Shape::Rect { w, h } => w * h,
    }
}

A trait method has no default body and a trait is not a type (4.7): a list of values of different types is a list of an enum, and code shared by several impls is a function they call.

4.4. Enums (Algebraic Data Types)

An enum is a type whose value is one of several distinct variants.

enum Message {
    Quit,                        // carries nothing
    Move { x: i32, y: i32 },     // named fields, read by name
    Write(String),               // positional, read by position
}

An enum is the type for a value that is one of a fixed set of things: two operators, four directions, the three states a connection can be in. An enum is read back with match (3.4): an arm per variant, and no arm for a case that cannot happen.

A type may hold itself. A syntax tree is an enum whose variants hold more of it, and a list is a node whose next is another node or nothing:

enum Expr {
    Num(i64),
    Add(Expr, Expr),
}

struct Node {
    value: i64,
    next: Node?,
}

The program writes the tree as the tree. A value that held a whole value of its own type inline would be larger than itself, so the compiler keeps such a field somewhere else and the program never names where: it is built, read, matched and assigned as the type it declares (ADR-246). What that costs is one allocation per such field built. A pattern cannot yet look inside such a part (Expr::Add(Expr::Num(n), b)); a name binds it, and a match in the arm asks the question.

An enum with one variant is built by its type’s name (ADR-347): the variant adds nothing the type does not say.

enum Meters { Value(f64) }
enum Pos { At { x: i32, y: i32 } }
enum Done { Only }

let m = Meters(2.5)            // Meters::Value(2.5)
let p = Pos { x: 1, y: 2 }     // Pos::At { x: 1, y: 2 }
let d = Done                   // Done::Only

The long form stays valid. An enum with more than one variant takes the long form (NK1252).

4.5. Collections

The standard library provides types for groups of values.

A list is written [1, 2, 3]. The elements are expressions, a trailing comma is allowed, and the type is Vec[T] where T is what the elements agree on. Elements that do not agree are refused with NK1154. Elements written only in number literals are one number without a type, decided as a let’s is (2.4): by the uses, and where none asks, i32, or i64 where an element does not fit an i32. An item a walk of the list hands a lambda is that number too, and a use of it inside the lambda is a use of the list’s number: xs.iter().filter(fn(v) { byte(v) }) makes xs a Vec[u8]. [] is the empty list and takes its element type from the first use that says one: let xs: Vec[i64] = [], or a push. Where nothing ever says the type, [] is refused with NK1153, asking for the type. A [ at the start of a line begins a literal and never an index of the line above it, so an index is always written where its subject is. The list type is written Vec[T]; there is no second spelling [User].

A list has no operators, and neither has a map or a set: a + b on two lists is refused with NK1191. One list’s elements join another with a.extend(b), which grows a in place (ADR-253).

A [] whose only uses cannot give it an element type (xs.len() and nothing else) is not refused by the compiler, and the backend reports it (Part III C.4).

A fixed-size array is Array[T, N], in the bracket generic every other parameterised type is written in; N is a number rather than a type, and it is the one place a type argument is one. It is N elements inline: in a struct it is part of the struct, as an argument it is passed as a value, and nothing is allocated. It is indexed as a list is, an index out of range aborts (Appendix A), and len() is the N it was declared with, known while the program is built.

struct Vector3 { parts: Array[f64, 3] }

let origin: Array[f64, 3] = [0.0, 0.0, 0.0]

The literal is the list literal: it takes the array type where the use asks for one, and a literal with no use to constrain it is a Vec. A use is an annotated let, an argument, a declared result or a struct literal’s field, and the answer descends with the literal, so Vec[Array[f64, 2]] and Array[Array[i64, 2], 2] take their shape too. The length is part of the type, so Array[f64, 3] and Array[f64, 4] are different types and a literal whose length does not match N is refused with NK1157 naming both numbers. [v; n] (4.5) is such a literal too: let buf: Array[u8, 4096] = [0; 4096], where n is a number the build knows and equal to N.

An array is walked as a list is (ADR-collections-2): for x in a lends it (3.3), and a.iter(), on an array and on a run of one, is the sequence a list’s iter() is, so a.iter().zip(b.iter()), map, filter and enumerate follow. Nothing is copied.

4.6. Generics (Type Parameters)

A generic declaration is written once for several types. A type parameter is declared inside square brackets [...].

struct Box[T] {
    item: T,
}

impl Box[T] {
    fn get(self) -> T {
        return self.item
    }
}

fn hand[T](x: T) -> T {
    return x
}

A type parameter is written, never inferred. Inside the body it is a type: x is a T, and a T is not an i64, because the caller picks what T is. At the call it is filled in from the arguments: hand(n) where n is an i64 hands back an i64, and Box { item: n } is a Box[i64]. A parameter list may hold the type itself, fn schema[T](T); the call then writes a type there, schema(User), and the function needs no value of it (Part II 10.3). A call may also name a function’s type arguments in brackets, parse[Config](text); the types fill the type parameters in order. The call is warned with NK1233 (ADR-330 D9) where a value argument’s type could have said the type, and not where the brackets are the only place that says it, json::read[Contact](bytes) (ADR-grammar-2 D11).

A type may take values as well as types in its brackets (ADR-333), as Array[f64, 3] takes its length. A value parameter is marked comptime and written with its type; those after a ; are named at the use and have a default, as a function’s options do (5.1):

pub struct Decimal[comptime precision: i64, comptime scale: i64; comptime rounding: Rounding = Rounding::HalfEven] { … }

let price: Decimal[10, 2] = …

An argument is a build-time value (Part II 10.2), refused with the codes a comptime is refused with where it is not one. Two uses name the same type when their arguments are equal. Inside the declaration a value parameter is a constant: precision is an i64 known while the program is built.

A function’s parameter may be comptime as well. The call writes its argument as any other, and the argument must be a build-time value; inside, the parameter is a constant, and the function is emitted once per value:

pub fn get[F: Fn](self, comptime path: String, handler: F) -> Server { … }

server.get("/orders/{id}") fn(id: i64) { … }       // "/orders/{id}" is read while building

A caller that passes its own parameter on declares it comptime too.

A parameter marked comptime? may be a build-time value (ADR-specialization-1): a call whose argument is one calls a copy of the function made for that value, two calls with equal values share it, and any other call calls the function as written. self may be marked. A value is a build-time value by rule: a comptime binding, a literal, a comptime or comptime? parameter inside its copy, a field, element or part of one, a call the build may run over them (Part II 10.2); a let is not one. Inside the function, the parameter may not stand where only a build-time value is allowed (NK1305), since the function as written receives run-time values. A result type may be computed from the function’s comptime parameters by a call the build may run: fn compile(comptime pattern: String) -> Regex[engine_for(pattern)].

A comptime parameter in a function’s brackets is read off its arguments’ types, as T is:

fn reversed[comptime N: i64](xs: Array[i64, N]) -> Array[i64, N] { … }
fn dot[comptime N: i64](a: Array[f64, N], b: Array[f64, N]) -> f64 { … }

dot([1.0, 2.0], [3.0, 4.0])          // N is 2
dot([1.0, 2.0], [3.0, 4.0, 5.0])     // refused: N cannot be 2 and 3

The call writes nothing for it; inside it is a constant, and a result type may name it. Array[T] as a keep parameter (2.2) is such a length that nobody names.

A T on its own has no members:

fn shout[T](x: T) -> String {
    return x.to_uppercase()   // error[NK1126]: `T` stands for a type the caller
}                             // picks, and nothing says it has a method
                              // `to_uppercase`

A bound, [T: Summarize], is 4.7’s subject.

4.7. Traits (Defining Behavior)

A trait declares a set of methods that different types can share.

trait Summarize {
    fn summary(self) -> String
}

impl Summarize for User {
    fn summary(self) -> String {
        return f"User: {self.username}"
    }
}

A trait’s methods are signatures without a body. The declaration says what a type must have, and the impl says what it does.

A type’s own methods are its declarer’s. An impl without a trait works only on a struct or an enum the program declares. One on a built-in type, a std type or any type written with a module in front is refused with NK1209. To give such a type a method, declare a trait and implement it for the type:

use std::time

trait Doubled { fn doubled(self) -> time::Duration }

impl Doubled for time::Duration {           // not `impl time::Duration`
    fn doubled(self) -> time::Duration { return self * 2 }
}

A type parameter may be bound by a trait. The bound is what gives a generic body something it may do (4.6):

trait Summarize {
    fn summary(self) -> String
}

fn shout[T: Summarize](x: T) -> String {
    return x.summary()     // `Summarize` says there is one
}

The bound answers the whole call: how many arguments summary takes, what they have to be, what it hands back, and whether it can fail or pause all come from the declaration. Several bounds are written [T: Named + Aged]. A bound may take a path, [H: http::Handler], and the ledger records a trait and each impl where they were written. Whether a type implements a trait is the union over every ledger the program reads plus its own. A call that passes a type with no impl of the bound’s trait is refused at the call, with NK1164.

A trait method reads like any signature. Without sync it may pause; without throws it cannot fail. An implementation is checked against the declaration: a body that pauses under a sync declaration is refused, and a body that does less than the declaration allows is accepted. A trait can therefore describe I/O, fn load(self) -> String throws, and a call through its bound pauses where the declaration says it may.

An impl owes its trait the declared methods and no others: a method the trait does not declare, or one it declares that the impl leaves out, is refused with NK1130. A method whose implementation pauses where the declaration says sync is refused with NK1129. A trait method has no default body, and a trait is not a type: there is no dyn and no fn f(x: Summarize).

Eq and Ord are traits of the language, in scope everywhere (a program that declares a trait or an enum of the same name keeps its own): Eq has eq(self, other: Self) -> bool sync, Ord has compare(self, other: Self) -> Ordering sync, and Ordering is the enum Less | Equal | Greater; they are the standard library’s Eq, Ord and std::cmp::Ordering in the Rust a program lowers to, so a type with an impl Ord is an Ord type to Rust code. A bound [T: Eq] gives the body == and != on two T; [T: Ord] gives <, <=, >, >=, compare, and also == and !=, because an ordered type is an Eq one. An operator on a type parameter that no bound allows is refused with NK1248. A bound is answered by an impl, and where there is none by the language: the numbers except the floats, bool, scalar, String and Bytes are Eq and Ord, a float is neither, and a container is when what it holds is, and so is a view of a run: two views of u8 runs are equal where their bytes are. A struct or an enum the program declares is Eq where its parts are, and Ord only where the program writes impl Ord for it. impl Eq and impl Ord are written for a type the program declares, and impl Ord only for a type that is Eq; any other is NK1249. An impl Eq replaces the equality the type would have had, and a body of either that can pause is NK1129. The laws are the program’s: equality is reflexive, symmetric and transitive, the order is total, and compare is Equal exactly where eq is true. The compiler does not check them. Hash is a trait of the language too: hash(self, mut into: Hasher) sync, where the body hands the parts that make a value what it is to into.add(x), each of a type that hashes; it is Rust’s Hash. impl Hash is written for a type the program declares and that is Eq, any other is NK1249; it replaces the hash the type would have had, and a type with an impl Eq is a hash map’s key only through one. Its law is the program’s as well: two values eq calls equal feed add the same values.


Chapter 5: Functions & Argument Architecture

5.1. The “Subject ; Config” Protocol

A function signature separates the data a function operates on (its subject) from its configuration options with a semicolon separator (;).

Zone 1: Subject (Positional) Parameters before the semicolon are the data the function operates on.

Zone 2: Configuration (Named Only) Parameters after the semicolon are options, flags, or modifiers.

// Definition
fn request(url: String; timeout: time::Duration = 30.seconds(), method: String = "GET") { ... }

// Valid calls
request("https://api.com")                                        // both options defaulted
request("https://api.com"; timeout: 60.seconds())                 // one of them named
request("https://api.com"; method: "POST", timeout: 5.seconds())  // in any order

// Invalid calls
// request("https://api.com", 60.seconds())      // a positional argument in the named zone
// request("https://api.com"; timout: 5.seconds())  // error[NK1109]: no option `timout`

The ; stands between the two zones, and where one zone is empty it is not written. A function whose parameters are all options is declared fn execute(target_age: i64 = 0) and called execute(target_age: 30). execute(; target_age: 30) is refused. A mixed call keeps its ;, and the ; is required there. A method call takes the same form. A driver’s deferred parameters (Part II 10.5) write no ; either, because a receiver stands outside the parentheses.

A call’s ( is on the line of what it calls (ADR-317), as an index’s [ is: a ( at the start of a line begins what is written there - a tuple, a parenthesised expression - and never the arguments of the line above it.

Every configuration parameter has a default. A caller may leave an option out, and the call then takes the default. A parameter that has to be passed belongs before the ;. An option without a default is a parse error that says so.

A default is a build-time value (ADR-318): a literal, or anything a comptime may hold (Part II 10.2), such as timeout: time::Duration = 30.seconds(). It is evaluated once, while the package that declares the function is built, and every call that leaves the option out receives that value, in the form a comptime crosses in. A default that cannot be evaluated or cannot cross is refused with the codes a comptime is.

Order is the declaration’s, not the call’s: method written first above is still passed second. The ledger records an option’s name, type and default, the default as its value (Part III, 13.5).

Optional Parentheses A function declared without parameters may omit the parentheses, matching the block lambda style.

fn init { 
    // No args, no parentheses required
}

5.2. There Is One Lambda Form

A lambda is written fn { … }, and 5.3 describes it. There is no second, shorter form for single-line bodies.

Naming the arguments, fn(user) { … } (5.3), is the same form spelled out: the body is a block either way, and both spellings stand in all the same places.

The arguments are the ones the lambda names. A lambda that names none takes none, so fn { … } is a lambda of no arguments. A body that uses a, b or c without declaring it is refused with NK1117.

fn: expression is refused with a message that names the block form.

5.3. Lambdas (fn { ... })

A block lambda holds any number of statements. Its arguments are the ones it names.

let complex = users.map fn(user) {
    let bonus = calculate_bonus(user)
    // Implicit return of the last line
    user.score + bonus
}

let ids = users.map fn(user) { user.id }

There are no automatic argument names. A local inside a lambda may be called anything. A body that uses a, b or c without declaring it names something nothing declares:

error[NK1117]: `a` isn't declared anywhere.

Trailing Syntax A lambda that is the last argument may stand outside the parentheses. Where there are no other arguments, the parentheses are omitted with it:

let ids = users.map fn(user) { user.id }
let sum = numbers.reduce(0) fn(acc, n) { acc + n }

A block ends at its }, so a chain continues after it and means what it reads as:

Routes()
    .get("/x") fn { first() }
    .get("/y") fn { second() }
// The count comes from the list, so a local may be called anything
users.map fn(user) {
    if user.is_guest() {
        return "Guest"
    }
    return user.name
}

A trailing lambda may follow a method call with or without other arguments, a plain call, or a path: users.map fn(user) { … }, numbers.reduce(0) fn(acc, n) { … }, access_all(a, b) fn(x, y) { … } (Part II, 12.3), and the chain above.

A lambda directly after async is run, not bound. let x = async fn { … }, async fn { … } and the handle forms async(handle) fn { … }, async(deferred) fn { … } and async(detached) fn { … } run the lambda’s body as a task (8.1.2); return in it ends the task with its value. A task whose value is a function is written let f = async fn { fn { … } }.

A lambda carries no effect marker. A parameter list is followed by the body and by nothing else; fn(info) sync { … } is not one construct (7.2).

5.4. Contextual Capture (The Lifecycle Rule)

Whether a lambda borrows or moves the variables it uses is inferred from the context the lambda is used in. The rule is the same at both values of user_parallelism.

A. Immediate Context (@immediate)

A function that runs the callback to completion before it returns is an immediate context.

let prefix = "User: "
let names = ["Alice", "Bob"]

// 'map' is @immediate. It executes completely within this stack frame.
// 'prefix' is implicitly borrowed.
let formatted = names.map fn (name) { prefix + name }

// 'prefix' is still valid here because it was only borrowed.
println(prefix)

B. Detached Context (@detached)

A function that stores the callback, runs it later, or hands it to another thread or task is a detached context.

let prefix = "Log: "

// 'async(detached)' is @detached. The lambda might outlive the current function.
// 'prefix' is implicitly moved into the background task to ensure safety.
async(detached) fn { println(prefix + "System started") }

// Compiler Error: 'prefix' has been moved!
// println(prefix)

C. Where the Distinction Lives

The context belongs to the function that takes the lambda, not to the call. map is immediate for every caller and async(detached) is detached wherever it is written, so the capture is decided where the lambda is written.

A function in user code declares a code parameter with a function type. The type is spelled the way a signature is, handler: fn(Request) -> Response, with sync or throws after the result where a declaration puts them. A parameter the code changes carries mut, and one it keeps keep, as in a signature: f: fn(mut S) lends the argument at each call as a mut parameter of a named function is lent, and both fn bump(mut s: S) and the lambda fn(mut s) { … } fit it. Without sync the code may pause; without throws it cannot fail. A lambda that does less fits a type that allows more. A pausing lambda handed to a fn() sync is refused.

The context of such a parameter is inferred, not written. A parameter the body only calls is immediate and borrows. A parameter the body keeps (stores, hands back, gives to a task) says keep, as every kept parameter does (6.5), and is detached and moves; there is no @detached to write. For an immediate parameter the lambda’s touches count for the callee, while whether the call may pause, and the shape it is lowered to, follow the parameter’s type. std’s own lambda-taking entries, such as map, are written by hand and follow the lambda in everything. For a kept parameter the callee’s promises about pausing follow the type, so a listen that calls a stored fn(Request) -> Response may pause. The capture at an async(detached) is reported with NK2101 (8.3).

A function type stands wherever a type does: a struct field, a result, a let, an element of a list. A function value in any of them is kept: the lambda moves what it captures, and a copy of the value shares the one closure. A field that holds one is called like a method, button.on_click(4), where the struct has no method of that name. A named function stands where a function value is wanted. A kept value handed to a parameter that only runs it is lent. Two values of a type that holds a function are not compared (NK1188).

struct Button { label: String, on_click: fn(i64) -> i64 sync }

fn adder(n: i64) -> fn(i64) -> i64 sync {
    return fn(x) { x + n }
}

fn main() {
    let b = Button { label: "ok", on_click: fn(x) { x * 2 } }
    let add = adder(3)
    println(f"{b.on_click(4)} {add(1)}")
}

D. A Lambda That Runs at Most Once

A lambda handed to a parameter that runs it at most once may give away what it captures (ADR-lambdas-1). Inside any other lambda, handing a captured value to something that keeps it is refused (NK2105, 6.5), because a second run would find it gone. A lambda that runs at most once has no second run: it may put a captured value into a field, a container or a kept argument, and after the call the value is gone, as a kept argument is, whether or not the lambda ran.

let t: Vec[i64] = [1]
m.entry("k").or_insert_with fn { H { a: "x", t: t } }   // `t` moves into the new `H`
// println(t.len())   // NK2105: `t` was handed over in the lambda above

In std these parameters are Entry::or_insert_with’s and Entry::and_modify’s lambda and the block of Locked::access and SharedMut::access; the contract says so with once (Part III). update is not one: its block may run again (Part II 12.2). For a code parameter of user code the answer is inferred, as the context is (C): it is once when the body calls it at most once on every path and hands it on only to a parameter that is once. A call in a loop, a second call, or keeping it makes it many. Nothing is written in the source. Where NK2105 refuses a hand-over inside a lambda, it names the parameter that may run it again, or the second call in the callee that made it many.

5.5. main’s Parameters Are the Command Line

A program starts at main. main may take parameters; they are the words the program is started with (ADR-360).

/// Multiplies a positive number.
fn main(a: int; times: int = 4, verbose: bool = false) {
    assert(a > 0)
    let b = times * a
    if verbose {
        println(f"{times} * {a} = {b}")
    } else {
        println(f"{b}")
    }
}

prog 5, prog 5 --times 3, prog --verbose --times=3 5.


5.6. Inlining

@inline asks that a function’s body be copied to where it is called (ADR-370). It stands before a function or a method: @inline is a hint, and lets another package inline a pub fn; @inline(always) asks it of every call, and @inline(never) of none, for a cold path; it stands once, and not before a trait’s method, which has no body. None of them changes what a program means. A function that calls itself is never inlined into itself. Where the compiler sees a hot call the backend would leave out of line - what a fold’s or par_fold’s step calls (Part II 10.7) - it adds the hint itself; a written @inline(never) wins.

@inline
fn digit(b: Bytes, i: i64) -> i64 {
    return (b[i] as i64) - 48
}

Chapter 6: Memory and Ownership

Memory is managed by ownership and borrowing, which the compiler enforces. There is no garbage collector, and user code frees no memory.

6.1. The Concept of Scope

When a variable goes out of scope, at the end of the block {} where it was created, its memory is released. User code frees no memory.

6.2. Unified Types

Three shared types carry a value that has more than one owner.

Shared[T] is written in the source; it is not inferred. The compiler decides which owner count each value gets: one a second thread may safely touch where the value may reach one, and a cheaper one where it may not. Whether a Shared may be handed to a task (Part II, 11.2) is decided from the type and from where it is going, at both values of user_parallelism. The count follows that answer.

Where the compiler proves that a value never leaves the thread that made it, it uses the cheaper count. This never changes meaning. Where nothing proves it, the value gets the safe count, and there is no way to ask for the other one.

At user_parallelism = no every count is the cheap one. User code runs on one thread, the runtime’s own threads run no user code, and a Shared may not be handed to code nothing written down describes. What a program passes out of itself is what is inside the Shared, a view or a copy; a foreign library that keeps a value puts it in a hull of its own. nikaia lower x.nika --sharing prints which count each value got, why, and what would have changed it. It changes no decision.

The second owner arises without a written step. Where a handle on a Shared[T] or a SharedMut[T] is handed on by value, passed to a function that keeps it or used by a task (Part II, 11.2), the handle is duplicated. Each handle is cleaned up at the end of its own block (6.1). No method is called. A duplicated handle copies no data: one value, one more owner. The rule is the same for both shared types; a SharedMut[T] may go into a task of the program’s own, and not into code nothing written down describes (Part II, 11.2).

Lending the inner value out duplicates nothing. A lent parameter handed a shared value is a view of the value inside it, so a function that only uses the value takes an ordinary view and never mentions sharing:

fn serve(db: Connection) { … }

let db = Shared(postgres::connect("…"))
serve(db)              // a view; no handle is made, and the count is untouched

A signature names the shared type only where the function keeps the value past the call, keep db: Shared[Connection]: puts it in a structure, gives it to a task, hangs it on something that outlives the call.

A SharedMut[T] is opened, and the opened value is an ordinary view. There is no view straight through a lock. The caller opens the lock with one of the four doors of 6.3 and passes the borrowed value in. The called function sees a plain value and obeys the rule for an open lock: it may not pause, and it may not touch a lock of its own (Part II, 12.2).

let db = SharedMut(postgres::connect("…"))
db.access fn(open) { serve(open) }    // `serve` must be `sync` and lock-free

The first handle is written in the source. Each of the three shared types makes one by being called with the value that goes in it:

let db = Shared(postgres::connect("…"))
let counter = SharedMut(0)
let frei = Locked(0)                    // a field's lock, one per field

One rule decides every hull in the language:

A hull you cannot see, the compiler writes. A hull you can see, you write.

The compiler puts a plain value into a T? (2.3). A shared type is written where the value is put into it.

A constructor stands wherever an expression may:

keep(Shared(connect(url)))                      // an argument
let p = Pool { db: Shared(connect(url)) }       // a field of a literal
fn shared(url: String) -> Shared[Connection] {
    return Shared(connect(url))                 // a result
}

A plain value where a shared one is wanted is refused with NK1115:

error[NK1115]: `serve` takes a shared value, but `db` isn't one.
   --> main.nika:11:5
    |
 11 |     serve(db)
    |     ^^^^^^^^^
    |
    = note: It's declared as `serve(db: Shared[Db])`.
    = help: Wrap it: `Shared(db)`.

A shared value may be returned, and a number may be shared: SharedMut(0) is a call, and the call gives the number its type as any other argument does.

Only the handle is duplicated. Ordinary data (a string, a number, a struct of those) is moved where it is handed on to something that keeps it (6.5, 8.3). For data, the .clone() is written in the source.

Each handle lives to the end of its own block, whether or not it is used again. The duplication is not conditional on a later use, so the value is cleaned up where the caller’s handle ends, which may be later than the task that holds the other one. A program that wants the cleanup earlier ends the block earlier (6.1). --sharing names each duplication site beside the count it printed for that value.

SharedMut[T] is one name and two hulls. Which hull a value gets is decided per value, as the owner count is. A message, a printed type and a ledger entry say the name the program wrote. Shared[Locked[T]] is refused with NK1123, and the message names SharedMut[T].

6.3. Changing Shared Data: The Four Doors

A value behind a lock is not changed by assignment. The lock is opened first, and the shape of the change decides which door opens it:

let kasse: SharedMut[i32] = ...

let stand = kasse.get()              // take a copy out
kasse.set(hole_neuen_stand())        // replace it; the new value comes from outside
kasse.update fn(mut v) { v += 100 }   // change it, under the lock

// Where the value is large, `update` changes it where it lies, and `access` reads it there.
let protokoll: SharedMut[Log] = ...
protokoll.update fn(mut log) { log.add("gebucht") }
let n = protokoll.access fn(log) { log.len() }

update and access run user code while the lock is open, so that code runs straight through: no I/O, and no second lock. The compiler checks both (Part II, 12.2 and 12.3).

A value a crashed task left is never read (ADR-327). A task that panics while it holds a lock poisons the value. An update or update_all whose changed values all copy - numbers, bools, scalars - works on a copy and puts it in place when its block ends, so a panic in it leaves the value as it was and poisons nothing; an update_all puts both copies back together, and where one of its two values does not copy, neither is copied and a panic poisons both. access and access_all cannot change a value, and a panic in them poisons nothing. A change an earlier door made stays: values that must agree are changed in one update_all. A door to a poisoned value panics in the task that opens it. A value made with SharedMut::supervised(fn { … }) is rebuilt instead: the runtime calls the function again, puts its result in place, and the holders keep their handle; how often is bounded as a supervisor’s restarts are (Part II 12.8).

A value taken out of a lock is stamped. kasse.get() is a Seen[i64], and so is what access computes. A Seen reads like the value it carries: a program prints it, compares it, sends it in a response, and hands it to any function that touches no lock. The stamp goes with it through arithmetic, calls, struct fields declared Seen[…], and time. A stamped value may not go back into a lock blind: kasse.set(stand + 100) is refused wherever stand was read, and so is if stand > 100 { kasse.set(0) }. The doors for what was seen are update, which decides inside the lock, and set(neu; after: stand), which stores only if the lock still holds what was seen and throws Overtaken otherwise. There is no word that removes the stamp.

Three mistakes are refused by name. Assigning to a SharedMut directly, kasse = 0, is refused, and the message names set. A set given a stamped value, or standing under a stamped condition, is refused, and the message names update and after:. An update block that assigns to v without reading it is refused as a set.

6.4. Resource Cleanup (RAII)

Resources are cleaned up deterministically, following the RAII principle (Resource Acquisition Is Initialization). There is no garbage collector.

Automatic Destruction When a variable goes out of scope, at the closing brace }, its memory is released.

Custom Cleanup (impl Drop) A struct that manages an external resource (a file handle, a socket, a C pointer) may implement the Drop trait. The drop method is called when the value is destroyed. drop is synchronous: it runs straight through and never pauses. It is for teardown that is pure memory work or a cheap native call.

struct FileHandle {
    fd: i32
}

impl Drop for FileHandle {
    fn drop(mut self) {
        println("Closing file descriptor...")
        // Native close call would go here
    }
}

Cleanup That Needs I/O (impl Cleanup) A resource whose teardown needs I/O (a buffered file flushing, a transaction rolling back, a TLS connection closing) implements Cleanup. I/O may pause (Chapter 8), and drop cannot pause.

use std::fs

struct BufferedFile {
    path: String,
    held: String,
}

impl Cleanup for BufferedFile {
    // Pausable teardown. May pause, may fail.
    // Runs where the value dies - on normal exit AND while an error is
    // bubbling up.
    fn cleanup(mut self) throws {
        fs::write(self.path.clone(), fs::Root::Anywhere, self.held.clone(); append: true)
    }
}

impl Cleanup has the one method, fn cleanup(mut self), with throws where it can fail. A type that also needs a synchronous last resort writes it as its own impl Drop: drop runs after cleanup, or alone where cleanup cannot run (below). Anything else written in impl Cleanup, a drop included, is refused with NK2601, and the message points at impl Drop.

User code never calls cleanup. The value dies where ownership says it does: at the closing brace of the block that owns it, or in the function it was handed to when that function’s parameter is keep (6.7). The compiler runs cleanup there: the end of that block is a place where the function may pause and where a failure surfaces. Values that die together are cleaned up in the order they die, the last one bound first. What holds such a value (a struct field, a list element) has its cleanup too, because the value dies with it.

std has one of its own: fs::create(path, root) hands back a buffered fs::Writer, whose write holds the text and whose cleanup writes what it holds.

use std::fs

fn report(lines: Vec[String]) throws {
    let mut out = fs::create("report.txt", fs::Root::Anywhere)
    for line in lines {
        out.write(f"{line}\n")
    }
}   // `out` dies here, and what it holds is written

A cleanup error is an error. The cleanup is a call the compiler writes, and it is answered like one. If cleanup declares throws, the function where the value dies can fail, and one that does not declare throws is refused with NK2605, the code of every call that can fail there. The message names the resource:

error[NK2605]: This function can fail, because cleaning up `f` can fail.
  --> report.nika:2:5
   |
 2 |     let f = fs::create("report.txt", fs::Root::Anywhere)
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: `f` is a `fs::Writer`. Its cleanup runs at the end of this block and can fail, so the function can fail with it.
   = help: Declare the function with `throws`, or close it yourself where you want to handle the failure: `f.close() catch { … }`.

Two refinements:

Where nothing may pause, nothing with a pausable cleanup may die. The cleanup is a pause, and the rules for pauses apply to it as written: a value that would die inside a door’s block is refused with NK2202 (Part II 12.2), inside a par_iter lambda with NK2209, and inside a lambda handed to a sync function type with NK2206. The message names the cleanup of the value; the way out is binding it outside, where it dies after the lock is released, or closing it there.

When cleanup cannot run. When a task is cancelled (it lost a select race, or a supervisor restarts it), what died in it and was not yet cleaned up is adopted by the runtime, which finishes it in the background before the program exits (“parked cleanup”). The runtime configuration cleanup-deadline bounds them (Part III 13.3b). A cleanup the deadline cut off is a failure of the program: exit status 70, with the resource named on the panic path. A panic runs no pausable cleanup: during panic teardown only the synchronous drop runs, and on a target that traps rather than unwinds, a panic ends the process immediately, so no destructors run at all (Part III, Appendix A). The panic hook (7.2) runs on every panic. A panic is for an unrecoverable bug; a recoverable failure uses throws, where full cleanup is guaranteed.

There is no defer keyword. Cleanup happens at the end of the block through Drop. A lock’s .access() releases the lock; there is no manual lock/defer unlock pair. A throws unwinds the stack and runs drop for every variable in scope.

6.5. References and Borrowing

A function that only looks at a value, and does not own it, takes a view of it. The owner keeps the value, the borrower may read it, and the loan ends on its own.

A view is never written; the compiler infers it (ADR-views-3 D4, D5). A parameter, a receiver, a field, an element, a local binding and a result are declared with their type (String, Vec[Row], Reading), and whether each is a view or a value of its own is read off the program. ref is written only inside extern "C" (Part III 15.1); a ref anywhere else is refused, with a help that removes it: at a call or in an expression with NK1137, ref mut with NK1158, in a declaration with NK1333.

Nikaia source contains no lifetime annotations. There is no syntax for them. Everything described below happens inside the compiler.

Nikaia source code contains no lifetime annotations. Ever.

Two things are guaranteed:

  1. Borrowing inside a function, across I/O. A function may pause at any I/O call (Chapter 8), and a borrowed value stays valid across the pause:

    use std::fs
    
    fn report(config: Config) throws {
        let name = config.name           // a view of `config.name`
        let data = fs::read("log", fs::Root::Anywhere)    // pauses here (I/O)...
        println(f"{name}: {data}")     // ...and the view is still valid.
    }
  2. Returning a view from a function. fn first_word(s: String) -> String needs no annotation. Where a function takes several, the compiler reads off its body which one the result comes from, a caller reads it off the ledger, and the function may write it, -> String from s (6.6, 6.7).

A borrow may not outlive its owner (6.6).

The caller writes nothing; the callee’s declaration says what an argument becomes (D1). serve(db), fs::map(path, root), into.merge(stats): whether an argument is lent, kept or changed is read off the callee’s parameter, and the compiler writes the reference, as it writes the pause and the failure a call carries (7.1, 8.1).

A plain parameter is lent; a parameter that keeps its argument says keep (D2): fn add(mut self, keep item: Item) { self.items.push(item) }. Keeps means: stores it into something that outlives the call (a field of the subject, a struct or collection handed back), gives it to a task, or passes it to a callee whose own parameter keeps it. Moving it into a struct, a variant’s value, a tuple or a list the body builds is not keeping it, unless that value is kept. Handing back a part of it is keeping it: a field whose type moves, taken out of the parameter and returned, keeps the parameter; a field that copies leaves it lent. A whole parameter handed back is a view the result points into (6.6). A plain parameter is lent: the caller’s value is usable again after the call. The compiler infers keeping over the call graph and fails closed, so a use it cannot follow counts as keeping. It checks the signature against that: a body that keeps a parameter not marked keep is refused with NK2302, with a help that adds the word, and a keep on a parameter the body never keeps is a warning, NK1334. The ledger records each parameter’s keep (6.7).

The receiver follows the same rule: self is lent, mut self is changed in place, keep self consumes the object.

A trait method’s signature fixes keep for every implementation (D2): an impl that keeps a parameter its trait method does not mark keep is refused with NK1264.

A parameter the function changes in place says mut in the declaration, fn fill(mut out: Vec[i64]) (D3); the call, fill(xs), shows nothing, as xs.push(1) shows nothing. A parameter the body changes without mut is refused with NK1138. A mut parameter handed to another mut parameter is passed straight on. A mut parameter given away whole is NK1131: it stays the caller’s, so the way out is .clone() or taking it keep. A callee that wants a changeable copy writes let mut v = x inside.

A part taken out of a mut parameter is given back before the function is left, and one taken out of a let mut view of a place before the view’s block is left (ADR-views-1). A match arm that changes a part it binds takes it (3.4), and so does handing a field to something that keeps it; assigning the part, or the whole, gives it back. On every way out of the function - its end, a return, a throw - the parameter is whole again, and between the taking and the giving back stands no throw and no call that can fail. Both are refused with NK2108. In match e { E::Add(a, b) => { … e = E::Add(l, r) } … } the arm takes a and b out of e, and the assignment makes it whole again. mut self is such a parameter too. A let mut over a place of something the function may change, let mut c = m[k] ?? continue, is a view through which a change reaches the place, and the same rule holds for it: on every way out of its block - its end, a continue, a break, a return, a throw - it is whole again. let kept = pairs(c.ensures, c.from) takes both lists out with no copy, and c.ensures = [], c.from = [] give them back. Handing the whole view to something that keeps it is NK1131, as for a mut parameter.

A for lends its list, so the list is still there after the loop; iteration that takes the elements away is written for x in xs.drain(). A for mut over a list or an array the function may change is a let mut view of each element in turn (ADR-views-2), lowered as iter_mut(), and the rule above holds for each turn: a part taken out of p in for mut p in v is given back before the turn ends, and handing p whole to something that keeps it is NK1131. A let that annotates a for binding with the element’s type, let copy: Row = r, is refused with NK1183: the binding is a view, so the annotation comes off, or .clone() makes the copy. A let over a place (config.name, totals.stations[name]) is a view of it where the value would otherwise have to move, and takes the part where the name is kept (handed to a keeper, returned, or assigned into a place) and the place is a part of a mut parameter, of mut self or of a let mut view (ADR-views-1 D6): let outer = walk.at; walk.at = P {..}; visit(walk); walk.at = outer copies nothing. In a loop, such a part is given back before the turn ends, and a refusal that comes from the taking names the line that keeps the name.

Fields, elements and local bindings are views where the code makes them so (D5). let s = totals[name] and let n = config.name over a place are views, and so is an arm of their value; taking the value out is written (remove, drain, .clone()). let y = x over a whole variable is a rename, and a let whose initialiser is a call or a literal owns what it is given. A field or an element holds a view, tethered to its buffer (6.6), where what flows into it is a piece of a buffer, and a value of its own otherwise. A field or an element the program changes in place, or hands to a keep parameter, holds a value of its own; where a view would flow into it, the refusal names where to write .clone():

struct Expr { n: i64 }

fn pick(given: Vec[Expr], tail: Expr, c: bool) -> i64 {
    let first = given[0]                           // a view of `given[0]`
    let either = if c { tail } else { given[0] }   // a view of either
    return first.n + either.n
}

A value that is a word by its kind - a number, a bool, a scalar, an enum whose variants hold nothing - and a tuple whose parts all are, is copied at a read, so it is never a view. In an extern "C" declaration ref i32 and ref mut i32 are the pointers C expects (Part III 15.1).

Nothing here inserts a copy. A value handed to a keep parameter, and used again afterwards, is refused with .clone() named as the way out (8.3).

Four kinds of argument are not lent and are passed owned: one handed to a keep parameter, a value that copies, an argument that is already a view, and a method’s argument.

A view handed to a lent parameter is passed as it is: in for p in team { greet(p) } the element p is a view of a Person, and a greet(p: Person) is lent that view, with no copy. A keep parameter needs a value of its own, so a view handed to it is refused (NK1102), with .clone() as the way out.

A value used after it was handed over is refused with NK2105: an argument handed to a keep parameter, a key or a value written into a container, a field, an element, a let that renames it, an assignment, and, inside a loop or a lambda, the next turn’s hand-over of what is already gone; a lambda that runs at most once has no next turn (5.4 D). A second use in the same statement is refused too. A part is tracked on its own: after xs.push(p.name), p.x is still there and p.name and p are not. A part of something only lent is refused where it is handed over, with NK2106. A parameter whose part is handed over is kept, so it says keep and its caller hands it over whole.

6.6. Escaping References Are Tethered

A borrowed value escapes when it is returned past the scope that owns the buffer, captured by a @detached lambda, or put into a collection that lives longer than the buffer. An escaping view is tethered: the buffer stays alive as long as the view does.

Transient = borrow. Escaping = tether. Copying = yours to ask for.

Every view (of text, of a run) is in one of three states. The compiler picks the cheapest one that works, and the states are never written in the source:

State What it is Cost
Borrowed a plain reference into the buffer nothing at all
Tethered a plain reference; the buffer has moved into an anchor nothing where a frame keeps it; one count per task, or per buffer where a container drops entries — no copy
Owned a String of its own one allocation, only where the program wrote .clone()

The buffer cannot die while anything still points into it. It is kept alive deterministically, by the frame that outlives it or, where none does, by a reference count, with no garbage collector.

Two rules keep this cheap on large data:

Nothing is written for a tether. Where the buffer lives is the compiler’s decision, like which count a Shared gets:

struct Token {
    text: String,   // a view into someone else's buffer
}

fn tokenize(path: String) -> Vec[Token] throws {
    let source = fs::read_to_string(path, fs::Root::Anywhere)
    // The returned tokens outlive this function, so `source` lives on in the
    // caller: no lifetimes, no copies of the text, no dangling references.
    ...
}

A buffer lives in the anchor of whatever holds its views: the anchor is the place a buffer moves to while a view of it lives, and who owns it is read off the program:

What a body owns is handed out the same way, whole or a part: a view of a local handed back, a piece of a text the body read, puts that local in the caller’s anchor, and so does a struct, a variant’s value, a tuple or a list handed back whose views point into s. A view handed back from a parameter points into the caller’s argument, which lives as long as the result does; an argument made in the call itself, let r = e(name.clone()), lives in the caller’s anchor. A view of a parameter kept in a place another parameter holds, such as a part of s pushed into mut v: Vec[String], points into the caller’s argument the same way, and that argument lives as long as the place’s owner does.

A struct built and consumed inside the scope that owns its buffer is borrowed, and costs nothing:

struct Reading { name: String, temp: i32 }   // `name` a view where views flow in

--tethers (on nikaia build and nikaia lower) prints where each buffer lives and why, and changes nothing; a change is a ledger diff in review.

There is no struct with lifetime parameters in Nikaia.

An escape whose buffer cannot be shared is refused. Where a slice escapes and its buffer lives on the stack or came from a foreign library, no tether is possible. The compiler refuses the program and names the ways out, .clone() among them. The compiler never inserts that copy. One buffer handed both to a task and out of the function is refused with NK2304, and so is a container of structs holding views that drops entries inside a loop where the container is not a list, or a struct’s views are not text.

A lent parameter is not kept past its call: storing it into a task or a field is keeping it, and the parameter says keep (6.5, NK2302). A view inside a struct carries the buffer it points into, so a function that keeps a view, and not a copy of its text, takes the struct that holds it keep:

struct Reading { name: String, temp: i32 }

impl Summary {
    fn record(mut self, keep m: Reading) { … }     // and not `keep name: String`
}

Handing a view back out of the buffer it came from is not this rule: fn count(seq: String, k: i64) -> HashMap[String, Tally] returns views of seq, and the result points into seq and nothing else. The same holds for a struct literal handed back: fn make(name: String) -> Reading whose body only moves name into the Reading returns a Reading that points into name.

A result points where the body says, view by view. Every way out of the body - a return, the last expression - is followed back to the parameters whose views reach what it hands back: the receiver and every lent parameter (6.5). A literal, a buffer the body made and a copy reach none. Each view the result holds - the result itself, or each view in a field, an element or a variant’s value - points into the parameters that reach it, and only those. Where the body chooses at run time, by two ways out or one value given either, the view points into each of them. Where no parameter reaches it, it points into nothing a caller passed.

impl Checker {
    fn label(self, world: World) -> String {
        return world.names[self.at]     // points into `world`; `self` is free again
    }
}

struct Pair { left: String, right: String }

fn pair(a: String, b: String) -> Pair {
    return Pair { left: a, right: b }   // `left` points into `a`, `right` into `b`
}

fn longest(a: String, b: String) -> String {
    if a.len() >= b.len() {
        return a
    }
    return b                            // points into `a` or `b`
}

The source may be written on the result with from (D8): fn first_word(s: String) -> String from s, fn longest(a: String, b: String) -> String from a | b. Where it is written, the body is checked against it: a way out that hands back a view of a parameter the from does not name is refused with NK1335. It must be written where there is no body to infer it from - a trait’s method, a function type, a foreign or Rust function - and a result there that is a view without a from is refused with NK1336. Elsewhere it is optional. A method implementing a trait hands back views of what the trait’s from names.

Every argument no view of the result points into is free when the call returns. The ledger (6.7) writes each view’s sources in the same spelling (D10): -> String from world, -> String from a | b, and for a value holding views each by its path, left: from a, right: from b. Each field of a struct keeps its own sources, whether the type is pub or not: pair(x, y).left points into x alone. A field whose type holds several views joins their sources, and an enum joins those of all its variants, since which variant a value is gets decided as the program runs.

While a view is used, the data it may point into stays as it was. Giving an argument up is not changing it: a caller may assign its variable a new value, leave its scope, move it into another variable or into a struct, a variant’s value, a tuple or a list it builds, or pass it to a lent parameter (6.5) while a view of it is still used, and the old buffer moves into the anchor of whatever holds the view, to be freed with the last such view. The place it moved into holds a view of that buffer, so a field, element or variant’s value is a view wherever such moves flow in, and holds either where text of its own flows in too (2.2). A variable or place it moved into is held to the same rule as the argument. Nothing is copied:

let mut y = "short".clone()
let r = longest(x, y)
y = "replaced".clone()      // the old text of `y` lives on while `r` is used
println(r)

Changing that data in place while the view is still used is refused with NK2305, at the change: a method that changes the value, or an assignment to a part of it. So is handing the data to a new owner, at the call: a callee whose parameter is keep or mut (push on a list too), since it may change or discard the data. So is moving it into a place that must hold text of its own, at the move: a field, element or variant’s value that the program, in any function, changes in place or hands to a keep parameter. The message names the view, the call that made it and the parameter it comes from, speaks of data and never of how long anything lives; for such a move, also the place that makes the field own its text. Use the view before the change, or take a copy of it with .clone() before the change; for a hand-over, a copy may also be what is handed over (take(y.clone())); for a move into a place, a copy may be what moves (Cursor { input: text.clone(), pos: 0 }), or the value is built first and the view taken from its field (let c = Cursor { input: text, pos: 0 } then parse(c.input)):

let mut y = "short".clone()
let r = longest(x, y)
y.push_str("er")            // NK2305: `r` may point into `y` (`longest`: `return b`)
println(r)

A tether keeps the whole buffer alive, not just the part pointed at. --tethers (on nikaia build and nikaia lower) names every buffer kept this way and what keeps it; .clone() keeps the part and lets the buffer go.

6.7. The Borrow Contract Ledger

For every function the compiler infers a borrow contract: which parameters it keeps, and what each view of its result points into, such as “the result of checker.label(world) points into world.” The source writes keep (6.5) and may write from (6.6), and the compiler checks them against the inferred contract. Contracts are stored in a generated file, nikaia.contracts, the ledger, which is committed alongside nikaia.lock (Part III, 13.5). It writes keeps per parameter and a result’s sources in the source’s spelling, -> String from a | b, a written from as written (ADR-views-3 D10). A change of either on a pub function is a contract change.

The cleanup point moves with a kept value (D6): a callee that keeps a File runs its cleanup at the end of the callee, not of the caller’s block (6.4). A change to a parameter’s keep on a type whose teardown has an effect is a contract change the ledger names, with the callers whose cleanup moved.

The ledger has two jobs:

  1. Cache: if a contract did not change, none of the function’s callers is re-checked.
  2. Explanation: if an edit to a function body changes its contract, the compiler compares the old and the new contract. If a caller elsewhere breaks, the error names the edit, the change in the contract, the caller that is affected, and the way out.

6.8. When the Compiler Says No

An ownership rule refuses a program that breaks it. Every such refusal explains itself in plain language and names the way out. Reading a Nikaia diagnostic requires no knowledge of Rust; a backend error that reaches user code is a defect of the compiler.

The most common case is changing a collection while looping over it:

let mut users = load_users()
for u in users {
    if u.is_duplicate() {
        users.remove(u)   // bug: pulling the rug out from under the loop
    }
}
error[NK2301]: You're changing `users` while a loop is still reading it.
  --> main.nika:4:9
   |
 2 | for u in users {
   |          ----- the loop reads `users` here
 3 |     if u.is_duplicate() {
 4 |         users.remove(u)
   |         ^^^^^^^^^^^^^^^ changed here
   |
   = note: Removing items mid-loop would lose the loop's place. In most languages that's a crash or a silent bug.
   = help: Use the method that does this safely: `users.retain fn (user) { !user.is_duplicate() }`.

For every known pattern of this kind, the standard library provides a safe, named method (retain, drain, entry, swap(i, j), …), and the diagnostic names it.


Chapter 7: Error Handling

Nikaia distinguishes two kinds of errors.

7.1. Recoverable Errors (throws)

A recoverable error is an expected problem: a file is missing, a connection drops, an input does not fit the format. A function that can fail says so with throws.

use std::fs
use std::net

fn fetch_config() -> String throws {
    let text = fs::read_to_string("config.txt", fs::Root::Anywhere)   // can fail
    let mut peer = net::connect("127.0.0.1:9000")   // can fail too
    peer.write(text)
    return text
}

throws stands after the result type, and sync with it: fn fetch_config() -> String throws. A function type has the same order. A declaration with no result type writes the word after the parameters: fn tick() sync { … }. The form before the arrow does not parse; the message names the order.

throws names no types. What a function can fail with follows from its body. The compiler infers it whole-program and writes it to nikaia.contracts (Part III, 13.5).

A function value keeps its set. A named function or a lambda used as a value carries the set inferred for its body wherever it goes: a let, a field, an element of a list or a map, a result, a parameter, called at once or kept. A call through it can fail with that set; writing, storing, handing on or returning it can fail with nothing. Where values with different sets meet (the arms of an if or a match, the elements of one list or map, the values assigned to one variable or field, the arguments every caller hands one kept parameter), the place carries their union. A place no failing value reaches cannot fail (ADR-351). Code that is kept lives in a value someone holds, reached from a parameter or a result: the set belongs to that storage, every handle to it shares it (a Shared, the two ends of a channel), and it holds everything put there in the value’s life. What a function puts into such a place is in its ledger entry (Part III 13.5, code).

A code parameter the body does not keep fails with what each call hands it. Where a function only calls its code parameter or hands it on, a call of the function fails with the function’s own errors and with what that call passed, never with what other callers pass, pub or not (ADR-error-sets-1). With fn retry(f: fn() throws) throws { f() }, a match over error around retry(read) is complete when it names what read fails with, whatever else retry is handed elsewhere. Inside the body, a catch around f() sees whatever any caller may pass, and needs else.

An error type is an enum.

enum ConfigError {
    NotFound(fs::Path),
    Unreadable(fs::Path),
    BadSyntax { line: i64, expected: String },
}

impl Error for ConfigError {
    fn message(self) -> String {
        match self {
            ConfigError::NotFound(p)   => f"no config at {p.display()}"
            ConfigError::Unreadable(p) => f"cannot read {p.display()}"
            ConfigError::BadSyntax { line, expected } => f"line {line}: expected {expected}"
        }
    }
}

An error carries what belongs to it: “not found” carries the path. A match over an enum (4.4) is checked for completeness. What is thrown implements Error, and the impl line says so. A number, a bool, a character and text are not errors, and a throw of one is refused with NK1161.

Raising: throw.

if !fs::exists(path, fs::Root::Anywhere) {
    throw ConfigError::NotFound(path)
}

A throw is an expression, and so are return, break and continue. Their type is never. A never fits every expected type without widening it, so each may stand wherever an expression may:

let user = find(id) ?? throw NotFound(id)

A throw leaves the function and makes it throws, a break needs a loop, and a statement after a break in the same block is refused with NK1133 (3.3). What a return written inside an expression hands back stands on its line: let x = y ?? return at the end of one returns nothing, and the next line is the next statement.

Propagation happens on its own, and nothing marks it. A call that can fail stands inside a function that declares throws. There is no operator and no sigil:

fn load() -> Config throws {
    let text = fetch_config()   // if it fails, `load` fails
    return parse(text)
}

Four things leave the control flow of a program without the line showing it: a call may pause (8.1), a block’s end may pause and fail (6.4), a call may fail (here), and a loop’s step may fail.

The declaration is required. A call that can fail, in a function that does not say throws, is refused with NK2605:

error[NK2605]: This function can fail, because `liest` can fail.
  --> app.nika:2:23
   |
 2 | fn ruft() -> String { return liest() }
   |                       ^^^^^^^^^^^^^^
   |
   = note: `liest` can fail with `io::IoError`.
   = note: A call that can fail looks like any other call, so its failure passes straight through this function unless you handle it.
   = help: Add `throws` to `ruft`, or handle it at the call: `… catch { … }`.

for fn liest() -> String throws { return fs::read_to_string("x.txt", fs::Root::Anywhere) } one line above. The shape is Appendix C.4’s: the statement is underlined, the first note says what the call can fail with, as the ledger the call was resolved against records it (13.5), and the help names the two ways out.

Handling: catch. The block supplies the replacement value, or it leaves the function.

let config = load() catch {
    eprintln(f"{error}")
    return                                // leaves the function
}

let port = read_port() catch { 8080 }     // replacement value

Inside the block the error is named error. A handler that passes the error on writes throw error. A catch over an expression that cannot fail is refused (NK1134): the expression is the value, and the handler goes.

The catch has the type of the value it guards, or that type with ? where the block hands back null: config above is what load() hands back, and a member of it is reached as on that value. A null from the block widens a T to T?, the way a ?? fallback or an if arm with null widens (3.5); a T that is already T? stays T?, there is no T??. A block that hands back a value of another type is refused (NK1103).

let port = read_port() catch { null }                // i64?
match parse(line) catch { null } {
    v => use(v)
    null => {}
}
let fine = (parse(line) catch { null }) != null      // bool

Telling failures apart. error is the sum of the errors that can arrive at this point, as the compiler inferred them. A handler tells them apart with the patterns of 3.4, where a path with :: names a variant:

let config = load() catch {
    match error {
        ConfigError::NotFound(p) => Config::default()
        ConfigError::BadSyntax { line, .. } => {
            eprintln(f"config broken at line {line}")
            return
        }
        else => throw error
    }
}

A match over error is complete when it names every type that can arrive: each variant of it, or the type by a bare name. A fatal type need not be named, since it passes on unless named. Where the set holds something the compiler cannot name - a call into code no ledger describes - the match needs else (NK1151). Outside a match, such an error is known by what every member has: Error’s methods, f"{error}" and throw error. What only one member has is reached through the match. A library’s error type arrives as that type: a function that reads a file hands its failure on as an io::IoError. No throw in the program raised it, so it has no site, and error.full() says so; the failures that joined it travel with it through every caller that hands it on.

A catch block that begins with an arm is that match (ADR-catch-1): f() catch { regex::Error => { … } else => throw error } means f() catch { match error { regex::Error => { … } else => throw error } }, under the same rules - patterns, a type by its name, completeness (NK1151), a fatal error taken only by name - and its value is the arm’s. A block holds statements or arms, never both; a block of statements is a handler as above.

Fatal errors. An error type is fatal where impl Fatal for T says so, beside its impl Error for T. Fatal has no methods.

enum Abort { Now(String) }
impl Error for Abort { fn message(self) -> String { return "aborted" } }
impl Fatal for Abort {}

A catch takes a fatal error only in a match arm over error whose pattern names a variant of its type. A handler without such an arm, an else arm, a bare name and throw error do not take it: it passes the catch as if no catch stood there, the handler does not run, and the error leaves the function, which declares throws (NK2605 otherwise).

let n = run() catch { 0 }                    // an `Abort` passes on
let m = run() catch {
    match error {
        Abort::Now(why) => {
            eprintln(why)
            1
        }
        else => 0                            // not for `Abort`
    }
}

process::exit(code) throws process::Exit(code), which is fatal (Part III 17.1). At the end of main it ends the program with code; every other error that leaves main ends it with 1 (Part III A.2).

When a set grows. The variants of an error type and the set of error types arriving at a catch are both closed: a match that names all of them is complete, and a new one makes it incomplete. When a callee gains a failure, a match that named every type without else is refused (NK1151), and the message names the new type and the call that brings it in. A handler with else, or one that does not match on error, takes the new error as it takes everything, and is named once in the build output (NK2402, given at the call inside the handler). Under --locked the build fails until the ledger is regenerated and committed. The author of a pub function whose set grows is warned (NK2404): callers that name every type will stop building.

Every error carries its site and its chain. The site is where the error was raised. The chain holds every error that joined on the way: a cleanup that failed while the stack was unwinding is attached to the original as a secondary error rather than replacing it (6.4), and so are the failures of tasks nobody read when their group ends (8.1.2). The joined errors are kept in the order they joined, each with its own site, and a log, an uncaught failure and nikaia explain show them under the first. They are a diagnostic, not a value: a program does not read them, and throw error hands them on. A catch catches one error and chooses by its type. The site costs nothing at run time: the compiler writes it into the binary as text.

An error that crosses an async boundary carries where it crossed. Beside its site (where it was raised), it records where the child was started (the async form) and where the error arrived (the wait point or the join, 8.1.2). A chain of bindings gives one started and arrived pair per crossing, in order, and each secondary error carries its own. Like the site, both are text the compiler writes into the binary, and nothing is recorded where nothing fails. The long form shows them as lines under the message:

raised   at fetch.nika:12:9
started  at main.nika:4:5
arrived  at main.nika:9:5

A stack trace is not carried. NIKAIA_TRACE=1 asks for one; without it there is none, and the long form says so. Under it, the trace of an error that crossed an async boundary is the child’s trace, a line ── started at <site> ──, and the trace of the code that waited, for each crossing.

Printing it: short is the default.

eprintln(f"{error}")           // the message, and nothing else
eprintln(f"{error.full()}")    // the site, the chain, and a trace if one was captured

{error} is the message the author wrote. A failed HTTP handler answers with a generic 500 and logs the rest.

The form you type without thinking is the one you may show a stranger.

full() is an ordinary call: a hole holds an expression (2.5), and a method call is one.

Every error knows the site that raised it and has a short form of it a person can read out. nikaia explain NK-2C7 leads from (NK-2C7) to the line that threw it, with no log file, also when the working tree has moved on.

A call the language performs can fail. Two places perform a call user code did not write: the end of a block, where a resource is cleaned up (6.4), and a loop’s step over a fallible stream (NK2701). In both the enclosing function declares throws, and the compiler names the resource or the loop.

It is one rule with two codes. Where a call can fail, written by user code or performed by the language, the failure fails the enclosing function, the function declares throws, and the compiler names the call that is the reason. NK2605 is the written call and the cleanup at a closing brace, which the message names as the cleanup of the value; NK2701 is the loop’s step.

7.2. Unrecoverable Errors (panic)

An unrecoverable error is a logic bug, such as reading the tenth item of a list of five. A panic in a task ends that task, and the program goes on; a panic in main ends the program (Part III A.2). Where the machine cannot unwind, the process ends.

The Panic Hook (std::panic::on_panic) An abort runs no normal cleanup. Before the process dies, or before the crashed task is isolated where the program survives, the runtime calls one registered function: the panic hook. It is the place for a crash dump, a crash report, or flushing a diagnostics log.

use std::panic

fn main() {
    // Global, one per application. Set it early.
    // The hook may not pause — mid-panic there is nothing to pause on — and
    // that promise is on `on_panic`'s parameter type, not on the lambda:
    // a lambda carries no promise of its own.
    panic::on_panic fn(info) {
        // 'info' carries: message, file/line, and the stack trace.
        // Pattern: open crash resources at startup, only WRITE here.
        crash_log.write_report(info)
    }

    run_app()
}

The rules:

The hook may not pause. That promise is on on_panic’s parameter type, not on the lambda; a lambda carries no promise of its own (5.3).


Chapter 8: Concurrency (Doing things at the same time)

A program performs several tasks concurrently at both values of user_parallelism, such as waiting for a download while answering input. The mechanism is the pause (8.1).

8.1. Pausing Without Marking Functions

A function that performs I/O, such as reading a file or downloading a URL, pauses without blocking the program. There is no await keyword.

Within one task, the order is the written order: let a = fs::read("x", root) pauses, and the line after it does not run until a is there. What runs meanwhile is some other task; a pause never starts one. A task exists only where the program starts one: with async (8.1.2), as an element of a par_iter chain (Part II 12.6), as a select arm (Part II 12.4), or under a supervisor or a server. Where the program waits for one - the first use of its value, h.join(), its group’s end - is where two tasks meet again. The marker for waiting stands where something branches, and nowhere else.

8.1.1. Statement Order Is the Written Order

Two statements run in the order they are written, whether or not they touch anything in common. No analysis stands between the source and the schedule. A program that wants two things to run together says so (8.1.2).

The compiler may write a statement that assigns to a place before the ifs directly in front of it where no program can tell: none of those ifs reads or writes that place, the statement reads nothing they write, neither side calls a function that is not pure, does input or output, pauses, locks or spawns, neither side can stop the program, and the ifs hold only such assignments and ifs, nothing that leaves. It does so in every build (ADR-379).

8.1.2. Running Work Beside the Code After It: async

What a task is. Every task is started in a group and lives at most as long as the group. A child task’s group is a block or a call; a free task’s group is the process (8.2). A task borrows what outlives its group and takes what does not: a child task borrows what was bound outside its group, a free task takes everything it uses (8.3). Every task allocates from a memory block of its own (Part II, Chapter 12), and ends in one of four ways: with a value, a failure, a crash (task::Crashed) or a full block (task::OverBudget). That end goes to its group’s receiver. Each form that starts tasks states only its group’s policy:

Form Its group Policy
async (here) the enclosing async { } or block wait for all, keep every failure
par_iter (Part II 12.6) the call per element: results in input order, every failure kept
select (Part II 12.4) the block the first wins, the rest are cancelled
supervisor::run (Part II 12.8) the call restart by strategy and policy
serve (Part III) the server one per connection, by admission
async(detached) (8.2) the process nobody waits but main’s end; a bound handle is the receiver

async starts a task. It is a prefix of an expression: it stands on the right of a =, as an argument, as an operand, or as an expression statement, and never before a let, a loop or a function’s declaration (NK2115): whether a function pauses is inferred (8.1). The task runs beside the code after its start, its window, and is waited for at its wait point. There is no await.

let user = async db::load_user(id)
let rights = async db::load_rights(id)
let prefs = cache::load_prefs(id)      // runs while both loads are in flight
render(user, rights, prefs)            // waits for both first

The value form, async e, starts e at once and hands back its value, waited for where the value is first needed:

The callee never sees a pending value: f is handed a plain T, so no function’s signature says whether its arguments were computed beside it. The value form has no handle and cannot be cancelled. Tasks start in the written order, left to right.

A task with nothing beside it is refused (NK2110): f(async g()) with no other argument computed beside it, a binding named in the very next statement, an async statement that ends its group. The help removes the word.

The handle forms. An option after the word hands back a handle instead of the value.

A task of several steps is a lambda. let x = async fn { … }, async fn { … } and the handle forms run the lambda’s body as the task (5.3): return in it ends the task with its value. A lambda that names an argument is refused (NK2103): a task is handed nothing.

async e as a statement starts a task whose value nobody reads; a value it hands back is dropped. It is waited for at the end of its group. async xs.par_iter() … .collect() is one task that runs the whole chain (Part II 12.6) beside the code after it.

async { … } is a group: its statements run in order, as any block’s, and its } waits for every task started inside it. Its value is its last expression’s, once every task has ended. An async { } that starts no task is refused (NK2111).

The group. A child task belongs to the innermost async { } around it in the same function, branches between them included; where there is none, to the innermost block that holds the async form. async starts only tasks whose number stands in the source: an async form inside a loop’s body is refused (NK2120), and the help writes xs.par_iter().for_each fn(x) { … }. A lambda’s body is a function of its own (5.3), so a lambda written in a loop may hold one. What was bound inside the group, in a block that ends before the group does, the task takes with it as a free task does (8.3). A break, continue or return that would leave a task is NK1132.

A task and the code beside it meet on nothing, and the compiler checks it. A bound task is checked against every statement of its window, other tasks’ starts included; the tasks of one call’s arguments and of one operation’s operands are checked against each other and against what is computed beside them on the main line. A pair that meets on a resource is refused with NK2104, and the message names the resource: two tasks that both write to stdout are refused. A binding that the window changes - a mut method, an assignment, a mut argument - while a task names it is refused the same way, naming the binding. Two reads meet on nothing. A task this compiler cannot account for is not refused (Part III C.4). --overlaps reports each task, its window, its wait point and its group.

A pending binding named in another task is waited for in that task.

let a = async load(id)
let b = async enrich(a)                // b's task waits for a
show(b)

b’s task reads a beside a main line that reads it too; the main line waits for a where it first names it. Where b’s task takes a by value, a is handed over where b starts, and a later mention of a is NK2105. Two tasks that read a read the one value. Where a fails, the first mention of it in written order receives the failure, and every other task that waits for a is cancelled.

Options. async(limit: n, within: d, admission: s) stands before { and before the task’s expression; options and the handle options are written in one list, without a positional zone.

Admission. A child task starts only where its block fits the budget of the task that runs the group and a descriptor is free; otherwise it waits, unstarted, until a sibling ends. limit: caps on top of that. At the system’s ceiling of descriptors a task waits; it never fails for it.

Cancelling is through a handle only. h.cancel() stops the task at its current pause; its values are torn down and its outcome, a failure included, is dropped. It takes the handle. The group’s end waits until a cancelled child task has stopped, its cleanups included. A value form cannot be cancelled; a program that may want to writes async(handle) fn { e }. within: cancels by itself.

A failure is held until it arrives, and the same handlers meet it in every form, in this order.

let page = async http::get(url) catch { Page::empty() }   // the fallback
let size = page.len() catch { 0 }                          // a catch where it arrives
let h = async(handle) fn { crawl(site) } catch { [] }     // a handle's fallback
let found = h.join() catch { [] }                          // a catch where it arrives
  1. In the task. A catch inside the task’s expression or lambda is the task’s own and runs at once, when the task fails.
  2. Where it arrives. Otherwise the failure is held, and nothing runs for it until it arrives: at the mention that waits, for the value form, or at h.join(), for a handle. A catch that encloses that point handles it.
  3. The fallback. Otherwise the catch written directly after the async expression runs, the last handler before the receiver: for the value form at the wait point, its value becoming the value; for a handle inside the join, its value becoming the join’s; for a detached task nobody joins, in the task itself when it fails.
  4. The receiver. Otherwise the failure leaves from the wait point or the join as a thrown error does, or, read by nobody, it is the group’s at its end (below); a detached task’s goes to standard error (8.2).

A cancellation the program asks for, h.cancel() or a select arm that loses, is not a failure and runs no fallback. task::OverBudget is one and runs it, and so is task::TimedOut from a deadline (within: above). The task::TimedOut of a join(within:) whose own limit ran out is the join’s error, not the task’s: it meets a catch around the join, and no fallback runs for it (8.2). Which handler runs depends on the path that reaches the mention. Once a catch where the failure arrived has taken it, a later mention of x on that path is refused unless the binding has a fallback (NK2118). The function throws what the fallback throws, or the task’s whole set where there is no fallback.

A failure nobody read is thrown at the end of the group. It is a failure of a statement form, of a binding nothing names, of one whose mention did not run, or of a child handle nobody joined; a fallback runs for it there. Where the group ends normally (its last statement, a return, a break), the first such failure in written order is thrown, and the others travel as its secondary list, in written order. Where a failure leaves the group, the group waits for every task, and that failure leads; the unread failures are its secondary list, in written order. Leaving a group waits for its tasks and cancels none.

A task that panics cancels its siblings, and the group throws task::Crashed where it ends. A task whose memory block is full ends with task::OverBudget (Part II, Chapter 12) and fails only itself.

A body that holds an async form pauses, so it is not sync, whatever its tasks do (Part II, 12.1). A task that never pauses runs on the pool where tasks run in parallel; at no, it runs when the main line next pauses or waits, so that the pausing tasks written before that point are started first. A child task that pauses is run by its group, on the group’s thread, and is never handed to the runtime. An async form inside an open lock is refused (NK2203).

The meaning is the same at both values of user_parallelism; the duration differs. At no, a task that computes still overlaps with another task’s waiting, because the waiting is not user code. Computation beside I/O overlaps under both values; computation beside computation overlaps only under yes (1.2).

8.2. Free Tasks: async(detached)

async(detached) starts a free task: its group is the process, so it may keep running after the function that started it has ended. It takes a lambda holding the code to run: the one lambda form (5.3), whose body is a block whether it holds one line or several.

async(detached) fn { println("I am running in the background!") }

spawn fn { … }, the form this replaces, is still accepted and means the same until every program is moved; then spawn is released (ADR-async-5 D13).

A free task does not end the program. An error that arrives in a task ends the task, process::Exit (7.1) included, and every such error has a receiver (ADR-346):

let h = async(detached) fn { fetch(url) }
let page = h.join(within: 5.seconds()) catch {
    match error {
        task::TimedOut => { h.cancel(); "" }   // the handle is still live here
        else => throw error
    }
}
let m = async(detached) fn { fetch(mirror) }
let copy = m.join(within: 5.seconds(), on_timeout: task::OnTimeout::Cancel) catch { "" }
async(detached) fn { log_forever() }  // detached
let w = async(detached) fn { watch() }
w.detach()                            // detached as well

8.3. Data Ownership in Free Tasks (Implicit Move)

A free task may keep running after the function that started it has finished, so it does not borrow. Under the contextual capture rules (5.4), async(detached) is a detached context: a variable used inside the task is moved into it. There is no move keyword.

let message = "Hello"

// 'message' is implicitly moved into the task (detached)
async(detached) fn { println(message) }

// Compiler Error: 'message' now belongs to the task.
// println(message)

The type of the value decides between two cases. For ordinary data (a string, a number, a struct or collection of those) a move is a move. A handle on a Shared[T] is duplicated rather than moved (6.2), so the name outside the task keeps working. message here is a string, so the rest of this section is the data case.

A program that needs the value afterwards clones it before the task is built and gives the task the copy. A .clone() inside the body clones the task’s own copy and leaves nothing for the parent.

let message = "Hello"
let copy = message.clone()   // made here, while `message` is still ours
async(detached) fn { println(copy) }   // the copy is what moves into the task
println(message)             // OK: `message` never left

Data moved into a task and used again afterwards is refused with NK2101:

error[NK2101]: You're using `message` after a background task took it.
  --> main.nika:4:5
   |
 4 |     println(message)
   |     ^
   |
   = note: A task started with `async(detached)` may outlive this function, so it takes the variables it uses with it. `message` is a `String`, so it went to the task.
   = note: Numbers, `bool`s, views and `Shared[T]` handles aren't taken: they're copied.
   = help: Make a copy before starting the task and give the task the copy: `let copy = message.clone()`, then use `copy` inside the task.

NK2101 belongs to the data case only. A handle on a Shared[T] used again after the task is built is not refused, and no .clone() is written.

NK2101 is raised only where the type is known and a move takes the value away. A number, a bool, a scalar and a view are copied, so let message = "Hello" is not this case and let message: String = "Hello" is. An assignment between the task and the later use clears it. The form above is the one spelling of a free task, the trailing lambda of 5.3. A lambda that names an argument is refused with NK2103: a task is handed nothing.

A task means the same thing at both values of user_parallelism. At yes a task goes to a pool of futures over the user-pool worker count and runs on a thread of its own, and its future must be Send. At no every task interleaves on the one thread (Part II 11.2).

8.4. The Runtime Sidecar Model

At user_parallelism = no, user code runs on one thread. The runtime uses a hidden sidecar to carry out heavy I/O without blocking that thread.

The runtime starts before the first statement of user code, with one I/O thread always and a pool for user code only at user_parallelism = yes. What runs on the I/O thread is std’s own code and nothing else: the boundary is a closed list of operations, not a queue of closures. A file read is handed to the kernel where the machine can complete it. A file operation is a slot, on the ring or in an I/O worker’s reply; asking whether it has finished never blocks, so the main loop runs whatever else is ready and parks in the I/O only when nothing is.


Chapter 9: Project Organization and Visibility

9.1. Packages and Files

A package is a directory. The files in it see one another with no use at all: they share one namespace, and a name declared in any of them can be written in any other.

// file: src/parse.nika      (the same package as src/main.nika)
pub struct Row { pub id: i64 }

fn helper() -> i64 { return 41 }
// file: src/main.nika
fn main() {
    let r = Row { id: helper() + 1 }   // no `use`, and no prefix
    println(f"{r.id}")
}

Two files of one package may not declare the same name. A use naming a file of the same package is refused.

A package reaches another package by its name. use http makes the package http reachable and does nothing else. Every name from it is written with its prefix, at every use. A package is depended on in [dependencies] (Part III, 13.3), and a use naming no dependency is refused.

use http
use request_handling as rh

fn handle(r: http::Request) -> rh::Response {
    let body: http::Body = http::read(r)
    return rh::ok(body)
}

The prefix falls where a name is written, not where a value is used: r.path and r.header("host") carry none.

No name is brought in: not by a glob, not by a braced list, not one at a time:

error: `use` can't bring in single names.
  --> main.nika:1:11
   |
 1 | use http::{Request, Response}
   |           ^
   |
   = help: Write `use http`, and `http::Request` where you need it, or `use http as h` for a shorter prefix.

use x as y shortens a long prefix once, in one place.

A prefix is introduced before it is used: http::Request without use http is refused. One name per file: two packages under the same name in one file, by alias or by collision, are refused.

A name denotes one thing. A second declaration is refused with NK1148, with the caret on the one that arrived. A fn, a struct, an enum, a trait and a grammar declare a name. A method belongs to its type, so two types may each have a len; a rule belongs to its grammar and is reached as Json::value.

A directory under src/ that holds .nika files is a package of the project, named by its path: src/model/ is model, src/model/rows/ is model::rows. It is reached with use model or use model::rows and needs no [dependencies] entry; its names are written with the last segment as their prefix, rows::Row. Packages of one project may use one another, in a cycle too. Part III 13.3’s [directories] treats a directory otherwise.

use std::fs names the standard library rather than a package. It brings no name in either: it names a module and the module’s items are reached through it, as a package’s prefix works. A use whose last segment is a type is refused with NK1156. What needs no use is the list in 1.3. HashMap is not on that list: it is use std::collections and collections::HashMap.

A diagnostic names the package rather than the alias: the type is http::Request whatever one file calls the package. Outside a project a .nika file is compiled on its own: a package is a directory of a project, and a directory of loose examples is a directory of programs.

9.2. Visibility Rules (Privacy)

Visibility is per package.

  1. Private to its package unless it says otherwise:
    • A function, a struct, an enum and a comptime binding are visible inside the package that declares them, in every file of that directory, and nowhere else.
    • A struct field is the same: visible throughout the package that declares the struct.
  2. The pub keyword:
    • An item another package may use is prefixed with pub.
    • A field another package may reach is prefixed with pub.
    • A public type may keep its fields private; 9.3 shows the ordinary case.

The boundary is the package and not the file. Moving a declaration from one file of a package to another changes no visibility.

Reaching a private item or field from another package, by reading a field or by giving one a value in a struct literal, is refused with NK1110, and the message says which package keeps it:

error[NK1110]: You can't use `secret` from here: it's private to `utils`.
  --> main.nika:4:5
   |
 4 |     let n = utils::secret()
   |     ^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: Everything is private to its package unless it's declared `pub`.
   = help: Write `pub fn secret` in `utils`, or use something it makes public.

9.3. Granular Control

pub makes an item available to every package. A private field is reached through a constructor or a method, so a type’s invariants hold outside its package.

// file: network.nika

// Private: only usable inside its package
struct Config {
    port: i32
}

// Public: Usable by anyone
pub struct Server {
    // Private field: Can only be changed by Server methods
    config: Config,
    
    // Public field: Can be read/written by anyone
    pub name: String 
}
This site is open source. Improve this page.