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)
}
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")
}
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)
}
}
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
}
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
range follows Go, including ranging over an integer:
| operand | one variable | two variables |
|---|---|---|
int n | i from 0 through n-1 | compile error |
[]T | index | index, element |
map[K]V | key | key, value |
str | index | index, character (a one-char str) |
py | iteration index | index, 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)
}
}
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
}
}
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
}
}
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
}
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
}
}
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 onerr != 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
}
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
}
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
}
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
}
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 testand 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:
| Helper | Behavior |
|---|---|
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 recordingtest.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 viacheckskips its own teardown.withcovers py resources; native cleanup is an open language question that testing will keep pressure on. - Benchmarks and fuzzing: out of scope for v1; the
Testname 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(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()):
| Type | Rendering |
|---|---|
int | decimal |
float | shortest decimal that round-trips; integral values render without a fractional part (3.0 renders as 3); infinities as inf/-inf, NaN as NaN |
bool | true / false |
str | the string itself, unquoted |
list | [e1, e2, ...], elements rendered recursively |
map | {k1: v1, k2: v2} in insertion order |
| struct | Name{f1: v1, f2: v2} in declaration order |
none | none |
error | error(<msg>) |
py | Python str() of the object |
fn | fn |
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:
| Verb | Argument type | Output |
|---|---|---|
%v | any | canonical rendering (section 14.1) |
%d | int | decimal |
%s | str | the string |
%t | bool | true / false |
%q | str | double-quoted, backslash-escaped |
%f | float | fixed-point, default 6 fractional digits |
%% | none | literal % |
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,noneif 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 trailingpif present, else unchanged.chars() []str— the characters as one-character strings.repeat(n int) str— the string tiledntimes; negativenfaults, 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
}
List methods
Receiver []T.
map(f fn(T) U) []U— applyfto each element.filter(f fn(T) bool) []T— keep elements wherefis true.each(f fn(T))— callfon each element; no result.sum() T—Tmust beintorfloat; the sum of the elements. Integer overflow faults (chapter 12). Summing an empty[]intyields 0; the result of summing an empty[]floatis unspecified in v1 (the reference implementation yields a value that faults on later float use).sorted() []T—Tmust beint,float, orstr; 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) str—Tmust bestr; 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]
}
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)— removeskin 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]
}
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; emptypytype,traceback, no cause, andoriginset to the call site (section 5.7).error.wrap(cause error, msg str) error— a new error with the message and the given cause;originis 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.
math
math.abs(int) intor(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 ofnuminbase.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.e—floatconstants.
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
}
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
}
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— deadlinesecsfrom now, clamped so a child deadline never exceeds its parent’s; negativesecsis treated as 0; non-finite or unrepresentably largesecsfaults.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?—nonewhile 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
}
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?—nonewhen the two values are structurally equal (the comparison of section 11.2’scontains); 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?—nonewhen given an error; an error when givennone. 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
}
}
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, invokingfper 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
}
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
Responsewithstatus404 and anoneerror. Only transport-level failures (connection refused, timeout, invalid request) are error values, with the zeroResponsein the value slot. - Redirects are followed automatically.
- For
http.request, an empty body on a GET request sends no body. http.streamPOSTsbodyand invokesfonce per response line as lines arrive, before the response completes (server-sent events are consumed this way). The returnedResponse.bodyholds 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:trueand hold the card if it was free,falseif 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--lowsemantics).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_FILEif set, else/etc/gputex/env;KEY=VALUElines) 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_DIRrelocates the state directory (tests, sandboxes) andGPUTEX_ENV_FILErelocates the managed environment file. - On non-unix builds (the playground) every
gpufunction 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
}
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).