17. Calling C
Base is the layer C bindings are written in. There is no marshalling: a Base pointer is a C pointer, a Base struct is a C struct, c.str is char*. What full Luce needs three layers for (a foreign declaration, an audited raw module, and a safe wrapper) is one layer in Base, and a safe wrapper for full Luce, when one is wanted, is ordinary Base code behind a .lucn module (§18.6).
17.1 Declarations#
extern type Window # opaque handle; pointer-shaped
extern func SDL_CreateWindow(title: c.str, x: i32, y: i32, w: i32, h: i32, flags: u32) -> Window?
extern func SDL_GetWindowSize(window: Window, out w: i32, out h: i32) -> bool
extern blocking func SDL_Delay(ms: u32)
extern var SDL_version_number: i32
extern func sdl_init as "SDL_Init"(flags: u32) -> i32
extern struct Rect:
x: i32
y: i32
w: i32
h: i32
extern union Event:
kind: u32
key: KeyEvent
padding: u8[56]extern funcbinds a C function.outparameters become extra results, received as a tuple after the declared return.blockingmarks a call that may park the thread; in a Base artifact it is informational, and inside a full Luce program it is the contract full Luce's workers rely on.as "name"binds a C symbol under a Base-style name.extern typedeclares an opaque pointer-shaped handle; bare it is never null,?is the null niche.extern type Handle = u32declares an integer-shaped one.handle Name:withdestroy functionbeneath declares an opaque pointer-shaped type the same way and names thepubfunction of the module, taking the handle and returning nothing without failing, that releases it (§17.7).extern structandextern uniondeclare C layout and may carrypackedoralign(N). Fields may be any C-representable type (§17.6):name: c.char[32],next: Node*?,callback: func(void*) -> unit. Anextern structmay be passed and returned by value, because the backend performs the target's aggregate classification.extern varbinds a C global of scalar or pointer type; reads and writes are direct.- Pointers and function pointers in signatures are written as Base pointers and
functypes.stris not admitted in anexternsignature; C text isc.str, andstr(value)validates it.
The one boundary check. A bare pointer, function, or handle slot in an extern signature, or in an exported function's signature, is a contract the C side may violate. At every such crossing the compiler inserts one comparison: a zero arriving in a bare slot, in either direction, traps null_foreign. Declare the slot ? when null is legitimate, and no check is emitted. This comparison is the whole of what Base does at the boundary.
Why one check and not none. Without it, extern func malloc(size: usize) -> void* declared without ? would give the program a null pointer with a non-null type, and the never-null rule would not hold at the place where C code enters. One comparison per crossing is the cost of making the rule hold.
17.2 Variadic calls#
extern func printf(format: c.str, ...) -> i32 declares a variadic function. The argument types in a variadic position:
| Argument | Passed as |
|---|---|
| an untyped integer literal | i32; an error if it does not fit |
| an untyped float literal | f64 |
bool, char, i8, u8, i16, u16, c.char | promoted to i32 |
f32 | promoted to f64 |
| an integer-backed enum | its representation, promoted as above |
| a string literal | c.str |
| any other integer, float, or pointer | as itself |
str, a span, a struct, a union, an optional | rejected |
The linter asks for an explicit c.long or c.char when a value of one of the distinct c types is intended and a Base-width integer was written. Base functions cannot be declared variadic in this revision.
Why the literal rule. printf("%d", 5) must pass an int. In a variadic position there is no parameter type for a literal to adapt to, so the default would have been i64, which happens to work on the 64-bit targets Luce has because every variadic slot is eight bytes, and breaks on every ILP32 target. Naming C's own default type for the literal is the only rule under which printf("%d", 5) is correct on every target.
17.3 Using the results#
let window = SDL_CreateWindow("Luce", 100, 100, 800, 600, 0) else error(no_window, "no window")
let (ok, width, height) = SDL_GetWindowSize(window)Nothing is decoded. A handle is a pointer; a nullable result is unwrapped with the ordinary optional forms; an out parameter is a tuple component.
17.4 C sources and libraries#
def native "inputs" {
str[] sources = ["vendor/stb_image.c", "shims.c"]
str[] libraries = ["sqlite3", "m"]
str[] link_search = ["/opt/homebrew/lib", "$VULKAN_SDK/lib"]
str[] pkg_config = ["sdl2"]
}
def native "macos" {
str[] frameworks = ["Metal"]
}A def native element of package.prisma applies by its name: inputs (or all) to every target, an operating system (macos, linux, windows) to each of its architectures, an exact target (x86_64-windows) to that one; the elements that apply add up. A search directory that begins with $NAME takes the environment variable's value there, and is left out where it is not set. sources are compiled with the host C compiler the build already uses for assembly and linking, and linked into the artifact. libraries, link_search, frameworks, and pkg_config are passed to the linker. There is no inline C inside a .lucb file: the formatter, the language server, and the test runner would each need a C parser, and a sidecar .c file gives the same power with the tooling intact.
17.5 luce bind#
luce-base bind header.h [-I DIR] [-D NAME[=VALUE]] [--recipe FILE] -o module.lucb [--shims shims.c] writes a Base module from a C header and reports, one line each with its place, what it could not map. The header is read as the host C compiler reads it: the compiler's include directories and predefined macros are asked of it once, and the preprocessing and the declaration parsing are the tool's own; Clang is not a dependency. The module holds the declarations of the header's own directory and everything beneath it, so SDL3/SDL.h gives all of SDL3, and the types of the system headers it includes come through as Base's own. The recipe is a file beside the header that states what the header does not, one line each: nonnull FUNCTION.PARAM, nonnull FUNCTION.return, or nonnull TYPE.FIELD for a pointer that is never null; skip NAME for a declaration or macro to leave out; macro PROTOTYPE;, a C prototype for a function-like macro, which is then reached through a shim.
- Functions, structs, unions, enums, typedefs, pointers, arrays, function pointers,
const,_Noreturn,_Nonnull,__asm__symbol names,externvariables,static constscalars, constant#defines, andstatic inlinefunctions map directly. The last, and the recipe's macros, are called throughluce_NAMEfunctions the tool writes intoshims.c, which the manifest names as a source (§17.4). - Types map to §5.2:
intisi32,longisc.long,charisc.char,size_tisusize, the<stdint.h>names are the Base widths,va_listisc.va_list. A pointer is nullable,T*?, unless the header says_Nonnullor the recipe saysnonnull;const char*isc.str?andchar*isc.char*?; a pointer to a struct that another library's header defines isvoid*?. A struct the header only names is anextern type, a handle. A function pointer isfunc(...) -> R, nullable as(func(...) -> R)?; a typedef of one is an alias,Name?where it is used. - A C enum becomes an integer-backed enum,
as u32when every value fits, elseas i32,u64, ori64; a case whose value repeats an earlier one is a constant of the enum type; an enum without a name is constants. - A typedef that names a struct or enum without a tag, or repeats its tag, is that type's name; any other typedef is an alias,
pub type. - An array parameter
int a[4]becomesa: i32*?, because C adjusts it to a pointer. - A flexible array member
T data[];is omitted from the struct, and a functionStruct_data(record: Struct*) -> T*is generated as the address one record pastrecord,record + 1, where the fixed part ends. A struct declared in Base with a trailing payload allocates it withmemory.allocateand a computed size and uses the same accessor pattern. - An anonymous struct or union member becomes a type of its own, named after the enclosing type and the field,
Outer_field_union, and a member without a name ismemberN, so that the layout stays C's. A struct with bit-fields is refused and left as anextern type; its fields are reached through shims the recipe names. - A macro that expands to a number, a character, or a string becomes a
pub letof the type C gives it, casts and suffixes included; one that expands to a name is reported, since binding that name is the answer. - A name Base reserves (
type,str,out, ...) gets an underscore after it,type_, and a function so renamed keeps its symbol throughas. - Function-like macros without a recipe line,
_Generic,long double,_Complex,__int128,_BitInt,typeof, bit-fields, and Objective-C declarations are reported and left out; an Objective-C@interfaceis skipped whole. errno,stdin,stdout, andstderrare macros over accessors and are not bound;c.errno()andc.stdout()stand for them.
Generated files are ordinary source, checked in and reviewed; the gate writes them again from the installed headers before building the programs on them, so the checked-in copy is what this host last saw. luce-base bind HEADER --preprocess prints the preprocessed tokens and --declarations the declarations as C spells them, for looking at what the tool read.
Why not parse headers in the compiler. Zig moved @cImport out of the language into the build step and rewrote its translator off libclang; Odin and Kotlin/Native use generators. Every one of them fails on function-like macros, so luce bind requires a recipe for them rather than guessing.
17.6 Export#
export func blend(left: Pixel, right: Pixel) -> Pixel: ...
struct Cursor:
pub var offset: usize
export static func advance(count: usize) -> !: ...Export is opt-in. export func name(...) gives a function C linkage under name, or under the manifest's symbol_prefix followed by name. export on a method exports it as Type_method with self first. A pub function that is not exported has hidden visibility and a module-qualified symbol and cannot collide with C. An export is a compile error when the signature is not C-representable or when two exports share a symbol.
C-representable means: scalars, bool, usize/isize, c.* types, pointers, c.str, spans in parameter position (a pointer and a usize length, in that order, name and name_len; the wrapper reads a null pointer with any length as the empty span), structs and unions of C-representable fields, arrays as fields, integer-backed enums, and function pointers with C-representable signatures. Not representable: T[N] in a signature (C has no by-value array parameters), spans in result, field, or function-pointer positions, str, tagged optionals, interface views, fmt, and every generic. A fallible function exports in a status form: the C function returns an int status, 0 or the error's code, and writes the value through a final out-pointer named out; a fallible function without a value has no out-pointer. A function whose C face differs from its own signature, a fallible one or one with a span parameter, is reached through a wrapper the compiler writes; its own symbol stays internal.
The generated header spells:
- a struct as its C definition, with
__attribute__((packed))or__attribute__((aligned(N)))where declared, and a field'salign(N)as that attribute on the field; boolas C'sbool,usizeassize_t,isizeasptrdiff_t;T*?as the same pointer type with a comment that it may be null;- a function type as
typedef R (*luce_fn_N)(A, B);, numbered in order of first use, before what uses it; - an integer-backed enum as
typedef uint32_t Flags;withenum { Flags_empty = 0, ... };for C17, plusenum Flags : uint32_tunder C23, because a C17 enum's compatible type is implementation-defined; - every
puberror code of the package as#define name ((int)CODEu), the value a status carries, with the package's identity in the high half (§11.3).
luce build --lib produces a static or shared library plus the header for the package's exported surface.
Why opt-in. Making every pub function a global C symbol would make two modules' pub func init a link error and a pub func read an interposition of libc for the whole process. Export is a linkage decision, and it is written explicitly.
17.7 Handles#
pub handle File:
destroy close
pub func open(path: str) -> File!: ...
pub func close(file: File): ...A handle is a pointer-shaped opaque type, as extern type declares one, that names the function releasing it. Inside Base it is an extern type: never null bare, ? for the niche, passed and returned by value. The declaration exists for what imports the module: a full Luce program sees a handle as a class whose close() calls destroy once, and calls it at the last reference itself (its specification, §16.4). destroy is a pub function of the same module taking exactly the handle and returning nothing, without failing, since the caller may be a destructor.
luce-base describe module.lucb describes checked public declarations without requiring consumers to parse Base. Its single current format starts with description 9 and reports fields with their mutability, actual constructors, instance/static methods, interfaces and conformance (with the methods implementing a public interface that are not pub, as witnesses), functions, constants, enums, handles and aliases. Private fields remain private, and a representation record states whether the public fields cover the complete native storage. See the package description protocol for records, type identity and the current consumer limits. Both compilers and their fixtures are updated together when the format changes.
luce-base dependencies module.lucb checks a module and reports the source files resolved for its complete nonstandard import closure. The entry module and embedded standard modules are excluded from source records. Output begins with luce-base-dependencies-v4 and a NUL byte, then tagged triples describing sources, public aliases, original package owners and resolved native inputs. Every field is NUL-terminated, so paths retain whitespace. A parse, resolution or semantic failure returns status 1 without dependency records. See public package imports for the manifest and resolver contracts, standard module descriptions and relocation.