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

Introduction

nevla exists because training and tuning language models means living in Python, and Python kept core dumping mid-run. Everything good in ML is a python library; everything painful about those runs — the crash twenty minutes in, the typo that survives until the epoch ends, the exception that ate the metrics — is the language around the libraries. nevla is a new language around the same libraries: Go’s discipline, CPython’s ecosystem.

import py "torch"

// check propagates: the caller decides
fn logits(n int) (str, error?) {
    w := check torch.randn([784, 10], requires_grad: true)
    x := check torch.randn([n, 784])
    y := check (x @ w)
    return check str(y.shape), none
}

// main can act, so it handles
fn main() {
    shape, err := logits(32)
    if err != none {
        print("torch failed: " + err.msg)
        return
    }
    print("logits: " + shape)
}

▸ run it in the playground

That is real torch, one import away, with Python’s exceptions arriving as typed error values instead of tracebacks.

The tenets

These are the principles the language is built on, each with its reason. They are recorded as decisions in the repo’s ADRs; this is the reader’s digest.

The whole program is checked before any of it runs, and a checked program cannot crash the process. The worst outcomes are an error returned from main or a controlled runtime fault with a nevla stack trace. The reason is the twenty-minute crash: a training run should die at nevla check, in milliseconds, or not at all.

Errors are values, and handling them is mandatory. Dropping an error is a compile error; check propagates, v, err := handles. There is no exception control flow to forget about. Handle errors at the layer that can act; propagate only when the caller owns the decision.

Everything is data. Errors carry inspectable fields (including the file:line where they were born). A test is a fallible function whose outcome is an error value. A test table is a list of structs. Faults are the single deliberate exception — process death refuses to be a value, which is exactly what makes the no-crash guarantee provable.

Follow the Go way unless there is a compelling reason not to. The copy model, capitalization visibility, _test files seeing their module’s internals, one true format with no configuration — all Go, adopted wholesale. Deviations exist (option types instead of nil, a lowercase stdlib) and each one has its reason written down.

Python is the ecosystem, not the runtime. One file in the interpreter speaks to CPython. A chain of Python operations is one fallible unit. Dependencies are declared in the manifest or the program does not compile — a missing package is a compile error, not a stack trace at hour two.

Break early. Until the language has users to protect, fundamental improvements ship now, not after they calcify. The version stamp in every project says what it was built against, and that is the promise: honesty about change, not absence of change.

Nothing is remembered. Anything derived is generated or enforced by a test: these docs’ reference chapters render from the spec, every example in this book compiles in CI, the dependency lock fingerprints its manifest, releases flow from a git tag to PyPI and Homebrew without hands. If keeping two things in sync requires a human to remember, that’s a bug.

nevla is built primarily for its author and the agents that write most of its code — and it would be worth building even if it never gains another user. That freedom is why the tenets above can be held without compromise.

Getting started

Try it in the playground without installing anything, or install it:

uv tool install nevla      # or: brew install guygrigsby/tap/nevla
nevla new hello && cd hello
nevla run

You don’t need python installed first: uv fetches a managed CPython for the install, and the wheels carry their own libpython. The py bridge does need a matching CPython standard library at runtime, which the uv and brew installs both guarantee; a hand-rolled setup missing one gets a warning naming the fix (uv python install <version>).

Two binaries, split like uv and python: nevla does setup (new, py add, check, fmt, test, run), nv runs code (nv file.nv; bare nv is the repl).

This book is the guide. The normative reference is the language spec, and every behavior in it is pinned by golden tests.

A tour of nevla

Each example links to the playground, where the URL carries the program itself; edit and rerun as you go.

Hello

fn main() {
    print("hello, nevla")
}

▸ run it in the playground

fn main() is the entry point. print renders anything. Four-space indents and nevla fmt settles every other style question.

Errors are values

fn half(n int) (int, error?) {
    if n % 2 != 0 {
        return 0, error.new("odd number")
    }
    return n / 2, none
}

