Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Nevla Programming Language Specification

Version 1 (v1). This document is the normative reference for the nevla language: it states what a conforming implementation must do. Rationale and design history live in the design document at docs/specs/2026-07-01-mongoose-v1-design.md; where that document and this one could disagree, this one governs the language, and the golden tests under tests/golden/ are its executable companion (see the final chapter).

Source files use the .nv extension.

Table of contents

  1. Introduction
  2. Notation
  3. Source code representation
  4. Lexical elements
  5. Types
  6. Declarations and scope
  7. Expressions
  8. Statements
  9. Flow narrowing
  10. Errors and error handling
  11. Value semantics and equality
  12. Runtime faults
  13. The Python bridge
  14. Builtin functions
  15. Standard library
  16. Modules and multi-file programs
  17. Program execution
  18. Implementation limits
  19. Conformance and maintenance

1. Introduction

Nevla is a statically typed, interpreted language with Go’s copy model (scalars and structs copy; lists, maps, and functions are references), errors as values, option types instead of nil, and an embedded Python bridge. A nevla program is checked in full before any of it runs; no crash can originate in nevla. The worst outcomes available to a running program are returning an error from main and a runtime fault, both of which terminate the program in a controlled way (chapter 12, chapter 17), and every Python exception crossing the bridge arrives as an error value (chapter 13). The guarantee has one documented boundary: the bridge embeds real CPython in this process, so native code inside a C extension that itself segfaults or aborts takes the process with it, as it would take any host that loaded it.

Throughout this document, “must” states a requirement on conforming implementations or on valid programs, “may” states a permission, and “unspecified in v1” marks behavior a program must not rely on.

2. Notation

The syntax is specified using Extended Backus-Naur Form (EBNF) in the style of the Go specification:

Syntax      = { Production } .
Production  = production_name "=" Expression "." .
Expression  = Term { "|" Term } .
Term        = Factor { Factor } .
Factor      = production_name | token | Group | Option | Repetition .
Group       = "(" Expression ")" .
Option      = "[" Expression "]" .
Repetition  = "{" Expression "}" .

Productions are expressions built from terms and the following operators, in increasing precedence:

|   alternation
()  grouping
[]  option (0 or 1 time)
{}  repetition (0 to n times)

Lowercase production names denote lexical tokens. Non-terminals are in CamelCase. Terminal symbols appear in double quotes "".

The token newline denotes the line-terminator token produced by the lexer (section 4.2). Where a production does not mention newline, line breaks are significant unless section 4.2 permits them.

3. Source code representation

Source code is Unicode text encoded in UTF-8. A source file is a sequence of Unicode code points; the lexer processes them directly, without any normalization.

3.1 Shebang line

If the first two characters of a source file are #!, the entire first line up to and including nothing beyond the first line feed is ignored by the lexer. The line feed itself is retained for line counting, so diagnostics on subsequent lines report true line numbers. This permits executable scripts:

#!/usr/bin/env nv
fn main() {
    print("hello")
}

The shebang form is recognized only at the very start of the file. A # anywhere else in a program is a lexical error.

3.2 Characters

Identifiers and keywords are restricted to ASCII (section 4.3). Arbitrary Unicode may appear in string literals and comments. String indexing, slicing, and len operate on characters, not bytes (sections 7.5, 7.6, 14.4).

4. Lexical elements

4.1 Comments

A comment starts with // and runs to the end of the line. There is no block comment form. Comments do not produce tokens and do not suppress the newline that ends their line.

x := 1  // this is a comment

4.2 Tokens and line terminators

Tokens are identifiers, keywords, operators and punctuation, and literals. Space (U+0020), horizontal tab, and carriage return are white space and are ignored except as token separators.

A line feed produces a newline token, with these rules:

  • Consecutive line feeds produce a single newline token.
  • Line feeds before the first token of the file produce no token.
  • The newline token acts as the statement terminator inside blocks (section 8.1) and is otherwise skipped only where the grammar permits.

There are no semicolons; ; is not a token and its appearance is a lexical error.

Line breaks are permitted, and consumed without producing a statement break, in the following positions:

  • after a binary operator (x := 1 + may be continued on the next line);
  • after the opening delimiter of a parenthesized expression, list literal, call argument list, parameter list, parenthesized return-type list, map literal body, or struct literal body;
  • after a comma in any of those constructs;
  • before the closing delimiter of those constructs.

A line break is not permitted before a binary operator, before a . selector, before an else (which must follow its } on the same line; section 8.6), or in the middle of any other production. In particular, a method chain must keep each . on the same line as the expression it follows.

ok := 1 +
    2

// invalid: line break before the operator
// bad := 1
//     + 2

4.3 Identifiers

identifier = letter { letter | ascii_digit } .
letter     = "a" ... "z" | "A" ... "Z" | "_" .

Identifiers name variables, functions, structs, fields, parameters, and modules. They consist of ASCII letters, ASCII digits, and underscore, and must not start with a digit. Identifiers are case sensitive.

The identifier _ is the blank identifier (section 6.6).

4.4 Keywords

The following identifiers are reserved and must not be used as names:

break     check     continue  else      false     fn
for       if        import    none      py        range
return    struct    true      with

py is a keyword; it also serves as the name of the py type (section 5.8) and as the marker in import py declarations (section 6.4).

