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/Stringat 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¶
- Declare each native library in
mighty.tomlunder one or more[[extern_lib]]blocks (schema below). - Declare each C-ABI function in an
extern c { ... }block at the top of your Mighty source. Signatures use the standard Mighty type syntax —*U8for pointers,USizeforsize_t,I32/I64forint32_t/int64_t, etc. - Call those functions like any other Mighty fn. The cranelift backend
declares them as
Linkage::Importand 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.
Project-wide link knobs — [build] (v0.41 T4)¶
[[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
candjs. (jsis the wasm-host browser binding; seedocs/internals/wasm.md.) - Each fn's signature parses with the regular Mighty type grammar.
- The compiler stores an
ExternBinding { abi, name }per fn inProgram::extern_bindings. The cranelift backend reads that table duringdeclare_fnsand declares the symbol asLinkage::Import(vsLinkage::Localfor bodied fns).define_fnskips 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'sConst::FnPtrarm, which takes the fn's address viafunc_addragainst theLinkage::Localdeclaration. - 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:
- Document the safety contract at the call site so downstream reviewers can audit it without re-reading the C side.
- 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 inTypedPackage.coerce_nul_ok(subset ofcoerce_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:
- Required the caller's param type to be
*U8(notStr). - Implicitly read the ptr-half only — the C side had to call
strlenor 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_tcarrying the byte-pointer (same shape asStr → *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, everyStr/String-typed arg pulls both halves from the Str aggregate viastring_pairand 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
lenbytes from the pointer. len == 0is 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 unlesslen > 0).- Multi-byte UTF-8 is preserved as-is;
lenis 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¶
- Mutable Str / caller-owned buffer ergonomics for row 10's
snprintfshape — needs first-class mutable byte-buffer binding surface in Mighty (let mut buf: [U8; 256] = [0u8; 256]andffi(buf as *mut U8)should work cleanly). - ~~Variadic extern call extension — per-call-site
ir::Signature call_indirect.~~ Shipped in v0.38 T2. Calls likeprintf("%d\n", 42)now work end-to-end on the cranelift backend with C ABI default promotions applied to extras.#[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, ...) -> I32parses (the...is wrapped in aVARIADIC_MARKERCST node sibling to the trailingFN_PARAMs), lowers toHirFn { is_variadic: true, ... }, flows intoFnDef.is_variadic, and the SIRExternBindingcarries the flag so every backend can see it. - Typeck.
synth_callrecognises a single-segmentPathcallee that resolves to a variadicFnDef, switches the strictparams.len() != args.len()check toargs.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 cleanCodegenError::Unsupportedpointing 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:
-
Builds a fresh
ir::Signaturewhoseparamsare the declared fixed-prefix types followed by oneAbiParamper extra under the C ABI default-promotion rules (crates/mty-codegen-cranelift/src/abi.rs::cl_ty_for_variadic): -
bool/char→I32 I8/I16→I32(signed; widens viasextend)U8/U16→I32(unsigned; widens viauextend)F32→F64(widens viafpromote)- pointers /
USize/ISize/ wider scalars: pass through - Imports the signature with
FunctionBuilder::import_signature. - Takes the linked symbol's address with
func_addr(ct::I64, fn_ref)— the extern is already declaredLinkage::Importso the JIT symbol-resolver / static linker provides the real address. - 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¶
Static link against a vendored archive¶
# 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=...")
}
Dynamic library (system-search)¶
[[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.