Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Walkthrough

A five-minute tour of Oxide.

What is Oxide?

C semantics in Rust syntax. Oxide is a tiny, ahead-of-time-compiled, statically-typed language that lowers to LLVM and links against native code. If you’ve written C, the runtime model will feel familiar: manual memory management, raw pointers, no implicit allocations, no dispatch overhead, the C ABI for FFI. If you’ve written Rust, the surface syntax will too — let, fn, fn name<T>, mut, *const T / *mut T, extern "C", as casts, if/else as expressions.

What Oxide deliberately does not have: closures, traits, enum payloads (the keyword is reserved but unimplemented), match, unsafe, floats, async, or modules-with-visibility. No GC, no borrow checker, no overload resolution. The type system catches shape errors and enforces mutability and pointer-aliasing rules at compile time, then gets out of the way. The goal is idiomatic C with Rust-shaped syntax — nothing more.

Install

The fastest way to get the oxide binary onto your $PATH:

curl -sSf https://oxide.cwang.io/install.sh | sh

A first program

import "stdio.ox";

fn main() -> i32 {
    puts("hello world");
    0
}

Build and run:

oxide hello.ox

Tour by example

Bindings

#![allow(unused)]
fn main() {
let x = 1;            // immutable
let mut y = 0;        // mutable
y = y + 1;
let n: i32 = 42;      // type annotation optional
}

Integer literals default to i32. Widen or narrow with as.

Primitives

i8, i16, i32, i64, u8, u16, u32, u64, isize, usize, bool. No f32 / f64 yet. The unit type () has no surface spelling — write it by omitting a function’s return type.

Use u8 instead of char.

Strings and pointers

#![allow(unused)]
fn main() {
let s = "hi";              // *const [u8; 3]   — sized byte array pointer
let p: *const i32 = null;  // null pointer literal
let q = &x;                // *const i32       — address of x
let r = &mut y;            // *mut i32         — address of mut y
}

A string literal carries its length in its type (*const [u8; N]). An extern "C" parameter declared as *const [u8] erases the length so any literal fits. Pointer types come in two flavors, *const T and *mut T; *mut T coerces to *const T, but not the other way around.

if / else is an expression

#![allow(unused)]
fn main() {
let max = if a > b { a } else { b };

if x > 0 {
    puts("positive");
} else {
    puts("non-positive");
}
}

Conditions must be bool — no implicit int-to-bool coercion. Write x != 0 if you mean it.

Consts

Consts are compile-time constant values. They must be declared at top-level, explicitly typed, and given literal values.

import "stdio.ox";

const DEV: bool = true;
const SIZE: i32 = 10;
const NAME: *const [u8] = "Jimmy";

fn main() {
    if DEV {
        printf("Hello, %s\n", NAME);
    }
}

Loops

#![allow(unused)]
fn main() {
while i < n { i = i + 1; }

for (let mut i = 0; i < 4; i = i + 1) {
    // body
}

loop {
    if done { break; }
}
}

for is C-style (init, condition, step), not the iterator form. break and continue work everywhere.

Functions and unit return

#![allow(unused)]
fn main() {
fn add(a: i32, b: i32) -> i32 { a + b }

fn shout(s: *const [u8]) {     // returns ()
    puts(s);
}
}

A trailing expression is the return value; explicit return e; works too. A body that produces no value has unit type — drop the -> ().

Structs

#![allow(unused)]
fn main() {
struct Point { x: i32, y: i32 }

fn origin() -> Point { Point { x: 0, y: 0 } }

let mut p = Point { x: 1, y: 2 };
p.x = 5;        // requires `let mut p`
}

Mutability is per-binding, not per-field: to mutate a single field, the whole struct binding must be mut.

Pointer usage

As in C, a pointer dereference is valid on either side of an assignment:

#![allow(unused)]
fn main() {
let mut n = 1;
let ptr_to_n = &mut n;           // *mut i32

*ptr_to_n = 42;                  // writes through the pointer; n is now 42
let m = *ptr_to_n;               // reads through the pointer; m is 42
}

