Luce Base
LuceEngineeringLuciaOS

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, cstr is char*. What full Luce needs three layers for (a foreign declaration, an audited raw module, and a safe wrapper) collapses in Base to one, 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: cstr, w: c.int, h: c.int, flags: u32) -> Window?
extern func SDL_GetWindowSize(window: Window, out w: c.int, out h: c.int) -> bool
extern blocking func SDL_Delay(ms: u32)
extern var SDL_version_number: c.int
extern func sdl_init as "SDL_Init"(flags: u32) -> c.int

extern struct Rect:
    x: c.int
    y: c.int
    w: c.int
    h: c.int

extern union Event:
    kind: u32
    key: KeyEvent
    padding: u8[56]
  • extern func binds a C function. out parameters become extra results, received as a tuple after the declared return. blocking marks a call that may park the thread. as "name" binds a C symbol under a Base-style name.
  • extern type declares an opaque pointer-shaped handle; bare it is never null, ? is the null niche. extern type Handle = u32 declares an integer-shaped one.
  • extern struct and extern union declare C layout. Fields may be any C-representable type (§17.6): name: c.char[32], next: Node*?, callback: func(void*) -> unit. An extern struct may be passed and returned by value, because the backend performs the target's aggregate classification.
  • extern var binds 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 func types. str is not admitted in an extern signature; C text is cstr, and to_str() 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: c.size) -> void* declared without ? would hand the program a null wearing a non-null type, and the never-null rule would be a fiction at exactly the place C code enters. One compare per crossing is what the rule costs to be true.

17.2 Variadic calls#

extern func printf(format: cstr, ...) -> c.int declares a variadic function. In a variadic position: an untyped integer literal is c.int and an error if it does not fit; an untyped float literal is c.double; bool, char, i8, u8, i16, u16, c.char, and c.short promote to c.int; f32 and c.float promote to c.double; an integer-backed enum passes as its promoted representation; a string literal passes as cstr; every other integer, float, and pointer passes as itself; str, spans, structs, unions, and optionals are rejected. A non-literal integer of a Luce width passes as that width, and the linter asks for an explicit c. type. 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 64-bit targets because every variadic slot is eight bytes and breaks on wasm32 and every ILP32 target. Naming C's own default type for the literal is the only rule under which the obvious call is correct everywhere.

17.3 Using the results#

let window = SDL_CreateWindow("Luce", 800, 600, 0) else error(gfx.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#

[native]
sources = ["vendor/stb_image.c", "shims.c"]
libraries = ["sqlite3", "m"]
link_search = ["/opt/homebrew/lib"]
frameworks = ["Metal"]
pkg_config = ["sdl2"]

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: two grammars in one file defeat the formatter, the language server, and the tests, and a sidecar .c file gives the same power with the tooling intact.

17.5 luce bind#

luce bind header.h generates a .lucb declaration module from a C header and reports what it could not map. Its parser is a Luce-owned C declaration parser; Clang may validate its output but is not a dependency of the compiler.

  • Functions, structs, unions, enums, typedefs, pointers, arrays, function pointers, constant #defines, const, _Noreturn, and static inline functions (compiled into shims.c) map directly.
  • A C enum becomes an integer-backed enum over the compiler's compatible type.
  • An array parameter int a[4] becomes a: c.int*, because C adjusts it to a pointer.
  • A flexible array member T data[]; is omitted from the struct, and a method data(self) -> T* is generated from sizeof of the fixed part; a recipe may name the count field to produce a span instead.
  • errno becomes c.errno() and c.set_errno(v); stdin, stdout, stderr become c.stdin() and kin, because all are macros over thread-local or platform accessors.
  • Function-like macros, _Generic, va_list-taking definitions, long double, _Complex, attributes beyond noreturn, _BitInt, typeof, #embed, and compiler extensions are mapped only through a recipe that names them with signatures, for which the tool emits static inline wrappers into shims.c.

Generated files are ordinary source, checked in and reviewed.

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, which is why the recipe and shim door exists rather than a guess.

17.6 Export#

export func blend(left: Pixel, right: Pixel) -> Pixel: ...
export mutating func Cursor.advance(self, count: usize) -> unit!: ...

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, cstr, spans in parameter position (a pointer and a usize length, in that order; the wrapper normalises (NULL, 0) to the empty span), structs and unions of C-representable 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, and every generic. A fallible function exports in a status form: the C function returns an int status and writes the value through a final out-pointer.

The generated header spells a struct as its C definition with __attribute__((packed)) or __attribute__((aligned(N))) where declared, bool as C's bool, usize as size_t, T*? as the same pointer type with a comment, and an integer-backed enum as typedef uint32_t Flags; with enum { Flags_none = 0, ... }; for C17, plus enum Flags : uint32_t under C23, because a C17 enum's compatible type is implementation-defined.

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 promise to the linker, and it should be a word.