fn main() {
    v, err := half(42)
    if err != none {
        print("error: " + err.msg)
    } else {
        print(v)
    }
}

▸ run it in the playground

A fallible function ends its result list with error?. Dropping an error is a compile error; you either bind it (v, err :=) and decide, or propagate it with check, which requires your own function to return error?:

fn quarter(n int) (int, error?) {
    h := check half(n)      // on error: return it, zero values elsewhere
    return check half(h), none
}

A multi-value never travels as a unit, so Go’s return half(n) is a compile error here. Propagate early with return check half(n), none, or bind and decide: v, err := half(n); return v, err. The error slot stays visible at every hop.

Handle errors at the layer that can do something about them; propagate only when the caller owns the decision.

Options, no nil

fn main() {
    m := map[str]int{"a": 1}
    v := m["a"]             // a map read is int?: present or none
    if v != none {
        print(v + 1)        // narrowed to int inside the branch
    }
}

▸ run it in the playground

There is no nil. Absence is an option type (int?), and the checker makes you look before you touch: using v unnarrowed is a compile error.

The copy model

struct User {
    Name str
    Age int
}

fn main() {
    u := User{Name: "nevla", Age: 1}
    v := u
    v.Age = 99
    print(u.Age)    // 1: structs copy

    xs := [1, 2, 3]
    ys := xs
    ys[0] = 99
    print(xs[0])    // 99: lists are references
}

▸ run it in the playground

Go’s split: scalars, strings, and structs copy on assignment; lists, maps, functions, and py values are references. Closures capture by reference.

Modules and visibility

import "util.nv" binds a sibling file as module util. Capitalized top-level names are exported; lowercase is private to its file, fields included. The Go rule, no keywords.

The py bridge

import py "torch"

fn main() (error?) {
    w := check torch.randn([784, 10], requires_grad: true)
    x := check torch.randn([32, 784])
    logits := check (x @ w)
    print(check str(logits.shape))
    return none
}

▸ run it in the playground

A chain of Python operations is one fallible unit: any exception anywhere in model(x).loss.item() becomes one nevla error at the point of consumption. Keyword arguments pass through, @ is matrix multiplication, and for range iterates any Python iterable. Inside a project, every import py must be declared (nevla py add torch), so a missing dependency is a compile error rather than a crash twenty minutes into a run.

with: Python context managers

import py "torch"

fn main() (error?) {
    x := check torch.randn([4, 4])
    with torch.no_grad() {
        y := check (x * 2)
        print(check str(y.shape))
    }
    return none
}

▸ run it in the playground

with expr { } runs the block under a Python context manager: __enter__ before, __exit__ on every exit from the block. A return that carries an error reaches __exit__ as an exception, so a manager that branches on exception state (a transaction’s commit/rollback) sees the error path exactly as Python would. The statement itself has no error slot — fallible acquisition belongs before it (db := check connect(...), then with db.transaction() { }), and an exception raised by the manager itself is a fault. One manager per statement; nest for more.

Functions and structs

Declaring functions

fn declares a function. Every parameter of a top-level function has a declared type, results come after the parameter list, and a function with results must return on every path; the checker rejects a missing return at compile time, not twenty minutes into a run.

fn double(n int) int {
    return n * 2
}

fn divmod(a int, b int) (int, int) {
    return a / b, a % b
}

fn main() {
    print(double(21))       // 42
    q, r := divmod(7, 3)
    print(q, r)             // 2 1
}

▸ run it in the playground

A function may declare several results. A call with multiple results is a multi-value: bind every component (q, r := divmod(7, 3)) or, when the last result is error?, propagate with check. Multi-values are not first class; they cannot be stored, nested, or returned as a unit (the errors chapter covers the idioms).

A function with no results returns implicitly at the end of its body; a bare return inside one exits early.

Function values and literals

