Skip to content

Extern C signature matrix (v0.36 T2 + v0.37 T3 + v0.38 T3 + v0.46 T3)

This document is the human-readable mirror of tests/extern_c_matrix/. It pins which C-ABI shapes Mighty can call end-to-end today, what the manifest contract is for linking against vendored archives, and which shapes are deferred to v0.38.

v0.37 T3 closed the call-site ergonomics gaps that previously forced rows 3-10 of the matrix to ship via wrapper functions. Real Mighty source code can now spell those shapes directly. See the v0.37 ergonomics section near the end of this doc.

v0.38 T3 closes rows 07 (returned struct) and 11 (function pointer), plus adds the #[ffi_nul_ok] attribute as a metadata-only marker for the Str→U8 coercion's fast path. See the v0.38 surfaces* section.

v0.46 T3 adds row 12 — Mighty Str / String at an extern-c param slot now lowers to an ABI (const char* ptr, size_t len) pair. The Mighty source spells the call with a single Str arg; the cranelift backend expands it into two i64 ABI args at the call site. No more char-by-char staging through scalar helpers (L52). See the v0.46 T3 — (ptr, len) Str slice FFI section.

Audience: anyone shipping an FFI Mighty app — first-class downstream consumers are the native-IDE track (C:\Users\ihass\mighty-ide) and any user wrapping a C SDK (wgpu, winit, libsodium, …).

TL;DR

  1. Declare each native library in mighty.toml under one or more [[extern_lib]] blocks (schema below).
  2. Declare each C-ABI function in an extern c { ... } block at the top of your Mighty source. Signatures use the standard Mighty type syntax — *U8 for pointers, USize for size_t, I32 / I64 for int32_t / int64_t, etc.
  3. Call those functions like any other Mighty fn. The cranelift backend declares them as Linkage::Import and the system linker resolves them from the archives you named in step 1.

Manifest schema — [[extern_lib]]

[package]
name    = "ffi_demo"
version = "0.1.0"
edition = "2026"

[[extern_lib]]
name = "winit"
kind = "static"               # or "dynamic"; default = static
path = "vendor/libwinit.a"    # optional; falls back to -l<name>

# Cross-platform raw linker flags (always applied).
link_args = ["--whole-archive"]

# Host-OS-specific flags. Filtered by `cfg(target_os)` at build time.
link_args_macos   = ["-framework", "Cocoa"]
link_args_linux   = ["-lxkbcommon", "-lX11"]
link_args_windows = ["Userenv.lib"]

Fields:

Field Required Notes
name yes Logical name. Used as -l<name> when path is absent.
kind no "static" (default) or "dynamic". Case-insensitive.
path no Filesystem path relative to the manifest directory. Bypasses the linker search path.
link_args no Raw flags appended after the archive.
link_args_linux no Linux-only flags.
link_args_macos no macOS-only flags.
link_args_windows no Windows-only flags.

Multiple [[extern_lib]] entries are honored in source order. The linker walks them in the same order, so put archives that need other archives' symbols first.

[[extern_lib]] describes one specific library; [build] carries project-wide linker knobs that apply to every binary the package produces. Use [build] when you don't need a per-library shape — "link libm everywhere" or "search /opt/whatever/lib" — and [[extern_lib]] when you need a vendored archive with its own link_args_* matrix.

[build]
# Each name becomes -l<name> (rewritten to <name>.lib on MSVC linkers).
native-libs = ["m", "pthread"]

# Each path becomes -L<path> (rewritten to /LIBPATH:<path> on MSVC).
link-search = ["/opt/whatever/lib"]

# macOS-only. Becomes `-framework <Name>` on Darwin; dropped on other
# hosts. Also dropped by the MSVC rewriter (no Windows analogue).
frameworks = ["Cocoa", "Foundation"]

# Raw linker arguments. Cross-platform shapes are auto-translated by
# the MSVC rewriter:
#   --gc-sections / -Wl,--gc-sections → /OPT:REF
#   -Wl,-rpath,...                    → dropped (MSVC has no rpath)
# Anything unrecognised passes through unchanged — escape hatch for
# linker-specific flags that lack a portable spelling.
link-args = ["--gc-sections"]

