Nexium language reference

This describes what the nx compiler implements today. Section numbers refer to nexium-spec.txt (the design) and, where marked "archived", to nexium-systems-spec.txt sections 4 to 9 (the syntax reference).

Files and modules#

A file is a module. import foo.bar loads foo/bar.nx next to the root file; its pub items are reached as bar.item. import std.strings loads a module of the standard library (std.json, std.fs, std.http and the others), which is written in Nexium and embedded in the compiler; see std.md. The builtin namespaces math, io, os, time, random, mem, process, net, thread, and sync are always in scope and need no import.

Lexical structure (archived 4)#

Declarations#

fn name(a: T, b: U) -> R effects { ... }         // effects: e.g. !allocates !panics
pub fn f(x: i32) -> i32 export(c) { ... }        // exported with the C ABI
extern fn puts(s: *u8) -> i32                    // foreign; calling needs `unsafe`
struct Point derive(Eq, Ord) { x: f64, y: f64 }
struct Frame layout(c) { id: u32, len: u16 }     // C layout, usable across the boundary
struct Pair(T) { a: T, b: T }                    // generic
record Dose { mg: f64 where value > 0.0 }        // validated data
ref class Node { value: i32, next: ?Node }       // reference counted
enum Shape { Circle(f64), Rect { w: f64, h: f64 }, Empty }
type Meters = distinct f64                       // no implicit conversion
type Bytes = []u8                                // alias
error ParseError { Empty, NotANumber }
trait Shape { fn area(self: *Self) -> f64 }
impl Shape for Circle { fn area(self: *Self) -> f64 { ... } }
impl Point { fn origin() -> Point { ... } }
impl(T) Pair(T) { fn swap(self: *mut Self) { ... } }
const TABLE: [256]u8 = comptime build_table()
var counter: u32 = 0                             // mutable global; access needs `unsafe`
test "name" { ... }
bench "name" { ... }
artifact cabi { name = "lib", exports = [f] }

Fields inside { } are separated by commas or newlines. A field may have a default (verbose: u8 = 0).

Types (archived 5)#

syntaxmeaning
i8 … i128, u8 … u128, isize, usizeintegers; no implicit conversion between widths
f32, f64, bool, char, void, neverprimitives
[N]Tarray, N a compile-time constant
[]T, []mut Tslice: pointer plus length; []u8 is text
*T, *mut Tpointer to one value (&x, &mut x, p.*)
?Toptional; null is the empty value
!T, Set!Terror union
errorany error value (the anonymous error set)
List(T), String, Map(K, V)owning collections (values, section 5.3)
fn(A, B) -> R !effectsfunction value (closures and functions coerce to it)
(A, B)tuple; fields .0, .1; also allowed as a type argument, List((A, B))
weak Tweak reference to a ref class

Integer literals take the type the context asks for and default to i64; floats default to f64. A char literal fits any integer type that can hold it (c == 'a' with c: u8).

Casts: x as T between numbers (narrowing is checked at runtime unless the range is proven), between a distinct type and its representation, char and integers, bool to integer, a unit enum to integer. @truncate(T, x) wraps.

Values, bindings, ownership#

let x = e binds immutably, var x = e mutably. Mutability is shallow (archived 5.10): a let holding *mut T still mutates through it.

Collections own a heap buffer (5.3). let b = a moves a; using a afterwards is a compile error; a.clone() copies. Moves are tracked per branch: a value moved in one if branch or match arm is still available in the others, and counts as moved after the construct. Parameters are borrowed: a function receiving a List reads it; to mutate, take *mut List(T). To take ownership, mark the parameter own:

fn token(kind: u8, own text: String) -> Token {
    return Token{ .kind = kind, .text = text }     // moved in, moved on: no clone
}
let t = token(1, name)                             // `name` is moved; using it again is an error

An own parameter is mutable, is dropped when the function returns unless it was moved on, and only makes sense for owning types. Receivers cannot be own, exported functions cannot take own parameters, and a function with one cannot be used as a function value (its type would not say who owns the argument). List(T) and String coerce to []T / []u8 when passed where a slice is expected. Moving out of a field or element is an error. Owned values are released when their scope ends; that is the only automatic action at scope exit (H7).