Functions are first class. A declared function is a value; a function literal (fn(x int) int { return x * 2 }) is an expression. In a context that supplies the function type, parameter types can be omitted, and a literal whose body is a single expression returns that expression’s value:

fn main() {
    nums := [1, 2, 3, 4]
    big := nums.map(fn(x) { x * 2 }).filter(fn(x) { x > 2 }).sum()
    print(big)              // 18
}

▸ run it in the playground

Closures capture by reference, as in Go: the closure and the enclosing scope share the variable, and writes flow both ways.

fn main() {
    total := 0
    add := fn(x int) { total = total + x }
    add(1)
    add(2)
    print(total)            // 3
}

▸ run it in the playground

Loop variables are fresh per iteration (Go 1.22 semantics), so closures made in different rounds capture different variables.

Structs

struct declares a nominal record type. Fields are separated by commas or line breaks. A struct literal names the type and supplies every field exactly once, in any order:

struct User {
    Name str
    Age  int
}

fn main() {
    u := User{Age: 1, Name: "nevla"}
    u.Age = 2
    print(u.Name, u.Age)    // nevla 2
}

▸ run it in the playground

Structs are value types: assignment and argument passing copy (the copy model chapter has the full split). Capitalization controls visibility across modules, Go’s rule (modules). User structs have no methods in v1; write functions that take the struct.

A struct must not contain itself by value; such a value could never be constructed. Break the cycle with an option:

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

fn main() {
    n := Node{val: 1, next: none}
    print(n.val)            // 1
}

▸ run it in the playground

Values and the copy model

The types

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

int is 64-bit signed and overflow is a fault, never a silent wrap. float is IEEE 754 double. str is an immutable sequence of Unicode characters (“character” means code point everywhere in nevla). bool has exactly true and false; there is no truthiness, and conditions must be bool.

int and float never mix: 1 + 2.5 is a compile error. Convert explicitly.

Declaring and assigning

x := e declares x with the type of e; there is no way to declare a variable with an explicit type, and no top-level variables or constants. x = e assigns to an existing variable, element, or field. Shadowing in an inner scope is fine; redeclaring in the same scope is a compile error.

Conversions

int(x), float(x), bool(x), str(x), and []T(x) are the casts. Fallibility follows the operand: converting from str is a parse and returns (T, error?); numeric conversions cannot fail and are used inline.

fn main() (error?) {
    n, err := int("42")     // a parse can fail
    if err != none {
        return err
    }
    print(n)                // 42
    i := int(3.9)           // 3: truncation toward zero, single-valued
    s := str(123) + "!"
    print(i, s)             // 3 123!
    return none
}

▸ run it in the playground

Coming from Go: conversion is spelled like a call on the type name, and the fallible cases return an error value instead of silently producing a zero. int("x") gives you (0, error); nothing panics and nothing guesses.

Value types and reference types

Nevla splits its types the way Go does. Scalars, strings, structs, and errors are value types: assignment, argument passing, and iteration bind copies. Lists, maps, functions, and py values are reference types: one underlying object, however many names point at it.

struct User {
    Name str
    Age  int
}

fn main() {
    u := User{Name: "nevla", Age: 1}
    v := u
    v.Age = 99
    print(u.Age)    // 1: structs copy

    xs := [1, 2, 3]
    ys := xs
    ys[0] = 99
    print(xs[0])    // 99: lists are references
}

▸ run it in the playground

A struct copy is shallow in Go’s sense: a reference-typed field copies the reference, so the copy’s containers alias the original’s.

The zero value of a list or map is a fresh empty container, immediately usable. There is no nil and no nil-map write crash.

clone(xs) makes an explicit one-level copy of a list or map, matching Go’s slices.Clone. append(xs, v) is pure: it returns a fresh list and never mutates in place, so growth is visible only by rebinding (xs = append(xs, v)). The list methods (map, filter, sorted) also return fresh lists.

Equality

