Docs · Standard library

`@c`

@c is the package behind C interop. Its central export is the namespace c — which holds the C primitive type aliases and every C declaration (c_fn, c_struct, c_enum, c_union) in the program — alongside the C-string helper functions documented under Boundary helpers. This page is the lookup reference for what lives in the package; the narrative walkthrough of the declaration forms, c_ptr, and linking is guide 15 — C interop.

vyi
import { c } from "@c"
import { stdout } from "@io"

fn main() !u8 {
    con n: c.int = 42
    con m: i32 = n      // c.int and i32 are representation twins — flows implicitly
    stdout.writeInt(i64(m))
    stdout.write("
")
    return Ok(0)
}

The c namespace

import { c } from "@c" brings in the namespace; the string helpers import as siblings (import { c, cstrView, cstrDup } from "@c"). The namespace contains:

  • the primitive aliases below, always;
  • every c_fn, c_struct, c_enum, c_union declared anywhere in the

program, including in packages you import (so @libc's externs appear as c.write, c.malloc, …).

C declarations are reachable only through c. — declaring c_struct Size defines c.Size, never a bare Size — so C names cannot collide with Vyi names. The namespace is program-wide: one flat c. regardless of which file or package made the declaration.

Primitive aliases

Each c. name is a distinct nominal type, not an alias in the "same type" sense — c.int and i32 are two different types that happen to share a representation (same bytes, same machine layout). That representation equality is what makes them shape-compatible: a value flows implicitly between representation twins at the usual sites (call arguments, return, initialisers, struct-literal fields), the same rule that lets one declared struct flow into another (nominal identity, structural compatibility). A width change — moving between two primitives that do not share a representation — is not covered by that rule and needs the explicit cast TargetType(x).

Representation is target-keyed: most current targets are 64-bit Unix (LP64), where long, size_t, and the other pointer-width C types 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); the Width column notes where wasm32 (ILP32) differs.

C aliasVyi typeWidth (bits)
c.chari88
c.signed_chari88
c.unsigned_charu88
c.shorti1616
c.unsigned_shortu1616
c.inti3232
c.unsigned_intu3232
c.longi6464 (32 on wasm32)
c.unsigned_longu6464 (32 on wasm32)
c.long_longi6464
c.unsigned_long_longu6464
c.floatf3232
c.doublef6464
c.long_doublef6464
c.boolbool8
c.size_tu6464 (32 on wasm32)
c.ssize_ti6464 (32 on wasm32)
c.intptr_ti6464 (32 on wasm32)
c.uintptr_tu6464 (32 on wasm32)
c.voidvoid (no value)

On wasm32, c.long, c.unsigned_long, c.size_t, c.ssize_t, c.intptr_t, and c.uintptr_t are 32-bit, so their representation twin is i32/u32 there instead of i64/u64 — a value that flows implicitly between i64 and c.long on x86-64-linux needs an explicit cast on wasm32. c.long_long/c.unsigned_long_long and every fixed-width alias stay the same on every target.

Two rows stand out:

  • c.char is a byte (i8), not Vyi's char (a 32-bit Unicode codepoint).

C string and byte-buffer APIs take c.char / c.unsigned_char, never char.

  • c.long_double is f64. Vyi has no extended-precision float, so a C API

built around a wider long double loses precision at the boundary.

c.void has no values; it appears as an extern's return type and in pointer casts — *mut c.void is the Vyi spelling of C's void *:

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("
")
    return Ok(0)
}

Aliases as conversion functions

Every numeric alias also works as a conversion function with C cast semantics — in particular, float-to-int truncates toward zero:

vyi
import { c } from "@c"

fn main() !u8 {
    con d: f64 = -3.9
    con n = c.int(d)      // truncates toward zero: -3
    con w = c.long(n)     // to c.long (64-bit on LP64 targets, 32-bit on wasm32)
    con f = c.float(w)    // -3.0
    if f == -3.0 { return Ok(0) }
    return Ok(1)
}

Width changes are always explicit. A C-typed value gets no implicit narrowing or widening — you write the cast:

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)
}

Using the aliases

The aliases are the vocabulary of c_fn prototypes and c_struct / c_union fields — matching the C signature alias-for-alias is what keeps the ABI honest on both targets:

vyi
import { c } from "@c"
import { stdout } from "@io"

c_struct Size { w: c.int, h: c.int }

c_fn area(s: *con c.Size) c.int { return s.w * s.h }

