The Sigil Language
An overview of Sigil's type system, capability model, syntax, memory model, and the cc0 compiler. This is a reference companion — not a tutorial. For deep dives, see the full spec on GitHub.
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
| Type | Size | Description |
|---|---|---|
u8, u16, u32, u64, u128 | 1–16 bytes | Unsigned integers. Overflow is a compile error in safe mode; wrapping ops available via .wrapping_add() etc. |
i8, i16, i32, i64, i128 | 1–16 bytes | Signed integers. Two's complement, no undefined behavior. |
f32, f64 | 4–8 bytes | IEEE 754 floating point. NaN propagation is defined. |
bool | 1 byte | True or false. No implicit numeric conversion. |
char | 4 bytes | Unicode scalar value (always valid). Not an integer alias. |
usize, isize | pointer-sized | Platform-width integers for indexing and pointer arithmetic. |
Str | 2×usize | UTF-8 string slice (pointer + length). Not null-terminated. |
Bytes | 2×usize | Raw byte slice. Equivalent to &[u8]. |
() | 0 bytes | The 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.
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.
.restrict_path(prefix).| operator composes capabilities in the type system..restrict_host().Env from the process entry point — even stdout is not ambient.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.
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
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.
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
}