== and != work on scalars (int, float, bool, str) and for comparing an option against none. Nothing else compares with ==; lists, maps, structs, and errors are compile errors. Structural equality goes through contains or an explicit walk, and tests use test.eq, which compares structurally and reports the difference.

<, <=, >, >= order int, float, and str (lexicographic by character). Chained comparisons (a < b < c) are rejected by the checker.

Control flow

if and else

Conditions are plain bool expressions, no parentheses, braces always. else and else if sit on the same line as the closing brace of the previous block:

fn describe(n int) str {
    if n < 0 {
        return "negative"
    } else if n == 0 {
        return "zero"
    } else {
        return "positive"
    }
}

fn main() {
    print(describe(-3), describe(0), describe(7))   // negative zero positive
}

▸ run it in the playground

Conditions narrow option types inside the branches; that mechanism has its own chapter.

for: one loop keyword, three forms

fn main() {
    // forever: only break or return leaves it
    n := 0
    for {
        n = n + 1
        if n == 3 {
            break
        }
    }

    // while: condition checked before each round
    for n > 0 {
        n = n - 1
    }

    // range: over an int, list, map, str, or py value
    total := 0
    for i := range 5 {
        total = total + i
    }
    print(total)            // 10
}

▸ run it in the playground

range follows Go, including ranging over an integer:

operandone variabletwo variables
int ni from 0 through n-1compile error
[]Tindexindex, element
map[K]Vkeykey, value
strindexindex, character (a one-char str)
pyiteration indexindex, item

The variables can be dropped entirely (for range 3 { ... } runs the body three times), and _ discards one position (for _, v := range xs). Maps range in insertion order, always. Iteration variables are fresh bindings each round, so closures created in different rounds capture different variables.

fn main() {
    for _, c := range "abc" {
        print(c.upper())
    }
    // a map ranges in insertion order
    m := map[str]int{"one": 1, "two": 2}
    for k, v := range m {
        print(k, v)
    }
}

▸ run it in the playground

break and continue

break leaves the innermost loop; continue starts its next round. Either outside a loop is a compile error. Both count as divergence: the checker rejects unreachable statements after them, the same way it rejects code after return.

Coming from Go: there are no labels, no goto, no switch, and no three-clause for. for i := range n covers counting loops, and an if/else if chain covers the rest.

Options, not nil

This chapter is the biggest departure from Go. There is no nil, no nil pointer, no nil map, no “invalid memory address” at hour two. Absence is a type, and the checker makes you look before you touch.

The option type

For any type T, the option type T? holds either a T or the absent value none. Anywhere a value can be missing, the type says so: a map read is V?, a function that might not find its answer returns T?, a struct field that starts empty is declared T?.

struct Profile {
    Name str
    Nick str?       // may be absent
}

fn main() {
    p := Profile{Name: "nevla", Nick: none}
    n := p.Nick
    if n != none {
        print(n)
    } else {
        print(p.Name)       // nevla
    }
}

▸ run it in the playground

A plain T widens into T? on assignment; the reverse never happens implicitly. none is assignable to every option type and compares only against options; none == none is true.

Narrowing

An option must be narrowed before use. Using it unnarrowed is a compile error, not a runtime surprise:

fn main() {
    m := map[str]int{"a": 1}
    v := m["a"]             // v: int?
    // print(v + 1)         // compile error: v might be none
    if v != none {
        print(v + 1)        // 2: v is int inside this branch
    }
}

▸ run it in the playground

The checker follows the control flow (flow typing). A comparison against none narrows in both directions: if v != none gives you v as T in the then-branch, and if v == none gives it to you in the else-branch.

Early exits narrow the rest of the function. When one side of the branch always diverges (return, break, continue), the narrowing survives past the statement:

fn first(xs []int) int {
    if len(xs) == 0 {
        return 0
    }
    return xs[0]
}

fn main() {
    m := map[str]int{"a": 1}
    v := m["a"]
    if v == none {
        return
    }
    print(v + 1)            // 2: v is int from here on
    print(first([7]))       // 7
}

