ADR-0086: C FFI extensions
Status
Implemented (stable as of Phase 5).
Summary
Three extensions to the C FFI surface introduced by ADR-0085:
- C named primitive types. Thirteen new types matching Rust's
core::ffiset minusc_char(whose target-dependent signedness needs its own design): twelve arithmetic primitives (c_schar,c_uchar,c_short,c_ushort,c_int,c_uint,c_long,c_ulong,c_longlong,c_ulonglong,c_float,c_double) plus the incomplete typec_voidwhich only composes asPtr(c_void)/MutPtr(c_void). Each arithmetic primitive is distinct from its underlying native type but lowers to a target-resolved fixed-width type.c_intbecomes the canonical discriminant type for@mark(c) enum.MutPtr(c_void)doubles as the type for@mark(c) fnpointer values —@mark(c) fnidentifiers are castable toMutPtr(c_void)viaas, which unblocks the@thread_spawn/@thread_joinretirement in ADR-0087. - Enum FFI (
@mark(c) enum). Lifts the v1MarkerNotApplicablerejection. Field-less enums lower to a barec_int. Data-carrying enums lower tostruct { tag: c_int; union { variants… }; }— a closed-form C tagged-union layout. Niche optimisation disabled, same as@mark(c) struct. - Static linkage. New sibling keyword
static_link_extern("foo") { … }parallel tolink_extern. ELF link line wraps the library with-Wl,-Bstatic/-Wl,-Bdynamic; Mach-O link line emits-Wl,-search_paths_first -lfooso ld picks the.aarchive when one is present.
__gruel_* runtime cleanup is not in this ADR. The successor ADR-0087 handles it in full — including the five trivially-shaped libc-shim intrinsics (@alloc/@free/@realloc/@exit/@memcmp) that an earlier draft of this ADR had as Phase 5. The cleaner architecture is that codegen-emitted lowerings call prelude fns, and the prelude binds libc via ordinary source-level link_extern("c") blocks (which ADR-0085's library-set walker already handles); routing the five libc shims through a special "direct libc codegen + implicit link library set" mechanism would have introduced a parallel link mechanism for no real benefit.
The single preview feature c_ffi_extras gates the user-visible surface and retires at stabilisation.
Context
ADR-0085 shipped C FFI behind link_extern("…") { … } and @mark(c) (on fns and structs). It explicitly punted three things:
- Enum FFI. Field-less and data-carrying
@mark(c) enumtypes. The blocker named in ADR-0085 was the absence of a target-dependentc_intfor the discriminant. - Static linkage. Open Question 5: "syntax TBD (
link_extern(static, "foo") { … },link_extern("foo", kind = "static") { … }, or similar)." The decision was to ship-l<name>only. This ADR picks a third surface — a dedicatedstatic_link_externkeyword — keeping the parser-side recognition flat (one keyword per item form, no inner qualifier soup) and leavinglink_extern's grammar untouched. __gruel_*collapse. Neutral consequence 1: "cleanup to express them vialink_extern("c") { … }is mechanical but optional." The follow-up ADR-0087 handles this in full — every libc-wrapper-shaped__gruel_*symbol migrates to a prelude fn that uses ordinary source-levellink_externto bind libc. This ADR doesn't touch the runtime; an earlier draft included a "Phase 5: direct-libc codegen" for the five trivially-shaped shims (alloc/free/realloc/exit/memcmp) plus an implicit-link-library-set mechanism, but that route introduced a parallel link mechanism running alongsidelink_extern. The cleaner thing is to let ADR-0087's prelude bind libc via normallink_externfor all the migrated symbols including those five.
c_* types, enum FFI, and static linking are independent in principle but coupled by sequencing: enum FFI needs c_int. Bundling them into one ADR avoids three small ADRs that share most of the same machinery.
The capability seam from ADR-0085 (each link_extern block as a named lexical witness scope) is preserved unchanged — static-mode blocks are still per-library and still capability-addressable in the future ADR.
Decision
C named primitive types
Thirteen new built-in primitive types model the C named surface, mirroring Rust's core::ffi set minus c_char (target-dependent signedness needs its own design — see [Future Work]). Twelve are arithmetic primitives, each target-dependent in principle and target-resolved to a fixed width/alignment at compile time. The thirteenth, c_void, is an incomplete type usable only through pointers — its rules follow the arithmetic table.
On every blessed target (ADR-0077 list: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, aarch64-apple-darwin — all LP64), the arithmetic resolutions are:
| Gruel type | C type | Width | Signed | Underlying lowering |
|---|---|---|---|---|
c_schar | signed char | 8 | yes | i8 |
c_uchar | unsigned char | 8 | no | u8 |
c_short | short | 16 | yes | i16 |
c_ushort | unsigned short | 16 | no | u16 |
c_int | int | 32 | yes | i32 |
c_uint | unsigned int | 32 | no | u32 |
c_long | long | 64 (LP64) | yes | i64 |
c_ulong | unsigned long | 64 (LP64) | no | u64 |
c_longlong | long long | 64 | yes | i64 |
c_ulonglong | unsigned long long | 64 | no | u64 |
c_float | float | 32 | — | f32 |
c_double | double | 64 | — | f64 |
Future targets where the LP64 assumption doesn't hold (e.g. Windows LLP64, where c_long is 32-bit) resolve each type accordingly without breaking source.
Each type is distinct from its underlying native type. Specifically (taking c_int as the worked example; every other type in the table follows identically):
- Assignment, return, and argument passing require the static types to match or to be related by an explicit cast:
let x: c_int = 42 as c_int;is the canonical form. Integer (and float) literals coerce to the C named type the same way they coerce to any other concrete arithmetic type when the context fixes one (let x: c_int = 42;is shorthand forlet x: c_int = 42 as c_int;). ascasts between any C named type and any other arithmetic type are permitted and follow ADR-0058 truncation/extension rules with respect to the target-resolved width.- Binary operators require both operands to share the type;
c_int + c_int -> c_int,c_int + i32is a type error. The signed/unsigned C pairs (c_int/c_uint,c_long/c_ulong, etc.) are also distinct from each other. - Each C named type is
Copy,Send,Sync, has the same niches as the target-resolved underlying type, and otherwise behaves like its underlying arithmetic type for trait/marker purposes.
In LLVM lowering, each maps to the target-resolved fixed-width type from the table. Codegen never branches on "is this c_int?" — by the time codegen runs, the AIR carries the resolved width.
All twelve arithmetic types are permitted in the FFI allowed-types table from ADR-0085 §"Allowed FFI types"; numeric primitives now also include the C named integer and float types alongside i8–i64, u8–u64, isize, usize, f32, f64. The C named rows are documentary at the source level — same width as a native Gruel type on every current target — but distinguish the FFI signature from a "happens to be the same width" Gruel signature, and remain correct on future targets where the resolution shifts. Ptr(c_void) and MutPtr(c_void) are also added to the FFI allowed-types table (under the pointer row).
c_int specifically is the canonical discriminant type for @mark(c) enum (see next section) — C's default enum discriminant is signed int, and this ADR commits to that shape; c_uint-discriminant enums would need a separate convention to disambiguate against the signed default.
c_void: incomplete type with pointer-only use
c_void corresponds to C's void. Like Rust's core::ffi::c_void, it's an incomplete type: it has no values, no size, no alignment, and cannot be instantiated. The rules:
- Cannot appear as a value type. No
let x: c_void, noc_voidparameters by value, noc_voidstruct fields, noc_voidreturn type. Use()(unit) for Cvoidreturn values, matching ADR-0085's existing rule. - Composes only with pointer types.
Ptr(c_void)isconst void *;MutPtr(c_void)isvoid *. Both are allowed in FFI signatures and inside C struct fields and as variables / parameters / return types. - No deref, no arithmetic, no index.
*pforp: Ptr(c_void)is a compile error. Pointer offset (p + n) is a compile error. To do pointer arithmetic, cast through a typed pointer (p as MutPtr(u8)). - Pointer casts permitted.
MutPtr(T) as MutPtr(c_void)andMutPtr(c_void) as MutPtr(T)are legalascasts for anyT, mirroring C's implicitvoid *conversions. The cast is a no-op at the LLVM level (all pointers share representation on every blessed target). Same for the const variants. The user is responsible for the cast being meaningful — the compiler can't check it. - Function-pointer transport. Any
@mark(c) fnidentifier is castable toMutPtr(c_void)viaasin expression position. This is the type-level commitment that makes function pointers passable to FFI sinks likepthread_create'svoid *(*)(void *)parameter. Theascast preserves the function's address; the result is aMutPtr(c_void)value that can be passed to C code expecting a function pointer. Casting back (MutPtr(c_void) as <@mark(c) fn signature>) is not in this ADR — once a@mark(c) fnentersMutPtr(c_void)you can pass it through but not call it again on the Gruel side. (A typed extern fn pointer type for the round trip is Future Work.)
Function/data pointer ABI parity is assumed on every blessed target (LP64, unified address space). Future Gruel targets where this parity doesn't hold would need a distinct c_fn_ptr_void type or analogous; flagged in Future Work.
Enum FFI via @mark(c) enum
@mark(c)'s applicable_to field widens from FN_OR_STRUCT to FN_STRUCT_OR_ENUM. The existing MarkerNotApplicable rejection on @mark(c) enum lifts. Two sub-shapes:
Field-less enums (no variant carries any field):
@mark(c) enum Color { Red, Green, Blue }
- Discriminant type =
c_int. - Variant discriminants default to 0, 1, 2, … unless explicitly assigned (
Red = 1, Green = 2, Blue = 4). - Explicit discriminants must be
c_int-representable; out-of-range values are rejected withFfiEnumDiscriminantOverflow. - Layout = bare
c_int. Size =sizeof(c_int), alignment =alignof(c_int). - Niches disabled (consistent with
@mark(c) struct). - Passing by value across the FFI boundary is permitted.
Data-carrying enums (at least one variant carries fields):
@mark(c) enum Event {
Quit,
KeyPress { code: c_int },
MouseMove { x: c_int, y: c_int },
}
Layout = { tag: c_int; payload: union<Variants…> }, with explicit padding chosen so that payload starts at max(alignof(c_int), max(alignof(variant_i))). The payload union is laid out as a C union: each variant's fields occupy the same offset; the union's size = max variant size, alignment = max variant alignment. Total enum size = payload_offset + payload_size, rounded up to enum alignment = max(alignof(c_int), max(alignof(variant_i))). Niches disabled.
Mirrors Rust's #[repr(C, int)] (the variant noted in the Rust reference) and is the dominant in-the-wild C tagged-union convention (SDL_Event, LLVMValueRef sub-shapes). Picking it is a wire-format commitment, same shape as ADR-0085's commitment to struct layout.
Recursive C-FFI-type checks on variant payload fields use the same machinery and same FfiAggregateHasNonCField diagnostic as @mark(c) struct — variant payloads are exactly the same C-FFI-type discipline as struct fields, just packaged as a union.
Allowed on the FFI boundary: any @mark(c) enum whose discriminant is c_int (i.e. every @mark(c) enum in v2 — there's no other choice yet). Both field-less and data-carrying shapes pass by value via LLVM's default C calling convention; small-enough values go in registers, larger ones via sret/byval as LLVM decides. Rejected: passing a non-@mark(c) enum by value (continues to use FfiTypeNotAllowed).
fn __drop on @mark(c) enum is rejected with FfiAggregateHasDrop, matching the struct rule.
Static linkage
A new sibling keyword static_link_extern introduces a parallel item form to link_extern. Body grammar (items inside the block, body-less-fn rule, implicit @mark(c), @link_name override) is identical to link_extern; only the link-line treatment differs.
static_link_extern_block := "static_link_extern" "(" STRING ")" "{" item* "}"
static_link_extern("foo") {
fn foo_init() -> c_int;
}
link_extern("c") { // dynamic, unchanged from ADR-0085
fn write(fd: c_int, buf: Ptr(u8), n: usize) -> isize;
}
Semantics:
link_externkeeps ADR-0085 behaviour:-l<name>, dynamic linkage.static_link_externrequests static linkage from the system linker. The library name must follow the same rules as the dynamic form (non-empty string literal).- A library named by both a
link_externand astatic_link_externblock (in the same file or across files) is a compile error (LinkExternConflictingLinkage) — a library is either statically or dynamically linked, not both. - Multiple
static_link_extern("name") { … }blocks across files merge the same way dynamic blocks do. static_link_externblocks do not nest and do not nest insidelink_extern(or vice versa).
Linker-line construction extends ADR-0085's "deduplicated, lex-sorted library set":
- ELF (
target.is_elf()): static libs emit-Wl,-Bstatic -l<name>and the run of static libs is followed by-Wl,-Bdynamicbefore any dynamic libs (so the-Bdynamicbracket re-enables shared linkage for libc and the runtime). The deterministic order is: static libs (lex-sorted),-Wl,-Bdynamic, dynamic libs (lex-sorted). - Mach-O (
target.is_macho()): static libs emit-Wl,-search_paths_first -l<name>. macOSlddoes not have an ELF-style-Bstatic/-Bdynamictoggle;-search_paths_firstcauses the linker to scan a search directory forlib<name>.abeforelib<name>.dylib, which is the closest fit. If only a.dylibis present, the link succeeds dynamically — same outcome as ELF without-Bstatic, which is silently dynamic. A diagnostic warning (StaticLinkMachoFallback) fires when this happens on macOS, surfaced through the normal warning channel. link_system_with_warningsgains a parameter carrying the per-library linkage mode; the existingextra_link_libraries: &[String]becomesextra_link_libraries: &[(String, LinkMode)]whereLinkMode { Dynamic, Static }.
Static linkage is independent of the library's symbol set — empty static blocks (static_link_extern("foo") {}) are permitted and produce the -Wl,-Bstatic -lfoo -Wl,-Bdynamic bracket without declarations, useful when symbols are reached indirectly.
Preview gating
PreviewFeature::CFfiExtras (CLI: c_ffi_extras). Gate fires on:
- Any C named primitive type token (
c_schar,c_uchar,c_short,c_ushort,c_int,c_uint,c_long,c_ulong,c_longlong,c_ulonglong,c_float,c_double) — Phase 1. @mark(c) enum(Phases 2–3).static_link_extern(…)keyword (Phase 4).
The preview gate retires in Phase 5.
Diagnostics
New:
FfiEnumDiscriminantOverflow { variant, value }— explicit discriminant doesn't fit inc_int.LinkExternConflictingLinkage { library }— same library declaredstaticin one block and dynamic in another.StaticLinkMachoFallback { library }— Mach-O static-link request resolved to a.dylibbecause no matching.awas found (warning, not error).CFfiExtrasPreviewRequired— generic preview-gate error.
The ADR-0085 set (FfiTypeNotAllowed, FfiAggregateHasNonCField, FfiAggregateHasDrop, MarkerNotApplicable) cover the rest. MarkCOnEnum (named in ADR-0085's diagnostics list but implemented as a general MarkerNotApplicable) is no longer fired.
Implementation Phases
Phase 1: C named primitive types
- Lex/parse the thirteen type names as primitive type names. (Implemented as identifier-based resolution in
resolve_type/resolve_type_namerather than dedicated lexer tokens — the parser already routes unknown identifiers throughTypeExpr::Namedand sema resolves them by name, so dedicated tokens would be redundant.) - RIR + AIR: thirteen new
TypeKindvariants with tags 21–33 (signed integers 21–25, unsigned integers 26–30, floats 31–32,c_voidat 33). Per-tag variants composed cleaner with the existing range-based helpers (is_integer/is_signed/is_unsigned/is_float/is_numeric/is_copy/literal_fits) than a singleType::CNamed { kind }wrapper would have.c_voidis its own variant; it has no value semantics but joins the layout/Sync/Copy helpers as a sentinel. - Type resolution for the twelve arithmetic types resolves via
resolve_type/resolve_type_nameto the per-tag variant; widths come from the staticLayout::scalar(…)arms inlayout.rskeyed byTypeKind. The Phase-1 implementation hard-codes LP64 sizes; future LLP64 targets will need the layout arms (andliteral_fits) parameterised on the target — flagged in Future Work. - Sema: integer/float literals coerce to each arithmetic C named type via the existing
literal_fits/negated_literal_fitsmachinery (now extended to recognise tags 21–32). Binary ops use the existingTypeequality discipline —c_int + i32errors because theirTypevalues differ. Cast support is via the existing@cast(x)intrinsic (ADR-0050) which dispatches onis_integer()/is_float()and already handles the new tags. - Sema for
c_void:resolve_type(the public entry point) callsresolve_type_allow_voidinternally and rejects barec_voidwith an FFI diagnostic; thePtr(T)/MutPtr(T)constructor arms callresolve_type_allow_voiddirectly for their inner argument, soc_voidis only ever accepted as a pointer pointee. Inference'sresolve_type_nameis leaky (it doesn't go through sema) soanalyze_allocre-validates that a let-binding's resolved type isn'tType::C_VOID. Deref/arithmetic rejection onPtr(c_void)falls out of the existing ADR-0028 raw-pointer machinery —c_voidsimply has no LLVM lowering for deref. - Sema for pointer casts:
MutPtr(T) as MutPtr(c_void)lives downstream of the@castintrinsic / future raw-pointer cast surface; the pointee-allow-void plumbing here is the foundation that work will use. No standalone cast operator added in this phase. - Deferred — Sema for fn-pointer cast:
@mark(c) fnidentifiers toMutPtr(c_void). Deferred to ADR-0087's thread-retirement phase since that is the first consumer; ADR-0086 doesn't ship anything that uses the cast itself. Mechanism stays open via the existingMutPtr(c_void)plumbing. - Codegen: each arithmetic type lowers to the LLVM type from the target-resolved width row.
pointer_sized_int_type()not involved; none of the C named arithmetic types are pointer-sized (isize/usizecover that).Ptr(c_void)/MutPtr(c_void)lower to opaque pointer types (LLVM 15+ptr).c_void-typed values are unreachable at codegen. - FFI allowed-types table widens to include all twelve arithmetic types;
Ptr(c_void)/MutPtr(c_void)flow through the existingis_ptr_const() || is_ptr_mut()check. - Spec: new section
docs/spec/src/10-c-ffi/03-c-named-types.mdcovering the arithmetic table, distinctness rule, literal coercion, FFI permission, andc_void's incomplete-type rules. - Spec tests under
cases/types/c_named.toml: literal coercion (c_int/c_long/c_uint/c_doublereturns), preview-gating (c_int/c_voidboth require the preview),c_voidpointer-only use (MutPtr(c_void)allowed, barec_voidrejected as a return type and a parameter type), distinctness (i32↔c_int,c_long↔c_longlong), and a libcabsroundtrip. - Preview gate
c_ffi_extrasfires on any C named type token (includingc_void).analyze_allocre-fires the gate on the resolved var_type to catch the let-annotation path that goes through inference's looserresolve_type_name.
Phase 2: Field-less @mark(c) enum
- Widened
BUILTIN_MARKERSentry forcfromFN_OR_STRUCTtoFN_STRUCT_OR_ENUM. AddedItemKinds::FN_STRUCT_OR_ENUM = 0b111constant. - Sema fires
c_ffi_extraspreview gate when@mark(c)applies to an enum (the fn / struct paths stay ungated from ADR-0085).mark_outcome.c_layoutflows intoEnumDef.is_c_layoutvia the gather pass.EnumDef.discriminant_type()returnsc_intwhenis_c_layoutis set, regardless of variant count. - AIR + codegen: existing
is_unit_onlycodegen path already lowers field-less enums to their discriminant integer — switching the discriminant toc_intautomatically produces the bare-c_intlowering. Niche / layout machinery already consultsEnumDef.is_c_layoutindirectly throughdiscriminant_type. (Niche optimisation suppression for c-layout enums is layered in via the existingdiscriminant_strategyplumbing — field-less enums never get a niche-encoded enum layout because they're 1-byte today /c_int-wide now and the niche-encoded path is opt-in.) - FFI type validation in
validate_ffi_type:@mark(c) enumtypes are now accepted at parameter / return positions. Field-less variants pass; data-carrying variants are rejected with a Phase-2-only diagnostic that names ADR-0086 Phase 3 as the lift point. Non-@mark(c)enums continue to be rejected. - Spec: new section
docs/spec/src/10-c-ffi/04-enum-ffi.md(paragraphs 10.4:1–7) covering applicability, discriminant type, field-less layout, FFI permission, the data-carrying deferral, and the non-@mark(c)rejection. - Spec tests under
cases/items/c-ffi-enum.toml: field-less enum declaration, FFI self-roundtrip, preview gating, data-carrying rejection, non-@mark(c)enum rejection. 5 spec tests, all gated onc_ffi_extras. - Deferred — explicit discriminant values (
Red = 1) andFfiEnumDiscriminantOverflow. Gruel's enum surface doesn't currently support user-specified discriminant values; this Phase-2 sub-task is naturally bundled with that future feature rather than landed here.
Phase 2 also fixed a pre-existing latent bug in the parse-cache RemapSpurs impl for EnumDecl: directives weren't being remapped, which was invisible until ADR-0086 made directives on enums reachable. The fix lives in crates/gruel-cache/src/remap.rs and unblocks every directive on every enum — surfaced as a phantom @lang("drop") diagnostic before the fix.
Phase 3: Data-carrying @mark(c) enum
- Layout:
enum_layout_separategot adef.is_c_layoutbranch that follows the C tagged-union rule — discriminant at offset 0,payload_offset = max(alignof(c_int), max(alignof(variant fields))),enum_align = same, total sizealign_up(payload_offset + max_payload, enum_align). Niches stay empty. - Codegen: when
def.is_c_layoutand the enum is data-carrying, the LLVM type is{ c_int, [N x payload_elem] }wherepayload_elemisi64/i32/i16/i8picked from the max variant alignment, andN = ceil(payload_size / sizeof(payload_elem)). LLVM's struct-layout rules force the right alignment via the payload element type, matching the ADR layout exactly. - Sema: data-carrying
@mark(c) enumis no longer rejected wholesale. Variant payload field types are recursively validated againstvalidate_ffi_type; non-FFI payload fields produce a diagnostic that names the offending variant and field, matching the struct path'sFfiAggregateHasNonCFieldshape (the sameCFfierror kind, with a tailored message). - Codegen: by-value pass/return — LLVM handles small/large struct ABI automatically via the existing C calling convention path used by
@mark(c) struct. No special-case needed. - Spec tests in
cases/items/c-ffi-enum.toml: data-carrying compilation, FFI self-roundtrip, non-FFI variant payload rejection.
Phase 4: Static linking
- Lexer: reserved
static_link_externas a new keyword alongsidelink_extern. NewTokenKind::StaticLinkExternvariant. - Parser: rewrote
link_extern_parserto choose between thelink_externandstatic_link_externkeywords. Item rules inside the block are reused verbatim; the parser stamps theLinkMode(Dynamic / Static) on the resultingLinkExternBlockAST. - RIR + AIR:
RirExternFngained alink_mode: RirLinkModefield; the empty-block tuple grew from(Spur, Span)to(Spur, RirLinkMode, Span). Astgen propagates the parser'sLinkModeinto the RIR. - Sema:
collect_extern_fn_signaturesnow detects mixed linkage (same library declared in bothlink_externandstatic_link_externblocks) and rejects with aCFfidiagnostic. Thec_ffi_extraspreview gate fires on anystatic_link_externblock. Nesting (any link_extern keyword inside another) was already rejected by ADR-0085's grammar; the new keyword inherits that. - Compiler:
collect_extern_link_librariesreturnsVec<(String, LinkMode)>.BackendInputs.extra_link_librariesmatches that shape. - Linker:
link_system_with_warningsemits ELF-Wl,-Bstatic <static libs> -Wl,-Bdynamicbrackets and Mach-O-Wl,-search_paths_firstper the §"Static linkage" rules. Static libraries are emitted in lex-sorted order ahead of dynamic ones. - Spec: new section
docs/spec/src/10-c-ffi/05-static-linking.md(paragraphs 10.5:1–6). - Spec tests under
cases/items/c-ffi-static.toml: preview gating, mixed-linkage rejection (both with items and with empty blocks), same-mode dedup (twostatic_link_externblocks for the same library), andstatic_link_externnested insidelink_extern(rejected). - Deferred — Mach-O
StaticLinkMachoFallbackwarning emission. The fallback path (resolvinglib<name>.ato.dylibvia-Wl,-search_paths_first) is implemented; the warning is reserved by ADR text but not actually emitted from the linker yet (would require probing the filesystem before invoking the linker). Surface stays open for future work.
Phase 5: Stabilise
- Removed
PreviewFeature::CFfiExtrasfrom the closed enum, dropped everyrequire_preview(CFfiExtras, …)callsite in sema (4 sites: resolve_type's 13 type-name arms collapsed into a single set; analyze_alloc's let-annotation gate; declarations.rs's@mark(c)on enum gate; collect_extern_fn_signatures' static_link_extern gate). - Stripped
preview = "c_ffi_extras"andpreview_should_pass = truefrom all three ADR-0086 spec test files (cases/types/c_named.toml,cases/items/c-ffi-enum.toml,cases/items/c-ffi-static.toml). Removed the three preview-gating-specific test cases (c_int_requires_preview,c_void_requires_preview,mark_c_on_enum_requires_preview,static_link_extern_requires_preview) — they tested a gate that no longer exists. - ADR status →
implemented; spec sections 10.3–10.5 recorded in frontmatter. - ADR-0087 (successor) owns all
__gruel_*runtime cleanup, including the libc-shim symbols. This ADR neither modifies the runtime nor introduces an implicit-link mechanism.
Consequences
Positive
- Enum FFI in both shapes — libc-style field-less enums and
SDL_Event-style tagged unions both bind without manual struct-of-union wrapping. - The thirteen C named primitives make FFI signatures self-documenting and target-portable; the same source compiles on hypothetical future targets where
intisn't 32 bits orlongisn't 64 bits.c_voidplus the@mark(c) fn-to-MutPtr(c_void)cast give a type-safe-ish transport for C function pointers, enough for ADR-0087 to retire@thread_spawn/@thread_joinfrom the intrinsics registry. - Static linking closes the largest remaining ADR-0085 Open Question and unblocks workflows that ship pinned-version libraries (audio codecs, sqlite, compiled-in extensions).
- No new linkage mechanism. The library-set walker from ADR-0085 stays the single source of truth for which
-l<name>(and now-Wl,-Bstatic -l<name>/-Wl,-search_paths_first -l<name>) flags hit the link line; an earlier draft of this ADR introduced an implicitlink_extern("c") { … }block to support direct-libc codegen for five intrinsics, but that route was withdrawn in favour of ADR-0087 routing all libc binding through ordinary sourcelink_externin the prelude. - Capability ADR keeps full freedom over what FFI gating looks like; the static-linkage qualifier is orthogonal to call-site gating.
Negative
- C tagged-union layout is a wire-format commitment. Once published, the
{ tag: c_int; payload: union<…> }shape is hard to revise. Mirrors Rust's choice, so the cross-language story is at least familiar. - Static linking on Mach-O is best-effort: macOS
ldhas no-Bstatictoggle, so the static-mode block falls back to dynamic if no.ais found in the search path. The fallback warning surfaces it but does not error. - One new reserved keyword (
static_link_extern). Visually heavier than a qualifier onlink_externbut parser-side recognition stays flat — one keyword per item form, no inner argument soup — andlink_extern's grammar is untouched. Thestatic_link_extern/link_externpair reads naturally and grep-distinguishes the two link modes. - No runtime cleanup in this ADR — the
__gruel_*symbols continue to exist in their current shape until ADR-0087 lands. The user's original ask bundled runtime cleanup with the FFI extensions; the cleaner architectural cut is to ship the FFI extensions first and let ADR-0087 do the runtime work as one coherent piece.
Neutral
- The C named primitive set in this ADR is thirteen types — the full Rust
core::ffiset minusc_char, which is deferred to a follow-up ADR because of target-dependent signedness.c_void's incomplete-type semantics are handled in this ADR (pointer-only use, no values, no deref, no arithmetic). - The intrinsics registry is untouched by this ADR. ADR-0087 is the one that touches it.
Open Questions
- Mach-O static-link fallback. Warning is reasonable; some users may want it to be a hard error. Could expose a
--strict-static-linkingCLI flag in a follow-up. - C named type width on hypothetical non-LP64 targets. When/if Gruel supports a target where C's
intis 16-bit orlongis 32-bit (LLP64 Windows), do typed literals still accept arbitrary native-width values? Recommend: literals must fit the target-resolved width — this ADR's literal-coercion rule already enforces it. c_longvsc_longlongaliasing. On LP64 both are 64-bit signed and lower toi64. They're nevertheless distinct types in this ADR (so a signature withc_longdoesn't silently equal one withc_longlong). Confirmed yes — matches Rust'score::ffiand preserves the documentary signal across targets where the two diverge (LLP64).- Spec wire-format publication. The C tagged-union layout becomes a spec-normative paragraph in chapter 10. Are we ready to commit at the same time we ship Phase 3, or do we want a deprecation window? Recommend commit on Phase 3 stabilisation — the wire shape isn't going to change.
Future Work
c_char— target-dependent signedness (Linux aarch64 is unsigned; Linux x86_64 and macOS aarch64 are signed). Needs a design pass on whetherc_charis a distinct type with its signedness resolved per target, a separate signed/unsigned pair, or an alias to one of the existing fixed-signedness types per platform.- Typed extern fn pointer types. This ADR ships
MutPtr(c_void)as the untyped transport for C function pointers (sufficient forpthread_create-style sinks). A properextern "C" fn(args) -> rettype — round-trippable throughascasts in both directions, signature-checked at the call site — is a follow-up. The void-pointer transport from this ADR is the strictly-weaker version: forward-only, no signature safety, no callable-on-the-Gruel-side after the cast. c_size_t,c_ssize_t,c_ptrdiff_t,c_intptr_t,c_uintptr_t— already covered semantically by Gruel'susize/isize, but adding the C-named aliases would let FFI signatures match libc headers verbatim.- Bitfield support in
@mark(c) struct(Future Work bullet on ADR-0085's list). - Variadic FFI (
printf/scanf) — still deferred. - Packed C layout (
@mark(c, packed)) — still deferred. - Extern statics inside
link_externblocks — still deferred. - Additional ABIs (
system,stdcall,vectorcall,rust) — still deferred. - C header import (
@c_import("foo.h")) — still deferred. - Capability-system integration — orthogonal to this ADR.
- Moving libc-wrapper intrinsics out of the registry into prelude fns. Successor ADR-0087 — every libc-wrapper-shaped
__gruel_*symbol (heap/exit/memcmp/panic family/dbg/read_line/thread_* targets, plus the algorithmic wrappersparse_*,random_*,utf8_*,cstr_to_vec) migrates from intrinsic rows / runtime symbols to regular Gruel prelude fns. Intrinsics contract to "things that genuinely need compiler magic." - Re-implementing the algorithmic helpers (
parse_*,random_*,utf8_*) in Gruel once the stdlib grows the necessary primitives — orthogonal to the ADR-0087 cleanup; could land either before or after. --strict-static-linkingCLI flag to make Mach-O fallback-to-dynamic a hard error.
References
- ADR-0005: Preview Features
- ADR-0028: Unchecked Code and Raw Pointers
- ADR-0050: Intrinsics Crate — the intrinsics registry that ADR-0087 contracts (this ADR doesn't touch it).
- ADR-0054:
usizeindexing - ADR-0061: Generic Pointer Types
- ADR-0069: Layout Abstraction and Niches —
@mark(c) enumextends the same layout-mode mechanism. - ADR-0077: Target System —
c_*widths and alignments live next to the per-target queries. - ADR-0083:
@mark(...)directive —c'sapplicable_towidens here. - ADR-0084: Send/Sync markers
- ADR-0085: C foreign function interface — direct parent; this ADR closes its enum-FFI and static-linking follow-ups. The runtime-collapse follow-up belongs entirely to ADR-0087.
- ADR-0087: Prelude fns for libc-wrapper intrinsics — successor; owns all
__gruel_*runtime cleanup. Useslink_externin the prelude rather than introducing an implicit-link mechanism. - Rust reference:
#[repr(C, int)]enum layout - Rust
core::ffi— model for the thirteen C named primitive types.c_chardeliberately excluded; see Future Work. - Apple
ld(1)man page — Mach-O static-linkage flag conventions.