The driver concatenates the [build] argv after the per-[[extern_lib]] argv. A vendored static archive listed via [[extern_lib]] therefore takes precedence when both contribute the same symbol.

Detecting the linker flavor: the driver looks at the resolved linker's basename. link.exe and lld-link.exe are treated as MSVC; everything else (including clang-cl invoked as a frontend) is fed GNU-ld syntax. Override with MTY_LINKER_FLAVOR=gnu|msvc when your linker is a custom wrapper.

Mighty extern c block

extern c {
  fn winit_create_window(title: *U8, w: I32, h: I32) -> *Mut WindowHandle
  fn winit_destroy_window(handle: *Mut WindowHandle)
}
  • The block tag is the ABI — currently c and js. (js is the wasm-host browser binding; see docs/internals/wasm.md.)
  • Each fn's signature parses with the regular Mighty type grammar.
  • The compiler stores an ExternBinding { abi, name } per fn in Program::extern_bindings. The cranelift backend reads that table during declare_fns and declares the symbol as Linkage::Import (vs Linkage::Local for bodied fns). define_fn skips Import-fns entirely — the linker owns the body.

Signature matrix

Each row maps to a fixture under tests/extern_c_matrix/. The "Status" column reflects v0.36 reality. Rows marked wrapper-pattern ship today via a tiny zero-arg C entrypoint that builds the value(s) on the C side; the row's Mighty source still pins the link surface and the matrix test still proves the .a is reachable.

# Shape Status Notes
01 extern c fn foo() -> i32 (no args) works Simplest shape. Pins call-conv + return register.
02 extern c fn foo(a: i32, b: i32) -> i32 works Primitive in / out. Pins arg register ordering.
03 extern c fn foo(p: *const u8, len: usize) -> i32 works (v0.37 direct) ~~wrapper-pattern~~ — v0.37 T3 lifts the wrapper; Mighty Str literals coerce to *U8 at the call site. The fixture still ships the original wrapper for ABI coverage; the IDE agent (and real users) can call directly.
04 extern c fn foo(p: *mut i32) (out-param) works (v0.37 direct) ~~wrapper-pattern~~ — v0.37 T3 lifts the wrapper; &mut local produces a *mut I32 at the call site. Borrow check rules are unchanged.
05 extern c fn foo(s: Struct) -> i32 (by-value) works (v0.37 direct) ~~wrapper-pattern~~ — v0.37 T3 ships struct-literal-at-FFI-arg, so foo(Point { x: 1, y: 2 }) typechecks directly.
06 extern c fn foo(s: *const Struct) works (v0.37 direct) ~~wrapper-pattern~~ — v0.37 T3's &local of a struct produces a *const Struct. Pattern of choice for FFI handles (HWND, FILE, wgpu::Device).
07 extern c fn foo() -> Struct works (v0.38 direct) ~~wrapper-pattern~~ — v0.38 T3 ships returned-struct binding. The cranelift backend classifies the return by size: ≤8 bytes use the single-register convention (RAX), 9..=16 bytes use the two-register convention (RAX+RDX), >16 bytes use the hidden sret first param. Caller allocates the slot, folds the return registers in, and hands the slot address upstream. The matrix fixture still ships the original wrapper for ABI coverage; real Mighty code can call directly: let p: Point = make_point().
08 extern c fn foo(arr: *const [i32; 4]) works (v0.37 direct) ~~wrapper-pattern~~ — v0.37 T3's &local_array produces the array pointer. Identical ABI slot to any other pointer.
09 extern c fn foo(s: *const Str) (Str ↔ C const char*) works (v0.37 direct) ~~wrapper-pattern~~ — v0.37 T3 coerces Mighty Str literals/locals to *U8 (= const char * on every host).
10 extern c fn foo(s: *mut Str) -> usize (caller-owned buf) works (wrapper) The classic snprintf shape. Wrapper stays because Mighty doesn't yet expose a mutable Str buffer surface (you'd need a [U8; N] and pass &mut buf[0], which v0.37 T3 partially covers — full coverage is v0.38).
11 extern c fn foo(cb: fn(i32) -> i32) works (v0.38 direct) ~~wrapper-pattern~~ — v0.38 T3 ships the function-pointer surface. The Mighty parser already accepts fn(T1, T2) -> R as a type. Typeck unifies the Mighty fn's resolved TyData::Fn { params, ret } against the param's declared Fn type. The cranelift backend lowers a Const::FnPtr(FnRef::User(fid)) operand via func_addr against the local fn's Linkage::Local declaration; the linker resolves the address at final-link time. Builtin fn pointers (log, panic) are not addressable through this surface — use a plain Mighty fn for FFI callbacks.
12 Variadic call (printf(fmt, …)) works (v0.38 T2) extern c fn printf(fmt: *U8, ...) -> I32 parses + typechecks (v0.37 T6) and end-to-end calls with extra varargs lower through a per-call ir::Signature + call_indirect against func_addr of the imported symbol (v0.38 T2). C ABI default promotions applied to extras (f32→f64, i8/i16→i32, u8/u16→u32, bool/char→i32). ~~v0.38 follow-up~~ — v0.38 complete. Wasm stance unchanged (variadic externs still rejected).
13 extern c fn foo(s: Str) -> I32 (Str slice) works (v0.46 T3 direct) Mighty Str / String at an extern-c param slot expands to (const char* ptr, size_t len) at the ABI boundary. Mighty source spells the call with one Str arg; the cranelift backend emits two consecutive i64 args (the ptr-half and the byte-len half from the Str aggregate). Documented + tested in row 12 of tests/extern_c_matrix/ plus crates/mty-codegen-cranelift/tests/ffi_str_slice_v046_t3.rs. See v0.46 T3 section.