▸ run it in the playground

Invalidation

Narrowing is erased wherever it can no longer be proven. Assigning to the variable erases it; a loop body erases it when the body assigns the variable anywhere, because the body runs more than once. Rebinding with := makes a new variable and leaves the outer narrowing alone. Losing a narrowing is always sound; the fix is to re-check or bind the value out to a new name.

Coming from Go: the v, ok := m[k] two-value read does not exist. A map read is one value of type V?, and the ok check became a type the compiler enforces. You cannot forget it: code that compiles has looked.

Errors and check

Errors are values, and dropping one is a compile error. Those two sentences are most of the design; the rest is vocabulary.

Error values

error is an ordinary type. Every error exposes msg, cause (the wrapped inner error, or none), origin (the file:line where it was born), and, for errors that crossed the Python bridge, pytype and traceback. Construct with error.new and error.wrap:

fn main() {
    e := error.new("boom")
    w := error.wrap(e, "while testing")
    print(w.msg)            // while testing
    c := w.cause
    if c != none {
        print(c.msg)        // boom: the chain stays structured
    }
}

▸ run it in the playground

A fallible function ends its result list with error?:

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

Handling is mandatory

The checker rejects any path where an error is silently dropped. Calling a fallible function as a bare statement, or binding one fewer name than the value count, is the compile error “error result must be handled”. Handling means one of three things:

  • bind it and decide: v, err := f(), then branch on err != none;
  • propagate it: check f();
  • discard it explicitly: v, _ := f(). The underscore is legal and visible in review, which is the point.

Recovery

Recovery is the two-value form plus a none-check; flow narrowing unwraps the error? exactly like any other option:

fn half(n int) (int, error?) {
    if n % 2 != 0 {
        return 0, error.new("odd number")
    }
    return n / 2, none
}

fn main() {
    v, err := half(42)
    if err != none {
        print("failed: " + err.msg)
        return
    }
    print(v)                // 21
}

▸ run it in the playground

check propagates

check e evaluates a fallible expression. On success it yields the value(s); on error the enclosing function returns immediately, the error in its final slot and every other slot zero-filled. The enclosing function must itself end in error?, so propagation is visible in every signature it passes through.

fn half(n int) (int, error?) {
    if n % 2 != 0 {
        return 0, error.new("odd number")
    }
    return n / 2, none
}

fn quarter(n int) (int, error?) {
    h := check half(n)      // on error: return it, zeros elsewhere
    return check half(h), none
}

fn main() {
    v, err := quarter(44)
    if err != none {
        print(err.msg)
        return
    }
    print(v)                // 11
}

▸ run it in the playground

A multi-value never travels as a unit: return half(n) is a compile error even when the signatures line up. Bind and return (v, err := half(n); return v, err) or propagate early (return check half(n), none). The error slot stays visible at every hop.

main may declare (error?); returning an error from main prints it and exits nonzero.

Coming from Go: check is the if err != nil { return err } you were going to write anyway, reduced to one keyword and checked by the compiler. The differences that matter: you cannot forget (dropping an error does not compile), and wrapping keeps a typed cause chain instead of one flattened string.

Faults are not errors

Some failures do not return: index out of range, integer overflow, division by zero. These are faults; they print a nevla stack trace and terminate with a nonzero exit. Faults are deliberately not catchable, and no user program can crash the process any other way. If a failure is something a caller could reasonably handle, it is an error value; if it is a bug in the program, it is a fault.

The py bridge

Python is the ecosystem, not the runtime. import py "torch" gives you the real torch, and everything that crosses the boundary arrives as typed values with typed errors. Nothing in this chapter exists in Go; it is the half of nevla that Go could never give you.

Importing and calling

import py "math"

fn main() (error?) {
    v := check float(math.sqrt(2))
    print(v > 1.41 && v < 1.42)     // true
    return none
}

