On this page
Files and modules Lexical structure (archived 4) Declarations Types (archived 5) Values, bindings, ownership Expressions Statements and loops Binary patterns (archived 6) Effects (section 8) Standard library (builtins) Entry points Benchmarks Recursive types and matching through pointers Trait objects Parallel loops Allocation scopes Calling C Conditional compilation Compile-time tests Views and their storage Not implemented yetNexium 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)#
//comments;///doc comments attach to the next declaration.- Identifiers are ASCII. Non-ASCII outside strings and comments is an error.
- Integers:
42,0xFF,0o755,0b1010_1100. Floats:3.14,1e-9,0x1.8p3. Strings"text"(must be UTF-8), raw stringsr"...", byte stringsb"\x00\xff", chars'a'(a Unicode scalar, typechar). - Statements end at a newline. A line that starts with
|>,.method(,catch,orelse,and, ororcontinues the previous line, as does a line that ends with a binary operator or an open bracket. - Conventions: types
PascalCase, functions and variablessnake_case, constantsSCREAMING_SNAKE_CASE.
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)#
| syntax | meaning |
|---|---|
i8 … i128, u8 … u128, isize, usize | integers; no implicit conversion between widths |
f32, f64, bool, char, void, never | primitives |
[N]T | array, N a compile-time constant |
[]T, []mut T | slice: pointer plus length; []u8 is text |
*T, *mut T | pointer to one value (&x, &mut x, p.*) |
?T | optional; null is the empty value |
!T, Set!T | error union |
error | any error value (the anonymous error set) |
List(T), String, Map(K, V) | owning collections (values, section 5.3) |
fn(A, B) -> R !effects | function value (closures and functions coerce to it) |
(A, B) | tuple; fields .0, .1; also allowed as a type argument, List((A, B)) |
weak T | weak 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#
- Arithmetic
+ - * / %trap on overflow (a defined panic). Wrapping+% -% *%and saturating+| -| *|never fail. Bitwise& | ^ ~ << >>. Comparison== != < <= > >=on numbers, chars, bools,[]u8,String, unit enums, and types thatderive(Eq)/derive(Ord). Logicaland,or,!. derive(Clone)gives a struct or enum.clone(), a deep copy field by field, once every field clones (a pointer field cannot); tuples, optionals and arrays of clonable things clone without it, andwhere T: Clonebounds a type parameter.x |> f(a)isf(x, a).if c { a } else { b }is an expression;if let v = opt { } else { }unwraps (over a place the binding is a view, like a loop variable: clone it or take the value withopt.?). Conditions take no parentheses and bodies always take braces, so a one-liner isif c { return v };elsemay start the next line.match v { pat => expr, ... }on integers (literals, ranges1..=9), strings, bools, chars, enums (.Variant(p)), optionals (null, binding), error unions (error.Name, binding), tuples, slices by shape ([],[first, rest..],[.., last]; the rest is a[]Tview),whole @ pat, and byte slices (binary patterns). Enum and bool matches must be exhaustive, slice matches by length ([]with[x, rest..]); others need_ =>.- Blocks are expressions whose value is the final expression. A labeled block yields through
break :label value. try epropagates an error;e catch |err| handler;opt orelse default;opt.?unwraps (panics on null). The right-hand side oforelseandcatchmay be a jump:let v = opt orelse return null,let v = r catch |e| return -1.opt?.fieldandopt?.m(x)read through an optional:nullwhen it is, otherwise the member as an optional; the rest of a chain applies to the payload (a?.name.len orelse 0), and an optional result is not wrapped twice (a?.b?.c).let (a, b) = pairandfor (k, v) in pairsbind a name per tuple element (_skips one): over an owned value the names own the elements, over a place they are views of it, like anif letbinding.- An integer or float literal coerces into
?T:f(1)wheref(x: ?i32). defer stmtruns at scope exit,errdefer stmtonly when the scope exits through an error; both in reverse order of registration.- Closures:
|[captures] params| -> R { body }. Captures are explicit:[x]copies,[&x]and[&mut x]take references. unsafe { }is required for mutable globals, foreign calls, pointer casts, and.ptrof a slice.comptime exprevaluates at compile time;@embedFile("path")embeds a declared build input as[]u8.
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)#
println(fmt, .{args}),print,eprintln,format(...) -> String; named arguments.{ .name = v }with{name}/{name:spec}placeholders, and a width from an argument,{v:>w}. Placeholders:{},{x},{X},{b},{o},{e},{c},{:.N},{>N},{<N};{{and}}are literal braces.expect(cond),expect_eq(a, b),panic(msg).List(T):new,with_capacity,from,append,pop,clear,clone,last,first,insert,remove,swap_remove,extend,reserve,items,is_empty,len, plus slice methods.String:new,from,with_capacity,append,append_char(a code point, UTF-8 encoded),push_byte(one raw byte),clone,clear,pop,bytes,len, plus[]u8methods.Map(K, V)(keys: integers, bool, char,[]u8,String; iterated in the order the keys were first put, however they hash):new,put,get,get_or_put,contains,remove,clear,clone,keys,values,len,m[key];for k in miterates keys.m.get_or_put(key, default)returns a*mut Vto the value ofkey, puttingdefaultin first when the key is new, in one lookup:counts.get_or_put(word, 0).* += 1. The pointer is a view into the map (rule V3: stale once the map changes).- Slices:
len,fill,reverse,sort,swap(i, j),contains,index_of,copy_from,to_owned,is_empty;[]u8alsostarts_with,ends_with,find,trim,split,lines,to_string,parse_int(T),parse_float,eq_ignore_case. - Integers:
abs,min,max,checked_add/sub/mul(return?T),to_string. Floats:abs,sqrt,floor,ceil,round,min,max,pow,to_string. Chars:is_digit,is_alpha,is_space,to_lower,to_upper,to_digit. math:PI E TAU INF NAN,sqrt abs floor ceil round sin cos tan exp log log2 min max pow atan2 clamp.io.read_file(path) -> !String,io.write_file(path, bytes) -> !void,io.append_file(path, bytes) -> !void,io.read_line() -> ?String.- File system primitives (
std.fswraps them with paths and walking):io.file_kind(path) -> i32(0 missing, 1 file, 2 directory),io.file_size(path) -> !u64,io.file_modified(path) -> !i64(ms),io.make_dir(path) -> !void,io.remove_file(path) -> !void,io.remove_dir(path) -> !void(empty),io.rename(from, to) -> !void,io.list_dir(path) -> !List(String),io.cwd() -> !String,io.temp_dir() -> String. Failures areerror.NotFoundorerror.IoError. - File handles (
std.streamwraps them with buffering):io.open(path, mode) -> !i64(moder,w,a),io.read(h, n) -> !String(up tonbytes; empty at end of input),io.write(h, bytes) -> !void,io.flush(h) -> !void,io.close(h) -> !void. Handles 1, 2 and 3 are stdin, stdout and stderr;io.is_terminal(h) -> boolsays whether one of those is a terminal.io.raw_mode(on) -> boolgives the console's bytes as they are typed, without echo, with VT sequences in and out (the REPL's line editor; exit restores the console),io.read_key() -> ?i64is one byte of input andio.pending_input() -> i64how many are buffered (an escape sequence arrives whole). Not available at the REPL. - Sockets (
std.netandstd.httpbuild on these; every callblocks):net.connect(host, port, timeout_ms) -> !i64,net.listen(host, port) -> !i64,net.accept(listener, timeout_ms) -> !i64,net.send(sock, bytes) -> !void,net.recv(sock, n, timeout_ms) -> !String(empty when the peer closed),net.close(sock),net.peer(sock)/net.local(sock) -> !String(ip:port),net.resolve(host) -> !List(String),net.udp_bind(host, port) -> !i64,net.send_to(sock, host, port, bytes),net.recv_from(sock, n, timeout_ms) -> !Stringwithnet.last_peer()naming the sender. A timeout of 0 waits forever. Errors:NotFound(name lookup),ConnectionRefused,Timeout,IoError. Not available at the REPL. - TLS over TCP by the platform's own library, loaded when first used (SChannel on Windows, Security.framework on macOS, OpenSSL's libssl 3 or 1.1 elsewhere;
std.http'sSystemTlswraps these, and every callblocks):net.tls_available() -> bool,net.tls_connect(host, port, timeout_ms) -> !i64(the connection and the handshake within the timeout; the server's certificate checked against the system's roots and the host's name),net.tls_send(h, bytes) -> !void,net.tls_recv(h, n, timeout_ms) -> !String(empty at the end),net.tls_truncated(h) -> bool(the end came without close_notify),net.tls_close(h), andnet.tls_problem() -> String, why the last TLS call on this thread failed. Errors are the sockets'; a certificate that does not check out isIoError. Not available at the REPL. - Threads (
std.threadbuildsThread,Channel,Mutex,Atomic,select,eachandbothon these):thread.start(f: fn(*mut T) -> void, arg: *mut T) -> i64runsf(arg)on a new thread with its own context,thread.join(h)waits for it and re-raises its panic,thread.join_all(hs: []i64)waits for every one before it re-raises the first panic among them,thread.count() -> usizeis the hardware thread count.sync.mutex_new() -> i64,sync.lock(m),sync.unlock(m),sync.mutex_free(m),sync.cond_new() -> i64,sync.wait(cv, m),sync.wait_for(cv, m, ms) -> bool(false whenmspassed; below 0 waits for ever),sync.signal(cv),sync.broadcast(cv),sync.cond_free(cv). A bell is rung by any thread and waited for by one, a ring before the wait kept for it:sync.bell_new() -> i64,sync.bell_ring(b),sync.bell_wait(b, ms) -> bool,sync.bell_free(b). Atomics on ani64, sequentially consistent:sync.atomic_load(p: *i64) -> i64,sync.atomic_store(p: *mut i64, v),sync.atomic_add(p, n) -> i64andsync.atomic_swap(p, v) -> i64(both give the value before),sync.atomic_cas(p, expected, new) -> bool. Starting a thread carriesnondeterministicandshared_mutable; joining, locking and waitingblock, and thesynccalls areshared_mutable. Not available at the REPL. os.arch() -> []u8: the architecture the program runs on (x86_64,aarch64,x86,arm,riscv64, orunknown), decided when the program was compiled.os.args() -> [][]u8,os.exe_path() -> String(the running executable; empty when the platform will not say),os.env(name) -> ?[]u8,os.set_env(name, value)(for this process and the ones it starts; an empty value removes the variable),os.environ() -> List(String)(everyNAME=value),os.exit(code),process.run(argv: [][]u8) -> !i32(spawns, waits, returns the exit code;error.IoErrorwhen the program cannot be started),process.exec(argv, stdin, cwd) -> !i32(the same with stdin fed fromstdin, run incwdwhen non-empty, and stdout/stderr captured) followed byprocess.last_stdout()/process.last_stderr() -> String;std.processwraps these.- Programs that run alongside (
std.processbuildsChildon these; every call butchild_pidblocks):process.spawn(argv, cwd, flags) -> !i64starts one (incwd, or this program's directory when empty) with each standard stream, two bits offlagsapiece in the order stdin, stdout, stderr, as this program's (0), a pipe (1) or nowhere (2), or stderr into stdout (3).process.child_write(h, bytes, timeout_ms) -> !voidwrites all ofbytesas the program takes them,process.child_close_input(h)ends its input,process.child_read(h, stream, n, timeout_ms) -> !Stringis up tonbytes of stream 1 (stdout) or 2 (stderr), empty at its end,process.child_wait(h, timeout_ms) -> !i64is the exit code in the low 32 bits and the signal that ended it above them,process.child_signal(h, sig) -> !voidsends one (on Windows, which has none, every signal but 0 ends the program with exit code 128 +sig),process.child_pid(h) -> i64, andprocess.child_close(h)lets it go. While a call waits, what the program writes is kept for later reads, so it never stalls on a full pipe. A timeout below 0 waits for ever, and 0 does not wait. Errors:NotFound(no such program),Timeout,IoError(among them a program that no longer reads its input).process.trap_signals()keeps SIGINT, SIGTERM and SIGHUP from ending the program (on Windows Ctrl-C, 2, Ctrl-Break, 21, the console closing, 1, and logoff or shutdown, 15), andprocess.next_signal(timeout_ms) -> i32takes the next one caught, 0 when none came in time. Not available at the REPL. time.now() -> i64(ms since the epoch),time.monotonic() -> u64(ns),time.utc_offset(ms) -> i64(minutes east of UTC of local time at that instant;std.timebuilds dates on these),time.sleep(ms).time.zone_rules(name) -> !string: a zone of the IANA database as text, forstd.timeon Windows, which keeps the database in ICU rather than in zoneinfo files (Windows 10 1903 and later; loaded at the first call). The first line is the zone's name, then one line a period,start offset dst abbrev; an empty name is the system's zone.NotFoundfor a zone ICU lacks, and on every other platform, wherestd.timereads the zoneinfo files itself. Usetime.zone(name)fromstd.timeinstead.random.int(lo, hi),random.float(),random.seed(n): a fast generator thatrandom.seedmakes repeatable, never for secrets.random.secure(buf) -> !void: fills a[]mut u8from the operating system's secure generator (BCryptGenRandom, getrandom, arc4random), for keys, tokens and UUIDs;random.seeddoes not touch it, and it fails withIoErroronly where the system has no generator. The REPL andnx playdo not run it: it needs a compiled program.mem.copy(dst, src).@typeName(T),@sizeOf(T),@alignOf(T),@truncate(T, x),@bitCast(T, x)(same size, scalars),@min(a, b),@max(a, b),@errorName(e),@embedFile(path),@weak(x),@refCount(x),@cImport(header),@cstr(literal),@target()((os, arch, bits), a constant of the C build),@escape(v)(a copy ofvmade outside the innermostusing arenablock).
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.