Type System

Sigil's type system is static and strong. Types are inferred where unambiguous, and explicit elsewhere. The key innovation is the capability type Cap<T>, which encodes authority as a first-class value in the type system. Everything else follows patterns familiar from systems languages.

Primitive Types

TypeSizeDescription
u8, u16, u32, u64, u1281–16 bytesUnsigned integers. Overflow is a compile error in safe mode; wrapping ops available via .wrapping_add() etc.
i8, i16, i32, i64, i1281–16 bytesSigned integers. Two's complement, no undefined behavior.
f32, f644–8 bytesIEEE 754 floating point. NaN propagation is defined.
bool1 byteTrue or false. No implicit numeric conversion.
char4 bytesUnicode scalar value (always valid). Not an integer alias.
usize, isizepointer-sizedPlatform-width integers for indexing and pointer arithmetic.
Str2×usizeUTF-8 string slice (pointer + length). Not null-terminated.
Bytes2×usizeRaw byte slice. Equivalent to &[u8].
()0 bytesThe unit type. Returned by functions with no meaningful value.
!The never type. Returned by functions that diverge (panic, loop forever).

Compound Types

// Structs — named fields, value semantics by default
struct Point {
    x: f64,
    y: f64,
}

// Tuple structs — positional fields
struct Rgb(u8, u8, u8);

// Enums — tagged unions, exhaustive match required
enum Shape {
    Circle { radius: f64 },
    Rect { w: f64, h: f64 },
    Triangle(Point, Point, Point),
}

// Arrays — fixed-length, stack-allocated
let buf: [u8; 512] = [0; 512];

// Slices — borrowed view into contiguous memory
let view: &[u8] = &buf[0..128];

// Tuples — heterogeneous, positional
let pair: (u32, Str) = (42, "hello");

Option and Result

Sigil has no null pointers. Optionality is represented explicitly via Option<T>, and fallibility via Result<T>. The ? operator threads errors through call stacks without exceptions or unwinding.

// Option<T> — a value that may be absent
fn find_user(id: u64) -> Option<User> {
    if id == 0 { return None; }
    Some(User { id })
}

// Result<T> — a value or an error
fn parse_port(s: Str) -> Result<u16> {
    let n = s.parse::<u32>()?;   // ? propagates the error
    if n > 65535 {
        return Err("port out of range");
    }
    Ok(n as u16)
}

Capability Types

The Cap<T> type is the core innovation in Sigil's type system. It represents authority to perform an action. A value of type Cap<FileRead> is not a descriptor or a handle in the traditional sense — it is proof that the holder has been granted read access to the filesystem, expressible as a value that can be passed, stored, narrowed, and inspected.

The fundamental rule: You cannot exercise a capability you were never given. A function that does not receive a Cap<NetConn> cannot make a network connection — not by calling a helper, not by using a global, not at all. The compiler enforces this. There is no workaround.

The Cap Taxonomy

The standard library defines the following capability types. Kernel and driver code may define additional platform-specific capabilities.

Cap<FileRead>
Authority to open and read files from the filesystem. Can be scoped to specific paths via .restrict_path(prefix).
Cap<FileWrite>
Authority to create, write, truncate, or delete files. Separate from FileRead — a logger can hold FileWrite without FileRead.
Cap<FileRead | FileWrite>
Union capability — authority for both read and write. The | operator composes capabilities in the type system.
Cap<NetConn>
Authority to establish TCP/UDP connections. Scopable to specific hosts or port ranges via .restrict_host().
Cap<NetListen>
Authority to bind and listen on network ports. Separate from NetConn — a server can listen without outbound connect authority.
Cap<FbOverlay>
Authority to write to a framebuffer region. Scoped to a specific pixel rectangle — window managers hand out per-window sub-caps.
Cap<LogWrite>
Authority to append to the system log. Narrower than FileWrite — a library can log without having general filesystem write access.
Cap<Stdout>
Authority to write to standard output. Passed in via Env from the process entry point — even stdout is not ambient.
Cap<Spawn>
Authority to spawn child processes. Holding this does not grant the child any capabilities — those must be explicitly passed at spawn time.
Cap<Timer>
Authority to read the current time and set timers. Covert timing channels are a known research problem; this surfaces the authority.
Cap<HwMem>
Authority to map physical memory addresses. Kernel and driver code only. Tightly scoped to address ranges.
Cap<CapGrant>
Meta-capability: the authority to create and delegate new capability instances to other processes or tasks. Held only by trusted coordinators.

Capability Composition

Capabilities compose with the | union operator and & intersection operator in the type. Structs may hold capabilities as fields, making authority explicit in data.

