Skip to content

Learn Nerv

This guide introduces the language through small programs. Each code block is a complete source file. Save one as lesson.nerv and use:

nerv run lesson.nerv

For an application with several files, use the project tutorial. The language specification defines the finer details and unsupported constructs.

Layout and your first function

Source files use the .nerv extension. Newlines separate statements and indentation creates blocks. Use four spaces per indentation level. A line comment begins with two hyphens (--). Identifiers are case-sensitive.

fn main() -> i64
    println("Hello from Nerv")
    0

fn declares a function. main is the application entry point. -> i64 says it returns a signed 64-bit integer. The last expression is its result. Here, 0 indicates successful process termination. println prints a value followed by a newline.

Values and bindings

Use let to give a value a name. Bindings are immutable unless declared let mut. Nerv infers types from initial values; you can write an explicit type after a colon.

fn main() -> i64
    let name: string = "Ada"
    let ready: bool = true
    let mut count = 1
    count = count + 1
    println(name)
    assert(ready and count == 2)
    0

A new let can shadow an earlier name. Inner block names stay in that block. Reassignment must keep the original type.

Numbers and operators

Integer literals default to i64; decimal literals default to f64. Integer types include i8, i16, i32, i64, and their unsigned u equivalents, plus pointer-sized isize and usize. Floating types are f32 and f64. Convert explicitly rather than mixing numeric types.

fn main() -> i64
    let total = 40 + 2
    let fraction = f64(total) / 4.0
    let narrow = i32(42)
    assert(fraction == 10.5)
    assert(i64(narrow) == total)
    assert(-7 / 3 == -2)
    0

Arithmetic includes +, -, *, /, and %. Comparisons include ==, !=, <, <=, >, and >=. Boolean operators are and, or, and not, with short-circuit evaluation for and and or.

Integer addition, subtraction, and multiplication wrap at the type's width. Division by zero is a fatal runtime error. Narrowing integer conversions truncate; they do not perform a range check. Use parsing APIs when converting text.

Functions and expression results

Parameters have explicit types. A short function can use => for its body. A function without a result annotation returns void. Use return when leaving a function early.

fn add(left: i64, right: i64) -> i64 => left + right

fn absolute(value: i64) -> i64
    if value < 0
        return -value
    value

fn main() -> i64
    assert(add(20, 22) == 42)
    assert(absolute(-5) == 5)
    0

Conditions

An if can produce a value. When you use its result, both branches must agree on a type.

fn main() -> i64
    let age = 20
    let label = if age >= 18
        "adult"
    else
        "child"
    assert(label == "adult")
    0

Loops

0 .. 5 excludes 5; 0 ..= 5 includes it. A while loop continues while its condition is true. continue skips to the next iteration and break exits the nearest loop. They work inside nested conditionals.

fn main() -> i64
    let mut total = 0
    for number in 0 .. 10
        if number == 2
            continue
        if number == 5
            break
        total = total + number
    assert(total == 8)
    let mut remaining = 3
    while remaining > 0
        remaining = remaining - 1
    assert(remaining == 0)
    0

Strings

Strings contain UTF-8 text. == and != compare their contents. Use nerv.string.concat to concatenate text and import nerv.std for conversion, splitting, joining, and formatting helpers.

import nerv.std

fn main() -> i64
    let greeting = nerv.string.concat("Hello, ", "Ada")
    assert(greeting == "Hello, Ada")
    let pieces = split_text("red,green,blue", ",")
    assert(join_text(pieces, " / ") == "red / green / blue")
    let message = result_or(format_text("Count: {}", [integer_text(3)]), "invalid")
    println(message)
    0

format_text returns a result because mismatched placeholders are errors. Convert numbers to text explicitly. {} selects the next value; {{ and }} produce literal braces. There is no string interpolation syntax.

nerv.string.len counts UTF-8 bytes. char_count and char_at work with Unicode scalar values. Byte slices must respect UTF-8 boundaries; a scalar is not necessarily a complete visible character.

Arrays and growing collections

Arrays have fixed size and a single element type. []i64 means an array of signed 64-bit integers. Indexing starts at zero and is checked at runtime. A mutable binding permits element assignment.

import nerv.std

fn sum(values: []i64) -> i64
    let mut total = 0
    for index in 0 .. nerv.arrays.len(values)
        total = total + values[index]
    total

fn main() -> i64
    let mut values = [10, 20, 30]
    values[1] = 2
    assert(sum(values) == 42)
    let mut numbers = buffer(0)
    numbers = numbers:push(40)
    numbers = numbers:push(2)
    assert(numbers:get(0) + numbers:get(1) == 42)
    let mut scores = hash_map(0)
    scores = scores:insert("Ada", 42)
    assert(option_or(scores:get("Ada"), 0) == 42)
    0

The seed value in buffer(0) or hash_map(0) establishes the element type; it is not an initial entry. Assign returned containers to retain length and capacity updates. Buffers grow geometrically; hash maps use string keys.

Buffers and hash maps share backing storage when copied, so treat one binding as their logical owner. Vector, Map, and Set offer snapshot-style updates for small collections. They copy on update and have different performance characteristics.

Structs and methods

A struct groups named fields. Construct it with field names and access fields with a dot. Methods live inside the struct and use self; call them with a colon.

