Nova FFI Cookbook
Scope. The mechanics of the
.nv↔ native boundary:extern "C", opaque/typed pointers,CStr, tuple-by-value, C-ABI checking, and how to plug native artifacts into the build ([ffi]). Foundational FFI — Plan 115 D214; the typed-pointer family (*T,*mut T,Option[*T]-NPO) is merged (Plan 118/138.5/174.x; the sections below are no longer “preview”).How to build a MODULE (package layout,
nova.toml, stability, tests) — the general guide authoring-a-module (native-backed — its §7). Module design conventions (effect plumbing, types, errors) — module-conventions. Naming of external packages (nova-<package>) — D78 amendment Plan 195.⚠️ Plan 134 (2026-06-09):
ptrbuilt-in type removed. Use*()(pointer to unit type =void*in C) everywhereptrappeared. Compiler emitsE_TYPE_REMOVED_PTR_USE_UNIT_PTRforptrin type position.
This cookbook shows how to bind Nova code to third-party C libraries — sqlite3, libpng, libcurl — using the foundational FFI primitives introduced in Plan 115.
Quick reference
| Need | Tool | Spec |
|---|---|---|
| Opaque pointer | *() (pointer to unit = void*) | D214 / Plan 134 |
| NULL literal | 0 as *() | D214 amend Plan 134 |
| Typed handle | type X { ro value *() } record | D214 §3 |
| Multi-value return | (T1, T2) tuple-by-value | D214 §2 |
| Extern fn declaration | extern "nova" fn / extern "C" fn name(args) -> ret | D282 |
| Resource cleanup | consume close() method + defer | D90 / D131 |
Pointer modifier rules (FINAL — Plan 138.5)
When writing FFI signatures with pointer/typed wrappers, the pointee
modifier is written postfix, right after * (*mut T / *uninit T;
read-only has no modifier — a bare *T already means read-only, writing it
out as *ro T is redundant and rejected, E_REDUNDANT_POINTER_RO). A
prefix before * is forbidden (mut * T / ro * T /
uninit * T → E_POINTER_PREFIX_MODIFIER). Whether a pointer can be
re-pointed is a property of the binding (ro / mut), not of the
type.
Quick cheat sheet:
*T— pointer to read-only T (default; the only read-only spelling)*mut T— pointer to writable T (the caller may change the pointee)*uninit T— pointer to possibly-uninit T (MaybeUninit analog); the pointer itself is non-nullOption[*T]— a nullable pointer (NPO, 8 bytes); this replaces the oldunsafe * TOption[*uninit T]— FFI nullable-uninit pointer (None = null, Some = non-null ptr to uninit)*mut *Acc— postfix chain (writable-target ptr to read-only-target ptr to Acc)mut p *mut T— mut binding (p is re-pointable) + mut pointee;ro q *T— fixed binding + ro pointee
Full rules (arrow→box model, value-T composition §V3.1/§V3.2) — see docs/guide/typed-pointers.md. Spec — D216 §1 FINAL + Plan 138.5.
Layered FFI pattern
LAYER 1 Nova public API Database.open(path)
↓
LAYER 2 Nova wrapper construct typed handle from raw return
↓
LAYER 3 extern "C" fn declaration typed handle + tuple return
extern "C" fn nova_fn_sqlite3_open(path str) -> (*(), int)
↓
LAYER 4 C shim ~5-10 lines, adapts out-param → struct
_NovaTuple_2_8_nova_ptr_8_nova_int
nova_fn_sqlite3_open(nova_str path) { ... }
↓
LAYER 5 Actual C library sqlite3_open(path, &db_out)
Typed handles (canon 2026-07-09)
Declare every C handle as a C-prefixed newtype IN the extern signatures —
never let a bare int/*() handle flow through Nova code:
type CBrotliHandle(int)
extern "C" fn brotli_dec_new() -> CBrotliHandle
extern "C" fn brotli_dec_feed(h CBrotliHandle, p *u8, len int) -> int
Lowering: newtype-over-int → typedef nova_int Nova_CBrotliHandle; — the C shim
ABI is untouched; the Nova side gets nominal typing for free. Null-check via
(h as int) == 0. Normative rule: module-conventions §4а. Known gap: methods
on a newtype receiver mis-dispatch by name ([M-newtype-receiver-method-dispatch]) —
call the typed externs directly until fixed.
Plan 115 V1 setup
Nova V1 has these foundational pieces (commit <plan-115-merge>):
*()(pointer to unit) emitted asvoid*in C output (Plan 134; previouslyptrwithtypedef void* nova_ptr).- Tuple-by-value returns from external fn — leverages mono’d
_NovaTuple_<arity>_<L_i>_<T_i>...typedefs (Plan 59 mechanism). - D82 amended (Plan 115): user-level
external fnpermitted in any module — no longer restricted tostd.runtime.*.
Shipped since V1 (not “future” — already merged):
- Tuple newtype
type X(*())constructor ✅ ([M-115-newtype-constructor]) — the canonical form; a single-field record is no longer needed. - User-shim build pipeline ✅ — configured not via a CLI flag but
declaratively in
nova.toml:[ffi](ready-made.cshims + systemlibs). When the module isimported, the artifacts are compiled/linked automatically — no need to recompile the Nova compiler. See “Build pipeline” below. - Typed-pointer family ✅ (
*T/*mut T/Option[*T]-NPO/CStr) — Plan 118/138.5; see the sections below.
Still a followup:
- Auto-generated bindings from C headers —
[M-115-bindgen-tool](nova bindgen header.h, a separate tooling plan).
Example 1 — libsqlite3 binding
A complete example covering open, exec, prepare, step, finalize, close.
C shim (compiler-codegen/nova_rt/sqlite3_ffi.h)
/* sqlite3_ffi.h — Nova binding shim for libsqlite3.
*
* Compile + link target: libsqlite3 must be available.
* Plan 115 V1 ships header-only inline wrappers — link with -lsqlite3.
*/
#ifndef NOVA_SQLITE3_FFI_H
#define NOVA_SQLITE3_FFI_H
#include <sqlite3.h>
/* Forward-declare Nova mono'd tuple types matching what Nova codegen emits. */
#ifndef NOVA_TUPLE_TYPEDEF__NovaTuple_2_8_nova_ptr_8_nova_int
#define NOVA_TUPLE_TYPEDEF__NovaTuple_2_8_nova_ptr_8_nova_int
typedef struct _NovaTuple_2_8_nova_ptr_8_nova_int {
nova_ptr f0;
nova_int f1;
} _NovaTuple_2_8_nova_ptr_8_nova_int;
#endif
/* Open a database. Returns (db_handle, return_code).
* rc == 0 (SQLITE_OK) on success. */
static inline _NovaTuple_2_8_nova_ptr_8_nova_int
nova_fn_sqlite3_open(nova_str path) {
_NovaTuple_2_8_nova_ptr_8_nova_int r;
sqlite3* db = NULL;
/* sqlite3_open expects C-string; Nova str.ptr may not be NUL-terminated. */
char path_buf[1024];
if (path.len >= sizeof(path_buf)) { r.f0 = NULL; r.f1 = SQLITE_TOOBIG; return r; }
memcpy(path_buf, path.ptr, path.len);
path_buf[path.len] = '\0';
int rc = sqlite3_open(path_buf, &db);
r.f0 = (nova_ptr)db;
r.f1 = (nova_int)rc;
return r;
}
/* Close a database. Returns sqlite3 rc. */
static inline nova_int nova_fn_sqlite3_close(nova_ptr db) {
return (nova_int)sqlite3_close((sqlite3*)db);
}
/* Execute SQL (no result set). Returns rc. */
static inline nova_int nova_fn_sqlite3_exec(nova_ptr db, nova_str sql) {
char buf[4096];
if (sql.len >= sizeof(buf)) return SQLITE_TOOBIG;
memcpy(buf, sql.ptr, sql.len);
buf[sql.len] = '\0';
char* errmsg = NULL;
int rc = sqlite3_exec((sqlite3*)db, buf, NULL, NULL, &errmsg);
sqlite3_free(errmsg);
return (nova_int)rc;
}
/* Prepare statement. Returns (stmt_handle, rc). */
static inline _NovaTuple_2_8_nova_ptr_8_nova_int
nova_fn_sqlite3_prepare(nova_ptr db, nova_str sql) {
_NovaTuple_2_8_nova_ptr_8_nova_int r;
sqlite3_stmt* stmt = NULL;
int rc = sqlite3_prepare_v2((sqlite3*)db, sql.ptr, (int)sql.len, &stmt, NULL);
r.f0 = (nova_ptr)stmt;
r.f1 = (nova_int)rc;
return r;
}
/* Step. Returns rc (SQLITE_ROW = 100, SQLITE_DONE = 101). */
static inline nova_int nova_fn_sqlite3_step(nova_ptr stmt) {
return (nova_int)sqlite3_step((sqlite3_stmt*)stmt);
}
/* Column int value. */
static inline nova_int nova_fn_sqlite3_column_int(nova_ptr stmt, nova_int col) {
return (nova_int)sqlite3_column_int((sqlite3_stmt*)stmt, (int)col);
}
/* Finalize statement. */
static inline nova_int nova_fn_sqlite3_finalize(nova_ptr stmt) {
return (nova_int)sqlite3_finalize((sqlite3_stmt*)stmt);
}
#endif /* NOVA_SQLITE3_FFI_H */
Nova binding (my_app/sqlite3.nv)
module my_app.sqlite3
// Typed handles — V1 record form (tuple newtype `type X(*())`).
type Db { ro value *() }
type Stmt { ro value *() }
// Extern declarations matching the C shim (literal C symbol names).
extern "C" fn nova_fn_sqlite3_open(path str) -> (*(), int)
extern "C" fn nova_fn_sqlite3_close(db *()) -> int
extern "C" fn nova_fn_sqlite3_exec(db *(), sql str) -> int
extern "C" fn nova_fn_sqlite3_prepare(db *(), sql str) -> (*(), int)
extern "C" fn nova_fn_sqlite3_step(stmt *()) -> int
extern "C" fn nova_fn_sqlite3_column_int(stmt *(), col int) -> int
extern "C" fn nova_fn_sqlite3_finalize(stmt *()) -> int
// SQLite return codes (extract subset).
const SQLITE_OK int = 0
const SQLITE_ROW int = 100
const SQLITE_DONE int = 101
type DbError | OpenFailed(int) | ExecFailed(int) | PrepareFailed(int)
// Open database, wrap raw ptr in a typed Db handle.
fn Db.open(path str) Fail[DbError] -> Db {
ro (raw, rc) = nova_fn_sqlite3_open(path)
if rc != SQLITE_OK { Fail.throw(DbError.OpenFailed(rc)) }
Db { value: raw }
}
// Execute SQL (no result set).
fn Db @exec(sql str) Fail[DbError] -> () {
ro rc = nova_fn_sqlite3_exec(self.value, sql)
if rc != SQLITE_OK { Fail.throw(DbError.ExecFailed(rc)) }
}
// Close. consume — after @close, the handle is invalid (D131).
fn Db consume @close() -> () {
nova_fn_sqlite3_close(self.value)
}
// Example usage:
//
// ro db = Db.open("/tmp/test.db")!!
// db.@exec("CREATE TABLE users (id INT, name TEXT)")!!
// db.@exec("INSERT INTO users VALUES (1, 'Alice')")!!
// defer db.@close()
// ...
Key patterns
- Typed handle.
type Db { ro value *() }makesDbnominally distinct from raw*()— passing wrong handle is compile error. - Tuple return.
nova_fn_sqlite3_openreturns(*(), int). Nova destructures:ro (raw, rc) = nova_fn_sqlite3_open(path). - Cleanup.
fn Db consume @close()— invalidates handle, prevents use-after-close via consume bit (D131). Combine withdefer db.@close()for leak resistance. - Error mapping. Wrap C return codes in a Nova sum type for type-safe error handling.
Example 2 — libpng (read PNG into pixel buffer)
module my_app.png
type PngFile { ro value *() }
type PngInfo { ro value *() }
extern "C" fn nova_fn_png_create_read_struct() -> *()
extern "C" fn nova_fn_png_create_info_struct(png *()) -> *()
extern "C" fn nova_fn_png_init_io(png *(), fp *()) -> int
extern "C" fn nova_fn_png_read_info(png *(), info *()) -> int
extern "C" fn nova_fn_png_get_image_width(png *(), info *()) -> int
extern "C" fn nova_fn_png_get_image_height(png *(), info *()) -> int
extern "C" fn nova_fn_png_destroy_read_struct(png *(), info *()) -> ()
fn PngFile.from_handle(p *()) -> PngFile => PngFile { value: p }
fn read_image_dimensions(file_handle *()) -> (int, int) {
ro png = nova_fn_png_create_read_struct()
ro info = nova_fn_png_create_info_struct(png)
nova_fn_png_init_io(png, file_handle)
nova_fn_png_read_info(png, info)
ro w = nova_fn_png_get_image_width(png, info)
ro h = nova_fn_png_get_image_height(png, info)
nova_fn_png_destroy_read_struct(png, info)
(w, h)
}
(C shim mirrors sqlite3 pattern — see sqlite3_ffi.h.)
Example 3 — libcurl (synchronous HTTP GET)
module my_app.curl
type CurlHandle { ro value *() }
type CurlResult | Success | Failed(int)
extern "C" fn nova_fn_curl_easy_init() -> *()
extern "C" fn nova_fn_curl_easy_setopt_url(h *(), url str) -> int
extern "C" fn nova_fn_curl_easy_setopt_write_to_buffer(h *()) -> int
extern "C" fn nova_fn_curl_easy_perform(h *()) -> int
extern "C" fn nova_fn_curl_easy_cleanup(h *()) -> ()
extern "C" fn nova_fn_curl_get_response_body() -> str
fn CurlHandle.new() -> CurlHandle {
ro raw = nova_fn_curl_easy_init()
CurlHandle { value: raw }
}
fn CurlHandle @get(url str) -> (CurlResult, str) {
nova_fn_curl_easy_setopt_url(self.value, url)
nova_fn_curl_easy_setopt_write_to_buffer(self.value)
ro rc = nova_fn_curl_easy_perform(self.value)
ro body = nova_fn_curl_get_response_body()
if rc == 0 { (CurlResult.Success, body) }
else { (CurlResult.Failed(rc), body) }
}
fn CurlHandle consume @close() -> () {
nova_fn_curl_easy_cleanup(self.value)
}
ABI cheat sheet
For extern "C" fn tuple returns, the C ABI is determined by element layout:
| Tuple | Sys V AMD64 | Windows x64 MSVC | macOS ARM64 |
|---|---|---|---|
(*(), i32) (12 bytes) | registers (rax:rdx) | hidden-out-ptr (rcx) | X0:X1 |
(*(), int) (16 bytes) | registers (rax:rdx) | hidden-out-ptr | X0:X1 |
(*(), *()) (16 bytes) | registers | hidden-out-ptr | X0:X1 |
(*(), int, int) (24 bytes) | hidden-out-ptr | hidden-out-ptr | hidden-out-ptr |
| Larger | hidden-out-ptr | hidden-out-ptr | hidden-out-ptr |
Nova does not override calling convention — the C compiler chooses based
on platform ABI. C-side shim and Nova-side declaration must produce
matching struct layout (Plan 115 D214 #ifndef NOVA_TUPLE_TYPEDEF_<m>
guard ensures single definition).
Safety considerations
- Ownership. Nova GC does not track
*()values — these are FFI domain. Match every_open()/_init()/_alloc()with a_close()/_destroy()/_free(). Useconsumemethods anddeferfor leak resistance. - Lifetime. A
*()from a C library is valid only until the matching cleanup call. Nova compile-time cannot enforce this; rely on pattern (consume + defer). - Null check. Always check return values for null (
0 as *()) before using. Many C libraries return NULL on allocation failure. - Thread-safety. Most C libraries have thread-safety contracts. If Nova spawns fibers that touch the handle, ensure handle is either thread-safe or pinned to one fiber.
Typed pointers + unsafe model (Plan 118 — merged)
Status: merged (Plan 118 → 138.5 FINAL D216; unsafe fn keyword — 118.1.7; C-ABI checker — 174.6). Reference doc:
docs/guide/typed-pointers.md. Plan:docs/plans/118-typed-pointers-and-unsafe.md. Below — the evolution of FFI patterns from opaque*()to typed*T; both variants compile today (opaque — legacy-compatible, typed — preferred).
FFI patterns have moved from opaque *() to the typed pointer family *T
for type-safe FFI with buffers / structs / nullable returns:
// Plan 115 V1 / Plan 134 (current — works today):
extern "C" fn nova_sqlite3_open(path str) -> (*(), int)
ro (h, rc) = nova_sqlite3_open(path)
if rc != 0 { Fail.throw(DbError.OpenFailed(rc)) }
// Plan 118 V2 (typed + nullable + NPO):
extern "C" fn sqlite3_open(path str) -> (Option[Sqlite3Handle], i64)
type Sqlite3Handle(*sqlite3) // tuple newtype, zero-overhead
unsafe {
match sqlite3_open(path) {
(Some(h), 0) => use_handle(h)
(None, rc) => Fail.throw(DbError.OpenFailed(rc))
(Some(_), rc) => Fail.throw(DbError.OpenFailed(rc)) // C bug
}
}
Key improvements:
Plan 115 V1 / Plan 134 (*()) | Plan 118 V2 (*T family) | |
|---|---|---|
| Type safety | ❌ opaque *() cast by hand | ✓ compile-time pointee check |
| Mutability | ❌ no distinction | ✓ *T / *mut T |
| Null safety | ❌ 0 as *() runtime check | ✓ Option[*T] + NPO zero-cost |
| FFI buffer | ❌ untyped *() + manual offset | ✓ *u8 / *mut u8 typed |
| Callback registration | ❌ N/A | ✓ *fn(Args) -> Ret |
Migration path:
ptr→*()(Plan 134 — compiler error on bareptrin type position)0 as ptr→0 as *()null ptrliterals →0 as *()- Record handle wrappers
type X { ro value *() }→ tuple newtypetype X(*)()ortype X(*T)for a zero-overhead ABI
See docs/guide/typed-pointers.md for the full
reference documentation and examples/typed_pointers/
for minimal working samples.
Plan 118.1 — FFI intrinsics (foundation)
CStr handle (type-safe const char*)
import std.ffi.cstr.{CStr}
// Extern fn principal pattern — a typed handle instead of a bare *u8
extern "C" fn c_strlen(s CStr) -> i64
extern "C" fn c_printf(fmt CStr) -> i32
CStr backing type: *u8 (Plan 118 typed pointer). The ABI marshals to
const char* / uint8_t*.
Conversion methods (@to_cstr, copy-based, Plan 199 / D418, 2026-07-11):
ro s = "hello"
ro c = s.to_cstr() // GC-allocs a fresh byte_len()+1 NUL-terminated copy (panics on embedded NUL)
// Zero-alloc overload — copy into a caller-provided buffer (hot FFI paths):
ro buf = unsafe { RawMem.alloc(64) }
ro c2 = s.to_cstr(buf, 64) // copies ≤63 bytes + '\0'; TRUNCATES if longer, no scan
// Direct usage in an FFI call:
ro n = c_strlen(s.to_cstr())
Pure-Nova implementation in std/src/ffi/cstr.nv: str carries no trailing-NUL
guarantee (D418 retracts D26 §Nul-termination), so BOTH to_cstr overloads
COPY — there is no zero-copy path. to_cstr() allocates a fresh GC-managed
byte_len()+1-byte buffer, copies the bytes, and appends \0 — after an O(n)
embedded-NUL scan ([M-118.1-cstr-nul-check]). to_cstr(buf, buf_size) copies
into the caller’s buffer, clamping to buf_size - 1 + terminator (truncating,
no scan — the explicit “I own the buffer” hot path; as_cstr/as_cstr_unchecked
are RETIRED, to_ names the copy correctly).
&x — address-of (pointer creation)
unsafe {
ro x = 42
ro p = &x // *T pointer to a local
assert(p.read() == 42)
}
unsafe {
mut buf = 0
ro p = &buf // *mut T (mut binding auto-infers *mut)
p.write(100) // codegen TBD
}
&x (UnOp::AddrOf) is safe for all types since Plan 118.6 — no unsafe {}
is required for the promote path itself (kept here for grouping with the
FFI/pointer-op examples). The former addr_of(x) / addr_of_mut(x)
builtin-fn aliases are retired (E_ADDR_OF_REMOVED, Plan 118.6, D216 §4
amend) — use &x for both: a mut binding auto-infers *mut T, a ro
binding gives *T. Enforcement: #realtime ban, lvalue validation
(E_AMP_LITERAL / E_AMP_RECORD_LITERAL / E_ARRAY_INDEX_PTR_BANNED).
RawMem intrinsics (bulk memory ops)
import std.runtime.raw_mem.{RawMem}
unsafe {
RawMem.copy(src, dst, n_bytes) // memmove-safe
RawMem.copy_nonoverlapping(src, dst, n) // memcpy fast-path
RawMem.fill(dst, byte_value, n) // memset
ro cmp = RawMem.compare(a, b, n) // memcmp
}
Typed read/write on a primitive *T
unsafe {
ro p = &some_int
ro v = p.read() // typed primitive read
p.write(100) // typed write (on *mut T)
ro v_vol = p.read_volatile() // MMIO read
p.write_volatile(0xDEAD) // MMIO write
}
Cross-refs
- Spec D216 (typed pointers) + §22 (CStr type) —
spec/decisions/02-types.md - Spec D418 §
strwithout a NUL terminator; copy-basedCStr/to_cstr(retracts D26 §«Nul-termination») —spec/decisions/08-runtime.md - Plan-doc —
docs/plans/118.1-ffi-intrinsics-and-cstring.md,docs/plans/199-str-drop-nul-termination.md
unsafe fn — declaring and calling unsafe functions (Plan 118.1.7)
Plan 118.1.7 migrates from
#unsafe fnattribute tounsafe fnkeyword (type-consistent with TypeRef::Unsafe from Plan 118.5 and*unsafe fn(...)fn-ptr type from Plan 118.1.6).#unsafe fnis now a hard error (E_UNSAFE_ATTR_DEPRECATED).
Declaring an unsafe Nova function
// unsafe fn — body has implicit unsafe context (pointer ops allowed without unsafe {})
export unsafe fn read_first_byte(p *u8) -> u8 {
// No `unsafe { }` needed here — the body of an unsafe fn is implicitly
// unsafe, so the raw-pointer read below is permitted directly.
p.read()
}
Declaring an unsafe extern (runtime-backed) function
// extern "nova" unsafe fn — requires unsafe {} at call site
// pointee-mut written postfix: `*mut u8` = writable target (FINAL, Plan 138.5)
extern "nova" unsafe fn RawMem.copy(src *u8, dst *mut u8, n int) -> ()
extern "nova" unsafe fn RawMem.fill(dst *mut u8, byte_value u8, n int) -> ()
Calling an unsafe function
// Caller MUST wrap in unsafe {} — E_UNSAFE_CALL_REQUIRES_WRAP otherwise
unsafe {
RawMem.copy(src_ptr, dst_ptr, n)
ro b = read_first_byte(src_ptr)
}
unsafe fn as function pointer type
// &(unsafe fn) propagates unsafe to fn-ptr type: *unsafe fn(...)
unsafe fn risky(p *u8) -> () { /* ... */ }
ro fn_ptr = &risky // type: *unsafe fn(p *u8) -> ()
// Calling via unsafe fn pointer also requires unsafe {}
unsafe { fn_ptr(some_ptr) }
Cross-refs
- D216 §9 (unsafe fn keyword syntax) —
spec/decisions/02-types.md - D2 (unsafe effect model, Plan 118.1.7 amend) —
spec/decisions/04-effects.md - Plan-doc —
docs/plans/118.1.7-unsafe-fn-keyword-syntax.md
C-ABI types for extern "C" fn (Plan 174.6 / D282 rule 2 + D353)
Status: Plan 174.6 M1/M2 (2026-07-04). The checker validates every
extern "C" fnsignature (params and return) against a recursive C-ABI type-list; non-C-ABI types →E_FFI_NON_C_ABI_TYPE. Spec: D282 rule 2 + D353.
What may cross an extern "C" fn boundary
The set is defined recursively:
C_ABI ::= Scalar | RawPtr | FnPtr | Option[RawPtr] | Tuple[C_ABI…] | ValueRecord{ C_ABI… }
Scalar ::= int | uint | i8..i64 | u8..u64 | f32 | f64 | bool | char
RawPtr ::= *T | *() | CStr
FnPtr ::= *extern "C" fn(C_ABI…) -> C_ABI
| Category | C-ABI? | Notes |
|---|---|---|
int / uint | ✅ | address-sized (nova_int = intptr_t, nova_uint = uintptr_t); ≠ i64/u64 |
i8..i64, u8..u64, f32, f64, bool | ✅ | fixed-width scalars |
char | ✅ | uint32_t codepoint (validity is a Nova invariant; Rust improper_ctypes flags the analogue) |
*T, *(), CStr | ✅ | any pointee; recursion stops at the address. *() = void*, CStr = const char*/*u8 |
str | ✅ | value-record {ptr,len} (D139) — POD struct. NOT NUL-terminated; use s.as_ptr()/s.byte_len() or s.to_cstr() for C strings |
value-record type X value {…} | ✅ iff all fields C-ABI | by-value C struct; the value keyword is mandatory (without it → heap GC-record, by-reference, not C-ABI) |
named-tuple type X(a T, b U) / anon tuple (T, U) | ✅ iff all elements C-ABI | by-value; multi-value returns |
cyclic value-record type Node value {val int, next *Node} | ✅ | *Node is a raw pointer → recursion terminates |
Option[*T] (any pointer) | ✅ | NPO: None = 0, Some(p) = p (zero-cost nullable pointer) |
*extern "C" fn(…) -> … | ✅ | C callback (see below); signature types themselves C-ABI |
-> () (top-level return) | ✅ | lowers to C void |
() as a param/element | ❌ | C has no unit type |
Vec[T], heap-record refs | ❌ | GC-managed, not POD |
Result[T,E], Option[non-ptr], other sums | ❌ | tag+payload layout is not C-ABI |
bare fn(…) closure, Nova-ABI *fn(…) | ❌ | fat/handler-carrying; a real C callback must be tagged *extern "C" fn |
Layout assumption (S8). A value-record/tuple passed by-value assumes
Nova-layout == C-layout (field order + padding). Nova emits the struct in
declaration order; matching an external C library’s struct is the author’s
contract (a mismatch is not yet compiler-caught — follow-up
[M-174.6-ffi-struct-layout]).
Callbacks — *extern "C" fn (qsort comparator, libuv handler)
A Nova function passed to C as a callback must be tagged *extern "C" fn(...).
The coercion fn → *extern "C" fn is accepted iff the fn is (1) C-ABI in
every arg/return, (2) captureless (no env), and (3) effect-free (declares no
effect at all). Reason for (3): C invokes the callback with no Nova
handler-frame on the stack, so any effect-operation (Fail, IO, a custom
algebraic effect) has nowhere to resolve → unsound. Violations →
E_FFI_NON_C_ABI_TYPE / E_CLOSURE_HAS_ENV / E_CALLBACK_THROWS_OVER_C_ABI.
module my_app.sortdemo
// C: void qsort(void* base, size_t n, size_t sz,
// int (*cmp)(const void*, const void*));
extern "C" fn qsort(base *(), n uint, size uint,
cmp *extern "C" fn(*(), *()) -> i32) -> ()
// A captureless, effect-free free fn — coerces to the C callback type.
fn cmp_i32(a *(), b *()) -> i32 {
// read both operands via typed pointers (unsafe), compare … (elided)
0
}
fn sort_it(buf *(), n uint) {
// `cmp_i32 as *extern "C" fn(...)` — accepted (captureless + effect-free + C-ABI)
qsort(buf, n, 4 as uint, cmp_i32 as *extern "C" fn(*(), *()) -> i32)
}
libuv-style handler stored in a handle record (D353/M2 — the tag is legal as a value-record field, and its signature is still validated as C-ABI):
module my_app.uvdemo
// A handle that carries a C-ABI callback pointer (validated by the checker).
type UvTimer value {
handle *()
on_tick *extern "C" fn(*()) -> ()
}
extern "C" fn uv_timer_start(t *(), cb *extern "C" fn(*()) -> (),
timeout u64, repeat u64) -> i32
fn on_tick(h *()) -> () { /* … effect-free … */ }
What is rejected (each a distinct extern "C" fn neg case):
extern "C" fn bad1(v Vec[int]) -> int // E_FFI_NON_C_ABI_TYPE (GC)
extern "C" fn bad2(r Result[int, str]) -> int // E_FFI_NON_C_ABI_TYPE (tagged union)
extern "C" fn bad3(x Option[int]) -> int // E_FFI_NON_C_ABI_TYPE (no NPO)
extern "C" fn bad4(x ()) -> int // E_FFI_NON_C_ABI_TYPE (unit param)
extern "C" fn bad5(cb *fn(i64) -> i64) -> int // E_FFI_NON_C_ABI_TYPE (Nova-ABI *fn)
// coercions:
// (fn(x i64) => x*2) as *extern "C" fn(i64)->i64 → E_CLOSURE_HAS_ENV (env)
// throwing_fn as *extern "C" fn(...) → E_CALLBACK_THROWS_OVER_C_ABI (Fail)
// effectful_fn as *extern "C" fn(...) → E_CALLBACK_THROWS_OVER_C_ABI (any effect)
Ownership / pinning across the boundary
Nova pointers handed to C are borrowed for the duration of the call only.
The Boehm GC does not scan C-malloc’d memory, so a Nova pointer retained
by C past the call (stored in a C struct, captured by a callback registration)
can be collected → use-after-free. To keep a Nova object alive across calls,
pin it (keep a live Nova reference for the object’s C-visible lifetime; a
dedicated pinning API is a follow-up). This mirrors the str.as_ptr() lifetime
rule (D294): the pointer is valid only while the str is live.
Build pipeline — [ffi] manifest
Everything above is about writing the .nv ↔ native boundary. This
section is about plugging native artifacts into the build so that
importing the module pulls them in automatically, with no compiler
changes. The declaration lives in the package’s nova.toml. Full “how to
build a module” — authoring-a-module §7.
[ffi] — ready-made .c shims and system .libs (Plan 115 D214)
For a thin C shim and linking an already-built system library:
[ffi]
c_shims = ["native/sqlite3_shim.c"] # compiled and linked
include_dirs = ["native/", "third_party/sqlite3/"] # → clang -I
libs = ["sqlite3"] # → clang -lsqlite3 / sqlite3.lib
Paths are relative to nova.toml; they’re resolved to absolute paths
before invoking clang. .h-only inline shims are pulled in via
force-include (-include), .c files as a compilation unit. The [ffi]
section may be empty (an FFI-aware marker).
The native-module canon is [ffi] above, and only that: a .c shim
(compiled by clang, which is in the toolchain) + optionally a ready-made
.lib/.a (linked, not built — vcpkg/system package/vendored copy; see
detect_brotli/detect_boehm/detect_mbedtls in test_runner.rs for the
pattern of conditionally linking a library that is ALREADY built, rather
than one built by a build script). A native module never builds via
Rust/cargo as part of its own build — only .nv + optionally .c
(compiled by clang) + optionally a prebuilt .lib/.a.
Reference example. std/tls (nova_rt/tls_c_shim.c + vcpkg mbedTLS)
— a real example: mbedTLS is installed via vcpkg install (see
compiler-codegen/vcpkg.json, gitignored per-checkout), tls_c_shim.c
is compiled/linked CONDITIONALLY based on whether tls_* symbols are
used (the same D337 mechanism as brotli), with no manifest declaration
in std/nova.toml at all — the linking lives entirely in
test_runner.rs::build_command (like net.c/brotli_shim.c).
Followups
| Marker | What | Status |
|---|---|---|
[M-115-newtype-constructor] | tuple newtype type X(ptr) constructor + .0 access | ✅ CLOSED 2026-06-01 (canonical syntax shipped) |
[M-115-ffi-build-pipeline] | user-shim build/link pipeline | ✅ CLOSED — implemented declaratively via nova.toml [ffi] (ready-made shims/libs, Plan 115); a native module builds WITHOUT Rust/cargo. See “Build pipeline” |
[M-115-bindgen-tool] | nova bindgen header.h auto-generated bindings | 🟡 deferred (major tooling, separate plan) |
[M-115-d126-deprecation] | external type X D126 migration audit | ✅ CLOSED: Plan 91.12 V2 hard retract — external type X is now a hard error E_EXTERNAL_TYPE_RETRACTED (sequence: newtype-constructor ✓ → Plan 91.12 Pattern B → D126 retract done) |
[M-115-tuple-gc-types] | tuple elements GC-tracked types in external fn returns | 🟢 CLOSED as by-design (extern “C” boundary correctly excludes Nova-typed containers) |
[M-115-external-fn-method] | receiver-method external fn | 🟢 CLOSED as not needed (free fn + Nova-side wrapper sufficient) |
[M-115-examples-ffi-real-build] | real libsqlite3 link via vcpkg | 🟡 deferred (V1 ships embedded mini-sqlite-equivalent in nova_rt/sqlite_mini_ffi.h — proves end-to-end FFI mechanism with no external dependency; real link → CI step) |
[M-115-null-ptr-to-option-after-npo] | hard-retract null ptr after Plan 118 Option[*T] NPO | ✅ CLOSED Plan 134 (2026-06-09) — ptr removed; use *() and 0 as *() |