Calling C safely#

Nupp turns C declarations into checked LuaJIT FFI calls. Start by importing a header, then add ownership and borrowing contracts where the C signature alone cannot describe lifetime behavior.

There are three ways in, and they suit different sizes of problem:

 Route                    Use it when
 ───────────────────────  ─────────────────────────────────────────────
 cdef declarations        The API is small, or you want exact control
 cheader("mini.h")        You want the header typed with no generated file
 nupp import-c mini.h     You want a committed module you can edit

Import a header#

Given native/mini.h, generate a committed Nupp module:

nupp import-c native/mini.h --lib mini -o src/native/mini.nupp

Write it to a .nupp file rather than a .d.nupp one. A declaration file is excluded from runtime module resolution, so the cdef statements would never execute and no binding would exist. Without -o, the output is the header's basename with a .nupp extension, in the current directory.

The generated file contains cdef struct and cdef function declarations plus a returned module table. It is deliberately hand-editable. Review it, remove declarations your program does not use, and add contracts the header cannot express.

Only the header you name is imported. Whatever arrives through its #includes belongs to those files and is left in them, so the module stays about the API you asked for rather than the closure behind it. Their typedefs are still read, because the header is written in them, but their declarations and their macros are not yours.

That is worth knowing before you point this at a system header, because a system header is usually a facade: macOS declares strlen in _string.h and EPERM in sys/errno.h, so importing string.h or errno.h there is correct and almost empty. Import the file that holds the declarations, or write the few you need by hand.

Use the generated module like any other:

Nupp-written names use camelCase. Imported declarations such as mini_add keep the C library's spelling because those names identify ABI symbols. A camelCase local such as miniApi makes the boundary clear without disguising the foreign symbol.

Hand-write a small binding#

For a tiny API, a direct declaration can be clearer than importing a large header:

Use Nupp's C-compatible types and preserve the library's exact function name. The from clause loads the named native library through LuaJIT FFI; omit it to use the default namespace. Both emit an ffi.cdef and a namespace lookup:

pcall(ffi.cdef, "struct nativePoint { double x; double y; };")
const nativePoint = ffi.typeof("struct nativePoint")
pcall(ffi.cdef, "double point_length(struct nativePoint *);")
const point_length = ffi.load("mini").point_length

cdef bindings are always file-local. Export them by returning them in the module table, which is what import-c generates.

Type mapping#

 Nupp                C
 ──────────────────  ────────────────────────
 number              double
 float               float
 boolean             bool
 integer             int32_t
 int8 … int64        int8_t … int64_t
 uint8 … uint64      uint8_t … uint64_t
 cstring             const char *
 voidptr             void *
 T (a cdef struct)   struct T
 T*                  <C spelling> *
 function(A): R      R (*)(A)

cstring and voidptr are allowed in a cdef struct but rejected in a plain Nupp struct, because a GC-managed struct gives them no anchor.

Coming the other way, every pointer that import-c produces is nullable (T*?), since a C header does not say which pointers may be NULL. Widths come from the building machine, so long and size_t are correct per platform.

import-c handles scalars, named structs by value and by pointer, function pointers in parameter position, C varargs, object-like macros, and enum members. It leaves an -- import-c: skipped comment for what it will not translate: anonymous structs, unions, arrays, widths other than 8/16/32/64, function pointers in return or field position, function-like macros, and names that collide with Lua keywords.

A declaration LuaJIT itself will not parse gets the same comment and does not take the header with it — commonly a struct laid out from a type whose definition belongs to a header this import left alone. Those are counted on stderr as N of M declarations skipped, and the count is the part to read: one of thirty is a corner in the header, and most of thirty means the vocabulary broke upstream and the module is not worth keeping.

An enum's members come across as named int32 constants:

They arrive whether or not the enum itself is named, since typedef enum { ... } Mode; names the type and the members are the point either way. The type is an integer wherever it appears — that is all a C enum ever is, and C checks nothing about which integer — so a member passes to the function it belongs to without a cast, and any other integer passes too. A name declared twice keeps its first meaning.

Type the header in place#

cheader reads a header at compile time and gives you its exports, with no generated file to keep in step:

The path must be a literal. It is searched relative to the file, then as written, then against the project roots. The second argument names a library to load; without it the default namespace is used.

This suits a header that changes often, or one you would rather not vendor a translation of. import-c suits a header you want to prune and annotate, since its output is yours to edit.

Typed FFI operations#

The six FFI operations take a type argument, which is what makes them checked:

ffi.new<T> and ffi.cast<T> return T, ffi.typeof<T> returns ctype<T>, ffi.istype<T> returns boolean, and the last two return integer. Without a type argument the checker still reads a constant string spec through LuaJIT's own parser, so ffi.new("struct Point") types too; a spec built at run time yields cdata.

carray allocates a zero-based C array:

That is carray<T>, distinct from the one-based Lua array {T}.

Read a struct's layout#

layoutof(T) answers how a reified struct sits in memory:

Reifying puts a value where anything that walks a table cannot reach it: string.buffer.encode refuses cdata outright, pairs needs a __pairs, and type answers "cdata". This is what makes it reachable again without the language choosing a serialization format — a codec, a snapshot writer, or a GPU vertex-attribute descriptor is written against the layout, and the format stays yours.

size is a field's own; padding is the alignment gap that follows it. They are separate because they answer different questions — an int8 before a number has size 1 and padding 7, and a writer walking bytes needs both.

Every number is this platform's, asked of the FFI when the layout is first built and cached per ctype afterwards. The fingerprint therefore describes one platform's layout, which is what makes it usable for noticing that saved data no longer matches what is reading it; deciding what to do about a mismatch is the application's.

A fixed C array is a field like any other, laid out inline:

layoutof reports pos as float[3], twelve bytes wide at offset zero — which is a vertex attribute descriptor, and is what makes one derivable rather than hand-maintained. T[?] is not a field: a struct whose size depends on a count nobody wrote has no size, so it stays NUPP2201.

Only a struct has a layout. A record is a table, so layoutof on one is NUPP2402. Nothing is emitted for a struct nothing asks about: the lowering happens at the call site, so a program that never calls layoutof carries none of this.

Describe lifetime behavior#

C types do not reveal who frees a returned pointer. Add that fact explicitly:

@owned(buffer_free) says the caller receives one cleanup obligation. takes says buffer_free consumes it. The lexical owner guarantees cleanup across fallthrough, errors, and structured control flow.

Use borrows when C only observes a resource for the duration of a call, and exclusive when the call needs sole access for its duration. Use retains and releases for pointers C stores beyond a call. Do not use unsafe merely to silence a contract error; use it only where the program must state a fact the checker cannot prove.

Build native dependencies reproducibly#

For C code that ships with the project, declare a kind = "c" dependency in nupp.lua and pin remote sources to an exact revision. Let the build target depend on it so headers, generated bindings, native libraries, and Nupp modules share one cache key and one build graph.

See the build system reference for C and Cargo provider configuration, ownership for the resource workflow, and the ownership reference for output parameters, callbacks, pinning, and raw-pointer escape hatches.