struct Counter
    value: i64
    fn increment(self) -> Counter
        Counter { value: self.value + 1 }

fn main() -> i64
    let counter = Counter { value: 41 }
    let next = counter:increment()
    assert(next.value == 42)
    assert(counter.value == 41)
    0

This method returns a new struct. It does not update counter automatically.

Enums and matching

An enum represents alternatives. Variants can carry data. A match chooses an arm and can return a value. Each arm contains an expression after =>; put several statements in a helper function. Match all alternatives or provide a wildcard arm (_).

enum Reading
    Value(i64)
    Missing

fn value_or_zero(reading: Reading) -> i64
    match reading
        Value(number) => number
        Missing => 0

fn main() -> i64
    assert(value_or_zero(Value(42)) == 42)
    assert(value_or_zero(Missing()) == 0)
    0

Construct a payload-free variant with Missing(). Match it with Missing, without parentheses.

Recoverable errors

import nerv.std provides Option<T> (Some or None) and Result<T, E> (Ok or Err). Match a result to handle both cases. Inside a function returning a compatible result, ? extracts success or returns the error early.

import nerv.std

fn increment_text(text: string) -> Result<i64, ParseError>
    let value = parse_integer(text)?
    Ok(value + 1)

fn report(error: ParseError) -> i64
    stderr(error.message)
    0

fn main() -> i64
    let answer = match increment_text("41")
        Ok(value) => value
        Err(error) => report(error)
    assert(answer == 42)
    0

? also propagates None from an option-returning function. It is not an exception mechanism. result_or and option_or supply explicit fallbacks when that is appropriate.

Files and paths

File operations return structured I/O errors. Paths use the host operating system's rules. Lexical operations, such as joining a name, do not require a file to exist.

import nerv.std

fn main() -> i64
    let settings = path("config"):join("settings.txt")
    assert(settings:file_name() == "settings.txt")
    match read_text(settings.text)
        Ok(contents) => println(contents)
        Err(error) => println(error.kind)
    0

IoError has kind, operation, path, and message. A missing file has kind not_found. write_text creates or truncates a UTF-8 file; create_directory creates parent directories. Paths passed to I/O are relative to the process working directory unless absolute.

Generic functions and closures

Type parameters let one definition work with several types. Function values have types such as fn(i64) -> i64. An anonymous function can capture values from its surrounding scope.

fn identity<T>(value: T) -> T => value

fn adder(base: i64) -> fn(i64) -> i64
    fn(value: i64) -> i64 => base + value

fn main() -> i64
    let add_ten = adder(10)
    assert(add_ten(32) == 42)
    assert(identity("hello") == "hello")
    let mut value = 10
    let snapshot = fn() -> i64 => value
    value = 20
    assert(snapshot() == 10)
    0

Captures take a snapshot of values when the closure is created. Managed handles still follow the language's aliasing and allocation-region rules. Generic structs, enums, and trait bounds are also supported; see the detailed specification for their constraints.

Modules

Put related declarations in .nerv files and import them by dotted path. import helpers.text selects helpers/text.nerv relative to the importing source, with project resolution also supporting the package's src/ directory. Qualify calls as helpers.text.format_pair(...) when selecting that module's declaration.

Manifest dependency aliases resolve other packages: import pairs loads the local dependency's library. Standard-library imports begin with nerv, such as import nerv.std or import nerv.json.

Imported declarations can be qualified even if another module uses the same name. Functions are public by default. export fn is accepted, but private declarations are not implemented. See the multi-file project example.

Tests

A test block has a descriptive name and assertions. In a project, nerv test discovers tests in source and under tests/. For a single file, use nerv test lesson.nerv.

fn twice(value: i64) -> i64 => value * 2

test "doubles an integer"
    assert(twice(21) == 42)

fn main() -> i64
    assert(twice(2) == 4)
    0

nerv run runs main; use nerv test to execute test blocks. A failed assertion is fatal to the test file's process.

Memory and tasks

Nerv currently reclaims managed allocations when their execution region ends, rather than when an ordinary function or lexical block returns. Repeated allocation in a long-running process can accumulate memory. Arrays and managed container copies may alias the same storage; mutable bindings do not provide Rust-style borrowing guarantees.

For temporary allocation-heavy work, allocation_scope runs a noncapturing integer-in/integer-out worker in a nested region and reclaims its temporary allocations afterward. Handles from that region cannot escape through its integer result.

spawn_task starts a native thread with its own region. Workers take and return i64 and must not capture an environment. Always handle spawn/join errors.

import nerv.std

fn square(value: i64) -> i64 => value * value

fn finish(task: Task) -> void
    match join_task(task)
        Ok(value) => assert(value == 36)
        Err(error) => stderr(error.message)

fn main() -> i64
    match spawn_task(6, square)
        Ok(task) => finish(task)
        Err(error) => stderr(error.message)
    0

Cancellation is cooperative: request it with cancel_task and let the worker poll cancellation_requested(). Region teardown joins its unfinished tasks, so a worker ignoring cancellation can delay shutdown. Arbitrary captured tasks, channels, and async/await are not provided.

Next, build a complete program in your first project, or explore the standard-library reference.