v0.37 ergonomics — the FFI surface is now ergonomic

v0.37 Track T3 (commit body tagged v0.37 T3: FFI coercions) shipped three call-site coercions that close the wrapper-pattern gap. Mighty source code can spell every shape on rows 3, 4, 5, 6, 8, 9 directly now.

Surface 1 — Str → *U8 coercion

extern c {
  fn ffi_take(p: *U8, len: USize) -> I32
}

fn main() {
  let _ = ffi_take("hello", 5)  // v0.37 T3 reads the Str's ptr half
}

The Mighty Str is a (ptr, len) aggregate stored in a 16-byte stack slot. At an extern c arg position whose declared type is *U8, typeck records the arg in coerce_str_to_ptr and SIR lowering emits Rvalue::StrPtr(arg). The cranelift backend reads offset 0 (the ptr) and passes it as the i64 scalar. intern_string already null-terminates the literal data, so the resulting *U8 is directly usable as a const char * on the C side.

Pre-v0.37 workaround: build the string in a C wrapper and pass through uint8_t *. The matrix fixture under tests/extern_c_matrix/row_03_ptr_in/ still uses that pattern so the test pins the link-level ABI even when the typeck path is bypassed — but real Mighty code should call directly.

Surface 2 — &local / &mut local for FFI out-params

extern c {
  fn ffi_out(p: *I32) -> Unit
}

fn main() {
  let mut x: I32 = 0
  ffi_out(&mut x)
  log("x was filled by C")
}

& (immutable) and &mut (mutable) are already prefix unary ops in the parser. Typeck records the arg in coerce_addr_of; the existing HirExpr::Borrow lowering allocates a Ref-typed temp whose slot holds the place's address (cranelift sees this as an i64 scalar) — the call-arg path passes it straight through.

Borrow check is unchanged: &mut x is an exclusive borrow held for the duration of the call expression, and shared &x borrows allow aliased reads. The compiler does not insert a runtime barrier; if the C side leaks the pointer past the borrow's lifetime that's UB, same as in C/Rust.

Surface 3 — struct literal as FFI arg

struct Rect { x: I32, y: I32, w: I32, h: I32 }

