core
core is the bottom of the library. It neither allocates nor performs I/O, and everything above it is built from what lives here. Much of the language is defined against these declarations: a..b builds a Range, for-in drives an Iterator, a < b on a user type calls Ord::compare, and T[] iteration hands you a Slice<T>.
Most of core is in the prelude already. Option, Result, Slice, Range, Iterator, the panic helpers, and the primitive methods all resolve without an import. The rest — cmp, convert, hash, mem, ptr, marker — you import explicitly.
| Item | Import |
|---|---|
Option<T> | (prelude) |
Result<T, E> | (prelude) |
Slice<T> | (prelude) |
Range<T>, RangeInclusive<T>, Step | (prelude) |
Iterator, adapters | (prelude) |
panic, assert, unreachable, todo | (prelude) |
Methods on i32, char, ... | (prelude) |
Ordering, Eq, Ord, min, max | import std::core::cmp; |
From, Into, TryFrom, TryInto | import std::core::convert; |
Hash, Hasher, DefaultHasher | import std::core::hash; |
Copy, Send, Sync | import std::core::marker; |
Clone | import std::core::clone; |
Default | import std::core::default; |
Drop | import std::core::drop; |
NonNull<T> | import std::core::ptr; |
copy, zero, swap, transmute | import std::core::mem; |
catch_unwind, PanicInfo | import std::core::panic_unwind; |
VaArgs | (prelude) |
Option<T>
Option<T> is how Cryo says "there might not be a value here". It replaces the nullable pointer: the compiler will not let you reach the payload without handling the absent case.
type enum Option<T> {
Some(T);
None;
}
match (maybe_port) {
Option::Some(p) => { printf("port %d\n", p); }
Option::None => { printf("no port configured\n"); }
}
Querying and unwrapping
| Method | Returns | Notes |
|---|---|---|
is_some(&this) | boolean | |
is_none(&this) | boolean | |
unwrap(&this) | T | Panics on None. |
expect(&this, message: string) | T | Panics with your message on None. |
unwrap_or(&this, default_value: T) | T | Returns default_value on None. |
unwrap_or_else(&this, default_fn: () -> T) | T | Computes the fallback only when needed. |
Borrowing without consuming
unwrap and friends take the value out. When the payload owns heap storage and you only want to look at it, borrow instead:
as_ref(&this) -> Option<T*>—Some(v)becomesSome(&v),NonestaysNone. The pointer aliases the receiver's payload rather than copying it, so the receiver keeps ownership.unwrap_ptr(&this) -> T*— the same borrow, aborting onNone, and one less layer to unwrap.
Both pointers are invalidated by anything that moves or overwrites the receiver.
Transforming
| Method | Returns |
|---|---|
map<U>(&this, f: (T) -> U) | Option<U> |
and_then<U>(&this, f: (T) -> Option<U>) | Option<U> |
or_else(&this, f: () -> Option<T>) | Option<T> |
take(mut &this) | Option<T> — replaces the receiver with None and returns what was there |
ok_or<E>(&this, err: E) | Result<T, E> |
ok_or_else<E>(&this, err_fn: () -> E) | Result<T, E> |
contains(&this, value: &T) (where T: Eq) | boolean |
Option<T> implements Clone and Eq whenever T does.
Result<T, E>
Every fallible call in the standard library returns Result<T, E> with a module-specific error type. The rule of thumb: if the only way to fail is "it wasn't there", the signature returns Option; if failure carries a reason, it returns Result.
type enum Result<T, E> {
Ok(T);
Err(E);
}
import std::fs::file;
import std::core::result;
match (file::read_to_string(path)) {
Result::Ok(text) => { printf("%llu bytes\n", text.length()); }
Result::Err(e) => { printf("failed: %s\n", e.describe().as_ptr()); }
}
| Method | Returns |
|---|---|
is_ok(&this) / is_err(&this) | boolean |
unwrap(&this) | T — panics on Err |
expect(&this, message: string) | T |
unwrap_err(&this) | E — panics on Ok |
unwrap_or(&this, default_value: T) | T |
unwrap_or_else(&this, op: (E) -> T) | T |
as_ref(&this) | Result<T*, E*> — borrows both payloads in place |
map<U>(&this, op: (T) -> U) | Result<U, E> |
map_err<F>(&this, op: (E) -> F) | Result<T, F> |
and_then<U>(&this, op: (T) -> Result<U, E>) | Result<U, E> |
or_else<F>(&this, op: (E) -> Result<T, F>) | Result<T, F> |
ok(&this) / err(&this) | Option<T> / Option<E> |
Result<T, E> implements Clone and Eq whenever both payloads do.
Every error type in the standard library exposes the same accessor — describe(&this) -> Str — so you can report a failure without knowing which module it came from.
Slice<T>
A Slice<T> is a (pointer, length) pair: a borrowed view into a contiguous run of values. Algorithms take slices, collections hand them out with as_slice(), and so each algorithm gets written once.
A slice does not own its storage. The backing memory has to outlive every slice pointing into it.
| Method | Returns | Notes |
|---|---|---|
static from_raw(ptr: T*, length: u64) | Slice<T> | You certify that the range is valid initialized memory. |
length(&this) / is_empty(&this) | u64 / boolean | |
get(&this, index: u64) (where T: Copy) | Option<T> | Bounds-checked read by value. |
get_ref(&this, index: u64) | Option<T*> | Bounds-checked borrow; sound for every T. |
as_ptr(&this) | T* | Raw pointer to the first element; respect length. |
subslice(&this, start: u64, end: u64) | Slice<T> | [start, end). Panics if start > end or end > length. |
iter(&this) (where T: Copy) | implement Iterator<T> | Yields elements by value. |
get and iter are gated on T: Copy because they produce an independent bitwise duplicate while the backing storage still owns the element — sound only when T carries no drop obligation. For an owning T, walk by index with length() and get_ref.
NonNull<T>
A raw pointer that is guaranteed non-null by construction. Check once at the ownership boundary and let everything downstream rely on the invariant. The owning smart pointers — Box, Rc, Arc — build on this and live in alloc.
import std::core::ptr;
mut p: NonNull<i32> = NonNull<i32>::new(raw); // aborts if `raw` is null
const q: NonNull<i32> = p.offset(3); // 3 elements, not 3 bytes
new(raw: T*), as_ptr(&this) -> T*, offset(&this, count: i64), and cast<U>(&this) -> NonNull<U>. The cast cannot check that the alignment requirements line up; that is on you.
Iterator
Iterator has exactly one required method — next — and everything else is derived from it. for (x in seq) drives the scrutinee through this trait, so anything that implements it works in a for loop.
type struct Counter { n: i32; }
implement trait Iterator<i32> for struct Counter {
next(mut &this) -> Option<i32> {
if (this.n >= 5) { return Option<i32>::None; }
this.n = this.n + 1;
return Option<i32>::Some(this.n);
}
}
mut c: Counter = Counter { n: 0 };
for (v in c.take(3)) { printf("%d\n", v); }
Lazy adapters
Each of these returns a wrapper that is itself an Iterator, so they compose. Nothing is computed until something consumes the chain.
| Adapter | Yields |
|---|---|
take(this, n: u64) | At most the first n elements. |
skip(this, n: u64) | Everything after the first n. |
map<B>(this, f: (Item) -> B) | f applied to each element. |
filter(this, pred: (Item) -> boolean) | Only the elements where pred holds. |
chain<J>(this, other: J) | All of this, then all of other (same Item type). |
enumerate(this) | Pair<u64, Item> — .first is the 0-based index. |
zip<J>(this, other: J) | Pair<Item, B>, stopping when either side runs out. |
f and pred must be non-capturing — a plain function or a non-capturing lambda.
enumerate and zip yield Pair rather than a tuple because Cryo has no heterogeneous tuple literal.
Eager consumers
| Consumer | Returns |
|---|---|
count(mut &this) | u64 — walks the iterator to count, consuming it. |
fold<Acc>(mut &this, initial: Acc, f: (Acc, Item) -> Acc) | Acc |
for_each(mut &this, f: (Item) -> void) | void |
any(mut &this, pred) / all(mut &this, pred) | boolean — both short-circuit. |
find(mut &this, pred) | Option<Item> |
min(mut &this) / max(mut &this) (where Item: Ord) | Option<Item> |
On ties min keeps the first minimal element and max keeps the last maximal one, matching the conventional behaviour.
The adapters resolve on any concrete Iterator receiver, including your own structs. Re-adapting a local whose type is the opaque implement Iterator<T> works when its initialiser is a concrete constructor (mut it: implement Iterator<i32> = Range<i32>::new(0, 10); it.take(3)); a local initialised from a producer that itself returns an opaque iterator is still restricted, so chain directly off the source in that case.
Ranges and Step
a..b desugars to Range::new(a, b) and is half-open; RangeInclusive covers the closed form. Both implement Iterator.
for (i in 0..5) { printf("%d\n", i); } // 0 1 2 3 4
Step is what makes one generic Range<T> work across widths — it supplies successor, and it requires Ord because bounded iteration has to know when it has passed end. It is implemented for i8–i64, u8–u64, and char.
In pattern position
a..bis inclusive. In expression position it is half-open. See Pattern Matching.
Comparison
Eq is equality; Ord extends it with a total order. The relational operators are sugar over these: on an Ord type, a < b is rewritten to a.compare(&b).is_lt().
type enum Ordering { Less; Equal; Greater; }
Ordering carries reverse(&this) — useful for sorting by a reversed key without duplicating the comparator — plus the predicates is_lt, is_gt, is_le, and is_ge that back the operators. Implement compare and a type gets <, >, <=, and >= for free.
Both traits are implemented for every integer width, boolean, char, string, f32, and f64. The free functions min<T>, max<T>, and clamp<T> come with them; clamp panics if lo > hi, since that range is nonsense.
Floats compare bitwise
This is the one place where core deliberately departs from IEEE-754. Eq for f32 and f64 compares the raw bits, and Ord orders them as sign-magnitude integers:
-NaN < -inf < ... < -0.0 < +0.0 < ... < +inf < +NaN
That keeps Eq a genuine equivalence relation — reflexive even for NaN — and keeps it consistent with Ord, which IEEE comparison cannot be (NaN is unordered and -0.0 == 0.0). Two consequences worth knowing: (0.0).equals(&-0.0) is false, and two NaNs with different payloads are unequal. When you want IEEE semantics, use the == operator directly.
Conversions
Cryo has no implicit conversions. Every change of type is either an as cast or a conversion method.
From<T>— buildThisfrom aT. Always succeeds.Into<T>— consumeThis, produce aT. Always succeeds.TryFrom<T>/TryInto<T>— the fallible counterparts, returningResult<_, ConversionError>.
Implement From when the conversion is always defined (i32 to i64, where every value fits) and TryFrom when it isn't (i64 to i32, where it might not).
import std::core::convert;
const wide: i64 = i64::from(narrow);
match (i32::try_from(wide)) {
Result::Ok(v) => { /* fits */ }
Result::Err(e) => { printf("%s\n", e.describe().as_ptr()); }
}
The widening impls ship for every pair that cannot lose information — the signed ladder, the unsigned ladder, u8/u16 into the wider signed types, and f32 into f64. The narrowing and sign-crossing directions ship as TryFrom.
ConversionError carries a short static message, reachable through describe(&this) -> Str.
Hashing
A Hash type feeds its bytes to a Hasher, which folds them into a running state and finally produces a u64.
import std::core::hash;
mut h: DefaultHasher = DefaultHasher::new();
h.fold(user_id);
h.fold(name);
const digest: u64 = h.finish();
fold<T> is the composition entry point: it dispatches at compile time via static match (T) — no runtime cost and no trait bound, because the arms are the accepted types. It takes every primitive scalar, folding integers at their natural width (little-endian), floats as their IEEE bit pattern, char as its 32-bit scalar, boolean as one byte, and string as its bytes plus a separator NUL so that "ab" + "c" cannot collide with "a" + "bc". Anything non-primitive is a compile error.
Hasher also exposes write_bytes(Slice<u8>) and write_uint(value: u128, byte_count: u32).
Two implementations ship:
DefaultHasher— 64-bit FNV-1a. Fast and simple, and the right default forHashMapkeys when the inputs are trusted. It is not DoS-resistant.Fnv128Hasher— the same construction at twice the width, for cases where a collision is a correctness failure rather than a slow bucket.finish128()gives the full digest;high64()andlow64()split it for rendering. Still FNV, so still not hardened — this is width, not security.
from_state on either lets you resume from a previously finished digest and thread a running hash through several steps. The free function digest<T>(value: &T) -> u64 is the one-shot form.
Marker traits
Traits with no methods. Implementing one adds no behaviour; it tells the compiler and generic code something about the type's semantics.
Copy— values duplicate with a bitwise copy. Integers, floats, pointers, and pure-POD structs qualify; anything owning a resource does not. ACopytype never needs an explicitclone, and assignment does not move out of it. This is enforced by the move-checker.Send— safe to transfer between threads.Sync— safe to share between threads by reference;T: Syncwhen&T: Send.
Send and Sync are computed structurally and checked at any explicit where T: Send bound. The thread entry points carry those bounds, so moving a non-Send payload — an Rc<T>, a lock guard — into another thread is a compile error. See Ownership, Copy, and Drop for the full rules.
Clone, Default, and Drop
Clone produces an independent copy: a heap-owning type allocates fresh storage and copies the contents; a POD type copies bitwise. It stays distinct from Copy so generic code can ask for the weaker promise.
Default supplies a canonical zero value so generic code can construct without knowing the concrete type — zero for numbers, false for boolean, '\0' for char. Array::resize and similar growing APIs use it.
Drop is the destructor: drop(mut &this) -> void, the last operation performed on a value. Every primitive gets a trivial no-op impl so generic containers can write where K: Drop, V: Drop without locking out useful instantiations like HashMap<u32, V>; when the value carries no resource, the call lowers to no IR at all.
Operator traits
Implementing one of these lets the compiler rewrite the corresponding operator on your type. The rewrite is type-directed and left-hand-driven — a OP b dispatches on a's type — and it only fires when the primitive rules do not apply, so 1 + 2 and pointer arithmetic stay on the native path. Primitives deliberately do not implement these traits.
| Trait | Operator |
|---|---|
Add Sub Mul Div Rem | + - * / % |
Neg | unary - |
BitAnd BitOr BitXor Shl Shr | & | ^ << >> |
Not / BitNot | ! / ~ |
Index<Idx, Output> | a[i] |
Deref<Target> | *b, and auto-deref on b.field / b.method() |
The right operand is taken by reference so an owned aggregate is not moved, and Rhs/Output are separate type parameters so a type can (say) add a scalar to a vector and yield a vector. Compound assignment routes through the same trait: a += b evaluates a.add(&b) and stores the result back.
Index and Deref both return a pointer, which makes the single impl serve reads, writes, and compound assignment alike — v = a[i], a[i] = v, and a[i] += v all go through index. There is no separate DerefMut, because Cryo pointers are not const-qualified.
Memory utilities
core::mem is typed wrappers over the memory intrinsics. Everything operates on pointers you already hold; nothing here allocates.
| Function | Effect |
|---|---|
copy<T>(dest: T*, src: T*, count: u64) | Copy count values. Regions must not overlap. |
copy_overlapping<T>(dest: T*, src: T*, count: u64) | Same, handling overlap correctly. |
zero<T>(dest: T*, count: u64) | Zero count values. |
swap<T>(a: T*, b: T*) | Swap the two values. |
offset<T>(ptr: T*, count: i64) | Step by elements, not bytes. Negative walks backwards. |
align_up / align_down(ptr: void*, alignment: u64) | Round to a power-of-two boundary. |
is_aligned(ptr: void*, alignment: u64) | boolean |
transmute<From, To>(value: From) | Reinterpret the bytes. Aborts if the sizes differ; bit-pattern validity is your problem. |
Panicking
panic is typed never, so the compiler treats the call as divergent structurally rather than special-casing the symbol name.
| Function | Behaviour |
|---|---|
panic(message: string, file: string, line: u32) -> never | Abort with the message and source location. |
assert(condition: boolean, message: string) | Panic when the condition is false. Always checked. |
unreachable() -> never | For a path you believe cannot be taken. |
todo() -> never | For a function you have not written yet. |
Pass the compiler-provided FILE and LINE to panic; the helpers do it for you.
Catching a panic
catch_unwind(f) runs f and turns a panic that unwinds out of it into Err(PanicInfo) instead of letting it reach the process root:
import std::core::panic_unwind;
match (catch_unwind(risky)) {
Result::Ok(v) => { /* normal return */ }
Result::Err(info) => { /* message, file, line */ }
}
This is what makes destructors run on a panic: by the two-phase unwind contract, a catch is the phase-1 handler that lets every intermediate frame's cleanup pad run its drops in phase 2. Without a catch, an uncaught panic finds no handler and skips phase 2 entirely.
It requires --panic=unwind (or panic = "unwind" in cryoconfig). Under the default abort strategy a panic terminates the process, so there is nothing to catch and calling catch_unwind is a compile error. f is a plain function pointer rather than a capturing closure — bind captured state into a named function, the same as thread::spawn.
Methods on primitive types
implement blocks can extend the built-in types, and core::primitives uses that sparingly — only where the operators do not already say it in one line. All of it is in the prelude.
Every numeric width carries static min_value() and static max_value(). The signed types carry abs.
char carries the ASCII classifiers is_digit, is_alpha, is_alphanumeric, is_hex_digit, is_whitespace, is_ascii, is_c_ident_start, and is_c_ident_char, plus to_ascii_lowercase, to_ascii_uppercase, and hex_value (the digit's value, or -1 when it isn't one). Case folding is ASCII-only; full Unicode folding needs a table beyond this module.
Integer-to-text conversion lives here as buffer-writing methods rather than allocating ones: to_decimal_buf, and on the unsigned types also to_hex_buf, to_hex_upper_buf, to_oct_buf, and to_bin_buf, plus padded variants on u64. Each writes into a caller-owned u8* and returns the byte count. No heap traffic — the digit loop runs in a stack scratch and copies once. Size the buffer with the matching constant (U64_MAX_DECIMAL_DIGITS, U32_MAX_HEX_DIGITS, and so on).
Float formatting is not here — it needs libm, and keeping libc out of core matters more. It lives in fmt::float.
Integer overflow is wrapping
Integer arithmetic at every width is silent two's-complement wrapping on overflow. There is no trap and no diagnostic. This is a deliberate, frozen part of the 1.0 surface: Cryo ships no checked_*, wrapping_*, or saturating_* family, so code that must detect overflow has to range-check its operands first.
One visible consequence is abs: the most-negative value of a signed type has no positive counterpart, so i32::min_value().abs() is itself.
Variadics
A function declared with a trailing args... bucket mirrors C's variadic calling convention, which keeps it ABI-compatible for printf-style interop. The compiler emits va_start/va_end around the body and binds args to the raw va_list. VaArgs wraps that pointer so you can read typed values without hand-rolling va_arg:
function logf(fmt: string, args...) -> void {
mut va: VaArgs = VaArgs::new(args);
const count: i32 = va.next(); // T inferred as i32
const name: string = va.next();
}
as_ptr() hands back the raw va_list for forwarding the rest to a C v*-family function such as vfprintf.
Two limits come from C varargs and no wrapper can fix them. It is not count-safe — nothing records how many arguments were passed or their types, so the callee has to know out of band (a format string, a leading count, a sentinel). And the default argument promotions apply: a variadic call promotes i8, i16, and boolean to i32, and f32 to f64. VaArg is therefore implemented only for the promoted set, and va.next<i8>() is a compile error by design — read it as i32 and narrow.