& yields *const T (read-only); &mut yields *mut T (write-through).

Field access on a struct pointer auto-dereferences, so the explicit (*ptr).field form is unnecessary (and there is no -> operator):

#![allow(unused)]
fn main() {
let mut p = Point { x: 1, y: 2 };
let ptr_to_p = &mut p;

ptr_to_p.x = 5;                  // auto-deref
(*ptr_to_p).x = 5;               // explicit form, equivalent
}

extern "C" and variadics

extern "C" {
    fn printf(fmt: *const [u8], ...) -> i32;
}

fn main() -> i32 {
    let n: u8 = 42;
    printf("n = %d\n", n);   // u8 zero-extends to i32 at the call site
    0
}

An extern "C" block declares functions that link against C symbols. A trailing ... marks a C-variadic parameter list — you can call C variadics, but you can’t define your own. Narrow integer args at variadic positions widen to i32 automatically (signed-narrow types sign-extend, unsigned-narrow and bool zero-extend), matching C’s default argument promotions.

Generic types

Both fns and structs can take type parameters.

Generic structs:

#![allow(unused)]
fn main() {
struct LinkedList<T> {
    value: T,
    next: *mut LinkedList<T>,
}

// ✅ explicitly typed
let mut linked_list = LinkedList::<i32> {
    value: 0,
    next: null,
};

// ✅ inferred
let mut linked_list = LinkedList {
    value: 0,
    next: null,
};
}

Generic functions:

#![allow(unused)]
fn main() {
fn id<T>(x: T) {
    x
}

id::<i32>(x);   // ✅ explicitly typed
id(1);          // ✅ inferred, same as above

id::<[i32]>(x); // ❌ `T` must have a known size
}

Inside a generic body, a value of generic type can only be copied — assignments and pass-throughs are fine; operations that would require knowing more about T are not.

#![allow(unused)]
fn main() {
// ✅ can be assigned around
fn swap<T>(a: *mut T, b: *mut T) {
    let c = *a;
    *a = *b;
    *b = c;
}

// ✅ can be passed as a parameter
fn eat<T>(x: T) {
    eat(x);
}

// ❌ rejected: `T` may not support comparison
fn compare<T>(a: T, b: T) -> bool {
    a < b
}
}

Function pointers

A function name used outside a call position evaluates to a pointer to that function. The type is spelled fn(T1, T2) -> R:

fn add(a: i32, b: i32) -> i32 { a + b }

fn apply(f: fn(i32, i32) -> i32, x: i32, y: i32) -> i32 {
    f(x, y)              // indirect call through the pointer
}

fn main() -> i32 {
    apply(add, 1, 2)     // → 3
}

The parameter spelling supports an optional name for documentation (fn(name: i32) -> bool); only the type identity reaches the type system, so fn(i32) -> bool and fn(name: i32) -> bool are interchangeable.

fn-types are subtypes structurally — contravariant on parameters (more permissive args are accepted in stricter slots) and covariant on the return type. Other axes — arity, ABI, and c_variadic — must match exactly.

extern "C" fn pointers

C ABIs are part of the type — extern "C" fn(...) -> R is a distinct type from the bare fn(...) -> R:

#![allow(unused)]
fn main() {
extern "C" {
    fn qsort(
        base: *mut u8,
        nmemb: usize,
        size: usize,
        cmp: extern "C" fn(*const u8, *const u8) -> i32,
    );
}
}

Variadic fn pointers (extern "C" fn(*const u8, ...) -> i32) are parseable too, mirroring the C declaration.

Generic fns as values

Referencing a generic fn as a value works as long as later use sites constrain the type parameters. Each binding gets its own instantiation; the compiler picks a single concrete instance per fn-ref site:

fn id<T>(x: T) -> T { x }

fn main() -> i32 {
    let f = id;          // ✅ ?T0 unbound here…
    f(42)                // …pinned to i32 by this call
}

If nothing later constrains the type, you’ll get a cannot infer a type error — same as any other under-determined inference. Two distinct bindings yield two distinct instances:

#![allow(unused)]
fn main() {
let a = id;
let b = id;
let _ = a(1);            // a binds to id::<i32>
let _ = b(true);         // b binds to id::<bool>
}

A single binding cannot be reused at conflicting types — Oxide has no let-polymorphism (matching Rust). Compiler intrinsics (ox_size_of, ox_transmute) are not allowed as values; call them directly.

Building and emitting

oxide is a single-file driver: pass the entry point, and it walks imports from there.

FlagEffect
--emit exe (default)compile, link, run via execv
--no-runstop after linking; print the binary path to stderr
--emit irprint textual LLVM IR to stdout (or -o path)
--emit objemit a .o object file
--emit lex / ast / hir / typeckdump an intermediate representation, useful for tinkering
-O 0|1|2|3|s|zLLVM optimization level (codegen emits only)
-o <path>explicit output path; defaults to target/oxide-build/<stem>

Arguments after -- are forwarded to the running program:

oxide hello.ox -- --my-arg

Memory management

mem.ox contains general memory management tools. It ships:

  1. Typed wrappers around the C allocator.
  2. Pointer comparison utils. You must use them instead of == and !=.
#![allow(unused)]
fn main() {
fn ox_alloc<T>() -> *mut T;
fn ox_alloc_zeroed<T>() -> *mut T;
fn ox_dealloc<T>(p: *mut T);
fn ox_realloc<T>(p: *mut T, n: usize) -> *mut T;

fn ox_ptr_eq<T>(a: *const T, b: *const T) -> bool;
fn ox_is_null<T>(p: *const T) -> bool;
fn ox_is_not_null<T>(p: *const T) -> bool;
}

Example:

// memory-management.ox

import "stdio.ox";
import "mem.ox";

struct Point { x: i32, y: i32 }

fn main() -> i32 {
    let integer = ox_alloc::<i32>();             // uninitialized
    *integer = 42;

    let point = ox_alloc_zeroed::<Point>();      // zero-initialized
    printf("point.x = %d\n", point.x);           // → "point.x = 0"

    let null_ptr: *const i32 = null;
    let is_null = ox_is_null(null_ptr);
    printf(
        "null_ptr is null? %s\n",
        if is_null { "y" } else { "n" },         // → null_ptr is null? y
    );

    ox_dealloc(integer);
    ox_dealloc(point);
    0
}
> oxide memory-management.ox
point.x = 0
null_ptr is null? y

C standard library

Three header-shaped bindings are bundled with the compiler and import under their bare names:

  • stdio.oxprintf, puts, getchar, fopen / fclose, fread / fwrite, scanf, fflush, plus the rest of <stdio.h>.
  • string.oxstrlen, strcmp, strcpy, strcat, strchr, strstr, memcpy, memset, memcmp, plus the rest of <string.h>.
  • stdlib.oxmalloc / free / realloc, exit / abort, getenv, system, atoi, rand / srand.
import "stdio.ox";
import "string.ox";
import "stdlib.ox";

fn main() -> i32 {
    printf("Hello, %d\n", 42);
    0
}

A bundled file wins over a local file with the same name — rename your own to disambiguate. Symbols resolve at link time against the host’s C library (libc on Linux and macOS), so the available behavior tracks your platform’s libc.

Your own files import by relative path:

#![allow(unused)]
fn main() {
import "./geometry.ox";
}

Where to next

Browse example-projects/ in the repository for end-to-end programs. Each is a self-contained module you build with the same oxide path/to/main.ox invocation:

  • puts/ — the hello-world above.
  • fib/ — recursive Fibonacci with a C-extern print_int callout.
  • layout_intrinsics/ — a tour of ox_size_of, ox_transmute, and the mem.ox typed allocator wrappers.
  • layout_mem/ — a minimal ox_alloc / ox_dealloc round-trip.
  • socket-server/ — a small HTTP server built on structs, &mut, and loop.
  • flappy/ — a TUI game using arrays ([u8; N]), mutable indexing, and nested loops.

Pick whichever looks fun, copy it out, and start changing things.