ref class values are references; copying one retains it and the object is freed when the last reference goes (5.1). Cycles leak; use weak for back edges (@weak(x) or x.weak(), then w.upgrade()).

Expressions#

Statements and loops#

while cond { }
for x in items { }               // arrays, slices, lists, strings, map keys
for (k, v) in m { }              // a map's entries
for x in it { }                  // any value with next(self: *mut Self) -> ?T
for x, i in items { }            // with index
for x, y in a, b { }             // lockstep; lengths must match
for i in 0..10 step 2 { }        // 0 2 4 6 8; `for i in 10..0 step -1` counts down (signed)
while cond { } else { }          // the else runs when cond turns false, not after a break
for i in 0..n { }
outer: for a in ... { for b in ... { continue :outer } }
break, continue, return
_ = expr                          // explicit discard; unused values are errors

Binary patterns (archived 6)#

match packet {
    <<version:4, ihl:4, total_len:16/big, rest:bytes>> => ...
    <<0x1b, '[', 'A', rest:bytes>> => Key.Up
    <<len:16/little, payload:len*8, rest:bytes>> => payload
    _ => ...
}
let written = try <<4:4, 5:4, 0:8, 1500:16/big, "ab">> into buf[..]

Segments are name:size/modifiers or literals. Sizes are bits (default 8); bytes consumes the rest. Modifiers: big (default), little, native, signed, unsigned, float, utf8. A binding with a constant size up to 64 bits is an integer of the smallest fitting width; larger or dynamic sizes bind a []u8 view. Size arithmetic runs at pointer width and is always checked. Construction targets a []mut u8 buffer and returns ![]u8 (the written prefix), failing with error.BufferTooSmall.

Effects (section 8)#

allocates refcounts blocks shared_mutable nondeterministic panics ffi

Effects are inferred for every function. A negative bound on a signature (!allocates) is checked; the diagnostic points at the site that introduced the effect, through calls. Function types may carry negative bounds (fn(i32) -> i32 !allocates); a closure or function coerced to such a type must satisfy them. panics is discharged by proof (E6): indexing with a loop index over the same slice, comptime-known indices, arithmetic whose operand ranges fit, and guarded locals do not contribute. nx audit file.nx --lock writes file.effects.lock, every function's effects, and nx audit file.nx --check fails when a function gained an effect the lock does not name (semver for behaviour: a dependency that starts allocating or blocking fails the build until the lock is regenerated on purpose). nx effects file.nx prints the inferred set per function. nx explain file.nx f effect prints why f has an effect as a tree: the reason recorded in each function (an allocation, an overflow check, an explicit panic) and the calls that carry it in, down to the primitive that brings it.

Standard library (builtins)#

Predefined errors: OutOfMemory Panic InvalidRecord Truncated Overflow InvalidUtf8 NotFound IoError InvalidInput BufferTooSmall. Any error.Name creates a new one.

Entry points#

fn main(), fn main() -> !void, or fn main() -> u8. An error from main prints error: Name and exits with 1; a panic prints its location and exits with 101. test "name" { } blocks run with nx test.

Benchmarks#

bench "name" { ... } sits beside the tests and runs with nx bench, which builds the file optimized (safe mode unless --mode asks for another) and measures each block: it calibrates the number of iterations to a 10 ms sample (the calibration is the warmup), takes 21 samples and prints the median time per iteration, with the fastest and slowest sample:

bench  sum to 1000      187 ns/iter  (min 186 ns, max 193 ns; 21 samples of 64398)
bench  a list of 100    204 ns/iter  (min 202 ns, max 209 ns; 21 samples of 58241)

The value of the block's last expression is kept, so the work that makes it is not optimized away: bench "sum" { sum_to(1000) } measures the sum, where _ = sum_to(1000) might measure nothing. A value that is an error union must be try'd, so an error fails the benchmark rather than being measured. A panic or an error fails it; the others still run. Arguments after the file narrow the run to names containing them, and --quick takes 1 ms samples. nx test does not run benchmarks, and nx check checks them.