▸ run it in the playground

import py "modname" imports a Python module through the bridge (dotted paths like "os.path" work). Inside a project every py import must be declared in the manifest (nevla py add torch), so a missing dependency is a compile error, not a crash after the first epoch.

Values of type py are references to live Python objects. They are the one dynamic type in the language: attribute access, calls, indexing, and operators on them dispatch to Python at runtime.

Chains: one fallible unit

A sequence of Python operations is one fallible unit. Any exception anywhere in model(x).loss.item() becomes one nevla error at the point where the chain is consumed; you do not handle each step.

import py "json"

fn main() (error?) {
    parsed := check json.loads("{\"a\": 1}")
    n := check int(parsed["a"])
    print(n)                        // 1
    return none
}

▸ run it in the playground

Consume a chain with check (propagate), with v, err := (bind), or with a conversion, which absorbs the chain’s fallibility. Letting a chain’s error drop on the floor is the usual compile error.

Python exceptions become error values with the full story attached: .msg is the rendered exception, .pytype names the exception class, .traceback carries the Python traceback text.

Crossing the boundary

Nevla scalars pass into Python calls directly, as do lists and maps (converted recursively). Named arguments pass through to Python keywords: torch.randn([784, 10], requires_grad: true). @ is matrix multiplication, defined when an operand is py.

Coming back is explicit: a conversion extracts a typed value from a py, and the parse-like forms are fallible.

w := check float(logits.item())     // py to float, fallible
xs := check []float(tensor.tolist())

for x := range e iterates any Python iterable. with runs Python context managers; a nevla error return inside the block reaches __exit__ as an exception, so transaction-shaped managers see the error path exactly as Python would (see the tour).

What stays out

py values do not leak into the rest of the type system: you cannot put one in a condition, compare one with == and get a bool, or slice one. Every branch decision needs an extracted, typed value. That line is what keeps a nevla program checkable while half of it lives in CPython.

Modules

File imports

import "util.nv" binds a sibling source file as module util. The path is relative to the importing file, the module’s name is the file stem, and its members are reached with the dot: util.Double(3), util.Pair{...}. Diamond imports load once; an import cycle is a compile error naming the cycle.

// util.nv
fn Double(n int) int {
    return n * 2
}
// main.nv
import "util.nv"

fn main() {
    print(util.Double(21))      // 42
}

Modules are namespaces, not values: u := util does not compile, and module functions are called directly.

Visibility: the capital letter

A module’s top-level name is exported when it starts with a capital letter; otherwise it is private to its file. No keywords, Go’s rule, and it applies to struct fields too:

  • calling an unexported function through a module is a compile error;
  • a struct with any unexported field cannot be constructed outside its module at all, because literals must supply every field. That is the constructor pattern: keep one field lowercase and exports control creation.

Inside the defining file, everything is reachable; the rule binds at module boundaries only.

Test files are inside the boundary

A file named util_test.nv sees util.nv’s unexported names through the ordinary qualified syntax, exactly like Go’s same-package tests. The testing chapter covers the rest.

The three imports

import "file"           // standard library (math, error, file, ctx, gpu, http, test)
import "util.nv"        // another nevla file, namespaced by stem
import py "torch"       // a Python module through the bridge

Standard library modules are documented in the reference. Py imports are governed by the project manifest: inside a project, import py of an undeclared package is a compile error (the py bridge).

Testing

This chapter was written first as the design document for nevla test and the implementation follows it. Anything that behaves differently is a bug in one of the two.

A test is a fallible function. There is no test framework type, no assertion keyword, and no new control flow: a test fails by returning an error, which means everything you already know about errors — check, error.new, wrapping — is the testing vocabulary too.

Writing tests

Tests live in *_test.nv files beside the code they test, in functions whose names start with Test and whose only result is error?:

// util_test.nv
import "test"
import "util.nv"

fn TestDouble() (error?) {
    check test.eq(util.Double(21), 42)
    return none
}

