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:

vyi
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:

vyi-error
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 typec.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 aliasVyi typeC aliasVyi type
c.char, c.signed_chari8c.unsigned_charu8
c.shorti16c.unsigned_shortu16
c.inti32c.unsigned_intu32
c.long, c.long_longi64c.unsigned_long, c.unsigned_long_longu64
c.floatf32c.double, c.long_doublef64
c.ssize_t, c.intptr_ti64c.size_t, c.uintptr_tu64
c.boolboolc.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:

vyi
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:

vyi-error
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:

vyi
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:

vyi
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:

vyi-error
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.

vyi-error
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:

vyi
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:

vyi
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)
}
vyi-error
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:

vyi
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:

vyi
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:

vyi-error
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:

vyi
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:

vyi-error
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 ?Enum slot; 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.
vyi
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):

vyi
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)T must be a Vyi struct. The twin has the same field names in the same declaration order, C layout, and is interned exactly like a declared c_struct — it converts to and from T by the rule above, and stands as a c_ptr element in a c_fn's parameter and return types. Every field must be C-representable (a primitive, a pointer, a c_ptr, another struct — twinned recursively — or a c_union); a field that isn't (a payload-carrying enum, a [*]T view, a closure value, …) is a compile error naming the field.
  • c_enum(T)T must be a payload-less Vyi enum. Its twin is c.int itself, the same storage type a declared c_enum already 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.
vyi
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:

vyi-error
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:

vyi
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:

vyi-error
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:

vyi-error
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_ptr may be null. The null check is the explicit cast ?*mut T(p)0 becomes None, anything else Some over a strict pointer (see "Nullable pointers" below). NULL itself is spelled c_ptr c.void(None).
  • Arithmetic. p + n and p - n advance by n * sizeof(T); p[i] reads or writes the element i strides from p. No bounds checks — raw C semantics.
  • Deref. *p reads through it, like any pointer.
vyi
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 .ptrbuf.ptr on a [*]u8 is a c_ptr u8. This is the everyday way to pass a buffer to C.
  • cPtrOf(T, p) (from @core) — from a strict *T or [*]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 into cPtrOf, 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):

vyi
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:

CastMeaning
?*mut T(p) with p: c_ptr Tthe null check: 0 → None, else Some over a strict *mut T
c_ptr T(o) with o: ?*mut Tthe 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:

vyi
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:

vyi
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:

vyi
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):

vyi
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:

c
// mylib.c — next to main.vyi
int add3(int a, int b) { return a + b + 3; }
vyi
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.