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.
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_uniondeclared 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 alias | Vyi type | Width (bits) |
|---|---|---|
c.char | i8 | 8 |
c.signed_char | i8 | 8 |
c.unsigned_char | u8 | 8 |
c.short | i16 | 16 |
c.unsigned_short | u16 | 16 |
c.int | i32 | 32 |
c.unsigned_int | u32 | 32 |
c.long | i64 | 64 (32 on wasm32) |
c.unsigned_long | u64 | 64 (32 on wasm32) |
c.long_long | i64 | 64 |
c.unsigned_long_long | u64 | 64 |
c.float | f32 | 32 |
c.double | f64 | 64 |
c.long_double | f64 | 64 |
c.bool | bool | 8 |
c.size_t | u64 | 64 (32 on wasm32) |
c.ssize_t | i64 | 64 (32 on wasm32) |
c.intptr_t | i64 | 64 (32 on wasm32) |
c.uintptr_t | u64 | 64 (32 on wasm32) |
c.void | void (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.charis a byte (i8), not Vyi'schar(a 32-bit Unicode codepoint).
C string and byte-buffer APIs take c.char / c.unsigned_char, never char.
c.long_doubleisf64. 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 *:
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:
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:
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:
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:
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:
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:
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):
| Helper | From | Signature | Purpose |
|---|---|---|---|
cPtrOf | @core | fn cPtrOf(known T: type, p: *mut T | [*]T) c_ptr T | Raw c_ptr T out of a strict pointer or a view — Vyi data flowing out to C |
ArrayPointer(T).of | @core | fn (ArrayPointer(T)) of(ptr: c_ptr T, len: i32) [*]T | Pair a raw pointer with a length into a [*]T view — C data flowing in to Vyi |
markStablePtr | @intrinsics | markStablePtr(p) — pointer in, same pointer out | Assert a pointer's storage outlives the frame, so it may cross the boundary |
cstrView | @c | fn cstrView(p: *mut c.char) [*]u8 | Borrow a NUL-terminated C string as a [*]u8 view (walks strlen, NUL excluded) — C string flowing in |
cstrDup | @c | fn cstrDup(alloc: Allocator, s: [*]u8) OutOfMemory!*mut c.char | Copy 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:
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_ptr — buf.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:
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:
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:
| Extern | Signature |
|---|---|
c.write | c_fn write(fd: c.int, buf: c_ptr c.unsigned_char, len: c.size_t) c.size_t |
c.read | c_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.malloc | c_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.free | c_fn free(p: c_ptr c.unsigned_char) |
c.memmove | c_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.memcmp | c_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.memset | c_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:
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.