The names int, byte, float, bool, str, error, and map are not keywords. They are predeclared type names, resolved contextually; int, byte, float, str, and bool followed immediately by ( in expression position always denote a conversion (section 7.7), and map [ in expression position always begins a map literal. Slice types and slice conversions are written with the [] prefix ([]int, []float(x); sections 5.9, 7.7) and involve no reserved name. Builtin function names (print, printf, sprintf, len, append, clone, charcode, char) are also not reserved; a variable or function declaration with the same name shadows the builtin.

4.5 Operators and punctuation

+    -    *    /    %
==   !=   <    <=   >    >=
&&   ||   !
=    :=   ?    :
(    )    [    ]    {    }
,    .

A single & or a single | is a lexical error. All other characters not covered by this chapter are lexical errors.

4.6 Integer literals

int_lit = ascii_digit { ascii_digit } .

Integer literals are decimal only. There is no sign in the literal itself; negative values are formed with the unary - operator. An integer literal must fit in the range of int values, 0 through 9223372036854775807 (2^63 - 1); a larger literal is a lexical error. Consequently the minimum int value, -9223372036854775808, is not writable as a negated literal; it can only be produced by arithmetic.

4.7 Float literals

float_lit = int_lit "." ascii_digit { ascii_digit } .

A float literal is a digit sequence, a period, and at least one further digit. There is no exponent form and no leading or trailing period form. A period not followed by a digit is not part of a numeric literal, so 1.abs lexes as the integer 1, ., and the identifier abs.

2.5     // float
3.0     // float
1       // int

4.8 String literals

string_lit = `"` { unicode_char | escape } `"` .
escape     = `\` ( "n" | "t" | `"` | `\` ) .

String literals are delimited by double quotes and must not span multiple lines; a line feed inside a string literal is a lexical error, as is an unterminated string. Exactly four escape sequences are recognized:

EscapeMeaning
\nline feed (U+000A)
\thorizontal tab (U+0009)
\"double quote
\\backslash

Any other character after a backslash is a lexical error. All other characters, including arbitrary Unicode, stand for themselves.

5. Types

The complete set of v1 types is:

int   float   bool   str
[]T   map[K]V   T?
fn(...) ...   struct types   error   py

Two types are identical if and only if they have the same structure: the same kind, and identical element, key, value, parameter, and result types. Struct types are identical when they have the same name.

5.1 Boolean, numeric, and string types

  • bool has exactly the values true and false. There is no truthiness: no other type converts implicitly to bool, and conditions must have type bool (section 8.6).
  • int is a 64-bit signed integer. Overflow is a runtime fault (chapter 12), never a silent wrap.
  • byte holds the integers 0 through 255, zero value 0. It supports only comparison (==, !=, <, <=, >, >=, section 7.9.2); there are no arithmetic operators on byte in this version (section 7.9.1). Widen with int(b) to compute. byte(n) from int narrows and is a runtime fault when n is outside 0..255 (section 7.7); this is a deliberate deviation from Go, where uint8 wraps silently (ADR 0021).
  • float is an IEEE 754 64-bit binary floating point number. Float arithmetic follows IEEE 754: division by zero yields an infinity, 0.0/0.0 yields NaN, and no float arithmetic faults.
  • str is an immutable sequence of characters. A character is one Unicode code point; “character” means exactly that everywhere in this specification.

There are no implicit conversions between any of these types, including between int and float (section 7.9.1).

5.2 List types

[]T is an ordered sequence of values of element type T. Lists are reference types (chapter 11): assignment, argument passing, and capture alias the one underlying list. clone(xs) makes an explicit copy.

[]byte is an ordinary list type over byte: every rule above applies verbatim. Its runtime representation is a compact contiguous buffer rather than a boxed sequence of values (an implementation detail, section 11.1); the language-visible behavior is identical to any other []T.

5.3 Map types

map[K]V maps keys of type K to values of type V. The key type K must be int, byte, str, or bool; any other key type is a compile-time error. Maps preserve insertion order: iteration, keys(), and values() visit entries in the order the keys were first inserted, and delete preserves the relative order of the remaining entries. Maps are reference types (chapter 11), like lists.

Reading m[k] yields V?: a missing key reads as none (section 7.5). Writing m[k] = v inserts or updates (section 8.3).

5.4 Option types

For any non-option type T, the option type T? holds either a value of type T or the absent value none. There is no nil; absence exists only inside option types.

  • none is the untyped empty-option literal. It is assignable to every option type and compares only against operands of option type (section 7.9.3).
  • A value of type T is assignable where T? is expected (widening, section 5.10). The reverse never holds implicitly.
  • A value of option type must be narrowed (chapter 9) before its fields or methods can be used, and it does not support the operators of T. Using an unchecked option is a compile-time error:
fn find() User? { ... }

u := find()
print(u.name)        // compile error: value might be none
if u != none {
    print(u.name)    // ok: u is User here
}

Option types do not nest syntactically: T?? is not writable.

5.5 Function types

fn(P1, ..., Pn) R and fn(P1, ..., Pn) (R1, ..., Rm) are the types of function values with the given parameter and result types. A function type with no results is written fn(P1, ..., Pn). Functions are first class: declared functions and function literals are values of function type and may be stored, passed, and returned.

In a function-type result position, an unparenthesized result must begin with an identifier or py; a result that is itself a function type must be parenthesized: fn() (fn() int).

5.6 Struct types

A struct type is declared at the top level (section 6.3) and consists of an ordered list of named, typed fields. Structs are nominal: two structs with the same fields but different names are distinct types. Structs are value types (chapter 11); copies are shallow in Go’s sense, so reference-typed fields alias. Fields are accessed with . and assigned through assignment statements. User struct types have no methods in v1.

Recursive structs are restricted: a struct must not contain itself by value, directly or through a chain of struct-typed fields. Such a value could never be constructed, and the declaration is a compile-time error. The cycle must be broken with an option, list, or map along the way:

struct Node {
    val int
    next Node?      // ok; `next Node` would be a compile error
}

5.7 The error type

error is the type of error values (chapter 10). Every error value exposes the fields:

FieldTypeMeaning
msgstrhuman-readable message
causeerror?wrapped inner error, or none
pytypestrPython exception type name; "" for non-bridge errors
tracebackstrPython traceback text; "" for non-bridge errors
originstrfile:line of the statement where the error was born (error.new, error.wrap, a test helper, or a py exception entering value space); "" when unknown

Error values are constructed with error.new and error.wrap (section 15.1) and by the Python bridge (chapter 13). Error values do not support == (section 7.9.3); test for presence with err != none on an error? and inspect .msg.

5.8 The py type

py is the type of references to live Python objects (chapter 13). It is the single dynamic type in the language and the documented exception to value semantics: assigning a py value copies the reference, not the object.

The zero value of py is a handle to Python’s None; operations on it produce ordinary Python error values (for example AttributeError), never a fault.

5.9 Type syntax

Type      = BaseType [ "?" ] .
BaseType  = TypeName | SliceType | MapType | FnType | "py" .
TypeName  = identifier [ "." identifier ] .
SliceType = "[" "]" Type .
MapType   = "map" "[" Type "]" Type .
FnType    = "fn" "(" [ TypeList ] ")" [ FnResult ] .
FnResult  = "(" [ TypeList ] ")" | Type .
TypeList  = Type { "," Type } .

A TypeName is one of the predeclared names int, byte, float, bool, str, error, a struct name, or a dotted name module.Struct referring to a struct declared in an imported file module (chapter 16). Any other name is a compile-time error (“unknown type”). The unparenthesized FnResult alternative must begin with an identifier or py (section 5.5).

5.10 Assignability

A value of type V is assignable to a location (variable, parameter, field, element, return slot) of type T when:

  • T and V are identical; or
  • T is T0? and V is assignable to T0, or V is V0? and V0 is assignable to T0 (widening into options, applied recursively); or
  • T is []A, V is []B, and B is assignable to A; or
  • T is map[AK]AV, V is map[BK]BV, and BK, BV are assignable to AK, AV respectively; or
  • either side’s type could not be determined because of a prior compile error (assignability then does not produce a second error).

The literal none is assignable to every option type. An empty list literal [] is assignable to every list type except []byte, and only in a context that supplies the list type; the empty []byte is spelled []byte{} (section 7.2.1).

An integer literal in the range 0 to 255 is additionally assignable to byte: []byte{137, 80}, b = 255, x == 137 all work bare. An out-of-range integer literal in a byte position is a compile-time error (“cannot use n as byte”). This is the only implicit conversion in the language; a variable of type int is never assignable to byte without an explicit byte(...) conversion (section 7.7). The implicit applies only at a position already known to be byte (a scalar slot, or an element of a []byte{...} typed literal); it does not reach into a bare list literal’s own element-type inference, so []int (including a bare [1, 2]) is never assignable to []byte, and a bare list literal never has type []byte at all (section 7.2.1).

5.11 Zero values

Every type has a zero value, used to fill the non-error result slots when a check expression propagates an error (section 7.8) and as the success slot of failed conversions and stdlib calls:

TypeZero value
int0
byte0
float0.0
boolfalse
str""
[]T[]
map[K]Vempty map
T?none
structstruct with every field set to its zero value, recursively
error (bare, non-option)none
pya handle to Python None
fn(...) ...the zero function value; calling it is a runtime fault

6. Declarations and scope

6.1 Program structure

SourceFile  = { Declaration } .
Declaration = ImportDecl | StructDecl | FunctionDecl .

A source file consists solely of import, struct, and function declarations, separated by any number of line breaks. There are no top-level variables and no constants. Top-level declarations are visible throughout the program regardless of order; a function may call a function declared later in the file.

Declaring two functions with the same name, or two structs with the same name, is a compile-time error.

A complete program must declare fn main with an entry-point signature (section 17.1).

6.2 Function declarations

FunctionDecl = "fn" identifier Parameters [ Result ] Block .
Parameters   = "(" [ ParameterList [ "," ] ] ")" .
ParameterList = Parameter { "," Parameter } .
Parameter    = identifier [ Type ] .
Result       = Type | "(" [ TypeList [ "," ] ] ")" .

Every parameter of a top-level function must have a declared type; omitting one is a compile-time error. (The grammar shares Parameter with function literals, where types may be inferred; section 7.3.)

A function may declare zero, one, or several result types. A function with a nonempty result list must end in a statement that diverges (section 8.1.1) on every path; otherwise “missing return” is a compile-time error. A function with no results returns implicitly at the end of its body, and return with no values is permitted inside it.

fn fetch(url str) (str, error?) {
    return "", none
}

6.3 Struct declarations

StructDecl = "struct" identifier "{" { newline } [ FieldDeclList ] "}" .
FieldDeclList = FieldDecl { ( "," | newline ) { newline } FieldDecl } [ "," | newline ] { newline } .
FieldDecl  = identifier Type .

Whether a struct or field is visible to other file modules follows its name’s capitalization (section 16.3). Fields are separated by commas or line breaks; both of these are valid:

struct User { name str, age int }

struct User {
    name str
    age int
}

The recursive-struct restriction of section 5.6 applies.

6.4 Import declarations

ImportDecl = "import" ( ImportSpec | "(" { newline } [ ImportSpec { newline { newline } ImportSpec } ] { newline } ")" ) .
ImportSpec = [ "py" ] string_lit .

Three forms exist, distinguished by the py marker and the path string:

  • import "name" where name is one of the standard library modules math, error, file, ctx, gpu, http, test, time, os, regex, flag, proc imports that module (chapter 15).
  • import "path.nv" where the path ends in .nv imports another nevla source file as a module (chapter 16).
  • import py "modname" imports a Python module through the bridge (chapter 13). Dotted module paths (import py "os.path") are permitted.

An import path that is none of these is a compile-time error (“unknown module”).

The factored form groups any number of specs, one per line, each optionally py-marked, and means exactly the same sequence of single imports:

import (
    "ctx"
    "os"
    "time"
    py "torch"
)

fn main() {
    print(len(os.args()))
}

The one true style (17.6) renders two or more imports as one factored block in source order; nevla imports additionally manages the set itself, adding missing standard library imports, removing unused imports, and sorting (plain paths first, then py, alphabetical within).

6.5 Blocks and scope

Block = "{" { newline } [ StatementList ] "}" .

Each block introduces a new scope. A short variable declaration (section 8.2) declares its names in the innermost enclosing scope; declaring a name twice in the same scope is a compile-time error, while an inner scope may shadow an outer name. Function parameters are declared in the function’s outermost scope. for x := range xs declares its iteration variables in a scope enclosing the loop body, fresh on each iteration.

Name resolution inside a function proceeds from the innermost scope outward, then to top-level functions, then to imported module names, then to builtins. A local variable therefore shadows a top-level function of the same name, which shadows a module name, which shadows a builtin.

6.6 The blank identifier

The blank identifier _ discards a value. It may appear:

  • as a name in a short variable declaration: _, err := f() or _ := f();
  • as an iteration variable: for _, v := range m { ... }.

_ is never declared: it cannot be read, and it is not a valid assignment target (_ = f() is a compile-time error, “undefined: _”). Binding an error slot to _ counts as handling it (section 10.2): v, _ := f() is legal and deliberately drops the error.

7. Expressions

7.1 Operands

PrimaryExpr = int_lit | float_lit | string_lit
            | "true" | "false" | "none"
            | identifier
            | "(" Expression ")"
            | ListLit | MapLit | StructLit
            | FunctionLit
            | Conversion .

An identifier denotes, in resolution order, a variable in scope, a top-level function (as a function value), or an imported module. An identifier that resolves to nothing is a compile-time error (“undefined”).

A module name is not a first-class value: it may only appear as the receiver of a selector (math.pi, util.double(2)).

7.2 Composite literals

7.2.1 List literals

ListLit = "[" { newline } [ ExpressionList [ "," ] ] { newline } "]"
        | "[" "]" Type "{" { newline } [ ExpressionList [ "," ] ] { newline } "}" .
ExpressionList = Expression { "," { newline } Expression } .

A nonempty list literal has type []E where E is the element type supplied by context, or, absent context, the type of the first element; each subsequent element must be assignable to E.

An empty list literal [] requires a context that supplies its list type: an argument position, return position, field value, map value, or assignment to a location of known list type. A bare [] with no such context is a compile-time error (“cannot infer element type of []”).

The typed form []T{e1, ..., en} carries its element type and needs no context: every element must be assignable to T, and []T{} is the empty list of type []T. In expression position []T followed by { is always a list literal; followed by ( it is a conversion (section 7.7).

fn total(xs []int) int { return len(xs) }

print(total([]))     // ok: parameter supplies []int
// xs := []          // compile error
xs := []int{}        // ok: empty, typed
ys := []str{"a", "b"}

[]byte{e1, ..., en} is an ordinary typed list literal with E = byte: each element must be assignable to byte, so an integer literal in 0..255 works bare (the literal rule, section 5.10) and any other element needs an explicit byte(...) conversion.

b := []byte{137, 80, 78, 71}   // ok: literals assignable to byte

A bare list literal never has type []byte; only the []byte{...} form does. The literal rule applies only to the typed form: a bare list literal’s elements type themselves first (an integer literal types int), so a bare [1, 2] is []int regardless of a surrounding []byte context, and []int is not assignable to []byte (section 5.10). A bare empty [] does not take []byte from context either ([]byte is the one list type excluded from the empty-literal context rule above; write []byte{}), and a bare literal whose first element is byte-typed ([byte(1), byte(2)]) is a compile-time error rather than a []byte.

fn takesBytes(b []byte) bool { return len(b) > 0 }

takesBytes([]byte{1, 2})   // ok
takesBytes([]byte{})       // ok
// takesBytes([1, 2])      // compile error: expected []byte, got []int
// takesBytes([])          // compile error: expected []byte, got []?
// xs := [byte(1)]         // compile error: bare list literal cannot be []byte

7.2.2 Map literals

MapLit   = "map" "[" Type "]" Type "{" { newline } [ MapEntryList [ "," ] ] { newline } "}" .
MapEntryList = MapEntry { "," { newline } MapEntry } .
MapEntry = Expression ":" Expression .

A map literal names its key and value types explicitly. Each key must be assignable to K and each value to V; K must satisfy the map key restriction (section 5.3). Entries are inserted left to right; a repeated key overwrites, keeping the original insertion position.

m := map[str]int{"a": 1, "b": 2}

7.2.3 Struct literals

StructLit  = identifier [ "." identifier ] "{" { newline } [ FieldValueList [ "," ] ] { newline } "}" .
FieldValueList = FieldValue { "," { newline } FieldValue } .
FieldValue = identifier ":" Expression .

A struct literal must name a declared struct type, either bare (Pair{...}) or dotted for a struct of an imported file module (util.Pair{...}, chapter 16; the struct and, because every field is supplied, all of its fields must be exported there, section 16.3), and must supply every declared field exactly once, each value assignable to the field’s type. Missing fields and unknown fields are compile-time errors. Field order in the literal is free; the constructed value’s field order follows the declaration.

Struct literals are suppressed in control-flow headers: in the condition of an if or else if, in the header expression of a for statement (both the condition form and the operand of range), and in the operand of a with statement, an identifier followed by { is not parsed as a struct literal, so the { opens the statement’s block. To use a struct literal in a header, parenthesize it. Struct literals remain available inside any parenthesized or bracketed subexpression of a header.

The struct type Ctx (section 15.4) is opaque and cannot be constructed by a struct literal; Ctx{...} is a compile-time error.

7.3 Function literals

FunctionLit = "fn" Parameters [ Result ] Block .

A function literal (lambda) evaluates to a function value. Parameter types may be omitted when the context supplies an expected function type, from which they are inferred positionally; otherwise omitting a parameter type is a compile-time error (“lambda parameter needs a type here”). Contexts that supply a function type include arguments to list methods (map, filter, each, sorted_by), arguments to parameters of function type, and assignment to a location of known function type.

Result typing:

  • If the literal declares result types, its body must diverge on every path, exactly as for declared functions, and check may be used inside it subject to section 7.8.
  • If the literal declares no result types and its body is a single expression statement, the literal is an expression-bodied function: its result type is the expression’s type (no result if the expression has no value), and calling it returns the expression’s value. Exception: an expression-bodied function whose inferred result type is byte is a compile-time error (“lambda returning byte needs a declared return type”); a byte result must be written down (fn(x byte) byte { return x }).
  • Otherwise the literal has no results.
nums := [1, 2, 3, 4]
big := nums.map(fn(x) { x * 2 }).filter(fn(x) { x > 2 }).sum()   // 18

f := fn(x int) int { return x * 2 }

Function literals capture their free variables by reference, as in Go: the closure and the enclosing scope share the variable, and reads and writes flow both ways. Capture is per-variable (the names the body actually uses), so a closure keeps alive exactly what it references.

n := 1
f := fn() int { return n }
n = 2
print(f())    // 2: f shares n, it did not snapshot it

total := 0
add := fn(x int) { total = total + x }
add(1)
add(2)
print(total)  // 3: the accumulator idiom works directly

Loop iteration variables are per-iteration bindings (Go 1.22 semantics): closures created in different rounds capture different variables.

Top-level functions and imported modules are not captured; they resolve normally at call time.

7.4 Selectors, calls, and method calls

PostfixExpr = PrimaryExpr { Selector | Arguments | Index | Slice } .
Selector    = "." identifier .
Arguments   = "(" { newline } [ ArgumentList [ "," ] ] { newline } ")" .
ArgumentList = Argument { "," { newline } Argument } .
Argument     = Expression | identifier ":" Expression .

x.f selects, depending on the type of x:

  • a struct field, whose type is the field’s declared type;
  • an error field (msg, cause, pytype, traceback; section 5.7);
  • a member of a module: a stdlib constant (math.pi, math.e) reads as its value; a module function member (stdlib or file module) must be called directly (math.sqrt(4.0), util.double(3)). Module functions are not first class in v1: f := math.sqrt is a compile-time error;
  • a Python attribute, when x has type py (chapter 13).

Selecting through an option type is a compile-time error; narrow first (chapter 9).

f(a1, ..., an) calls a function value. The argument count must equal the parameter count and each argument must be assignable to the corresponding parameter type. A call of a non-function is a compile-time error (“not callable”).

x.m(a1, ..., an) is a method call. Methods exist only on strings, slices, and maps (section 14.9), on Ctx (section 15.4), on modules (where mod.f(...) calls the module function), on error as the receiver of the builtin constructors error.new and error.wrap (section 15.1), and on py values (chapter 13). User struct types have no methods in v1.

A named argument (identifier ":" Expression) binds the value to the named Python parameter and is permitted only in calls whose callee is a py value (chapter 13); a named argument in any other call is a compile-time error, as is a positional argument following a named one. Nevla functions are positional only.

Arguments are evaluated left to right, after the callee (or receiver) expression. Calls with multiple results are covered in section 7.10.

7.5 Index expressions

Index = "[" { newline } Expression { newline } "]" .

For a[i]:

  • If a has type []T, i must be int and the result is T. Indices run from 0. An index outside 0 <= i < len(a) is a runtime fault. Negative indices are not supported. []byte indexes to byte under this same rule; its compact runtime representation (section 11.1) does not change the semantics.
  • If a has type str, i must be int and the result is a str holding the single character at position i (positions count characters). Out of range is a runtime fault.
  • If a has type map[K]V, i must be assignable to K and the result is V?; a missing key yields none. Reading a map never faults.
  • If a has type py, indexing is a Python subscript operation (chapter 13).

Indexing any other type is a compile-time error.

m := map[str]int{"k": 1}
v := m["missing"]     // v: int?, none here
if v != none {
    print(v + 1)
}

7.6 Slice expressions

Slice = "[" { newline } Expression ":" Expression "]" .

a[lo:hi] slices a []T (yielding []T) or a str (yielding str, positions in characters). Both bounds are required and must be int. The bounds must satisfy 0 <= lo <= hi <= len(a); anything else is a runtime fault. The result is a copy of the half-open range [lo, hi); a[n:n] is empty. Slicing any other type, including py, is a compile-time error. b[lo:hi] on a []byte yields a fresh []byte, copying the range out of the compact buffer; the source buffer is unaffected.

7.7 Conversions

Conversion = ( "int" | "float" | "str" | "bool" | "byte" | "py" ) "(" Expression ")"
           | SliceType "(" Expression ")" .

Conversions are the explicit casts of the language. Fallibility follows the operand type: a conversion from str (a parse) or from py (a bridge extraction, section 13.5) has the multi-value type (T, error?) and must be consumed accordingly (sections 7.10, 10.2). One conversion is fallible by its operand rather than its target: str(x) is fallible when x is []byte (a UTF-8 decode) — the sole non-py fallible str(x). Every other permitted pair, including every other str(x), cannot fail, is single-valued, and is used inline:

n, err := int("42")       // 42, none: str source is a parse
m, err2 := int("x")       // 0, error("cannot parse \"x\" as int")
i := int(3.9)             // 3: numeric conversions are single-valued
s := str(123) + "!"       // "123!": str(x) never fails
buf := []byte("hi")       // UTF-8 encode: []byte(s) never fails
t, err3 := str(buf)       // "hi", none: []byte source is a UTF-8 decode

Permitted operand types and behavior, for non-py operands:

ConversionOperand typesBehavior
int(x)intidentity
bytewidening, exact
floattruncation toward zero; values outside the int range saturate to the nearest bound, NaN yields 0
strdecimal parse after trimming leading and trailing white space; failure is an error value
byte(x)byteidentity
intnarrowing; a runtime fault (“byte conversion out of range: n”) when the operand is outside 0..255, never a silent truncation. Bounds-check first for data-driven narrowing. str is not a permitted operand; parse with int(s) and narrow
[]byte(x)[]byteidentity, per the []T(x) pass-through rule below
strUTF-8 encode; never fails
float(x)floatidentity
intexact or nearest representable value
strfloat parse after trimming; failure is an error value
bool(x)boolidentity
strafter trimming, exactly "true" or "false"; anything else is an error value
str(x)any type except []bytethe canonical rendering of section 14.1; never fails
[]byteUTF-8 decode; failure (“invalid UTF-8 in byte conversion to str”) is an error value. The sole non-py fallible str(x); every other operand renders and cannot fail
py(x)int, float, bool, str, the literal nonethe inbound bridge conversion (section 13.5) as a py handle; never fails. py(none) is the zero value of py (section 5.11). Containers, structs, functions, and option-typed values are compile-time errors (“cannot convert … to py”); pass them to py calls directly
[]T(x)[]Uyields the operand list unchanged; element types are not validated in v1 (see below). Excluded when T and U disagree on byteness at any depth: []byte(xs) on a []int, []int(b) on a []byte, and the nested pairs ([][]byte(xss) on a [][]int, and its mirror) are compile-time errors (“cannot convert”); convert element-wise with a for loop

Any other operand type is a compile-time error (“cannot convert”).

[]T applied to a nevla list performs no per-element checking in v1: the list value passes through single-valued, and the expression’s static type becomes []T. The pass-through never crosses the byte boundary at any depth (the exclusion in the table above, applied recursively through nested list types); []byte([]byte) and [][]byte([][]byte) identity are pass-throughs like any other. If the actual elements do not match T, later operations on them fault at runtime. Programs must not rely on this as a checked cast; its intended use is extraction from py values, where elements are genuinely converted (section 13.5).

Conversions applied to py operands are the outbound bridge conversions and are specified in section 13.5. A conversion applied to a py chain absorbs the chain’s fallibility: if the chain raised, the conversion yields (zero value of T, the error).

7.8 check expressions

UnaryExpr = PostfixExpr | ( "check" | "!" | "-" ) UnaryExpr .

check is a prefix operator over a unary-postfix chain: it binds the whole selector/call/index chain to its right, but nothing past a binary operator. check f() + 1 means (check f()) + 1; check torch.randn([2, 3]) applies to the whole call.

check e requires:

  1. The enclosing function’s declared result list must end in error?; otherwise “check requires enclosing function to return error?” is a compile-time error. This applies per function; a check inside a function literal looks at the literal’s declared results.
  2. e must be fallible: its type must be (T1, ..., Tn, error?) for n >= 0 (including a lone error?, and including a py chain, which is fallible as a unit; section 7.11). Otherwise “check needs a fallible expression” is a compile-time error.

Semantics: evaluate e. If its error slot is none, the check expression yields the remaining values: no value for n = 0, the single value for n = 1, the multi-value for n > 1. If the error slot holds an error, the enclosing function returns immediately: its final result slot is the error, and every preceding result slot is filled with the zero value of its declared type (section 5.11).

fn boom() (int, str, error?) {
    return 0, "", error.new("bad")
}

fn run() (int, str, error?) {
    a, b := check boom()    // on error: run returns 0, "", the error
    return a, b, none
}

Applied to a py chain, check yields the chain’s py value on success and propagates the converted Python exception on failure (section 13.3).

7.9 Operators

Binary operators, in increasing precedence:

PrecedenceOperators
1||
2&&
3== !=
4< <= > >=
5+ -
6* / % @

All binary operators are left associative. Unary operators (!, -, check) bind tighter than any binary operator.

@ is matrix multiplication. It is defined only when at least one operand is py (section 13.2) and is a compile-time error otherwise (“@ needs py operands”). There is no native matrix type.

Expression = UnaryExpr | Expression binary_op Expression .
binary_op  = "||" | "&&" | "==" | "!=" | "<" | "<=" | ">" | ">="
           | "+" | "-" | "*" | "/" | "%" .

7.9.1 Arithmetic operators

OperatorOperand typesResult
+ - * / %int, intint
+ - * /float, floatfloat
+str, strstr (concatenation)
+[]A, []Blist concatenation, see below

int and float never mix: 1 + 2.5 is a compile-time error (“int and float do not mix”). byte has no arithmetic operators in this version (section 5.1): widen with int(b), compute, and narrow with byte(n). % is defined on int only. Integer / and % fault on a zero divisor; integer +, -, *, /, %, and unary - fault on overflow (chapter 12). Integer division truncates toward zero. Float arithmetic never faults (section 5.1).

List concatenation requires one element type to be assignable to the other; the result takes the wider element type, so concatenation widens toward options and never narrows:

xs := [1]              // []int
ys := [maybe()]        // []int?
zs := xs + ys          // []int?

If neither element type accepts the other, concatenation is a compile-time error. When either operand of any of these operators has type py, the operation is a bridge operation instead (section 13.2).

Unary - requires int or float; unary ! requires bool.

7.9.2 Comparison operators

<, <=, >, >= are defined on int with int, byte with byte, float with float, and str with str (lexicographic by character number). The result is bool. byte and int never mix, except that an integer literal in 0 to 255 compares directly against a byte operand (section 5.10); otherwise widen with int(b) to compare across the two. All other operand combinations are compile-time errors. Because comparisons are left associative and yield bool, a chained comparison such as a < b < c parses but is rejected by the type checker.

7.9.3 Equality operators

== and != yield bool and are defined in exactly two shapes:

  • Scalar equality: both operands have the same type, which must be int, byte, float, bool, or str — except that a byte operand compares directly against an integer literal in 0 to 255 (section 5.10). Float equality follows IEEE 754 (NaN is not equal to itself).
  • None comparison: one operand is the literal none and the other has an option type. x == none is true iff x is absent. Comparing none against a non-option operand is a compile-time error (“none only compares to option types”). none == none is true.

Everything else, including list, map, struct, error, fn, and option-to-option comparison, is a compile-time error (“cannot compare”). Structural equality is available through the contains method (section 11.2). When either operand is py, equality is a bridge operation yielding py (section 13.2).

7.9.4 Logical operators

&& and || require bool operands and yield bool. They short-circuit: a && b does not evaluate b when a is false; a || b does not evaluate b when a is true. py operands are not permitted.

7.10 Multiple values

A call of a function whose type declares n >= 2 results, a conversion (always (T, error?)), and a py chain at its point of consumption ((py, error?)) produce a multi-value. Multi-values are not first class: they cannot be stored, nested, or passed on. A multi-value may be consumed only:

  • by a short variable declaration whose name count equals the value count: a, b := f();
  • by a check expression (which strips the error slot; section 7.8).

In particular, a multi-value cannot be forwarded by a return statement as a unit; each result is returned by listing expressions (section 8.5). When a return holds a single multi-value whose count matches the function’s result list, the diagnostic is “a multi-value cannot be returned as a unit” and names the binding idiom.

Using a multi-value in a single-value context is a compile-time error (“multiple values in single-value context”), and binding it to the wrong number of names is a compile-time error, with the special diagnostic “error result must be handled” when exactly the trailing error? was left unbound (section 10.2).

7.11 py chains

An expression is a py chain when it applies an operation to a value of type py: attribute selection, call, method call, indexing, or a binary operator with a py operand. Reading a py-typed struct field, or writing py(x) (section 7.7), yields a plain py value and starts no chain. Within further postfix or binary operations the chain continues to act as py, so consecutive Python steps need no per-step handling. A chain appearing as an argument (positional or named) to an enclosing py call is absorbed into that chain: f(g(x)) on py values is one fallible unit, not f(check g(x)). At its point of consumption the chain as a whole has type (py, error?):

import py "json"

fn main() (error?) {
    a := check json.loads("40")        // whole call is one fallible unit
    b := check (a + json.loads("2"))   // operators extend the chain
    print(check int(b))                // 42
    return none
}

The first Python exception raised anywhere in the chain, including inside an absorbed argument, aborts the rest of the chain and becomes the chain’s error value (section 13.4).

A py chain must be consumed by one of: a two-name destructure (v, err := chain), a check, or a conversion (which absorbs the fallibility; section 7.7). Binding a chain to a single name, or evaluating it as a bare expression statement, is a compile-time error (“error result must be handled”). Assigning into a py target (obj.attr = x or obj[i] = x) is a statement, not a chain consumption; its semantics are given in section 13.2.

When a destructured chain fails, the value slot receives the zero value of py (a Python None handle) and the error slot the error; on success the error slot is none.

Comparisons on py operands yield py (Python semantics), not bool, so a py expression cannot appear directly in a condition; extract with check bool(x) first. && and || reject py operands outright.

7.12 Evaluation order

Expressions evaluate left to right:

  • binary operands: left then right (subject to short-circuit, 7.9.4);
  • calls: callee (or method receiver), then arguments left to right;
  • index and slice: the indexed expression, then the index or bounds;
  • list literals: elements left to right; map literals: for each entry in order, key then value; struct literals: field values in the order written.

In an assignment statement the right-hand side is evaluated first; index expressions inside the target are then evaluated from the outermost (rightmost) index inward (section 8.3).

8. Statements

Statement = ShortVarDecl | Assignment | ExpressionStmt
          | ReturnStmt | BreakStmt | ContinueStmt
          | IfStmt | ForStmt | WithStmt .

8.1 Statement lists and terminators

StatementList = Statement { newline { newline } Statement } [ newline { newline } ] .

Inside a block, statements are separated by one or more line breaks. Every statement except the last before the closing } must be followed by a line break; two statements on one line are a syntax error. The line-continuation positions of section 4.2 do not terminate a statement.

8.1.1 Divergence and unreachable code

A statement diverges when control cannot flow past it: return, break, continue, a for with no condition whose body contains no break lexically at its own level, a with whose body diverges, and an if statement with an else block in which the then block, every else if block, and the else block all diverge. An if without an else never diverges. A statement following a diverging statement in the same block is a compile-time error (“unreachable code”).

8.2 Short variable declarations

ShortVarDecl   = IdentifierList ":=" Expression .
IdentifierList = identifier { "," identifier } .

x := e declares x in the innermost scope with the type of e and binds a copy of e’s value. e must produce a value; x := f() where f has no results is a compile-time error.

With multiple names, e must be a multi-value (or py chain) of matching arity; each name is declared with the corresponding component type. The blank identifier discards its component. Redeclaring a name already declared in the same scope is a compile-time error. There is no way to declare a variable with an explicit type; the type is always the initializer’s.

8.3 Assignments

Assignment = AssignTarget "=" Expression .
AssignTarget = identifier
             | PostfixExpr Index
             | PostfixExpr Selector .

The target must be a variable, an element access, or a field access rooted at a variable, for example x, xs[i], u.name, ps[0].y, m["k"]. Any other expression as target is a syntax or compile-time error. The assigned value must be assignable to the target’s type. Assignment to a bare variable invalidates any flow narrowing of that variable (chapter 9).

Element and field targets mutate in place through the path:

  • xs[i] = v on a list requires 0 <= i < len(xs); out of range faults.
  • m[k] = v on a map inserts or updates; the static type of the assigned value must be V (not V?). A map access may appear only as the final operation of a target path: because a map read has type V?, indexing or selecting through it (m["a"][0] = v) is a compile-time error; narrow the value out first.
  • s[i] = v on a string type-checks but is a runtime fault (“cannot assign into a string”); strings are immutable.

The right-hand side is evaluated before the target path; the target’s index expressions are evaluated outermost first (section 7.12).

8.4 Expression statements

ExpressionStmt = Expression .

An expression may stand alone as a statement. Its value, if any, is discarded, subject to the mandatory error handling rule: an expression statement whose type is error?, ends in error?, or is a py chain is a compile-time error (“error result must be handled”), except that a check expression handles the error itself and may stand alone:

fn main() (error?) {
    check boom()      // ok: check consumes the error slot
    // boom()         // compile error
    return none
}

8.5 Return statements

ReturnStmt = "return" [ Expression { "," Expression } ] .

return e1, ..., en returns from the enclosing function (or function literal). The number of expressions must equal the number of declared results, and each expression must be assignable to the corresponding result type. In a function with no declared results, return takes no expressions. A bare return in a function with declared results is a compile-time error.

8.6 If statements

IfStmt    = "if" Condition Block { "else" "if" Condition Block } [ "else" Block ] .
Condition = Expression .

A Condition is an ordinary expression, parsed with struct literals suppressed (section 7.2.3); the same production supplies the header expressions of for statements (section 8.7).

Each condition must have type bool; any other type, including py, is a compile-time error (“condition must be bool”). else and else if must appear on the same line as the closing } of the preceding block. Struct literals are suppressed in conditions (section 7.2.3). Conditions may narrow option-typed variables inside the branches (chapter 9).

8.7 For statements

ForStmt = "for" Block
        | "for" [ IdentifierList ":=" ] "range" Condition Block
        | "for" Condition Block .

Nevla has one loop keyword with three forms, as in Go:

  • for { ... } loops forever; only break or return leaves it.

  • for cond { ... } evaluates cond (a bool) before each iteration and stops when it is false.

  • for x := range e { ... } ranges over e. The range operand and the bindings follow Go:

    operand typeone variabletwo variables
    int ni: int, values 0 through n-1compile-time error
    []Tindex i: intindex i: int, element v: T
    map[K]Vkey k: Kkey k: K, value v: V
    strindex i: intindex i: int, character c: str
    pyiteration index i: intindex i: int, item x: py

    An int operand of n <= 0 runs zero iterations. Lists range in element order, maps in insertion order; a []byte operand binds v: byte under the same []T row. Strings range by character: the index is the character index (the same index s[i] uses, section 7.5) and the value is a one-character str. A py operand may be a py chain (ranging absorbs it); iteration calls Python’s iter() once, then __next__ per round, and StopIteration ends the loop silently. Any other exception, including from iter() itself, faults (“py range: …”): a loop is a statement with no error slot, and a data source raising mid-iteration is not a per-round condition a program handles. The variables and := may be omitted (for range e { ... }) to run the body once per element. Ranging over any other type, or with more variables than the operand admits, is a compile-time error. The iteration variables are fresh bindings each round, copied by their kinds (chapter 11): rebinding or mutating a value-typed variable does not affect the container; a reference-typed variable aliases the element. A list operand’s length is fixed at loop entry (element writes during iteration are visible; growth is a new list anyway, since append is pure); a map operand’s keys are snapshotted at entry, entries deleted mid-iteration are skipped and additions are not visited.

Struct literals are suppressed in the loop header (section 7.2.3).

8.8 Break and continue

BreakStmt    = "break" .
ContinueStmt = "continue" .

break terminates the innermost enclosing loop; continue begins its next iteration. Either outside any loop is a compile-time error. Both diverge for the purposes of section 8.1.1: statements after them in the same block are unreachable.

8.9 With statements

WithStmt = "with" Expression Block .

A with statement runs its block under a Python context manager. The operand must be py: a py chain (the statement absorbs it, like range; section 7.11) or a py-typed value. Any other operand type is a compile-time error (“with needs a py value”).

The operand is evaluated to a Python object and its __enter__ method is called; the result is discarded. The block then runs as an ordinary block scope, and __exit__ runs on every nevla-level exit from it:

  • On normal completion, on break or continue (which bind to an enclosing loop as usual), and on a return whose final error? result is none (or whose function declares no error? result), the call is __exit__(None, None, None) and its return value is ignored. Control then continues where it was headed.
  • On a return whose final error? result holds an error, whether written explicitly or produced by check propagation, __exit__ receives a synthesized exception: an instance of a dedicated exception class (a subclass of Python’s Exception) carrying the error’s rendered text, passed as __exit__(type, instance, None). A context manager whose exit branches on exception state (a transaction commit/rollback, for example) therefore sees the error path as Python would. If __exit__ returns a truthy value, which in Python suppresses the exception, the program faults (“py with: __exit__ cannot suppress a nevla error”): nevla control flow cannot be resumed by a py call. On a falsy return the return propagates unchanged.

The statement has no error slot. A Python exception raised while evaluating the operand, by __enter__, or by __exit__ faults (“py with: …”), the same rule as py assignment and py iteration (chapter 12). Fallible acquisition belongs before the statement:

import py "torch"

fn eval_loss(model py, batch py) (float, error?) {
    with torch.no_grad() {
        out, err := model(batch)
        if err != none {
            return 0.0, err    // __exit__ sees the error as an exception
        }
        return check float(out.item()), none
    }
}

A fault raised inside the block terminates the program without calling __exit__: faults are not catchable and no construct observes one (chapter 12). A with whose body diverges diverges itself (section 8.1.1). There is no binding form and no multi-operand form in v1; nest statements for multiple managers, and reach state through the manager object bound before the statement.

9. Flow narrowing

Flow narrowing (flow typing) is the mechanism that unwraps option types. The checker refines the type of a variable within regions where a condition proves it is not none.

9.1 Narrowing conditions

Exactly two condition forms narrow, and only when the whole condition is that form:

  • x != none (or none != x), where x is a variable of option type T?: narrows x to T where the condition holds.
  • x == none (or none == x): narrows x to T where the condition fails.

No other form narrows. In particular, a compound condition such as x != none && y != none narrows neither variable, a narrowing comparison on a field or element (u.next != none) narrows nothing (bind it to a variable first), and a function call returning bool never narrows.

u := find()          // User?
if u != none {
    print(u.name)    // u: User here
}

9.2 Branch scope

For if cond with a narrowing condition:

  • the positive narrowing applies within the then block;
  • if there are no else if arms, the negative narrowing applies within the else block:
if x == none {
    // x: int? here
} else {
    print(x + 1)     // x: int here
}

Each else if arm applies its own condition’s positive narrowing within its own block. Narrowings do not combine across arms.

9.3 Terminal narrowing

For an if statement with a narrowing condition and no else if arms, narrowing extends past the statement in exactly two cases:

  • the then block diverges (section 8.1.1) and there is no else: the negative narrowing applies after the statement;
  • there is an else block that diverges while the then block does not: the positive narrowing applies after the statement.
x := maybe()         // int?
if x == none {
    return
}
print(x + 1)         // x: int from here on

9.4 Invalidation

Narrowing is erased wherever it can no longer be proven:

  • An assignment x = e anywhere, including in a nested block, erases every active narrowing of x from that point on (in all scopes; losing a narrowing is always sound):
x := maybe()
if x != none {
    if true {
        x = none
    }
    y := x + 1       // compile error: x is int? again
}
  • A loop body runs more than once, so an assignment to x anywhere in a loop body erases the narrowing of x for the entire body, including statements before the assignment:
x := maybe()
if x != none {
    for i := range 2 {
        y := x + 1   // compile error: x assigned below in this body
        x = none
    }
}

Rebinding with := creates a new variable and does not affect the outer one’s narrowing.

10. Errors and error handling

10.1 Error values

Errors are ordinary values of type error (section 5.7). There are no exceptions and no user-visible panic. Fallible functions return their error in a trailing error? result:

fn fetch(url str) (str, error?) { ... }
fn cleanup() (error?) { ... }

By convention, and as required by check, the error slot is the last result.

10.2 Mandatory handling

Dropping an error is a compile-time error. Specifically, the diagnostic “error result must be handled” is issued when:

  • an expression statement’s value is error? or ends in error?, or is a py chain (section 8.4), unless the expression is a check;
  • a short variable declaration binds one fewer name than the value count and the unbound trailing component is error? (n := f() where f returns (int, error?));
  • a py chain is bound to a single name or otherwise consumed as one value (section 7.11).

Handling means one of: binding the error slot to a name (which may be _, an explicit and legal way to discard it), or propagating with check.

10.3 Recovery

Recovery is the two-value form plus a none-check, with flow narrowing unwrapping the error?:

v, err := run()
if err != none {
    print("failed: " + err.msg)
    return
}
print(v)

10.4 Propagation and wrapping

check (section 7.8) propagates: on error, the enclosing function returns the error in its final slot with all other slots zero-filled. error.wrap adds context while preserving the cause chain:

fn parse_age(s str) (int, error?) {
    n, err := int(s)
    if err != none {
        return 0, error.wrap(err, "bad age")
    }
    return n, none
}

main may itself declare (error?); returning a non-none error from main terminates the program with a nonzero exit status (section 17.2).

11. The copy model and equality

11.1 Value and reference types

Nevla splits its types the way Go does. Value types copy on assignment, argument passing, returning, iteration binding, and placement in a container: int, float, bool, str (immutable), error, structs, and tuples. A struct copy is shallow in Go’s sense: fields of reference type copy the reference, so the copy’s containers alias the original’s.

Reference types copy the reference; there is one underlying object: []T, map[K]V, fn, py (section 5.8), and Ctx (section 15.4). []byte is a []T like any other (reference type, aliasing, clone); its compact contiguous-buffer representation is an implementation detail with no language-visible effect (design 2026-07-13).

a := [1, 2, 3]
b := a
b[0] = 99
print(a[0])   // 99: a and b are the same list

fn mutate(xs []int) { xs[0] = 42 }
mutate(a)
print(a[0])   // 42

The zero value of a list or map is a fresh empty container, immediately usable; nevla has no nil and no nil-map write fault.

clone(x) returns a one-level copy of a list or map: the container is new, its elements are copied by their own kinds (values copy, references alias), matching Go’s slices.Clone and maps.Clone. clone of a value type is a compile-time error; those copy already.

Mutation happens through element and field assignment (xs[i] = v, m[k] = v, s.f = v) and through m.delete(k), which removes in place. append(xs, v) stays pure, Go’s contract: it returns a fresh list, and growth becomes visible to other names only by rebinding (xs = append(xs, v)). xs.sorted(), xs.map(f), and the other list methods also return fresh lists.

Aliasing makes cyclic values constructible (a struct field list can reach its own container through a chain of references). Every deep walk over values is bounded: structural comparison and bridge conversion fault with “value too deep or cyclic” past depth 256, rendering truncates to “…”, and an assignment path that reaches the same container twice faults (“assignment path aliases itself”). A cyclic value can exist; it cannot hang or crash the interpreter.

11.2 Equality

The == operator is scalar-only (section 7.9.3). Structural equality is provided by list.contains, which compares its argument against each element recursively: lists element-wise, structs by name and field values, maps by key set and per-key values, scalars and none by value. py, fn, Ctx, and module values never compare equal to anything under structural equality.

ps := [Point{x: 1, y: 2}]
print(ps.contains(Point{x: 1, y: 2}))   // true

12. Runtime faults

A fault is a runtime error that terminates the program. Faults are not catchable in v1: no language construct observes or recovers one. A fault must terminate the program with a nonzero exit status and a diagnostic including a nevla call-stack trace, and must never crash the hosting process, raise a foreign exception, or trigger undefined behavior. An interpreter panic is an implementation bug, never specified program behavior. (Native code inside a C extension crashing below the bridge is outside this chapter’s reach; section 1 documents that boundary.)

The complete set of fault conditions reachable from checked programs:

  • integer division or remainder by zero;
  • integer overflow in +, -, *, /, %, or unary - on int (including -9223372036854775808 / -1), in sum over []int, or in math.abs of -9223372036854775808;
  • list or string index out of bounds;
  • slice bounds out of range (lo < 0, hi < lo, or hi > len);
  • assignment into a string index (s[i] = v);
  • repeat whose result would exceed the implementation’s string size limit;
  • calling the zero value of a function type (section 5.11);
  • exceeding the call-depth limit (chapter 18), diagnostic “recursion limit exceeded”;
  • printf/sprintf with a non-literal format string whose verbs do not match the arguments at runtime (wrong count, wrong type, unknown verb, or a format ending inside a verb; section 14.3);
  • printf/sprintf with a non-literal format string whose width or precision exceeds the implementation’s pad limit (section 14.2);
  • operations on list elements whose actual type does not match the list’s static element type after an unchecked []T conversion (section 7.7);
  • structural comparison or bridge conversion of a value deeper than the implementation’s depth limit (a cyclic value; section 11.1), and an assignment whose path reaches the same container twice;
  • __exit__ of a with statement returning truthy against a propagating nevla error (section 8.9).

Float arithmetic never faults (section 5.1). Reading a map never faults. Python exceptions are not faults; they become error values (chapter 13) – except in assignment into a py target, in for range over a py iterable, and in the operand, __enter__, or __exit__ of a with statement (all statements with no error slot), where an exception faults (sections 13.2, 8.7, and 8.9).

13. The Python bridge

13.1 import py

import py "modname" binds the named Python module as a value of type py. Module resolution follows Python’s own import rules in the embedded interpreter. A dotted path imports the submodule and, as in Python, binds the top-level segment: import py "os.path" loads os.path and binds os, so os.path.join(...) is an ordinary chain. Several imports sharing a top segment are one binding. If an import fails at program start (for example the module does not exist), the program terminates with a runtime error before main runs, carrying the Python exception text.

Inside a project, py imports are validated against the manifest at compile time (section 17.5).

13.2 Operations on py values

A py value supports:

  • attribute selection x.attr, yielding py;
  • calls x(args...) and method calls x.m(args...), yielding py. Named arguments (f(x, lr: 0.001)) pass as Python keyword arguments;
  • subscript x[i], yielding py;
  • assignment into attributes and subscripts (x.attr = v, x[i] = v, including at the end of a longer chain): the value converts per the inbound table of section 13.5, and the referent mutates in place. py is a reference type; this is the mutation that references exist for. Assignment is a statement with no error slot, so a Python exception (or an unconvertible value) here faults (“py assignment: …”), unlike the expression operations below. The assigned value may itself be a py chain; the assignment absorbs it, and a chain exception faults the same way;
  • the binary operators + - * / % @ == != < <= > >= when either operand is py, dispatched to the corresponding Python operation and yielding py (comparisons included: the result is a Python bool as a py value, not a nevla bool). @ dispatches to Python matrix multiplication (__matmul__/__rmatmul__); unlike the arithmetic operators it has no meaning on native operands (section 7.9).

&& || reject py operands; unary ! and - are not defined on py; py values can be ranged over with for (section 8.7) and run as context managers with with (section 8.9), but cannot be sliced or used as conditions.

13.3 Fallibility

Every operation of section 13.2 may raise a Python exception. As specified in section 7.11, a chain of such operations is fallible as a single unit typed (py, error?) at consumption. The first exception in a chain converts to a nevla error value and aborts the remainder of the chain.

13.4 Exception conversion

A Python exception becomes an error value with:

  • pytype: the exception type’s name (for example "JSONDecodeError", "ModuleNotFoundError");
  • msg: "<pytype>: <str(exception)>";
  • traceback: the formatted Python traceback, or "" when unavailable;
  • cause: none.

13.5 Conversions across the bridge

Data crosses the bridge by one rule per kind:

  • Value types (scalars, str, structs): convert, i.e. copy. A value has no identity to preserve, so the copy is lossless.
  • Contiguous primitive buffers ([]byte; the compact numeric list types []float64/[]int64 inherit this design if they arrive): cross by reference, zero-copy, through CPython’s buffer protocol. This is the data plane.
  • Structured containers (lists other than []byte, maps): copy, per element, into a fresh Python list/dict. CPython cannot view foreign memory as a list.
  • py handles: already references; the referenced object crosses unchanged.

Inbound (nevla value passed as an argument or index to a Python operation): conversion is automatic, per this table, applied recursively to list elements and map entries:

NevlaPython
intint
byteint
floatfloat
boolbool
strstr
noneNone
[]bytea buffer-protocol view (nevla.bytesview), zero-copy; see below
[]T (Tbyte)list (a per-element copy)
map[K]Vdict
pythe referenced object itself

A []byte argument crosses as nevla.bytesview, a bridge-defined object exposing the buffer’s memory (pointer and length) through CPython’s buffer protocol. memoryview, hashlib.update, numpy.frombuffer, torch.frombuffer, and file.write consume it with no copy at any size, with no size threshold and no opt-in. The view shares the buffer’s memory, so mutations are visible both ways: a nevla b[i] = x changes what a frombuffer tensor sees, and a Python in-place write changes what nevla reads. nevla is single-threaded and the GIL serializes Python, so there is no torn access. The view is not a Python bytes; code that requires a true bytes (an isinstance(x, bytes) check, a dict key, .decode()) calls bytes(x) py-side, the one explicit copy, paid at the call site that demands it.

Passing any other type (a struct, function, error, Ctx, or module) is not a conversion error at compile time but produces an error value at runtime (“cannot pass … to python”), flowing through the chain’s error slot like any Python exception.

Outbound (Python object to nevla value): always explicit and fallible, via the conversions of section 7.7 applied to a py operand. Each yields (T, error?):

ConversionSucceeds whenNotes
int(x)the object is a Python integer (extractable as a 64-bit int)a Python float is an error
float(x)the object supports extraction as a Python floatPython ints convert
bool(x)(practically) alwaysPython truthiness of the object
str(x)(practically) alwaysPython str() of the object
[]T(x)the object is iterable and every element extracts as TT may be int, float, bool, str, or py; py keeps elements as handles
[]byte(x)the object supports the buffer protocol (bytes, bytearray, memoryview, including a nevla.bytesview round-tripped through Python)one copy out of the buffer, into a fresh compact []byte; a non-buffer object is an error

Failure produces (zero value of T, error) with the Python exception converted per section 13.4.

13.6 Rendering

print, %v, and str() render a py value as Python’s str() of the object.

14. Builtin functions

Builtins are available without import. They are resolved only when the name is not bound by a variable in scope or a declared function; such a binding shadows the builtin entirely (a shadowed builtin is not callable through any other path).

14.1 print

print(v1, ..., vn)

Takes zero or more arguments of any single-value types, renders each canonically, joins them with single spaces, and writes the result followed by a line feed to standard output. Canonical rendering (shared by print, the %v verb, and str()):

TypeRendering
intdecimal
bytedecimal (byte(7) renders as 7)
floatshortest decimal that round-trips; integral values render without a fractional part (3.0 renders as 3); infinities as inf/-inf, NaN as NaN
booltrue / false
strthe string itself, unquoted
list[e1, e2, ...], elements rendered recursively
map{k1: v1, k2: v2} in insertion order
structName{f1: v1, f2: v2} in declaration order
nonenone
errorerror(<msg>)
pyPython str() of the object
fnfn

14.2 printf and sprintf

printf(format, a1, ..., an)          // writes to standard output, no implicit newline
sprintf(format, a1, ..., an) str     // returns the formatted string

The format string uses Go-style verbs:

VerbArgument typeOutput
%vanycanonical rendering (section 14.1); renders byte as decimal
%dintdecimal; byte is not accepted, widen with int(b)
%sstrthe string
%tbooltrue / false
%qstrdouble-quoted, backslash-escaped
%ffloatfixed-point, default 6 fractional digits
%%noneliteral %

A verb may carry a minimum width (%5s) and a precision (%.2f), both decimal digit sequences, in the form %[width][.precision]verb. Width pads on the left with spaces to the given count of characters (not bytes). Precision is honored by %f; on other verbs it is accepted and ignored. There is no left-align or zero-pad flag. A width or precision exceeding the implementation’s pad limit (2^20 in the reference implementation) is rejected: a compile-time diagnostic when the format string is a literal (section 14.3), a fault otherwise (chapter 12).

14.3 Static and dynamic format checking

When the format argument is a string literal, the format is checked at compile time: verb count must equal argument count, each argument’s type must match its verb, verbs must be from the table, and the format must not end inside a verb. Violations are compile-time errors.

When the format is not a literal, the same checks happen at runtime and a violation is a fault (chapter 12).

14.4 len

len(x) int

For str, the number of characters; for list (including []byte), the element count; for map, the entry count. Any other argument type is a compile-time error.

14.5 charcode

charcode(c str) int

The Unicode code point (the character’s number) of c, which must be exactly one character; any other argument value is a runtime fault.

14.6 char

char(n int) str

The one-character string for the Unicode code point n. A value that is not a valid Unicode scalar (negative, greater than 0x10FFFF, or a surrogate) is a runtime fault. char(charcode(c)) == c for every one-character c.

Program arguments and standard input live in the os module (section 15.9); nevla has no args or input builtin.

14.7 append

append(xs []T, v1 T, ..., vn T) []T

A fresh list: xs with the values appended, as in Go’s idiom xs = append(xs, v). The first argument must be a list; every following value must be assignable to its element type. Zero values yield a plain copy. The original list is never modified; other names bound to it see growth only through rebinding (chapter 11).

append on []byte observes the same contract: every call returns a fresh buffer, and xs is never modified in place from the caller’s perspective. An earlier reference-count-based in-place growth optimization was removed (design 2026-07-14) after it broke exactly this contract: no refcount threshold can distinguish xs = append(xs, v) (safe to reuse storage, since xs immediately rebinds to the result) from ys := append(xs, v) (xs must stay unchanged) without move semantics or escape analysis, which the reference implementation has neither of. []byte append now always copies, exactly like every other []T.

14.8 clone

clone(x []T) []T
clone(x map[K]V) map[K]V

A one-level copy of a list or map (chapter 11): the container is new, its elements copy by their kinds, exactly Go’s slices.Clone/maps.Clone. Applying clone to a value type is a compile-time error; value types already copy. clone on []byte copies the underlying buffer.

14.9 Methods on builtin types

All receivers are unchanged; results are new values.

String methods

Receiver str. Positions and counts are in characters.

  • split(sep str) []str — split on the separator.
  • trim() str — strip leading and trailing white space.
  • upper() str, lower() str — case conversion.
  • contains(sub str) bool, starts_with(prefix str) bool, ends_with(suffix str) bool — substring, prefix, and suffix tests.
  • replace(from str, to str) str — replace all occurrences.
  • find(sub str) int? — character index of the first occurrence, none if absent.
  • fields() []str — split on runs of white space; no empty fields.
  • lines() []str — split on line feeds; a trailing line feed adds no empty line.
  • trim_prefix(p str) str, trim_suffix(p str) str — remove a leading or trailing p if present, else unchanged.
  • chars() []str — the characters as one-character strings.
  • repeat(n int) str — the string tiled n times; negative n faults, as does a result exceeding the implementation’s size limit (2^30 bytes in the reference implementation).
fn main() {
    s := "  the nevla book  "
    t := s.trim()
    print(t.to_upper())                  // THE NEVLA BOOK
    print(t.split(" ").join("-"))     // the-nevla-book
    print(t.replace("book", "spec"))  // the nevla spec
    i := t.index("nevla")
    if i != none {
        print(i)                      // 4
    }
    print("na".repeat(2) + " batman") // nana batman
    print(len("héllo"))               // 5: characters, not bytes
}

List methods

Receiver []T.

  • map(f fn(T) U) []U — apply f to each element.
  • filter(f fn(T) bool) []T — keep elements where f is true.
  • each(f fn(T)) — call f on each element; no result.
  • sum() TT must be int or float; the sum of the elements. Integer overflow faults (chapter 12). Summing an empty []int yields 0; the result of summing an empty []float is unspecified in v1 (the reference implementation yields a value that faults on later float use).
  • sorted() []TT must be int, byte, float, or str; a fresh ascending list.
  • sorted_by(before fn(T, T) bool) []T — a sorted copy per the comparator; the sort is stable.
  • contains(v T) bool — structural membership (section 11.2).
  • join(sep str) strT must be str; concatenation with the separator.

[]byte (T = byte) has every method above except sum and join, which byte does not satisfy (byte is not int/float, and is not str); sorted, sorted_by, contains, filter, each, and map all apply, per the general rules above.

fn main() {
    xs := [3, 1, 4, 1, 5]
    print(xs.sorted())                          // [1, 1, 3, 4, 5]
    print(xs.map(fn(x) { x * 10 }).sum())       // 140
    print(xs.filter(fn(x) { x > 2 }))           // [3, 4, 5]
    print(xs.contains(4))                       // true
    print(xs.sorted_by(fn(a, b) { a > b }))     // [5, 4, 3, 1, 1]
}

Map methods

Receiver map[K]V. Iteration order is insertion order (section 5.3).

  • keys() []K — the keys, in insertion order.
  • values() []V — the values, in insertion order.
  • has(k K) bool — key presence.
  • delete(k K) — removes k in place, Go’s delete; the remaining order is preserved.

keys() on a map[byte]V and values() on a map[K]byte are compile-time errors: the result would need to be a compact []byte, but the two methods build their result generically across every K/V, independently of byteness, and have no compact []byte repack. Iterate with a for k, v := range m instead (section 8.7), which binds the byte key or value directly with no repack in the way.

fn main() {
    m := map[str]int{"b": 2, "a": 1}
    print(m.keys())      // [b, a]: insertion order, not sorted
    print(m.has("a"))    // true
    m.delete("b")
    print(m.values())    // [1]
}

15. Standard library

Standard library modules are imported by bare name: import "math". The module name then acts as a namespace: math.sqrt(2.0), math.pi. The v1 modules are math, error, file, ctx, and http.

Where a stdlib signature below ends in error? or (T, error?), failures are ordinary error values subject to chapter 10; stdlib functions do not fault on I/O failure.

15.1 error

The constructors error.new and error.wrap require no import; they are part of the core language. import "error" remains legal and adds nothing.

  • error.new(msg str) error — a new error with the message; empty pytype, traceback, no cause, and origin set to the call site (section 5.7).
  • error.wrap(cause error, msg str) error — a new error with the message and the given cause; origin is the wrap site.
fn fetch() (error?) {
    return error.new("connection refused")
}

fn main() {
    err := fetch()
    if err != none {
        wrapped := error.wrap(err, "startup failed")
        print(wrapped.msg)          // startup failed
        cause := wrapped.cause
        if cause != none {
            print(cause.msg)        // connection refused
        }
    }
}

Error fields are specified in section 5.7.

15.2 math

  • math.abs(int) int or (float) float — absolute value, polymorphic over the two numeric types; abs(-9223372036854775808) faults with integer overflow.
  • math.min(int, int) int / (float, float) float, math.max(...) likewise — both arguments the same numeric type.
  • math.sqrt(float) float — square root.
  • math.cos(float) float, math.sin(float) float, math.tan(float) float — trigonometry, radians.
  • math.pow(base float, pow float) float — exponentiation.
  • math.exp(pow float) float — e raised to the argument.
  • math.ln(num float) float — natural logarithm.
  • math.log(base float, num float) float — logarithm of num in base.
  • math.floor(float) int — round down. math.ceil(float) int — round up. math.round(float) int — round half away from zero (not banker’s rounding).
  • math.pi, math.efloat constants.
import "math"

fn main() {
    print(math.max(2, 40) + math.abs(-2))     // 42
    print(math.pow(2.0, 10.0))                // 1024
    printf("%.4f\n", math.log(2.0, 1024.0))   // 10.0000
    print(math.round(2.5))                    // 3
    printf("%.5f\n", math.pi)                 // 3.14159
}

15.3 file

Paths are str. File contents are UTF-8 str or raw bytes []byte.

  • file.read(path str) (str, error?) — whole-file read; the value slot is "" on error.
  • file.write(path str, s str) error? — create or truncate, then write.
  • file.append(path str, s str) error? — create if missing, append.
  • file.readbytes(path str) ([]byte, error?) — whole-file read; the value slot is an empty []byte on error.
  • file.writebytes(path str, b []byte) error? — create or truncate, then write binary data.
  • file.exists(path str) bool — existence test; never errors.
  • file.list(dir str) ([]str, error?) — entry names, sorted lexicographically.
  • file.remove(path str) error? — remove a file or an empty directory.
  • file.mkdir(path str) error? — create the directory and any missing parents.
  • file.glob(pattern str) ([]str, error?) — the paths matching a shell-style pattern, sorted. * and ? stay within one path segment and do not match a leading dot; ** crosses directories; [...] matches a character class. Relative patterns resolve against the process working directory. Zero matches is an empty list; only a malformed pattern is an error. Unreadable directories along the way are skipped, as in the shell.
  • file.modified(path str) (int, error?) — the file’s last modification time, epoch nanoseconds (the one time currency, section 15.8).

Importing "file" also declares the opaque struct type File, a handle to an open file, with reference semantics (section 11.1); file.open and file.create make them. There is no seek and no append-mode handle in v1.

  • file.open(path str) (File, error?) — open an existing file for reading.
  • file.create(path str) (File, error?) — create or truncate, open for writing.
  • f.read(n int) ([]byte, error?) — read up to n bytes from the handle’s current position; negative n is treated as 0 (an empty read, no error). EOF is ([]byte{}, none), not an error value. Reading a closed handle is an error value, not a fault.
  • f.write(b []byte) error? — write b to the handle. Writing a closed handle is an error value, not a fault.
  • f.close() error? — close the handle. Idempotent: closing an already-closed handle succeeds.
import "file"

fn main() (error?) {
    path := "/tmp/nevla-book-handle-example.bin"
    w := check file.create(path)
    check w.write([]byte{1, 2, 3})
    check w.close()

    r := check file.open(path)
    chunk := check r.read(2)
    print(chunk)                     // [1, 2]
    check r.close()
    check file.remove(path)
    return none
}
import "file"

fn main() (error?) {
    path := "/tmp/nevla-book-example.txt"
    check file.write(path, "one\n")
    check file.append(path, "two\n")
    body := check file.read(path)
    print(body.lines())              // [one, two]
    print(file.exists(path))         // true
    check file.remove(path)
    return none
}

15.4 ctx

Importing "ctx" also brings the opaque struct type Ctx into scope. A Ctx is a cancellation handle: a deadline plus an interrupt flag. Ctx values are handles with reference semantics (section 11.1) and cannot be constructed with a struct literal (section 7.2.3).

  • ctx.background() Ctx — never done.
  • ctx.timeout(parent Ctx, d int) Ctx — deadline d nanoseconds from now (the one time currency, section 15.8; 30 * time.second), clamped so a child deadline never exceeds its parent’s; negative d is treated as 0.
  • ctx.interrupt(parent Ctx) Ctx — additionally becomes done when the process receives SIGINT.

Methods on Ctx:

  • done() bool — whether the deadline has passed or the interrupt fired.
  • err() error?none while live; "deadline exceeded" or "interrupted" when done.
import "ctx"

fn main() {
    c := ctx.timeout(ctx.background(), 0)     // already expired
    print(c.done())                           // true
    e := c.err()
    if e != none {
        print(e.msg)                          // deadline exceeded
    }
    print(ctx.background().done())            // false
}

15.6 test

Importing "test" provides the helpers nevla test is built around (section 17.7); each returns error? so it composes with check, and each failure carries an origin (section 5.7).

  • test.eq(got, want) error?none when the two values are structurally equal (the comparison of section 11.2’s contains); otherwise an error naming both sides. Comparing values deeper than the implementation limit faults.
  • test.neq(got, unwanted) error? — the negation.
  • test.err(e error?) error?none when given an error; an error when given none. Asserts that something failed.
  • test.skip(reason str) error? — an error the test runner reports as skipped rather than failed.
import "test"

fn main() {
    print(test.eq([1, 2], [1, 2]) == none)   // true: structural
    bad := test.eq(2, 3)
    if bad != none {
        print(bad.msg)                        // expected 3, got 2
    }
}

15.5 http

Importing "http" also declares two struct types:

struct Request  { method str, url str, body str, headers map[str]str }
struct Response { status int, body str, headers map[str]str }
  • http.get(c Ctx, url str) (Response, error?) — GET.
  • http.post(c Ctx, url str, body str) (Response, error?) — POST with the given body.
  • http.request(c Ctx, req Request) (Response, error?) — any method, with headers.
  • http.stream(c Ctx, url str, body str, f fn(str)) (Response, error?) — POST, invoking f per response line as it arrives.
import "ctx"
import "http"
import "time"

fn main() (error?) {
    c := ctx.timeout(ctx.background(), 5 * time.second)
    resp, err := http.get(c, "http://localhost:9/unreachable")
    if err != none {
        print("transport error, as expected here")
    } else {
        print(resp.status)
    }
    return none
}

Behavior:

  • If the ctx is already done, the call returns an error before any network I/O.
  • A live ctx deadline bounds the whole request; without a deadline, an implementation-defined default timeout applies (30 seconds in the reference implementation).
  • A completed HTTP exchange is a success regardless of status code: a 404 is a Response with status 404 and a none error. Only transport-level failures (connection refused, timeout, invalid request) are error values, with the zero Response in the value slot.
  • Redirects are followed automatically.
  • For http.request, an empty body on a GET request sends no body.
  • http.stream POSTs body and invokes f once per response line as lines arrive, before the response completes (server-sent events are consumed this way). The returned Response.body holds the accumulated lines, newline terminated, so the program can reparse the full payload afterward; closures historically could not accumulate it themselves (pre-ADR-0010 closures captured by value); kept for compatibility. Its default deadline, absent a ctx deadline, is 300 seconds rather than 30.
  • Response header names are as received; values that are not valid strings read as "".

15.7 gpu

GPU sharing. The module speaks the gputex lock protocol (an advisory flock plus a holder registry under $GPUTEX_DIR, default ~/.gputex; the contract is documented in the gputex repository), so a nevla program coordinates with every other job on the host — wrapped in the gputex CLI or not — without an external wrapper.

Every function takes the card id first ("default" on single-card hosts; multi-card hosts name their cards, e.g. "cuda0" — the host’s convention is whatever gputex status lists); label names the job for status displays. A card id that is empty or contains a path separator is an error value (“bad card id”): ids become file names in the shared state directory.

  • gpu.lock(card str, label str) error? — take the card exclusively. Preemptible holders (shared acquirers, and any registry entry marked preemptible) on this host are first asked to leave with SIGTERM, given about ten seconds, then removed with SIGKILL; holders on other hosts are never signaled, dead registry entries are pruned, and a non-preemptible holder is simply waited out, blocking in the kernel until the card is free. The evict-then-take sequence retries a bounded number of times (flock has no fairness; a new shared holder can slip in) before settling into the blocking wait. Errors if this program already holds that card.
  • gpu.trylock(card str, label str) (bool, error?) — non-blocking probe: true and hold the card if it was free, false if it is busy (including when held by this program). Busy is data, not an error; the error slot is for real failures (an unwritable state directory).
  • gpu.shared(card str, label str) error? — take the card as a shared, lowest-priority holder: many coexist, all yield to an exclusive acquirer, which may terminate them (the gputex --low semantics).
  • gpu.unlock(card str) error? — release. Errors if that card is not held.

Behavior:

  • A program may hold several cards at once (training on one while embedding on another), one hold per card; a second acquire of a held card is an error.
  • A hold lasts until gpu.unlock(card) or process exit — any exit. The kernel releases the flock when the process dies, so a fault, kill, or crash never strands a card.
  • Acquiring also injects the managed environment ($GPUTEX_ENV_FILE if set, else /etc/gputex/env; KEY=VALUE lines) into the process environment, existing values winning: taking the card and getting the metrics contract (MLFLOW_TRACKING_URI) are one step, as with the CLI.
  • Two environment variables configure the module, mirroring gputex: GPUTEX_DIR relocates the state directory (tests, sandboxes) and GPUTEX_ENV_FILE relocates the managed environment file.
  • On non-unix builds (the playground) every gpu function faults (“gpu.lock is not available in this build”).
import "gpu"

fn main() (error?) {
    check gpu.lock("default", "tinyllama eval")
    // the card is ours until unlock or exit
    check gpu.unlock("default")

    ok, err := gpu.trylock("default", "opportunistic sweep")
    if err != none {
        return err
    }
    if !ok {
        print("card busy; skipping")
        return none
    }
    check gpu.unlock("default")
    return none
}

15.8 time

Clocks, sleeping, and civil time. The single time currency is int nanoseconds: every duration and every instant in the standard library is an integer count of nanoseconds (exact in int until the year 2262), and durations are written with the constants below.

Importing "time" also declares:

struct Parts { year int, month int, day int, hour int, minute int, second int }

Constants (all int):

ConstantValue
time.nanosecond1
time.microsecond1000
time.millisecond1000000
time.second1000000000
time.minute60 · 10⁹
time.hour3600 · 10⁹

Functions:

  • time.now() int — the wall clock, nanoseconds since the Unix epoch.
  • time.clock() int — a monotonic clock, nanoseconds since an arbitrary origin; only differences are meaningful. Unaffected by wall-clock adjustment.
  • time.sleep(c Ctx, d int) error? — block for d nanoseconds, then none. A non-positive d returns none without blocking. If the ctx is done, or becomes done while sleeping, the sleep ends promptly and returns the ctx error ("deadline exceeded" or "interrupted"). Wake-up latency after the ctx ends is implementation-defined but bounded (the reference implementation checks at least every 50ms).
  • time.parts(epoch int) Parts — the local civil time for an epoch instant, split into fields. Nanoseconds within the second truncate. An epoch outside the representable civil range faults.
import "ctx"
import "time"

fn main() {
    t0 := time.clock()
    e := time.sleep(ctx.background(), 250 * time.millisecond)
    if e != none {
        print("interrupted early: " + e.msg)
    }
    elapsed_ms := (time.clock() - t0) / time.millisecond
    p := time.parts(time.now())
    printf("%d:%d:%d slept about %dms\n", p.hour, p.minute, p.second, elapsed_ms)
}

In contexts with no usable clock (the browser playground) time.now, time.clock, time.sleep, and time.parts report their absence as a fault naming the build, the same contract as ctx.timeout (15.4).

15.9 os

The process’s own surroundings. Absence is an option or an error value, never a sentinel.

  • os.workdir() (str, error?) — the current working directory as an absolute path.
  • os.env(name str) str? — the environment variable’s value, which may be the empty string, or none when unset (or set to bytes that are not valid unicode). There is no get-with-default; narrowing is the mechanism:
import "os"

fn main() {
    bin := "./a.out"
    v := os.env("BIN")
    if v != none {
        bin = v
    }
    print(bin)
}
  • os.args() []str — the program’s arguments: everything after the source file on the command line (nv prog.nv a b and nevla run prog.nv a b both yield ["a", "b"]). In contexts with no command line (tests, embedding) the list is empty.
  • os.readline() (str, error?) — read one line from standard input. The returned string excludes the line terminator. End of input and read failures are error values, not faults (eof on end of input). A prompt is the caller’s own printf; when a program runs through the CLI runner its output is streamed unbuffered, so a prompt written before os.readline is visible before the read blocks.

In contexts with no operating system to speak of (the browser playground) every os function reports its absence as a fault naming the build.

15.10 regex

Pattern matching. Importing "regex" also declares:

struct Match { text str, start int, end int, groups []str }

and the opaque struct type Re, a compiled pattern. Re values are handles with reference semantics (section 11.1) and cannot be constructed with a struct literal; regex.compile makes them.

  • regex.compile(pattern str) (Re, error?) — compile a pattern. A malformed pattern is an error naming the problem.

Methods on Re:

  • matches(s str) bool — whether the pattern matches anywhere in s.
  • find(s str) Match? — the leftmost match, or none.
  • find_all(s str) []Match — every non-overlapping match, left to right; an empty list when there are none.
  • replace(s str, repl str) strs with every match replaced. $1, $2, and $name in repl substitute capture groups; $$ is a literal dollar.

A Match is plain data: text is the matched text, start and end are character indices into the subject (half-open, so s[m.start:m.end] == m.text, section 7.6), and groups holds captures 1 through n in order, with a group that did not participate reading as "".

The flavor is the RE2 family (the Rust regex crate): matching runs in time linear in the input, and backreferences and lookaround do not exist; a pattern that wants them is a compile error naming the missing feature. Case-insensitivity and other flags are written inline ((?i), (?m), (?s)). Full backtracking semantics remain available through the bridge (import py "re").

import "regex"

fn main() (error?) {
    re := check regex.compile("(?i)(\\w+)=(\\d+)")
    for _, m := range re.find_all("A=1 b=22 c=x") {
        printf("%s is %s\n", m.groups[0], m.groups[1])
    }
    return none
}

15.11 flag

Command-line flags, data-shaped: no registry, no global state, no output. Importing "flag" also declares:

struct Flag { name str, short str, fallback str, usage str, toggle bool }
struct Parsed { values map[str]str, rest []str }
  • flag.value(name str, short str, fallback str, usage str) Flag — a flag that takes a value.
  • flag.toggle(name str, short str, usage str) Flag — a presence flag; parses to "true", fallback "false".
  • flag.parse(argv []str, flags []Flag) (Parsed, error?) — parse argv (typically os.args()). Pure: same inputs, same outputs.
  • flag.get(p Parsed, name str) str — read a parsed value. Parse fills every declared flag with its fallback first, so a lookup by declared name always answers; an undeclared name reads "". Plain map reads on p.values work too and narrow as options (7.6).

Grammar, Go-shaped:

  • --name value, --name=value, -s value, -s=value.
  • A toggle takes no value; --name=value on a toggle is an error.
  • Parsing stops at -- (consumed) or at the first argument that does not begin with -; everything from there lands in rest verbatim. A bare - is an argument, not a flag.
  • -h and --help are synthesized: parse returns an error whose message is the usage text, one line per flag, value flags showing their fallback. Declaring a flag named help or with short h is itself an error. The module never writes output and never exits; main decides what an error means (usually print it and return it).
  • An unknown flag, a toggle given a value, and a value flag at the end of argv with nothing to take are errors carrying the usage text.
  • Values are strings; int(x) and float(x) conversions are the typed layer, already mandatory-checked.
import "flag"
import "os"

fn main() (error?) {
    p := check flag.parse(os.args(), [
        flag.value("addr", "a", ":8080", "listen address"),
        flag.toggle("verbose", "v", "log more"),
    ])
    if flag.get(p, "verbose") == "true" {
        print("listening on " + flag.get(p, "addr"))
    }
    return none
}

15.12 proc

Subprocesses. Importing "proc" also declares:

struct Cmd { argv []str, dir str, env map[str]str, stdin str, log str }
struct Result { status int, stdout str, stderr str }

and the opaque struct type Proc, a handle to a started child, with reference semantics (section 11.1); proc.start makes them.

  • proc.run(c Ctx, argv []str) (Result, error?) — run to completion and capture output. Shorthand for proc.exec with an empty Cmd around argv.
  • proc.exec(c Ctx, cmd Cmd) (Result, error?) — the full form. cmd.dir sets the working directory ("" inherits); cmd.env entries are ADDED to the inherited environment, overriding on collision (an empty map inherits unchanged; there is no way to drop the inherited environment in v1); a non-empty cmd.stdin is written to the child and closed.
  • proc.attach(c Ctx, argv []str) (int, error?) — run a child that OWNS the terminal: stdin, stdout, and stderr are inherited, nothing is captured, and the call blocks until the child exits, returning its status with run’s exit semantics. For editors, REPLs, and anything else interactive.
  • proc.start(cmd Cmd) (Proc, error?) — start a long-running child. Its stderr merges into stdout as one stream, interleaved at line granularity in arrival order. A non-empty cmd.log appends the stream to that file instead; readline on a logged child returns an error naming the file.

Exit semantics for run/exec: a child that ran and exited zero is a Result with a none error. A nonzero exit fills Result AND sets the error (exit status 3): handling stays mandatory, the output stays data. A child terminated by a signal reports status -1 and the error names the signal. Failure to spawn at all (missing binary, bad directory) returns the zero Result and an error. If the ctx is already done, nothing spawns. If the ctx ends while the child runs, the child is terminated (then killed after a short grace) and the call returns the ctx error with output captured so far and status -1.

Methods on Proc:

  • pid() int — the child’s process id.
  • running() bool — whether the child is still alive.
  • readline(c Ctx) (str, error?) — the next line of the merged stream, blocking until one arrives, the stream ends (eof, the os.readline contract), or the ctx ends (the ctx error; the child is left alone).
  • wait(c Ctx) (int, error?) — block until the child exits and return its status, or return the ctx error when the ctx ends first (the handle stays valid; waiting again is fine).
  • stop(grace int) error? — terminate politely, wait grace nanoseconds, kill. Idempotent; stopping an exited child is none.

The runtime owns the pipes: child output is moved into buffers (or the log file) by the implementation the moment it exists, so a child that fills one pipe while the program reads another cannot deadlock. No part of a program observes the threads this implies.

import "ctx"
import "proc"
import "time"

fn main() (error?) {
    c := ctx.timeout(ctx.background(), 30 * time.second)
    r := check proc.run(c, ["git", "status", "--short"])
    printf("%s", r.stdout)

    p := check proc.start(Cmd{
        argv: ["make", "serve"],
        dir: "", env: map[str]str{}, stdin: "", log: "/tmp/serve.log",
    })
    printf("serving as pid %d\n", p.pid())
    check p.stop(2 * time.second)
    return none
}

In contexts with no processes (the browser playground) every proc function reports its absence as a fault naming the build.

16. Modules and multi-file programs

16.1 File imports

import "util.nv" imports another nevla source file. The path is resolved relative to the directory of the importing file. The imported file’s exported top-level functions and structs (section 16.3) become visible under a namespace equal to the file’s stem (the file name without .nv):

// util.nv
struct Pair { A int, B int }
fn Double(x int) int { return twice(x) }
fn twice(x int) int { return x * 2 }    // private to util.nv
fn Make(a int, b int) Pair { return Pair{A: a, B: b} }

// main.nv
import "util.nv"

fn sum(p util.Pair) int { return p.A + p.B }

fn main() {
    print(util.Double(21))       // 42
    p := util.Make(1, 2)
    print(sum(p))                // 3
}

Struct types of a file module are named with the dotted form util.Pair, in type positions (section 5.9) and in struct literals (util.Pair{A: 1, B: 2}, section 7.2.3).

16.2 Semantics

  • Imports are transitive: an imported file may import further files, each resolved relative to its own directory.
  • A file imported through multiple paths is loaded once (diamond imports are fine).
  • An import cycle is a compile-time error naming the cycle.
  • An unreadable or missing import path is a compile-time error.
  • The root file (the one being run) is not namespaced. Namespacing respects local shadowing inside the imported module: a local variable that shadows a module-level name refers to the local.
  • Modules are namespaces only; they are not first-class values.

16.3 Visibility

A module’s top-level name is exported when its first character is an ASCII capital letter; otherwise it is private to its file. There are no visibility keywords. The rule binds at module boundaries only: inside the defining file every name is reachable, and the root file’s own names are unaffected.

  • Calling an unexported function through a module (util.twice(...)) is a compile-time error (“twice is not exported by util”).
  • A foreign struct literal must name an exported struct (“pair is not exported by util”), and every field of the struct must be exported: because literals supply every field (section 7.2.3), a struct with any unexported field cannot be constructed outside its module at all (“util.Pair has unexported fields (secret); construct it inside util”), Go’s constructor pattern.
  • Reading or assigning an unexported field of a foreign struct is a compile-time error (“field secret of util.Pair is not exported”).

An exported function may mention unexported types: its results flow, and their exported fields read fine; the importer just cannot write the type in a literal or touch its unexported fields.

One file is inside another’s boundary: a file whose stem is the other’s plus _test (util_test.nv for util.nv) may use the paired module’s unexported names through the ordinary qualified syntax. Test files are same-module code, as in Go. Standard library modules (chapter 15) are exempt — their members are defined by this specification — as are py values, whose access rules are Python’s. Visibility is a compile-time rule; the REPL is unchecked (section 17.6).

17. Program execution

17.1 Entry point

Program execution begins at fn main. main must be declared, must take no parameters, and must declare either no results or the single result (error?). Any other signature, or a missing main, is a compile-time error. The exception is a test file run by nevla test (section 17.7), whose entry points are its test functions and which needs no main.

Before main runs, all import py modules are imported; a failing Python import terminates the program as a runtime error.

Program arguments follow the file on the runner command line and are exposed through os.args() (section 15.9).

17.2 Termination and exit status

A program run terminates in one of four ways:

OutcomeExit statusDiagnostics
main returns (no error)0none
compile error (lex, parse, or typecheck)nonzeroeach diagnostic as line:col: message on standard error; the program does not run at all
main returns a non-none errornonzerothe error’s msg on standard error
runtime fault (chapter 12)nonzerothe fault message and a nevla stack trace on standard error

Program output written by print/printf up to the point of termination is delivered to standard output in all cases. A program that typechecks must never terminate by crashing the host process.

17.3 The two binaries

  • nevla is the toolchain: nevla run [file] typechecks and runs (defaulting to the enclosing project’s src/main.nv); nevla check [file] typechecks only and never runs code or provisions an environment; nevla new <name> scaffolds a project; nevla py add <pkg> declares a Python dependency and syncs the environment; nevla fmt [paths] rewrites source in the canonical style (--check reports instead); nevla imports [paths] organizes imports (add missing stdlib, drop unused, sort) and then formats; nevla test [paths] runs test functions (section 17.7); nevla repl starts the REPL.
  • nv is the runner: nv file.nv typechecks and runs the file; bare nv starts the REPL.

Bare nevla run and nevla check outside any project fail with a diagnostic. nevla check on a valid program produces no output and exits 0.

17.4 Projects

A project is a directory tree rooted at a nevla.toml manifest, found by walking upward from the file being operated on (or the working directory). The layout:

  • nevla.toml: project name, Python version pin, and declared Python dependencies ([py-deps]).
  • nevla.lock: exact resolved Python package versions. Manifest and lock together fully determine the Python environment.
  • .nevla/: the generated virtual environment and sync markers. Disposable; deleting it is always safe, it regenerates on the next run.
  • src/main.nv: the default entry point for bare nevla run.

A project’s nevla.toml may carry nevla = "x.y.z", the version the project was built against; nevla new writes it. Running or checking the project under a different nevla prints a warning naming both versions, so a language change surfaces as “built against 0.1.5” rather than a mystifying compile error. The pin never blocks execution.

17.5 The manifest rule for py imports

When the compiled file lies inside a project, every import py "m" is validated at compile time: the top-level segment of m (the part before the first .) must either be declared under [py-deps] in nevla.toml or be a module of the Python standard library. Declared names match import names case-insensitively with - and _ interchangeable, mirroring PyPI name normalization (sentence-transformers satisfies import py "sentence_transformers"). An undeclared import is a compile-time error directing the user to nevla py add.

A [py-deps] entry is either a version string or a table with optional version (default "*") and optional module naming the import the package satisfies when the two differ:

[py-deps]
torch = "*"
mlflow-skinny = { module = "mlflow" }

A module override replaces the package-name match: mlflow-skinny above satisfies import py "mlflow" and no longer satisfies import py "mlflow_skinny". Only the package name and version reach the dependency resolver. nevla py add <pkg> writes the string form; nevla py add <pkg> --module <name> writes the table form, and re-adding an existing package preserves its table entry.

Hand-editing [py-deps] is first-class: the lock’s first line records a fingerprint of the resolution inputs (the python pin and the requirement lines), and provisioning with a drifted manifest re-resolves the lock automatically before syncing the environment. A lock is never trusted past its manifest. A changed lock rebuilds .nevla/venv from scratch rather than syncing in place: packages that own overlapping directories (mlflow and mlflow-skinny both own mlflow/) corrupt in-place removals, and the venv is disposable by design.

The manifest’s python pin must match the interpreter embedded in the running nevla (major.minor); a mismatch is a compile-time error naming both versions. When the pin is omitted it defaults to the embedded version. nevla new scaffolds with the embedded version.

Inside a project, sys.executable in the embedded interpreter refers to the project venv’s python, so Python libraries that spawn worker interpreters (multiprocessing and similar) function normally.

Outside a project there is no manifest to check; py imports resolve at program start against the embedded interpreter, and a missing module is a runtime error (section 13.1). Running a project with declared Python dependencies provisions the environment automatically before execution; nevla check never provisions.

17.6 The REPL

Bare nv (or nevla repl) starts an interactive session. The v1 REPL is unchecked: input goes to the evaluator without typechecking, and faults are reported and survived rather than ending the session. A line whose first word is fn, struct, or import is treated as a declaration and registered; any other line is executed as a statement, and an expression statement’s value, if any, is printed in canonical rendering. Bindings and declarations persist for the session. REPL behavior beyond this paragraph is unspecified in v1.

17.7 nevla test

nevla test discovers *_test.nv files (the whole project’s src/ when bare, or the given files and directories) and runs their test functions. A test function’s name starts with Test, takes no parameters, and returns (error?); any other Test-prefixed shape in a test file is an error. Test files need no main (section 17.1) and may use the unexported names of the module their stem pairs with (section 16.3).

Each test function runs in a fresh interpreter instance; tests run in parallel (-j N bounds the workers, -j 1 serializes). A test passes when it returns none, is reported skipped when it returns test.skip’s sentinel, and fails otherwise; a runtime fault fails its test with the nevla stack trace and the run continues. Failure reports lead with the error’s origin (section 5.7). print output is captured per test and shown only for failures. The exit status is nonzero when any test fails.

The embedded CPython and the filesystem are shared across tests; python module state persists and parallel tests race on shared paths exactly as any two programs would.

18. Implementation limits

Limits a program may rely on, stated as minimum guarantees:

  • Syntactic nesting: an implementation must accept at least 256 levels of combined expression, type, and block nesting. Input exceeding the implementation’s limit must be rejected with a compile-time diagnostic (“expression too deeply nested”), never a crash. The reference implementation’s limit is exactly 256.
  • Call depth: an implementation must support at least 1000 simultaneously active nevla function calls. Exceeding the implementation’s limit is a runtime fault (“recursion limit exceeded”) carrying a (possibly truncated) stack trace, never a host stack overflow. The reference implementation’s limit is exactly 1000.
  • int is exactly 64-bit two’s complement (section 5.1); this is not implementation-defined.

19. Conformance and maintenance

The golden tests under tests/golden/ are the executable companion to this specification: each .nv file paired with an .out (expected stdout of a successful run) or .err (required substrings of the compile or runtime diagnostic) fixes observable behavior. A conforming implementation must pass them. Where this document and a golden test disagree, the golden test is taken as the intended behavior and this document must be corrected.

Any change to language semantics must land together with a matching edit to this document, in the same commit, alongside the golden tests that prove the new behavior.