extern c {
  fn ffi_draw_rect(r: Rect) -> Unit
}

fn main() {
  ffi_draw_rect(Rect { x: 0, y: 0, w: 100, h: 50 })
}

The parser already accepts struct literals at expression position inside call arguments. v0.37 T3 locks in the typeck path: the struct literal flows through synth_expr → check_expr as a normal ADT value and the cranelift backend emits an AdtInit then passes the slot address. Small (≤ 16-byte) structs ride a single ABI register on x86_64, ARM64 and RISC-V — wgpu/winit's Point-, Color-, Extent3d-shaped arguments all fit.

Where the v0.37 wiring lives

Concern File
FnDef.extern_abi marker crates/mty-types/src/defs.rs (FnDef::extern_abi)
Extern-block ABI propagation crates/mty-types/src/resolve.rs (extern-block branch of declare_item)
Call-site coercion gate crates/mty-types/src/check.rs (try_extern_c_coercion, callee_is_extern_c)
Side tables crates/mty-types/src/lib.rs (TypedPackage::coerce_str_to_ptr, ::coerce_addr_of)
IR rvalue for Str→*U8 crates/mty-ir/src/ir.rs (Rvalue::StrPtr)
IR lowering wiring crates/mty-ir/src/lower/exprs.rs (lower_call per-arg coercion branch)
Cranelift StrPtr lowering crates/mty-codegen-cranelift/src/lower.rs (Rvalue::StrPtr arm)
Tests crates/mty-types/tests/ffi_coercions_v037.rs (18 cases across all three)
Demo demos/11_ffi_winit_stub/ (now uses all three surfaces)
Example examples/41_ffi_clean.mty (minimal side-by-side showcase)

v0.38 surfaces — returned struct, fn pointer, #[ffi_nul_ok]

