How to author a Nova module
A general guide: from an empty directory to a publishable package. A native-backed module (a wrapper over
.c/a prebuilt.lib) is a special case at the end (§7): a native module must build WITHOUT Rust/cargo — only.nv+ optionally.c(compiled by clang) + optionally a prebuilt.lib/.a(linked, not built).Related documents (not duplicated here — follow the link to read further): module-conventions (module design: effect plumbing, value/must-consume types, error domain), nv-coding-style (
.nvcode style), ffi-cookbook (FFI mechanics:extern "C", pointers,CStr,[ffi]), spec D78 (normative rules fornova.toml/ module-path).
0. TL;DR
- Create a directory; put
nova.tomlwith[package] nameat its root. - Write
.nvfiles — file path = module path (foo/bar.nv⇒module foo.bar). - Tests go alongside, in
*_test.nvfiles (ortest "…" { }blocks inside the module). - Mark the public surface with
export+#stable(since = "X"). - Need C artifacts — declare them in
[ffi](a prebuilt.cshim + optionally a prebuilt.lib/.a); they’ll be built and linked automatically when the module is imported (§7).
1. Package layout
A package is a directory with nova.toml at its root. Source root =
package root (there’s no separate src/ — D78, 2026-05-22). Modules live
directly in subdirectories:
nova-greet/ repository: nova-<package> (§8)
├── nova.toml manifest (required)
├── LICENSE
├── README.md
├── greet.nv module greet (the package's root module)
├── greet_test.nv tests alongside the module
└── format/
├── ascii.nv module format.ascii
└── ascii_test.nv tests alongside
Service directories (target/, .git/, hidden .-prefixed ones) are
skipped by the resolver. Non-.nv directories (assets/, docs/) are not
treated as modules.
2. nova.toml — the manifest
The minimum is [package] name; version is desirable. The full schema —
D78.
[package]
name = "greet" # snake_case (D30); the package name = the modules' prefix
version = "0.1.0" # semver
nova-version = "0.5" # minimum Nova version
description = "Greetings in different languages"
license = "MIT OR Apache-2.0" # SPDX
repository = "https://github.com/you/nova-greet"
[[bin]] # optional: a binary entry point
name = "greet"
path = "bin/greet.nv"
[dependencies] # optional: external packages
some-lib = "1.2" # from the registry
internal = { path = "../internal" } # local
remote = { git = "https://github.com/…", tag = "v1" } # git (Plan 03.1/03.2)
A package is a library by default: its export declarations are
importable by other packages with no [lib] section at all. [[bin]] adds
binary entry points (a package can be both a library and a set of binaries).
3. Module path = file path (D78)
The compiler always checks the module … declaration against the file
path; a mismatch gives E_D78_MODULE_PATH_MISMATCH with a hint. The rule
(rev-3):
File (from package greet’s root) | Declaration | Import |
|---|---|---|
greet.nv | module greet | import greet.{hello} |
format/ascii.nv | module format.ascii | import format.ascii.{…} |
format/ascii/upper.nv (a folder peer) | module format.ascii | — |
A folder = ONE module made of co-equal files (peer files share one
parent.folder declaration). A file and a folder of the same name in one
directory are forbidden.
4. Public surface and stability
export— what’s visible outside the module/package; withoutexportan item is module-private. Cross-package import goes only throughexport(D216-ecosystem).#stable(since = "X")on every public item — a semver contract. For libraries this can be made mandatory:[lib] enforce-stability = trueturns a missing marker into annova doc --checkerror (D127).- An immature API —
#unstable/#experimentalinstead of#stable.
module greet
#stable(since = "0.1")
export fn hello(name str) -> str => "Hello, ${name}!"
5. Tests alongside the module
Tests live alongside the module — in *_test.nv files (excluded from
the release graph) or as test "…" { } blocks inside the module itself.
Don’t put tests in a separate tree. Pos/neg classification goes by the
EXPECT_* marker, not by directory (test-conventions).
module greet
test "hello inserts the name" {
assert(hello("Ada") == "Hello, Ada!")
}
For effect modules (§6) a mock-handler test is mandatory — determinism without a real resource.
6. Module design (brief; the full picture — module-conventions)
For I/O, OS, and resource subsystems, Nova’s canon is effect plumbing + a type-level facade (module-conventions):
- The effect is the internal dispatch point (
type Fs effect { … }); the user doesn’t call it directly → mockability (with Fs = mem_fs() { … }). - The user API — methods on types + free functions (
File.open(path)), the effect is visible in the signature’s effect row, not in an op’s name. - Small values —
valuerecords; resources — must-consume@close() -> Result. - Errors — one structural
XError { kind, … }+ an OPENErrorKind. - byte-first: raw I/O is
[]u8;stronly viafrom_utf8 -> Result.
Pure algorithmics (parsing, encodings, calendar) — ordinary .nv functions
with no effect.
7. Native-backed module (a special case)
A module can sit on top of a C library or a Rust crate. The thin FFI layer
(extern "C" fn, handle types, CStr, pointers, ABI) is covered entirely in
ffi-cookbook; here — only how to wire artifacts into the
build so that importing the module pulls them in automatically.
A native dependency is declared in nova.toml through a single section:
7.1. Prebuilt .c shims and system .libs — [ffi]
[ffi]
c_shims = ["native/sqlite3_shim.c"] # compiled and linked
include_dirs = ["native/", "third_party/sqlite3/"] # clang -I
libs = ["sqlite3"] # system: clang -lsqlite3 / sqlite3.lib
If a system .lib isn’t on the standard search path (a vcpkg triplet, a
vendored copy) — the link is resolved and wired directly into the build
pipeline (test_runner.rs::build_command), the same used-if-referenced D337
pattern as brotli/net.c. A native module never requires building a
native artifact (cargo build, make) as part of its own build — only
linking a prebuilt one; the reference pattern is std/tls in the monorepo
(nova_rt/tls_c_shim.c + vcpkg mbedTLS, mbedTLS installed AHEAD OF TIME via
vcpkg install, with no manifest declaration whatsoever). Full mechanics —
ffi-cookbook §Build pipeline.
8. Naming and publishing (an external package)
The convention for external (including native-backed) packages — D78 amendment, Plan 195:
| Entity | Convention | Example |
|---|---|---|
| Repository | nova-<package> | nova-tls |
Package name ([package] name) | <package> | tls |
| Module root | <package>.* | import tls.{TlsStream} |
| Native artifacts | native/ | native/tls_shim/ |
Publishing: commit the package into the nova-<package> repository; a
consumer wires it in as a git dependency —
[dependencies] tls = { git = "https://…/nova-tls", tag = "v0.1.0" }. The
registry (named <package> = "1.2") is Plan 03.3, separately.
9. New-module checklist
nova.tomlwith[package] nameat the root..nvfiles:module path = file path; a folder = one module.- Public surface —
export+#stable(since); for a library —enforce-stability = true. - Tests alongside (
*_test.nv/testblocks); an effect module → a mock test. - Design per module-conventions (effect plumbing + facade; value/must-consume;
one
XError). - Native —
[ffi](prebuilt.cshims + a prebuilt.lib/.a); no Rust/cargo build step as part of the module’s own build. - An external package — repo
nova-<package>, native innative/, a git dependency.