Guides · 15
C interop
Vyi talks to C directly: you declare the C functions and types you need in Vyi
source, call them like ordinary functions, and the system C toolchain does the
linking. The FFI is a handful of c_-prefixed declaration forms plus one import —
no separate binding generator or interface file.
Vyi rules and C rules stay on their own sides of the boundary. On the Vyi side, pointers are non-null, fields are initialised, and checks apply as usual. On the C side, C's rules (implicit zeroing, nullable pointers, unchecked arithmetic) apply to C-declared types only. Which side a value is on is in its type.
The c namespace
Everything C lives under one name: c, imported from the virtual package @c:
import { c } from "@c"
import { stdout } from "@io"
fn main() !u8 {
mut n: c.int = 10
con p: *mut c.int = &n // c.int has i32's representation — pointer types line up
*p = *p + 7
con sum: i32 = n + 25 // representation twins flow implicitly into i32 arithmetic
stdout.writeInt(i64(sum))
stdout.write("\n")
return Ok(0)
}c. contains the C primitive type aliases, and every c_fn, c_struct,
c_enum, and c_union your program declares. C declarations are visible only
through this namespace — declaring c_struct Pair gives you c.Pair, not a
bare Pair — so C names can never collide with your Vyi names:
import { c } from "@c"
c_struct Pair { a: c.int, b: c.int }
fn main() !u8 {
con p: Pair = _{ a: 1, b: 2 } // error: undefined name "Pair" — it lives at c.Pair
return Ok(0)
}The namespace is program-wide: a c_fn declared in a package you import (like
@libc below) is callable as c.name from anywhere in the program.
The primitive aliases
Each c. name is a distinct nominal type — c.int is not i32, even
though the two share a representation. Vyi's ordinary rule for nominally
distinct but shape-compatible values applies here exactly as it does between
declared structs (guide 03): a value flows
implicitly between two types with the same representation at the usual
sites — call arguments, return, initialisers, struct-literal fields — and
the explicit cast TargetType(x) is always available. A width change (e.g.
i32 into a 64-bit c.size_t) is not a representation match, so it needs
that explicit cast, exactly as i32 → i64 does everywhere else in Vyi.
Integer literals still infer their context type, so c.clamp(50, 0, 42)
above stays clean without any casts.
Which conversions count as "same representation" is target-keyed: some C
types have no fixed width. Most current targets are 64-bit Unix (LP64) —
long, size_t, and friends are 64-bit — but the wasm32 target is ILP32,
where those same names are 32-bit. The table below is x86-64-linux's (LP64):
| C alias | Vyi type | C alias | Vyi type | |
|---|---|---|---|---|
c.char, c.signed_char | i8 | c.unsigned_char | u8 | |
c.short | i16 | c.unsigned_short | u16 | |
c.int | i32 | c.unsigned_int | u32 | |
c.long, c.long_long | i64 | c.unsigned_long, c.unsigned_long_long | u64 | |
c.float | f32 | c.double, c.long_double | f64 | |
c.ssize_t, c.intptr_t | i64 | c.size_t, c.uintptr_t | u64 | |
c.bool | bool | c.void | ()-like void |
On wasm32 (ILP32), c.long, c.unsigned_long, c.size_t, c.ssize_t,
c.intptr_t, and c.uintptr_t are 32-bit instead — their representation
twin is i32/u32, not i64/u64. c.long_long and
c.unsigned_long_long stay 64-bit on every target. Code that moves a value
between, say, i64 and c.long therefore compiles differently per target:
implicit on x86-64-linux, an explicit cast on wasm32 — the same
target-dependence the rest of the toolchain already carries
(reference/toolchain/targets).
One distinction to keep straight: c.char is a byte (i8), while Vyi's
own char is a 32-bit Unicode codepoint. C string and byte-buffer APIs want
c.char / c.unsigned_char, never char.
The alias names also work as conversion functions with C semantics — a float-to-int conversion truncates toward zero, exactly like a C cast:
import { c } from "@c"
import { stdout } from "@io"
fn main() !u8 {
con d: f64 = 42.9
con n = c.int(d) // truncates toward zero, like a C cast: 42
con back = c.double(n) // 42.0
if back == 42.0 {
stdout.writeInt(i64(n)) // prints 42
stdout.write("\n")
return Ok(0)
}
return Ok(1)
}Width changes are still explicit, though — being C-typed does not buy
implicit narrowing or widening. A c.long_long does not silently narrow
into a c.int; you write the cast, on both sides of the boundary:
import { c } from "@c"
fn main() !u8 {
con n: c.long_long = 5
con m: c.int = n // error: cannot assign c.long_long to c.int — cast explicitly
return Ok(0)
}c_fn: declaring C functions
A c_fn with no body is an extern prototype — a promise that the symbol
exists in some C library the program links against. c_include names that
library:
import { c } from "@c"
import { stdout } from "@io"
c_include "mymath"
c_fn clamp(x: c.int, lo: c.int, hi: c.int) c.int
fn main() !u8 {
stdout.writeInt(i64(c.clamp(50, 0, 42)))
stdout.write("\n")
return Ok(0)
}Type-checking never needs the C code — vyi check passes without mymath
existing anywhere. The library only has to be resolvable at link time (see
Linking below).
A c_fn with a body is an ordinary function written in Vyi but emitted
under its bare C name — useful for providing a C-visible symbol, or for
stubbing out a C dependency in pure Vyi during development:
import { c } from "@c"
import { stdout } from "@io"
c_fn add_two(a: i32, b: i32) i32 {
return a + b
}
fn main() !u8 {
stdout.writeInt(i64(c.add_two(30, 12)))
stdout.write("\n")
return Ok(0)
}A c.name(args) call checks arity and argument assignability exactly like
any other Vyi call — too many or too few arguments is an error, and each
argument must be assignable to its declared C parameter type (representation
twins flow implicitly per the previous section; a width change needs a
cast). Integer literals still infer their parameter's type, so passing a bare
literal never needs a cast:
import { c } from "@c"
c_fn take_size(n: c.size_t) c.int { return c.int(0) }
fn main() !u8 {
con n: i32 = 7
c.take_size(n) // error: cannot assign i32 to c.size_t — cast explicitly
c.take_size(64) // fine — the literal infers c.size_t
c.take_size(64, 65) // error: take_size takes 1 argument, got 2
return Ok(0)
}A c_fn signature also can't take a known parameter: a C symbol has one
fixed runtime signature, so there's nothing to monomorphize.
import { c } from "@c"
c_fn bad(known n: c.int, x: c.int) c.int
fn main() !u8 {
return Ok(0)
}C data types: c_struct, c_enum, c_union
c_struct
A c_struct is a composite with C's layout guarantees: fields in declaration
order, C alignment and padding, no reordering. That's what makes it safe to
hand across the boundary. On the Vyi side it behaves like a struct — literals
use the inferred-type form _{ … }, field access and pointers work exactly as
they do for native structs:
import { c } from "@c"
import { stdout } from "@io"
c_struct Rect {
w: c.int,
h: c.int,
}
fn scale(r: *mut c.Rect) {
r.w = r.w * 2
r.h = r.h * 2
}
fn main() !u8 {
mut r: c.Rect = _{ w: 3, h: 4 }
scale(&r)
stdout.writeInt(i64(r.w + r.h)) // prints 14
stdout.write("\n")
return Ok(0)
}scale above takes r behind a pointer; that shape is always fine. A
by-value c_struct (or c_union) as a c_fn parameter or return is also
legal C: backends classify it under the target ABI (SysV eightbytes on
x64 — INTEGER/SSE registers up to 16 bytes, byval/sret beyond; clang's
byval/sret convention on wasm32). Vyi-only aggregates that are not C
layout (Optional, tuples, arrays, interfaces, …) stay rejected at the
c_fn boundary:
import { c } from "@c"
import { stdout } from "@io"
c_struct Pair { a: c.int, b: c.int }
c_fn take_pair(p: c.Pair) c.int
c_fn make_pair(a: c.int, b: c.int) c.Pair
fn main() !u8 {
con p = c.make_pair(1, 2)
stdout.writeInt(i64(c.take_pair(p))) // prints 3
stdout.write("\n")
return Ok(0)
}import { c } from "@c"
c_fn take_opt(o: ?c.int) c.int
c_fn make_tuple() (c.int, c.int)
fn main() !u8 {
return Ok(0)
}For non-C aggregates, pass a pointer (or reshape into a c_struct) —
exactly the shape scale already uses for shared mutation.
Opaque c_struct declarations
A c_struct with no body — just the name — declares an opaque C type: a
type with no known fields and no known size, usable only behind a pointer.
This is the standard shape for C's handle types (FILE, sqlite3, and
similar), where the struct's definition is private to the C library and Vyi
only ever holds a pointer to it:
import { c } from "@c"
c_include "handles"
c_struct Handle
c_fn handle_new() c_ptr c.Handle
c_fn handle_value(h: c_ptr c.Handle) c.int
c_fn handle_free(h: c_ptr c.Handle)
export fn run() i32 {
con h = c.handle_new()
con v = c.handle_value(h)
c.handle_free(h)
return i32(v)
}
fn main() !u8 { run(); return Ok(0) }An opaque type has no size, so anything that would need one is a compile
error: constructing or dereferencing to a value of it, using it as a
struct/c_union field, an array/tuple element, or an enum payload, and
pointer arithmetic or indexing on a c_ptr/[*] over it (there's no stride
to advance by). The pointer itself — *c.Handle, c_ptr c.Handle — is
always fine; that's the whole point of the form.
Converting between struct and c_struct
A Vyi struct and a c_struct convert into each other by the same
shape-compatible rule that lets one declared struct flow into another
(reference/language/types § nominal identity, structural
compatibility):
matched by field name, with the target's fields a subset of the source's.
Across the Vyi/C line, "the field types are themselves compatible" means
representation twins — the same rule the primitive
aliases use — so a 64-bit i64 field lines up with
a 64-bit c.long_long field, but not with a 32-bit one. The conversion is
implicit at the four usual sites (call arguments, return, initialisers,
struct-literal fields), and the explicit form works both ways —
c.Target(v) from Vyi to C, Target(cv) back:
import { c } from "@c"
import { stdout } from "@io"
c_struct Timeval { sec: c.long_long, usec: c.long_long }
struct Time { sec: i64, usec: i64 }
struct Stamp { sec: i64, usec: i64, tag: i32 } // extra field: dropped when narrowing
fn sum(tv: c.Timeval) i64 { return tv.sec + tv.usec }
fn main() !u8 {
con t = Time{ sec: 1, usec: 2 }
con tv: c.Timeval = t // implicit, Vyi struct -> c_struct
con explicit = c.Timeval(t) // explicit form
con back: Time = tv // implicit, c_struct -> Vyi struct
con s = Stamp{ sec: 4, usec: 5, tag: 7 }
con narrowed: c.Timeval = s // tag dropped: Stamp has every Timeval field
stdout.writeInt(i64(sum(narrowed) - back.sec + explicit.usec)) // prints 9 - 1 + 2 = 10
stdout.write("\n")
return Ok(0)
}A field pair that isn't a representation twin blocks the whole struct conversion — there's no per-field narrowing cast hiding inside a struct copy; give the field a matching width or convert it by hand:
import { c } from "@c"
c_struct Wide { n: c.long_long }
struct Narrow { n: i32 }
fn main() !u8 {
con a = Narrow{ n: 5 }
con w: c.Wide = a // error: n is i32, not c.long_long's representation twin
return Ok(0)
}c_enum
A c_enum is C's integer enumeration, backed by c.int. Enumerators count up
from 0; an explicit = N sets that enumerator and counting continues from it.
A c_enum value converts to its integer type the way C's does:
import { c } from "@c"
import { stdout } from "@io"
c_enum Status {
Idle, // 0
Busy, // 1
Error = 10, // explicit value
Retry, // continues from it: 11
}
fn main() !u8 {
con s: c.Status = c.Status.Retry
con code: c.int = s // a c_enum converts to its integer type
stdout.writeInt(i64(code))
stdout.write("\n")
return Ok(0)
}This is nothing like a Vyi enum — there are no payloads, no exhaustive
switch arms, no type safety beyond the integer. It exists to match C APIs.
A Vyi enum is not an integer, at the C boundary or anywhere else: c.int
is a primitive, and casting an aggregate (a struct, enum, or tuple) to a
primitive is rejected the same way everywhere in Vyi (reference/language/
expressions § casts) — a
payload-less enum gets no exception. c.int(someEnum) fails exactly like
i32(someEnum) would; c_enum (declared or derived)
is the C-integer story, not a cast off a Vyi enum:
import { c } from "@c"
enum Shade { Light, Dark }
fn main() !u8 {
con n = c.int(Shade.Dark) // error: an enum is not an integer, even a payload-less one
return Ok(0)
}From a C integer: the partial cast ?Enum(n)
Inbound is a different question: C hands you an integer — a c_enum value,
a status code — and you want a Vyi enum. An integer's domain is open (any
value can arrive), a payload-less enum's is not, so the cast is partial
and its result type carries the check: ?Shade(n) produces an ordinary
?Shade. An ordinal in [0, variantCount) selects the variant with that
tag (Some); anything else — too high, or negative (the range check is
signed-aware, so a negative operand is None, never undefined behaviour) —
is None. The rules:
- Explicit only. An integer never flows into an
?Enumslot; the range-check branch exists only as this visible cast. - Any integer primitive operand, Vyi or C —
i32,c.int,i64, … The check runs at the operand's own width. - Payload-less enum targets only. The cast selects a variant by ordinal (its tag); a payload-carrying variant has no payload value an ordinal could construct, and the compiler says so.
- Outbound stays
c_enum: this cast has no reverse — a Vyi enum is not an integer.
import { c } from "@c"
enum Shade { Light, Mid, Dark }
c_fn level() c.int { return c.int(1) }
fn main() !u8 {
switch ?Shade(c.level()) { // 1 → Some(Mid); 9 or -1 → None
Some(s: _): { return Ok(0) },
None: { return Ok(1) },
}
}c_union
A c_union is C's untagged overlap: every field lives at offset 0 and the
union's size is its widest member's. Writing one member and reading another
reinterprets the same bytes (current targets are little-endian):
import { c } from "@c"
import { stdout } from "@io"
c_union Word {
i: c.int,
b: u8,
}
fn main() !u8 {
mut w: c.Word = _{ i: c.int(300) } // 300 = 0x12C
con low = w.b // reads the low byte: 0x2C = 44
stdout.writeInt(i64(low))
stdout.write("\n")
return Ok(0)
}There is no tag and no checking — you are asserting you know which member is meaningful, exactly as in C.
Deriving C twins: c_struct(T), c_enum(T), c_union(T)
Instead of hand-declaring a parallel C type next to a Vyi one you already
have, derive its C-layout twin directly — a type-level operator applied to
the Vyi type, used in a type declaration like any other type expression:
c_struct(T)—Tmust be a Vyi struct. The twin has the same field names in the same declaration order, C layout, and is interned exactly like a declaredc_struct— it converts to and fromTby the rule above, and stands as ac_ptrelement in ac_fn's parameter and return types. Every field must be C-representable (a primitive, a pointer, ac_ptr, another struct — twinned recursively — or ac_union); a field that isn't (a payload-carrying enum, a[*]Tview, a closure value, …) is a compile error naming the field.c_enum(T)—Tmust be a payload-less Vyi enum. Its twin isc.intitself, the same storage type a declaredc_enumalready uses —type CLevel = c_enum(Level)names that integer type after the enum it corresponds to, for signatures where the intent is worth making visible.c_union(T)— not yet supported. A Vyi structural union's members are unnamed, and a C union's members are not, so there is no natural mapping to derive; writing it is a compile error.
import { c } from "@c"
import { stdout } from "@io"
struct Point { x: i32, y: i32 }
type CPoint = c_struct(Point) // same fields, declaration order, C layout
enum Level { Low, Mid, High }
type CLevel = c_enum(Level) // payload-less enum only; the twin IS c.int
fn sumC(cp: CPoint) i32 { return cp.x + cp.y }
c_fn clamp(n: CLevel) CLevel { return n }
fn main() !u8 {
con p = Point{ x: 3, y: 4 }
con cp: CPoint = p // implicit, the ordinary struct <-> c_struct rule
con explicit = CPoint(p) // explicit form
con back: Point = cp // implicit, the other direction
con clamped = c.clamp(c.int(2))
stdout.writeInt(i64(sumC(cp) + sumC(explicit) - (back.x + back.y) + clamped)) // prints 7+7-7+2=9
stdout.write("\n")
return Ok(0)
}Every rejection names the operand it was given, and — for a c_struct(T)
field — the offending field:
import { c } from "@c"
enum Tagged { A(i32), B }
struct HasPayloadEnum { tag: Tagged, n: i32 }
type BadOperand = c_struct(i32) // error: operand must be a struct type
type BadField = c_struct(HasPayloadEnum) // error: field "tag" is not C-representable
fn use(a: BadOperand, b: BadField) {}
fn main() !u8 { return Ok(0) }C types follow C rules
Vyi requires every struct field to get a value (or a declared default) at
construction. C types opt out: fields you omit from a c_struct or c_union
literal are implicitly zeroed, because that is the contract C code expects
of its own types:
import { c } from "@c"
import { stdout } from "@io"
c_struct Pair { a: c.int, b: c.int }
fn main() !u8 {
con p: c.Pair = _{ a: 3 } // b is implicitly zero — a C type, C's rule
stdout.writeInt(i64(p.a + p.b)) // prints 3
stdout.write("\n")
return Ok(0)
}The same literal on a Vyi struct is an error — Vyi's rules stop at the boundary, in both directions:
struct Pair { a: i32, b: i32 }
fn main() !u8 {
con p = Pair{ a: 3 } // error: missing field "b" in Pair literal
return Ok(0)
}The full rule of thumb: a type declared with a c_ form gets C's semantics
(implicit zeroing, integer-backed enums, untagged unions, nullable unchecked
pointers); everything else gets Vyi's. Nothing about using C types from Vyi
code relaxes Vyi's checking of your own types.
The rule cuts both ways: because C fields follow C initialization, a
c_struct or c_union field cannot declare a Vyi default — there is no
construction site for it to apply at. The compiler rejects it:
import { c } from "@c"
c_struct Pair { a: c.int = 3, b: c.int } // error: field "a" cannot declare a default
fn main() !u8 { return Ok(0) }Pointers at the boundary: c_ptr
Vyi's own pointers are strict: a *T is always valid, and supports only ==
and != (guide 09). C APIs need the other kind —
possibly-null, freely offset. That's c_ptr T:
- Nullable. A
c_ptrmay be null. The null check is the explicit cast?*mut T(p)—0becomesNone, anything elseSomeover a strict pointer (see "Nullable pointers" below). NULL itself is spelledc_ptr c.void(None). - Arithmetic.
p + nandp - nadvance byn * sizeof(T);p[i]reads or writes the elementistrides fromp. No bounds checks — raw C semantics. - Deref.
*preads through it, like any pointer.
import { cPtrOf } from "@core"
import { markStablePtr } from "@intrinsics"
import { stdout } from "@io"
fn main() !u8 {
mut buf = [4]i32[10, 20, 30, 40]
con p = cPtrOf(i32, markStablePtr(&buf[0]))
con q = p + 2 // advances by 2 * sizeof(i32)
mut r = *q // 30
r = r + p[3] // 40 — index reads i strides from p
p[1] = 5 // writes buf[1]
stdout.writeInt(i64(r - buf[1] - 23)) // prints 30+40-5-23 = 42
stdout.write("\n")
return Ok(0)
}Getting a c_ptr from Vyi data:
- A view's
.ptr—buf.ptron a[*]u8is ac_ptr u8. This is the everyday way to pass a buffer to C. cPtrOf(T, p)(from@core) — from a strict*Tor[*]T.markStablePtr(from@intrinsics) asserts a pointer's storage outlives the boundary crossing; you'll need it when taking the address of a local to feed intocPtrOf, and when pulling C-managed memory back into Vyi pointer types.
C's void * maps to pointer casts through c.void: in expression position a
pointer cast spells the qualifier, *mut T(p):
import { c } from "@c"
import { stdout } from "@io"
fn main() !u8 {
mut x = 42
con p = &x
con v = *mut c.void(p) // typed pointer -> void*
con q = *mut i32(v) // and back
stdout.writeInt(i64(*q))
stdout.write("\n")
return Ok(0)
}Nullable pointers: c_ptr T ↔ ?*T
C signals absence with NULL; Vyi uses ?T for absence (guide
07). The bridge is a pair of explicit casts, and
each emits real code (a branch), so every null check is visible in the source:
| Cast | Meaning |
|---|---|
?*mut T(p) with p: c_ptr T | the null check: 0 → None, else Some over a strict *mut T |
c_ptr T(o) with o: ?*mut T | the other way: None → 0, Some(p) → p |
c_ptr c.void(None) — the out-cast over a bare None — is how NULL itself is
spelled. There is no direct c_ptr T → *T cast, and neither conversion ever
happens implicitly: a maybe-null pointer must pass through the visible ?*T
check, then unwrap the Optional.
A binding keeps the RAW c_ptr in its signature — the cast is the caller's
null handling:
import { c } from "@c"
import { stdout } from "@io"
// A stand-in for a C API returning NULL-or-address; a real binding is a
// bodyless `c_fn lookup(...) c_ptr c.int` with the same shape.
fn lookup(found: bool, p: c_ptr c.int) c_ptr c.int {
if found { return p }
return c_ptr c.int(None) // NULL
}
fn main() !u8 {
mut x: c.int = c.int(7)
con opt = ?*mut c.int(lookup(true, &x)) // the null check, visibly
if Some(p) = opt {
stdout.writeInt(i64(i32(*p) - 7)) // prints 0 — the pointer was present
stdout.write("\n")
return Ok(0)
}
return Ok(1)
}The in-cast is also the boundary's lifetime claim: like markStablePtr, it
asserts the pointee (C-provided storage) outlives the frame, so the wrapped
pointer may flow onward — out of functions, into fields. ?*T itself is an
ordinary tagged Optional; nothing about its representation is special.
Out-parameters
C returns extra results through pointer parameters. Pass &local straight to a
c_ptr parameter:
import { c } from "@c"
import { stdout } from "@io"
c_include "sensor"
c_fn sensor_read(out: c_ptr c.int) c.int
fn main() !u8 {
mut value: c.int = 0
con status = c.sensor_read(&value) // C writes through the pointer
if status != 0 { return Ok(1) }
stdout.writeInt(i64(value))
stdout.write("\n")
return Ok(0)
}C strings: cstrView / cstrDup
C strings are NUL-terminated char*; Vyi strings are length-carrying [*]u8.
@c exports the pair that bridges them, as ordinary siblings of the c
namespace:
import { c, cstrView, cstrDup } from "@c"
import { systemAllocator } from "@mem"
import { stdout } from "@io"
fn main() !u8 {
con s = "hello, world"
con p = cstrDup(systemAllocator, s) catch { return Ok(1) } // *mut c.char + NUL
con view = cstrView(p) // [*]u8 over the same bytes
stdout.writeInt(i64(view.len)) // prints 12
stdout.write("\n")
return Ok(0)
}cstrDup copies the string into allocator-owned storage, writes the trailing
NUL, and hands back a *mut c.char you can pass to any c_fn(c_ptr c.char) via
cPtrOf. cstrView goes the other way: it walks the terminator and returns a
borrowing [*]u8 view (NUL excluded) over the same storage. Null is handled by
casting a c_ptr to ?*c.char before the call, so neither helper re-answers
it. c.char is a signed byte, not a u8 twin, so the helpers reinterpret
across that element type internally — the trust boundary stays inside the
helper, out of your code.
Calling libc: @libc
The standard package @libc declares extern prototypes for a slice of the C
standard library — c.write, c.read, c.malloc, c.free, c.memmove,
c.memcmp, c.memset. You import it for its declarations (they land in the
c. namespace, so the import list is empty):
import { targetOS } from "@target"
if targetOS != "wasm" {
import "@libc"
import { c } from "@c"
fn heapByte() u8 {
con p = c.malloc(c.size_t(1))
if i64(p) == 0 { panic "out of memory" }
*p = 42
con v = *p
c.free(p)
return v
}
}
fn main() !u8 { return Ok(0) }The if targetOS != "wasm" guard is the standard shape for target-conditional
code, and it matters here: @libc only loads on non-wasm targets (there is no
libc inside a bare WebAssembly module — the wasm target's I/O goes through WASI
instead). Code inside a false target guard isn't compiled for that target, so
one source file can carry both a native libc path and a wasm path. This is
exactly how @io implements stdout per target — for portable I/O and memory,
prefer @io and @mem, which do the target-switching for you.
Linking C into your program
Type-checking and code generation never touch a C toolchain — vyi check
works on a machine with no C compiler. Linking is where the targets differ:
WebAssembly (the default target). c_include "mylib" with a mylib.c
next to your entry file makes vyi run compile that C file with a
wasm-capable clang and instantiate the result alongside your module, so your
bodyless c_fn externs resolve against its exports:
// mylib.c — next to main.vyi
int add3(int a, int b) { return a + b + 3; }import { c } from "@c"
import { stdout } from "@io"
c_include "mylib"
c_fn add3(a: c.int, b: c.int) c.int
fn main() !u8 {
stdout.writeInt(i64(c.add3(30, 9))) // prints 42
stdout.write("\n")
return Ok(0)
}A c_include with no sibling .c file names a host-provided module and is
left for the runtime environment to supply. vyi build for wasm emits only
your module; the sibling-.c compile-and-instantiate step is a vyi run
convenience.
Native (--target x86-64-linux). The final link is delegated to the system C
compiler (cc, gcc, or clang — whichever is installed), which means the
entire C standard library is available for free: c_include "libc" externs
(everything in @libc) resolve with no extra input. Compiling your own .c
sources into a native build and linking prebuilt .a/.o archives are
(planned) — today, native C interop means libc and whatever the system
linker pulls in by default.
Both targets run the same Vyi semantics either side of the call — a program whose C dependencies are available on both targets behaves identically on both.
See also: reference/stdlib/c, reference/toolchain/targets, guide 09 — Pointers and memory.