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:
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 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.