// Union — either FileRead or FileWrite authority (or both)
fn copy_file(
    src:  Cap<FileRead>,
    dest: Cap<FileWrite>,
    from: Str,
    to:   Str,
) -> Result<()> {
    let data = src.open(from)?.read_all()?;
    dest.create(to)?.write_all(&data)
}

// Structs hold capabilities as first-class data
struct App {
    fs:  Cap<FileRead | FileWrite>,
    net: Cap<NetConn>,
    log: Cap<LogWrite>,
}

fn App::run(&self) -> Result<()> {
    // self.net cannot be used for FileRead
    // self.fs cannot be used for NetConn
    // The type system enforces this.
    Ok(())
}

Capability Delegation and Narrowing

A capability holder can create a strictly narrower capability and pass it to a callee. This implements the principle of least privilege: give each function exactly the authority it needs, no more.

fn run_plugin(
    fs: Cap<FileRead>,
    plugin_path: Str,
) -> Result<()> {
    // Narrow the FileRead cap to only the plugin's directory
    let scoped_fs = fs.restrict_path("/var/sigil/plugins/");

    // Plugin receives ONLY access to /var/sigil/plugins/
    // It cannot read /etc/, /home/, or any other path.
    load_and_run_plugin(scoped_fs, plugin_path)
}

fn scoped_fetch(
    net: Cap<NetConn>,
) -> Result<Bytes> {
    // Narrow to one specific host before passing inward
    let api_cap = net.restrict_host("api.example.com");
    call_api(api_cap, "/v1/status")
}

Functions

Syntax

// Basic function — no authority needed
fn add(a: i32, b: i32) -> i32 {
    a + b
}

// Generic function
fn max<T: Ord>(a: T, b: T) -> T {
    if a > b { a } else { b }
}

// Function with capabilities — authority is in the signature
fn write_log(
    log: Cap<LogWrite>,
    level: LogLevel,
    msg: Str,
) -> Result<()> {
    log.write(level, msg)
}

// Closures — capture environment by reference or move
let double = |x: i32| x * 2;
let result = [1, 2, 3].map(double);

Capability Threading

When a function needs to call other functions that require capabilities, it must thread them through explicitly. This is intentional — the chain of authority is always visible in the code.

fn start_server(
    net: Cap<NetListen>,
    fs:  Cap<FileRead>,
    log: Cap<LogWrite>,
) -> Result<()> {
    let cfg = load_config(fs)?;   // passes fs inward
    log.info("config loaded");
    let listener = net.bind(cfg.port)?;
    loop {
        let conn = listener.accept()?;
        handle_conn(conn, log)?;
    }
}

// load_config has no net access — it was never passed one
fn load_config(fs: Cap<FileRead>) -> Result<Config> {
    let raw = fs.open("/etc/server.sg")?.read_all()?;
    Config::parse(raw)
}

Methods and Traits

trait Serialize {
    fn to_bytes(&self) -> Bytes;
}

struct Packet {
    id:   u32,
    data: Bytes,
}

impl Serialize for Packet {
    fn to_bytes(&self) -> Bytes {
        let mut buf = BytesBuf::new();
        buf.write_u32(self.id);
        buf.write_bytes(&self.data);
        buf.into_bytes()
    }
}

Error Handling

Sigil uses Result<T> for fallible operations. There are no exceptions. Errors propagate explicitly. The ? operator provides ergonomic early-return on error without hiding control flow.

Design principle: Errors are values. They appear in function signatures. They are not thrown across the call stack invisibly. When you see a Result<T> return type, you know the function can fail. When you don't, it cannot.
enum AppError {
    Io(IoError),
    Parse(ParseError),
    Config(Str),
}

fn bootstrap(
    fs:  Cap<FileRead>,
    log: Cap<LogWrite>,
) -> Result<Config, AppError> {
    // ? unwraps Ok, or returns Err(e) immediately
    let raw = fs.open("/etc/sigil.conf")?
                 .read_all()?;

    let cfg = Config::parse(raw)
        .map_err(AppError::Parse)?;

    if cfg.version != SUPPORTED_VERSION {
        return Err(AppError::Config(
            "unsupported config version"
        ));
    }

    log.info("bootstrap complete");
    Ok(cfg)
}

// Pattern matching on errors is exhaustive
match bootstrap(fs, log) {
    Ok(cfg)                   => run(cfg),
    Err(AppError::Io(e))     => fatal("IO error:", e),
    Err(AppError::Parse(e))  => fatal("parse error:", e),
    Err(AppError::Config(s)) => fatal("config error:", s),
}

Panic

