Luce Base
LuceSourceLuciaOS

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 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; 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 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.
  • handle Name: with destroy function beneath declares an opaque pointer-shaped type the same way and names the pub function of the module, taking the handle and returning nothing without failing, that releases it (§17.7).
  • extern struct and extern union declare C layout and may carry packed or align(N). 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 c.str, and str(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:

ArgumentPassed as
an untyped integer literali32; an error if it does not fit
an untyped float literalf64
bool, char, i8, u8, i16, u16, c.charpromoted to i32
f32promoted to f64
an integer-backed enumits representation, promoted as above
a string literalc.str
any other integer, float, or pointeras itself
str, a span, a struct, a union, an optionalrejected

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, extern variables, static const scalars, constant #defines, and static inline functions map directly. The last, and the recipe's macros, are called through luce_NAME functions the tool writes into shims.c, which the manifest names as a source (§17.4).
  • Types map to §5.2: int is i32, long is c.long, char is c.char, size_t is usize, the <stdint.h> names are the Base widths, va_list is c.va_list. A pointer is nullable, T*?, unless the header says _Nonnull or the recipe says nonnull; const char* is c.str? and char* is c.char*?; a pointer to a struct that another library's header defines is void*?. A struct the header only names is an extern type, a handle. A function pointer is func(...) -> 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 u32 when every value fits, else as i32, u64, or i64; 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] becomes a: i32*?, because C adjusts it to a pointer.
  • A flexible array member T data[]; is omitted from the struct, and a function Struct_data(record: Struct*) -> T* is generated as the address one record past record, record + 1, where the fixed part ends. A struct declared in Base with a trailing payload allocates it with memory.allocate and 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 is memberN, so that the layout stays C's. A struct with bit-fields is refused and left as an extern 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 let of 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 through as.
  • 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 @interface is skipped whole.
  • errno, stdin, stdout, and stderr are macros over accessors and are not bound; c.errno() and c.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's align(N) as that attribute on the field;
  • bool as C's bool, usize as size_t, isize as ptrdiff_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; with enum { Flags_empty = 0, ... }; for C17, plus enum Flags : uint32_t under C23, because a C17 enum's compatible type is implementation-defined;
  • every pub error 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.