fn TestParseRejectsGarbage() (error?) {
    _, err := util.Parse("nope")
    if err == none {
        return error.new("Parse accepted garbage")
    }
    return none
}

check is the assertion propagator: the first failing test.eq returns its error, and that error is the failure report. Anything else in a _test.nv file (helpers, lowercase functions, structs) is ordinary code.

Run them:

nevla test              # every *_test.nv under the project
nevla test src/util_test.nv
ok   util_test.nv  TestDouble
ok   util_test.nv  TestParseRejectsGarbage
FAIL util_test.nv  TestHalf
     util_test.nv:14: expected 21, got 20
2 passed, 1 failed

The runner exits nonzero when anything fails.

The test module

import "test" provides helpers that return error?, built to sit behind check:

HelperBehavior
test.eq(got, want)structural equality over any two values (the deep comparison == deliberately does not offer); error describes both sides
test.neq(got, unwanted)structural inequality
test.err(err)fails when err is none: asserts an error happened
test.skip(reason)returns a sentinel the runner reports as skipped, not failed

That is the whole v1 surface. test.eq covers most assertions because its error message carries both values; when it doesn’t fit, build the error yourself — return error.new(...) is always available, and a custom helper is just a function returning error?.

Table tests

Go’s table-driven idiom is a stance, not a framework feature: a table is a list of structs and the runner is a for loop, so nevla has table tests by construction:

struct Case {
    In int
    Want int
}

fn TestDouble() (error?) {
    cases := [
        Case{In: 1, Want: 2},
        Case{In: 0, Want: 0},
        Case{In: -3, Want: -6},
    ]
    for _, c := range cases {
        err := test.eq(util.double(c.In), c.Want)
        if err != none {
            return error.wrap(err, sprintf("double(%v)", c.In))
        }
    }
    return none
}

The error.wrap names the failing case in the report. Tables are also exactly the workload that will eventually justify the two deferred features below: test.run for named cases, soft failures for reporting every bad row instead of the first.

Failure locations

An error value records its origin: the file and line where error.new created it (or where a py exception crossed the bridge). The runner prints the origin with the failure, so test.eq’s errors point at the check test.eq(...) line that produced them. Origins ride the error through check propagation and error.wrap, so a failure deep in a helper still names the source line that started it. (Origins are a property of all nevla errors, not a test feature; production error reports carry them too.)

Isolation and parallelism

Every test function runs in a fresh interpreter instance: globals, imports, and module state are rebuilt per test, and the test module’s bookkeeping hangs off that instance — per-test identity is injected by the runtime, not threaded through your code as a handle. Because tests share nothing on the nevla side, the runner executes them in parallel by default (-j 1 to serialize).

Two things per-test isolation cannot isolate, both properties of the world rather than the runner:

  • CPython is one interpreter per process. Python module state (imports, caches, monkeypatching) is shared across tests, and the GIL serializes py-heavy tests. The same is true of pytest in one process.
  • The filesystem is shared. Two parallel tests writing the same path race, exactly as in Go with t.Parallel. test.tmpdir() is the planned escape hatch; until then, derive per-test paths.

A fault in a test (integer overflow, index out of range) fails that test with its nevla stack trace and the run continues; faults cannot cross test boundaries because nothing else lives in that interpreter.

Output discipline: print output is captured per test and shown only for failures. A passing test is silent.

Deliberate omissions

  • Soft failures (Go’s t.Error, “record and continue”): v1 is fail-fast per test. The design composes — a recording test.fail(msg) can be added without changing any existing test — but it waits for the first table test that genuinely hurts.
  • Subtests (t.Run): same posture. test.run(name, fn () (error?)) fits the model when named table cases earn it.
  • In-test concurrency: nevla has no concurrency yet. When it lands, spawned work inside a test will carry identity the way all nevla code carries cross-cutting context — through ctx — not through a test-only handle. This is a standing requirement on the concurrency design.
  • Cleanup: nevla has no defer, so a test that creates external state and fails via check skips its own teardown. with covers py resources; native cleanup is an open language question that testing will keep pressure on.
  • Benchmarks and fuzzing: out of scope for v1; the Test name prefix leaves room for siblings.