Recursive types and matching through pointers#

A List may hold the type being defined, so trees and JSON values are plain enums: enum Json { Null, Arr(List(Json)), Obj(List(Member)) }. Matching through a pointer binds owning payloads by reference:

fn push(v: *mut Json, own item: Json) {
    match v.* {
        .Arr(items) => items.append(item),   // items: *mut List(Json), aliases the payload
        _ => {},
    }
}

With v: *Json the binding is *List(Json). Scalars (.Num(n)) are copied. A *String or *List(T) coerces to []u8 or []T where a slice is expected.

Trait objects#

dyn Trait is a fat pointer made from *T or *mut T where T implements the trait: let s: dyn Shape = &circle, List(dyn Shape), []dyn Shape. Calls dispatch through a vtable and acquire every effect the object type permits; dyn Shape !allocates !blocks is a distinct type that only implementations satisfying those bounds coerce into. A trait used as an object may mention Self only in receiver position.

Parallel loops#

for parallel x, i in items { ... } runs the body over the index range on a thread pool (spec 7.2). The body may not have the shared_mutable effect, may not return or break (use continue), and writes results through a mutable slice indexed by i. A panic in a worker is re-raised in the caller after all workers finish. The loop carries the blocks effect (it joins).

Allocation scopes#

using arena { ... } installs a bump allocator for the block: values created inside come from the arena, their releases are no-ops, and the whole arena is freed when the block ends (spec 5.1, "replaceable at any scope"). Containers created outside the block keep using the heap when they grow inside it, so collecting results into an outer List, String, or Map is safe. Values created inside must not escape the block.

Calling C#

const libc = @cImport("string.h")
const cv = @cImport("cvendor.h")               // found next to the source file
artifact link { c_sources = ["cvendor.c"], libs = [], include = [], libs_windows = ["gdi32"] }

unsafe {
    let n = libc.strlen(@cstr("hello"))
    let p = cv.cv_point{ .x = 1.0, .y = 2.0 }
    _ = cstdio.printf(@cstr("%d\n"), 42)      // variadics take scalars and pointers
}

@cImport runs the C preprocessor and imports functions, typedefs, structs, enums, and literal macros. const T* becomes *T, other pointers *mut T, void* becomes *mut u8. Foreign calls need unsafe and carry the ffi effect. A struct whose fields cannot be translated (function pointers, bit-fields, nested definitions) is imported as an opaque type, usable through pointers like a forward declaration; that is how FILE works on every libc. Declarations that cannot be translated at all (unions, function-pointer typedefs, function-like macros) are named in the error when used.

Conditional compilation#

if comptime C { ... } else { ... } evaluates C while the program is checked and builds only the branch it picks. The other is never checked, so it can call what exists only on another platform:

fn line_ending() -> []u8 {
    if comptime @target().0 == "windows" {
        return "\r\n"
    } else {
        return "\n"
    }
}

@target() is (os, arch, pointer_bits) of the build, --target's when cross-compiling. An if comptime with a value has the type of the branch taken.

Compile-time tests#

comptime test "name" { ... } runs in the interpreter during checking; a failure is a compile error pointing at the expectation.

Views and their storage#

A function may not return a slice or pointer into one of its own locals (rule R1, an error since 1.0); views into borrowed parameters are fine because the caller owns them. 1.2 adds the view rules V1 to V5 (SPEC.md 5.6 and 5.7): a view stored past the storage it points into, a view used after its container grew or its value moved, a value holding a view returned, a value kept past its using arena block. They were warnings in 1.2 and are errors since 1.3; each error names the fix, and nx fix makes the mechanical ones (a .clone() where the value moves, an @escape). @escape(v) copies a value out of an arena block.

Not implemented yet#

nx publish and the registry. soa and packed layouts, pool and stack allocation strategies, and the archived region rules R2 to R4 are not part of the language (decision 88); the compiler rejects the spellings.