v0.38 Track T3 (commit body tagged v0.38 T3: FFI returned-struct + fn-pointer surface + #[ffi_nul_ok] fast path) closes rows 07 and 11 of the matrix and adds the #[ffi_nul_ok] attribute.

Surface 1 — Returned-struct binding (row 07)

struct Point { x: I32, y: I32 }

extern c {
  fn make_point() -> Point
}

fn main() {
  let p: Point = make_point()
  log("p=({},{})", p.x, p.y)
}

The cranelift backend's new build_extern_signature classifies the return type by size:

  • ≤ 8 bytes — single-register return (RAX). Caller allocates a stack slot, stores the i64 return value at offset 0, hands the slot address upstream as the call's value.
  • 9..=16 bytes — two-register return (RAX + RDX on SysV; same shape on Windows-x64 via cranelift's calling convention modelling). Caller stores both i64 returns at slot offsets 0 and 8.
  • 16 bytes — hidden sret first param. Caller allocates the slot, prepends its address as the first arg, ignores any return value.

The 16-byte cut-off mirrors the SysV ABI §3.2.3 INTEGER+INTEGER rule. Typical wgpu/winit return shapes (Point, Extent3d, Rect) stay in the register regime; large state structs use sret transparently.

Surface 2 — Function pointer (row 11)

extern c {
  fn ffi_sort(buf: *U8, n: USize, sz: USize, cmp: fn(*U8, *U8) -> I32) -> Unit
}

fn my_cmp(a: *U8, b: *U8) -> I32 {
  0
}

fn main() {
  ffi_sort("buf", 4, 4, my_cmp)
}

The parser already accepted fn(T1, T2) -> R as a type since v0.1; v0.38 T3 ties it to FFI call sites end-to-end:

  • Typeck unifies the Mighty fn's resolved TyData::Fn { params, ret } against the declared param type. Arity mismatch and return-type mismatch surface as the usual MT2001.
  • IR lowering already emitted Const::FnPtr(FnRef::User(fid)) for fn-typed path expressions (since v0.x); v0.38 T3 lights up the cranelift backend's Const::FnPtr arm, which takes the fn's address via func_addr against the Linkage::Local declaration.
  • Builtin fn pointers (log, panic) intentionally remain unsupported — the runtime helpers don't have stable C-ABI symbols. Use a plain Mighty fn for FFI callbacks.

Surface 3 — #[ffi_nul_ok] attribute

extern c {
  fn strlen(#[ffi_nul_ok] s: *U8) -> USize
}

fn main() {
  let cs = "hello"
  let n = strlen(cs)
}

The attribute documents that the caller has guaranteed the bytes arrive at the C side as a null-terminated const char *. v0.37 T3's Str→U8 coercion already takes the no-copy fast path for both Str literals and dynamic Str locals (intern_string null-terminates; runtime-built Strs ride a (ptr, len) aggregate whose ptr-half is what gets passed). The attribute therefore is a metadata-only* marker today — its purpose is to:

  1. Document the safety contract at the call site so downstream reviewers can audit it without re-reading the C side.
  2. Reserve the syntax + side-table for a future hardening pass that inserts a runtime null-terminator check on un-marked Str→*U8 coercions when the input came from an effectful source (std.io.read_line, net.body, …).

Implementation details:

  • Parser: param() now accepts #[attr] prefixes on FN_PARAM nodes. Generic — future per-param attributes land without a re-walk.
  • HIR: HirParam.attrs: Vec<String> carries the attribute name list. Empty for the vast majority of params.
  • Typeck: at extern-c call sites where Str→*U8 fires, if the matching FnDef's HIR param has attrs.contains("ffi_nul_ok"), the arg also lands in TypedPackage.coerce_nul_ok (subset of coerce_str_to_ptr).
  • Lowering: no behavioural change today (already on the no-copy path). The side table is read-only for future passes.

Where the v0.38 T3 wiring lives

Concern File
#[attr] prefix on FN_PARAM crates/mty-syntax/src/parser/items.rs (param)
HirParam.attrs + lower from CST crates/mty-hir/src/nodes.rs + crates/mty-hir/src/lower/items.rs (lower_param_attrs)
coerce_nul_ok side table crates/mty-types/src/lib.rs (TypedPackage::coerce_nul_ok)
Nul-ok lookup at call site crates/mty-types/src/check.rs (callee_param_has_nul_ok)
AggregateReturnKind classifier crates/mty-codegen-cranelift/src/abi.rs (classify_aggregate_return, build_extern_signature)
extern_return_kinds LowerCtx table crates/mty-codegen-cranelift/src/lower.rs (LowerCtx::extern_return_kinds)
Call-site slot + register folding crates/mty-codegen-cranelift/src/lower.rs (lower_call / FnRef::User)
Const::FnPtr cranelift lowering crates/mty-codegen-cranelift/src/lower.rs (eval_const)
Tests — typeck crates/mty-types/tests/ffi_v038_t3.rs (16 cases)
Tests — codegen crates/mty-codegen-cranelift/tests/ffi_v038_t3.rs (9 cases)
Demo demos/11_ffi_winit_stub/ (now exercises rows 07 + 11 + nul_ok)

v0.46 T3 — (ptr, len) Str slice FFI (L52 fix)

Mighty Str / String values are (ptr, len) aggregates internally — the ptr-half points at the UTF-8 byte blob (intern_string null-terminates literals; runtime-built Strs ride a real heap pointer), and the len-half is the BYTE count.

Before v0.46 T3 the only path for passing string data across extern c was the v0.37 Str → *U8 coercion, which:

  1. Required the caller's param type to be *U8 (not Str).
  2. Implicitly read the ptr-half only — the C side had to call strlen or rely on null-termination to recover the length.

That worked for shapes like strlen(s) (row 09), but broke down for ANY shape where the C side wanted to handle non-null-terminated bytes, dispatch on the byte length first, or avoid the O(n) strlen walk. L52 documents the IDE's downstream pain: every prompt-driven command in src/main.mty (Open, New Folder, Rename, Delete, …) had to copy its query buffer through the Rust shim ONE CODEPOINT AT A TIME via mui_path_push(handle, mui_prompt_char(handle, i)) because there was no way to hand a single Mighty string across the FFI boundary.

v0.46 T3 closes the gap with a transparent compiler transform: write the Mighty signature with a Str (or String) param, write the C signature with a (const char* ptr, size_t len) pair, and the cranelift backend bridges the two at the call site.

Surface — Str at an extern-c param slot

extern c {
  fn mui_file_rename(handle: I64, path: Str) -> Unit
}

fn main() {
  let h: I64 = 1
  mui_file_rename(h, "newname.txt")
}

The C header declares:

#include <stddef.h>
#include <stdint.h>

void mui_file_rename(int64_t handle, const char *path_ptr, size_t path_len);

Each Str / String param expands to two consecutive ABI args:

  • ptr_half : int64_t carrying the byte-pointer (same shape as Str → *U8's ptr extraction).
  • len_half : int64_t (size_t) carrying the BYTE count.

The expansion happens uniformly in:

  • mty_codegen_cranelift::abi::build_extern_signature — the extern signature has the doubled slot count.
  • mty_codegen_cranelift::lower::lower_call — at the call site, every Str/String-typed arg pulls both halves from the Str aggregate via string_pair and pushes them in (ptr, len) order.
  • mty_codegen_llvm::lower::fn_type_of + lower_call — same expansion for the LLVM backend (literal-Str only; dynamic-Str routes through cranelift today).

Ownership + lifetime contract

  • The bytes are owned by the Mighty caller. The pointer is live for the duration of the call expression and no longer.
  • The C side must not store the pointer in a heap structure or return it through a global — Mighty may free / move / reuse the backing arena after the call returns.
  • The C side must read at most len bytes from the pointer.
  • len == 0 is legal; the pointer arg is undefined in that case (the cranelift backend usually passes the literal's symbol address even for "", but C must not dereference unless len > 0).
  • Multi-byte UTF-8 is preserved as-is; len is the BYTE count, NOT the codepoint count.

Interaction with the v0.37 Str → *U8 coercion

The two surfaces compose:

Mighty source Param type Lowering
f("hi") *U8 v0.37 — passes one i64 (ptr-half), C reads via strlen / nul.
f("hi") Str/String v0.46 T3 — passes two i64s (ptr, len), C reads exactly len.

Same Mighty source, different C contract — pick *U8 when the C side is a libc-style nul-terminated function, pick Str when you want the length up front. Mixed-arg calls (fn foo(slice: Str, cstr: *U8)) work without ceremony.

Wasm backend stance

Wasm core doesn't have a 64-bit-style C ABI; the wasm backend treats the existing Rvalue::StrPtr as a pass-through and an extern c fn foo(s: Str) call still surfaces a single-arg call to the import. If you target wasm and need the byte length too, declare a separate extern js fn foo_len() and lower per-target. Same stance as variadics.

IDE simplification example

Pre-v0.46 T3 (the L52 staging-loop pattern from mighty-ide/src/main.mty):

// Stage the prompt string char-by-char into a shim buffer.
mui_path_clear(handle)
let mut i: USize = 0
while i < mui_prompt_len(handle) {
  mui_path_push(handle, mui_prompt_char(handle, i))
  i = i + 1
}
// Call the real op against the shim's staged buffer.
mui_file_rename_active(handle)

Post-v0.46 T3 — direct call, no shim buffer:

extern c {
  fn mui_file_rename_active(handle: I64, path: Str) -> Unit
}

// Pull the prompt text into a Mighty String, hand it across as-is.
let path: String = mui_prompt_text(handle)
mui_file_rename_active(handle, path)

The shim's C-side mui_file_rename_active declaration changes from void mui_file_rename_active(int64_t) to void mui_file_rename_active(int64_t, const char*, size_t); it can delete mui_path_clear / mui_path_push / mui_prompt_char / the backing per-handle staging buffer. The L52 pattern survives only for backwards compatibility with pre-v0.46 callers.

Where the wiring lives

Concern File
Signature expansion (cranelift) crates/mty-codegen-cranelift/src/abi.rs (build_extern_signature, is_str_slice_param)
Call-site Str→(ptr, len) emit (cranelift fixed) crates/mty-codegen-cranelift/src/lower.rs (lower_call, FnRef::User arm)
Call-site Str→(ptr, len) emit (cranelift variadic) crates/mty-codegen-cranelift/src/lower.rs (variadic per-call signature)
Signature expansion (LLVM) crates/mty-codegen-llvm/src/lower.rs (fn_type_of)
Call-site Str→(ptr, len) emit (LLVM) crates/mty-codegen-llvm/src/lower.rs (lower_call)
Cranelift object-shape tests (8 cases) crates/mty-codegen-cranelift/tests/ffi_str_slice_v046_t3.rs
End-to-end matrix integration (3 cases) crates/mty-driver/tests/extern_c_matrix.rs (row_12_*)
Matrix fixture tests/extern_c_matrix/row_12_str_slice/{app.mty, impl.c}

Unresolved

  • Out-params / mutable buffers (mut Vec[U8] for caller-allocated output buffers) deferred to v0.47. Today the IDE uses fixed shim- side scratch buffers for the C-writes-back pattern; once mutable byte-buffer FFI is in, the shim can vanish too.
  • String ownership transfer across FFI (move semantics) is out of scope — the caller-owns model is the safer default.

Remaining v0.39 follow-ups

  1. Mutable Str / caller-owned buffer ergonomics for row 10's snprintf shape — needs first-class mutable byte-buffer binding surface in Mighty (let mut buf: [U8; 256] = [0u8; 256] and ffi(buf as *mut U8) should work cleanly).
  2. ~~Variadic extern call extension — per-call-site ir::Signature
  3. call_indirect.~~ Shipped in v0.38 T2. Calls like printf("%d\n", 42) now work end-to-end on the cranelift backend with C ABI default promotions applied to extras.
  4. #[ffi_nul_ok] runtime enforcement — pivot the attribute from metadata-only to opt-in for the future safety-wrapper pass. The Mighty Str-builder API (format!, runtime accumulators) may eventually start producing non-null-terminated bytes; once that shift lands, the un-marked Str→*U8 coercion path inserts a bounded-length safety wrapper, and #[ffi_nul_ok] opts back into the raw pointer.

v0.37 T6 — variadic externs (parse / typeck / decl)

Lands the ... token and the full parse → HIR → SIR plumbing for variadic C signatures. What works today on the cranelift backend:

  • Declaration. extern c fn printf(fmt: *U8, ...) -> I32 parses (the ... is wrapped in a VARIADIC_MARKER CST node sibling to the trailing FN_PARAMs), lowers to HirFn { is_variadic: true, ... }, flows into FnDef.is_variadic, and the SIR ExternBinding carries the flag so every backend can see it.
  • Typeck. synth_call recognises a single-segment Path callee that resolves to a variadic FnDef, switches the strict params.len() != args.len() check to args.len() >= params.len(), and synthesises a fresh inference variable for each extra arg (typed independently). Below-fixed-arity calls still emit MT2005.
  • Codegen — fixed-arity prefix. Calls that pass exactly the fixed-arity prefix (e.g. printf(fmt)) lower like any other extern C call: the linker resolves the symbol, the declared signature is exact, the call instruction validates.
  • Codegen — variadic call extension. ~~Calls with extra args (printf(fmt, 1, 2)) surface a clean CodegenError::Unsupported pointing at this doc.~~ Tracked as v0.38 follow-up — shipped in v0.38 T2, see below.
  • Wasm backend. Any program containing a variadic extern fn fails the wasm compile with WasmError::Unsupported, regardless of whether the fn is actually called. Core wasm has no varargs ABI and the Component Model FFI surface forbids it. Use the cranelift backend instead.

v0.38 T2 — variadic call codegen (complete)

Per-call signatures + call_indirect are wired. Every variadic call site whose extras list is non-empty:

  1. Builds a fresh ir::Signature whose params are the declared fixed-prefix types followed by one AbiParam per extra under the C ABI default-promotion rules (crates/mty-codegen-cranelift/src/abi.rs::cl_ty_for_variadic):

  2. bool / charI32

  3. I8 / I16I32 (signed; widens via sextend)
  4. U8 / U16I32 (unsigned; widens via uextend)
  5. F32F64 (widens via fpromote)
  6. pointers / USize / ISize / wider scalars: pass through
  7. Imports the signature with FunctionBuilder::import_signature.
  8. Takes the linked symbol's address with func_addr(ct::I64, fn_ref) — the extern is already declared Linkage::Import so the JIT symbol-resolver / static linker provides the real address.
  9. Dispatches through call_indirect(sig_ref, addr, &arg_vals).

Zero-extras variadic calls still go through the regular direct call path (sig matches the declared one exactly). Non-variadic calls with unexpected extras emit CodegenError::Unsupported instead of trapping — defensive guard, since typeck should reject this shape.

The wasm-side stance does NOT change — variadic externs stay rejected with WasmError::Unsupported. See crates/mty-codegen-cranelift/tests/variadic_call.rs for the codegen + ABI + real-libc-printf-round-trip coverage.

Practical examples

# mighty.toml
[package]
name    = "winit_demo"
version = "0.1.0"
edition = "2026"

[[extern_lib]]
name = "winit_shim"
kind = "static"
path = "vendor/libwinit_shim.a"
link_args_macos = ["-framework", "Cocoa", "-framework", "CoreFoundation"]
link_args_linux = ["-lX11", "-lxkbcommon"]
link_args_windows = ["Userenv.lib"]
// src/main.mty
extern c {
  fn winit_demo_open_window() -> I32
}

fn main() {
  let rc = winit_demo_open_window()
  log("opened with rc=...")
}
mty build src/main.mty --release
./target/main
[[extern_lib]]
name = "z"
kind = "dynamic"
# no `path` → linker emits `-lz` and searches LD_LIBRARY_PATH /
# DYLD_FALLBACK_LIBRARY_PATH / PATH at runtime.

Multiple archives in dependency order

# wgpu depends on winit's surface helpers; declare winit first so its
# symbols are on the command line when the linker walks wgpu's
# unresolved set.
[[extern_lib]]
name = "winit"
path = "vendor/libwinit.a"

[[extern_lib]]
name = "wgpu"
path = "vendor/libwgpu.a"

Runtime symbol stub (test-only)

The cranelift backend pre-declares every mty_runtime_* symbol as a Linkage::Import. Even when the Mighty program doesn't call any of them, the symbol references still land in the emitted .o. Real deployments link against libmty_runtime.a (in development) or a profiled runtime build. The matrix tests don't need the real runtime, so the test harness in crates/mty-driver/tests/extern_c_matrix.rs builds a tiny no-op stub archive (build_runtime_stub) and threads it through alongside the row's own archive. See the comment block at the top of build_runtime_stub for details — it's a useful pattern for any external integrator who wants to ship a stand-alone FFI binary without pulling the full runtime.

Where the wiring lives

Concern File
[[extern_lib]] parse + types crates/mty-driver/src/manifest.rs (ExternLib, HostOs)
Manifest → flat linker args crates/mty-driver/src/build.rs (build_linker_args)
Linker invocation (extra args) crates/mty-codegen-cranelift/src/object.rs (link_executable_with_libs)
Linkage::Import for extern fns crates/mty-codegen-cranelift/src/lower.rs (declare_fns)
Extern fn signature propagation crates/mty-types/src/items.rs (the pre-pass at the top of check_package_typed)
extern_bindings table crates/mty-ir/src/ir.rs (Program::extern_bindings, ExternBinding)
Matrix tests crates/mty-driver/tests/extern_c_matrix.rs
Manifest tests crates/mty-driver/tests/manifest.rs (rows starting extern_lib_* + build_linker_args_*)

Demo

demos/11_ffi_winit_stub/ ships a minimal scaffold for FFI app authors — a mighty.toml with the [[extern_lib]] block, a winit_shim.c that compiles on every host, and a main.mty calling into it. The smoke check is gated on MTY_FFI_SMOKE=1 so CI doesn't open a real window.