White box, Go’s way

util_test.nv sits inside util.nv’s trust boundary: the _test stem pairing lets the test file touch util’s unexported names — functions, struct fields, literals — through the ordinary qualified syntax (util.helper(...) just compiles there). This is Go’s same-package testing translated to file modules: decomposed internals are unit-testable without exporting them, while every other file still sees only the API. The pairing follows the file name, so it holds anywhere a _test.nv file appears, and nothing else about visibility changes.

Builtins

Generated from the language spec, which is normative.

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

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

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)
%dintdecimal
%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).

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

len

len(x) int

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

args

args() []str

Returns 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"]). Takes no arguments. In contexts with no command line (tests, embedding) the list is empty.

input

input(prompt str) (str, error?)

Writes prompt to standard output (no trailing newline, flushed), then reads 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). When a program runs through the CLI runner its output is streamed unbuffered, so a prompt is visible before input blocks.

ord

ord(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.

chr

chr(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. chr(ord(c)) == c for every one-character c.

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

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.

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.upper())                  // THE NEVLA BOOK
    print(t.split(" ").join("-"))     // the-nevla-book
    print(t.replace("book", "spec"))  // the nevla spec
    i := t.find("nevla")
    if i != none {
        print(i)                      // 4
    }
    print("na".repeat(2) + " batman") // nana batman
    print(len("héllo"))               // 5: characters, not bytes
}

▸ run it in the playground

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, 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.
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]
}

▸ run it in the playground

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.
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]
}

▸ run it in the playground

Standard library

Generated from the language spec, which is normative.

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.

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
        }
    }
}

▸ run it in the playground

Error fields are specified in section 5.7.

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
}

▸ run it in the playground

file

Paths are str. Contents are UTF-8 str; there is no bytes type in v1.

  • 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.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.
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
}

▸ run it in the playground

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, secs float) Ctx — deadline secs from now, clamped so a child deadline never exceeds its parent’s; negative secs is treated as 0; non-finite or unrepresentably large secs faults.
  • 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.0)   // already expired
    print(c.done())                           // true
    e := c.err()
    if e != none {
        print(e.msg)                          // deadline exceeded
    }
    print(ctx.background().done())            // false
}

▸ run it in the playground

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
    }
}

▸ run it in the playground

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"

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

▸ run it in the playground

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

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, blocking in the kernel until it is free. 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
}

▸ run it in the playground

Nevli the mongoose

Nevli the mongoose

Nevli is the mascot; nevla is the language. Both are the mongoose in Hindi: नेवला (nevlā) is the noun, and नेवली (nevlī) is a female mongoose, which she is. The language borrows her disposition: small, fast, and fatal to snakes.

The name this project gave up

This language spent its first week named rikki, with a runner called tk, after Rikki-Tikki-Tavi in Kipling’s Jungle Book. The story was loved in childhood and the homage was sincere.

It was also naive. Read as an adult, Rikki-Tikki-Tavi is imperial allegory: a domesticated mongoose loyally defending an English family’s bungalow garden against the native cobras, the garden standing in for colonized India kept safe for its colonizers. This is the mainstream scholarly reading, not a stretch; Kipling is the poet of “The White Man’s Burden”, and the politics are in the story’s bones. A project name is an endorsement renewed every time it is typed, and this project will not carry that one.

So it was renamed, days after the naming, while the cost was near zero (ADR 0014 records the decision). The new name walks the other direction: the story’s failure was the colonial gaze on India, and nevla returns the animal to its own language.

The mongoose herself stays. The animal was never the problem, and no story owns her.

The artwork lives in art/ (CC BY 4.0; the code is MIT and stays that way).