fn main() !u8 {
    con s: c.Size = _{ w: 6, h: 7 }
    stdout.writeInt(i64(c.area(&s)))
    stdout.write("
")
    return Ok(0)
}

(Aggregates cross the boundary behind a pointer — a by-value c_struct in a c_fn signature is a compile error until C ABI aggregate classification lands. A bodyless c_struct Name with no fields declares an opaque C type instead — sized only behind a pointer, the shape for handle types like FILE.)

A c.name(args) call checks arity and argument assignability like any other Vyi call; a c_fn signature also rejects a known parameter — a C symbol has one fixed runtime signature, so there's nothing to monomorphize.

A bodyless c_fn is an extern prototype resolved at link time; &local passes directly to a c_ptr parameter for C's out-parameter idiom:

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("
")
    return Ok(0)
}

Struct ↔ c_struct conversion, and twin derivation

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: matched by field name, the target a subset of the source's fields, each matched field pair a representation twin. It's implicit at the four usual sites (call arguments, return, initialisers, struct-literal fields) and explicit both ways — c.Target(v) from Vyi to C, Target(cv) back. A field pair that isn't a representation twin (a width mismatch) blocks the whole conversion — there's no per-field narrowing hiding inside the copy:

vyi
import { c } from "@c"
import { stdout } from "@io"

c_struct Size { w: c.int, h: c.int }
struct Rect  { w: i32, h: i32 }

fn area(s: c.Size) c.int { return s.w * s.h }

fn main() !u8 {
    con r = Rect{ w: 6, h: 7 }
    con s: c.Size = r          // implicit: matching fields, representation twins
    stdout.writeInt(i64(area(s)))
    stdout.write("
")
    return Ok(0)
}

Three type-level operators derive a C twin directly from a Vyi type instead of hand-declaring a parallel one: c_struct(T) (a Vyi struct's field-for-field twin — every field must be C-representable, or the operator names the offending one), c_enum(T) (a payload-less Vyi enum's twin, which is c.int — the same storage type a declared c_enum already uses), and c_union(T) (not yet supported — a Vyi union's members are unnamed, a C union's are not, so there's no member mapping to derive). A Vyi enum never casts to c.int directly, payload-less or not — casting an aggregate to a primitive is rejected everywhere in Vyi, and an enum is no exception; c_enum (declared or derived) is the C-integer story. The inbound direction is the partial cast ?Enum(n): an integer's domain is open, so the explicit cast range-checks the operand (any integer primitive, Vyi or C; signed-aware, so a negative value is None) and produces an ordinary Optional — an ordinal in [0, variantCount) is Some(variant with that tag), anything else None. Payload-less enum targets only; never implicit:

vyi
import { c } from "@c"

enum Shade { Light, Mid, Dark }

c_fn level() c.int { return c.int(2) }

fn main() !u8 {
    switch ?Shade(c.level()) {   // 2 → Some(Dark); 9 or -1 → None
        Some(s: _): { return Ok(0) },
        None:       { return Ok(1) },
    }
}

Full rules and worked examples: guide 15 § converting between struct and c_struct, § deriving C twins, and § the partial cast ?Enum(n).

The declaration forms themselves (c_fn, c_struct, c_enum, c_union, c_include, c_ptr, and the implicit-zeroing rule for C types) are specified in guide 15.

Boundary helpers

Functions that move data across the Vyi/C line. The generic pointer helpers come from @core / @intrinsics; the C-string pair are sibling exports of @c itself (alongside the c namespace):

HelperFromSignaturePurpose
cPtrOf@corefn cPtrOf(known T: type, p: *mut T | [*]T) c_ptr TRaw c_ptr T out of a strict pointer or a view — Vyi data flowing out to C
ArrayPointer(T).of@corefn (ArrayPointer(T)) of(ptr: c_ptr T, len: i32) [*]TPair a raw pointer with a length into a [*]T view — C data flowing in to Vyi
markStablePtr@intrinsicsmarkStablePtr(p) — pointer in, same pointer outAssert a pointer's storage outlives the frame, so it may cross the boundary
cstrView@cfn cstrView(p: *mut c.char) [*]u8Borrow a NUL-terminated C string as a [*]u8 view (walks strlen, NUL excluded) — C string flowing in
cstrDup@cfn cstrDup(alloc: Allocator, s: [*]u8) OutOfMemory!*mut c.charCopy a Vyi string into allocator-owned storage with a trailing NUL, returning a real char* — Vyi string flowing out

markStablePtr is the explicit lifetime claim: the compiler's escape check refuses to let a frame-bound &local escape (into cPtrOf, a view, or a return), and wrapping the pointer is you asserting the storage is safe. Every crossing is therefore greppable:

vyi
import { ArrayPointer, cPtrOf } from "@core"
import { markStablePtr } from "@intrinsics"
import { stdout } from "@io"

fn main() !u8 {
    mut x: i32 = 7
    con sp = markStablePtr(&x)              // claim: x outlives every use of sp
    con cp = cPtrOf(i32, sp)                // c_ptr i32 — hand this to C
    con view = ArrayPointer(i32).of(cp, 1)  // and back: [*]i32 over the same byte
    stdout.writeInt(i64(view[0]))
    stdout.write("
")
    return Ok(0)
}

A view's own .ptr field is already a c_ptrbuf.ptr on a [*]u8 is the everyday way to pass a buffer to C, no helper needed.

Nullable pointers — the c_ptr T ↔ ?*T casts

Null handling is not a helper — it is a pair of explicit casts, each emitting a real branch. ?*mut T(p) null-checks a raw c_ptr T: 0 → None, anything else Some over a strict *mut T whose storage the cast asserts stable (the same lifetime claim markStablePtr makes, made by the boundary). c_ptr T(o) goes the other way: None → 0, Some(p) → p — and c_ptr c.void(None) is how NULL itself is spelled. Neither conversion is ever implicit, and there is no direct c_ptr T → *T cast: a maybe-null pointer passes through the visible ?*T check, then unwraps. A binding keeps the raw c_ptr in its signature; the caller's cast is the null check:

vyi
import { c } from "@c"
import { stdout } from "@io"

fn firstByte(raw: c_ptr u8) u8 {
    if Some(p) = ?*mut u8(raw) { return *p }   // the null check, visibly
    return 0
}

fn main() !u8 {
    mut b: u8 = 7
    con present = firstByte(&b)                // 7 — Some inside
    con absent = firstByte(c_ptr u8(None))     // 0 — NULL in, None inside
    stdout.writeInt(i64(present - absent - 7))
    stdout.write("
")
    return Ok(0)
}

The worked C-boundary example (a c_fn returning NULL-or-address, both arms, the round trip back out) is guide 15 § nullable pointers.

Strings — cstrView / cstrDup

C strings are NUL-terminated char*; Vyi strings are length-carrying [*]u8. The @c pair bridges the two, and null is handled by the pointer cast table before the call — neither helper re-answers it:

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-terminated
    con view = cstrView(p)                                          // [*]u8 view over the same bytes
    stdout.writeInt(i64(view.len))                                         // 12
    stdout.write("
")
    return Ok(0)
}

cstrDup allocates s.len + 1 bytes, copies s, writes the NUL at [s.len], and returns a *mut c.char to the first byte — a bona fide C string you can hand to a c_fn(c_ptr c.char) via cPtrOf, or straight back to cstrView. cstrView walks the terminator and returns a borrowing [*]u8 (NUL excluded) that aliases the same storage.

Two deviations from the settled design are in force in the bootstrap: cstrDup returns a mut pointer (not con), so it flows into cstrView's *mut c.char parameter — cPtrOf accepts only *mut T | [*]T today; and the length of the source [*]u8 is dropped, exactly as a C char* carries no length. c.char is a signed byte and not a representation twin of u8, so both helpers reinterpret across that element type internally — the trust boundary lives in the helper, never in caller code.

@libc

The standard package @libc declares extern prototypes for a slice of the C standard library. Its export list is empty — you import it purely for its declarations, which land in the c. namespace:

ExternSignature
c.writec_fn write(fd: c.int, buf: c_ptr c.unsigned_char, len: c.size_t) c.size_t
c.readc_fn read(fd: c.int, buf: c_ptr c.unsigned_char, len: c.size_t) c.size_t — count read; 0 at end of file
c.mallocc_fn malloc(size: c.size_t) c_ptr c.unsigned_char — NULL on failure; check it with the ?*mut c.unsigned_char(p) cast
c.freec_fn free(p: c_ptr c.unsigned_char)
c.memmovec_fn memmove(dst: c_ptr c.unsigned_char, src: c_ptr c.unsigned_char, n: c.size_t) c_ptr c.unsigned_char — regions may overlap
c.memcmpc_fn memcmp(a: c_ptr c.unsigned_char, b: c_ptr c.unsigned_char, n: c.size_t) c.int<0 / 0 / >0 ordering
c.memsetc_fn memset(p: c_ptr c.unsigned_char, v: c.int, n: c.size_t) c_ptr c.unsigned_char

@libc loads only on non-wasm targets — there is no libc inside a bare WebAssembly module — so imports of it live behind the standard target guard:

vyi
import { targetOS } from "@target"

if targetOS != "wasm" {
    import "@libc"
    import { c } from "@c"

    fn fill(p: c_ptr c.unsigned_char, n: c.size_t) {
        c.memset(p, 65, n)
    }
}

fn main() !u8 { return Ok(0) }

For portable I/O and memory, prefer @io and @mem — they do this target-switching for you and use @libc internally on native. How externs actually resolve per target (system C compiler on native, sibling-.c modules on wasm) is covered in reference/toolchain/targets and guide 15 — Linking.

See also: guides/15 — C interop, reference/stdlib/core, reference/stdlib/mem.