Sigil has a panic! macro for truly unrecoverable conditions — invariant violations, out-of-bounds access in debug builds. Panics are not a substitute for Result. In release builds, most bounds checks that can be proven statically are elided; the remainder trap the process rather than producing undefined behavior.

Memory Model

Stack by Default

Sigil values live on the stack unless explicitly heap-allocated. Structs, arrays, and slices are stack values. This is the fastest and simplest option for most data. Stack allocation is also deterministic — no allocator overhead, no GC pause.

Explicit Heap Allocation

Heap allocation is performed through an explicit allocator type. There is no global allocator that operates silently. This means allocations are visible in function signatures when they require allocator authority.

// Box<T> — a heap-allocated T, unique ownership
let boxed: Box<Config> = Box::new(cfg);

// Vec<T> — heap-allocated growable array
let mut items: Vec<u32> = Vec::new();
items.push(42);

// Custom allocators can be passed explicitly
fn build_table<A: Alloc>(
    alloc: &A,
    size: usize,
) -> Vec<Entry> {
    Vec::with_capacity_in(size, alloc)
}

Ownership and Borrowing

Sigil uses a single-owner model similar to Rust's. A value has exactly one owner at a time. Ownership can be transferred (moved) or temporarily shared (borrowed) via references. The compiler tracks borrows and prevents use-after-free, double-free, and data races at compile time.

fn process(buf: Vec<u8>) -> Vec<u8> {
    // buf moved into this function — caller no longer owns it
    buf.iter().map(|b| b ^ 0xFF).collect()
}

fn inspect(buf: &Vec<u8>) -> usize {
    // Immutable borrow — caller still owns buf
    buf.len()
}

fn append(buf: &mut Vec<u8>, byte: u8) {
    // Mutable borrow — exclusive, no aliases
    buf.push(byte);
}

No GC, No Use-After-Free

Because ownership is tracked statically, memory is freed when the owner goes out of scope. No garbage collector is needed. And because the compiler prevents use after a value is dropped or moved, use-after-free is a compile error, not a runtime crash.

The cc0 Compiler

cc0 is the Sigil compiler. It is written entirely in Sigil — a self-hosted compiler. This means the same capability and safety guarantees that apply to your code apply to the compiler itself.

Target Architectures

🖥️
x86-64
Linux, macOS (Intel), bare-metal. SSE2 baseline; AVX-512 where available. Primary development target.
📱
ARM64 (AArch64)
Linux, macOS (Apple Silicon), Raspberry Pi 4+, bare-metal. NEON SIMD. Primary embedded target.
🍓
ARM32
Raspberry Pi 3 and older ARMv7 hardware. Thumb-2 instruction set. Minimal-hardware tier: the Pi 3 floor always works.

Invocation

# Compile a single file to a native binary
cc0 main.sg -o myapp

# Compile with optimizations
cc0 main.sg -O2 -o myapp

# Cross-compile to ARM64
cc0 main.sg --target aarch64-linux -o myapp.aarch64

# Compile to ARM32 (Raspberry Pi 3)
cc0 main.sg --target armv7-none -o myapp.arm32

# Emit assembly for inspection
cc0 main.sg --emit asm -o myapp.s

# Run the capability-check pass only (no codegen)
cc0 main.sg --check

The Bootstrap Story

Bootstrapping a self-hosted compiler requires an initial trusted build. cc0 maintains a minimal C-language seed — a small, auditable C program that can compile a restricted subset of Sigil sufficient to compile the full cc0 source. From that point, the build is fully self-contained.

The bootstrap chain is documented and reproducible. Each stage's output can be verified against a known hash. This is important for supply-chain integrity: the compiler you're using to build your code was itself built by a compiler you can inspect and verify.

Reproducible builds: cc0 is designed for reproducible output. Given the same source and flags, the binary output is bit-for-bit identical across machines and build times. This allows independent verification that the binary you received was produced from the source you can read.

Compile-time Capability Verification

The capability checker is a mandatory pass in cc0. It verifies that every resource access site has a valid capability in scope, that capabilities are not forged (there is no unsafe escape hatch for capabilities), and that delegation narrows rather than widens authority. Failing the capability check is a compile error, not a warning.

// This fails to compile — no Cap<FileRead> in scope
fn bad_read() -> Result<Str> {
    // ERROR[E0401]: no capability `FileRead` in scope
    // Consider adding `fs: Cap<FileRead>` as a parameter
    fs.open("/etc/passwd")?.read_all()
    // ^^ `fs` is not defined
}

// This also fails — you cannot create a Cap from nothing
fn forge_cap() -> Cap<FileRead> {
    // ERROR[E0402]: Cap values cannot be constructed
    // outside of the capability-grant subsystem
    Cap::<FileRead>::new()
    // ^^ no such constructor
}