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

The Kāra Programming Language

Welcome to The Kāra Book — the official guide to learning the Kāra programming language.

What is Kāra?

Kāra is a systems programming language where you declare what and why, and the compiler decides how. You write sequential-looking code with clear intent, and the compiler infers borrow lifetimes, optimizes memory layout, and parallelizes independent work — without explicit annotations.

The language is built on four layers, in order of importance:

  1. Values and types define structure.
  2. Effects define observable behavior.
  3. Ownership defines aliasing and lifetimes.
  4. Layout defines physical memory representation.

If you're coming from Rust, think of Kāra as a language that shares many of the same safety goals but takes a different path — owned by default with explicit ref / mut ref modes (no <'a> lifetime parameters), and using an effect system to track side effects and enable parallelization of independent work.

If you're coming from Python, Go, or TypeScript, Kāra will feel familiar in syntax while giving you performance and safety guarantees that those languages can't offer.

What this book covers

This book walks through the language from first principles. You don't need prior systems programming experience, though familiarity with any typed language will help.

  • Getting Started covers the basics: variables, functions, control flow.
  • Core Concepts introduces structs, enums, pattern matching, error handling, traits, and generics.
  • What Makes Kāra Different digs into the features that set Kāra apart: the effect system, ownership without lifetime annotations, and the module system.
  • Advanced Topics covers concurrency, data layout control, and testing.

Each chapter builds on the ones before it, with code examples you can run.

Who is this for?

Anyone who wants to write fast, safe code without fighting the compiler. Whether you're building a web server, a CLI tool, an embedded system, or a data pipeline — Kāra is designed to get out of your way while keeping your programs correct.

Let's get started.

Hello, Kāra

Every journey starts with a first program. Here's yours:

fn main() {
    println("Hello, world!");
}

Let's break it down:

  • fn main() declares the program's entry point. Every Kāra executable starts here.
  • println(...) prints a line to standard output. It's available everywhere — no imports needed.
  • Semicolons end statements. Braces delimit blocks. If you've used C, Rust, Go, or Java, this feels familiar.

A slightly bigger program

fn greet(name: String) {
    println(f"Hello, {name}!");
}

fn main() {
    greet("Kāra");
    greet("world");
}

A few things to notice:

  • Functions are declared with fn, parameters are name: Type.
  • String interpolation uses f"..." with {expr} inside. No format macros, no concatenation — just prefix the string with f and write expressions in braces.
  • No return needed. The last expression in a block is its value. You can use return for early exits, but for the common case you just write the value.

Comments

// This is a line comment.

/* This is a block comment.
   Block comments /* can nest */. */

What you get for free

Even in this tiny program, the Kāra compiler is doing work behind the scenes:

  • Effect inference: greet writes to stdout via println. The compiler knows this — it infers a writes(Stdout) effect. You didn't have to declare it because greet isn't a public API function.
  • Ownership feedback: name is declared String (owned by default). Since the body only reads it, karac explain greet will suggest tightening the signature to ref String — the compiler doesn't change your signature, but it tells you when a tighter mode would also work.

You don't need to understand effects or ownership yet. The point is that the compiler is your partner from the very first line of code — quietly making good decisions so you can focus on what your program does.

We'll explore both systems in depth in later chapters.

Getting Started, Part 2: Two Surfaces

Kāra is one language with two everyday surfaces. You can save your code in a .kara file and run it with karac run, or you can paste it line by line into karac repl and watch each piece take effect immediately. Both surfaces run the same compiler, see the same diagnostics, and apply the same ownership rules. The difference is the rhythm: a file is a finished thought, the REPL is a thought in progress.

This chapter walks one example — a binary search over a sorted vector — through both surfaces side by side, so you can feel where each one shines.

The same program, on disk

Save this to search.kara:

fn binary_search(haystack: ref Vec[i32], needle: i32) -> Option[i64] {
    let mut lo: i64 = 0;
    let mut hi: i64 = haystack.len();
    while lo < hi {
        let mid = (lo + hi) / 2;
        let value = haystack[mid];
        if value == needle {
            return Some(mid);
        } else if value < needle {
            lo = mid + 1;
        } else {
            hi = mid;
        }
    }
    None
}

fn main() {
    let nums: Vec[i32] = [1, 3, 5, 7, 9, 11, 13];
    match binary_search(nums, 7) {
        Some(i) => println(f"found at index {i}"),
        None => println("not found"),
    }
}

Then run it:

$ karac run search.kara
found at index 3

A few things worth pointing at:

  • ref Vec[i32] says "I want to read this vector, not take ownership of it." The caller keeps nums and can use it again afterward.
  • Option[i64] is the standard "maybe an index" return. Pattern-match on it; the compiler will warn you if you forget a case.
  • No allocator imports, no module declarations. A .kara file with fn main() is a complete program.

The same program, in the REPL

Now start the REPL:

$ karac repl
Kāra REPL — :help for commands, :quit to exit.
karac> 

We'll build the same example cell by cell. Each line you submit is a cell — its own unit of evaluation, kept around so later cells can see it.

karac> fn binary_search(haystack: ref Vec[i32], needle: i32) -> Option[usize] {
    ...     let mut lo: usize = 0;
    ...     let mut hi: usize = haystack.len();
    ...     while lo < hi {
    ...         let mid = (lo + hi) / 2;
    ...         let value = haystack[mid];
    ...         if value == needle { return Some(mid); }
    ...         else if value < needle { lo = mid + 1; }
    ...         else { hi = mid; }
    ...     }
    ...     None
    ... }
karac> let nums = [1, 3, 5, 7, 9, 11, 13];
karac> binary_search(nums, 7)
Some(3)

That last line — a bare expression with no let — is shown as a value. The REPL prints Some(3) because that's what the expression evaluated to. Compare this to the file version, which had to wrap the result in match and println to see it.

Cells remember each other

The fn binary_search declaration is a pure-items cell: it adds a function to the session. Later cells can call it without redefining it. Same for let nums = … — that binding stays in scope for every cell that follows.

karac> binary_search(nums, 100)
None
karac> binary_search(nums, 5)
Some(2)

nums is still here. So is binary_search. The REPL holds onto your work the same way a file's top-to-bottom order does, just one cell at a time.

Re-declaring is allowed

You don't have to invent new names for retries:

karac> let nums = [10, 20, 30, 40, 50];
karac> binary_search(nums, 30)
Some(2)

The second let nums shadows the first — same name, fresh binding. The old vector is dropped at the moment you re-declare. This is what you'd want: experimenting in the REPL shouldn't pile up nums1, nums2, nums_v3 in your head.

Ownership crosses cells, honestly

This is the part most REPLs cheat on. They evaluate each cell in isolation and pretend ownership doesn't exist. Kāra doesn't pretend.

karac> let owned = [1, 2, 3];
karac> let sum: i32 = owned.iter().sum();
karac> println(f"sum={sum}, owned still here: {owned.len()}");
sum=6, owned still here: 3

owned.iter().sum() borrows; the original is still yours. But:

karac> let s: String = "hello".to_string();
karac> let taken = s;
karac> println(s);
error: use of moved value `s`
  --> cell 3:1
   |
 1 | println(s);
   |         ^ value moved into `taken` in cell 2
   = the move happened in a previous cell; this cell sees the post-move state.
   = consider `let taken = s.clone();` if you need both bindings.

The diagnostic doesn't just say moved — it tells you which cell the move happened in and suggests a fix. This is the UseAfterMove notebook-aware tail at work; ownership in the REPL behaves exactly like ownership in a file, but the diagnostics know about your cell history.

Teaching ownership honestly from day one matters: when you graduate from REPL doodles to compiled .kara files, nothing has to be un-learned.

Meta-commands

The REPL ships with a handful of :command helpers. Two are worth knowing right away.

:effects — what does this session touch?

karac> fn read_config() -> String {
    ...     read_file("config.toml").unwrap()
    ... }
karac> :effects
session effects: reads(FileSystem), panics
  read_config: reads(FileSystem), panics

Every function the session knows about, every effect it carries. This is the same effect analysis that the compiler runs on .kara files — you're just getting a live readout instead of waiting for a diagnostic to fire. We'll cover the effect system properly in chapter 11; for now, treat :effects as a "what would I have to declare if this were a public API" lens.

:save — graduate to a file

When the REPL session has earned its keep, hand it off to disk:

karac> :save search.kara
wrote search.kara (4 cells, 1 fn, 1 let)

:save writes a real .kara file. The session's items become top-level definitions, the cell history becomes the body of fn main(), and any :provide scopes you opened are emitted as with_provider[R](…) { … } blocks. The file karac runs without modification.

This is the natural lifecycle: prototype in the REPL, :save when the shape feels right, edit the file from there. The same compiler runs both ends; nothing gets translated.

Which surface, when?

Both surfaces are first-class. As a rough rule:

  • Files for anything with a real main, anything you'll come back to next week, anything you'd put under version control. They're cheap to start — fn main() { … } is the whole ceremony.
  • REPL for learning the language, exploring a new crate, sanity-checking a one-liner, or shaping a function whose signature you're not sure about yet. :effects and the cell-history-aware diagnostics make it a teaching surface, not just a calculator.

Reach for whichever feels right. The compiler doesn't care which surface called it — your code's behavior is the same either way.

What's next

You've seen Kāra running. The next few chapters introduce the building blocks — variables and types, functions, control flow — using whichever surface fits each example. We'll mostly show file form, because it's easier to read on a page, but every example also runs in the REPL if you'd rather experiment.

Variables and Types

Bindings

Variables in Kāra are declared with let:

let x = 42;
let name = "Kāra";
let pi = 3.14159;
let active = true;

The compiler infers the type from the value. You can also annotate explicitly:

let x: i32 = 42;
let name: String = "Kāra";

Mutability

Bindings are immutable by default. To allow reassignment, use let mut:

let x = 5;
// x = 10;  // compile error: x is immutable

let mut y = 5;
y = 10;     // ok

This is a deliberate default. Most values don't need to change, and immutability helps the compiler reason about your code — for ownership, for parallelization, and for correctness.

Primitive types

Kāra has the numeric types you'd expect:

TypeDescription
i8, i16, i32, i64Signed integers
u8, u16, u32, u64Unsigned integers
f32, f64Floating-point numbers
booltrue or false
charA Unicode scalar value: 'a', '\n', '\u{1F600}'

Integer literals default to i64, floats to f64. If you annotate a different type, the compiler checks that the literal fits:

let small: u8 = 255;    // ok
// let overflow: u8 = 256;  // compile error: 256 doesn't fit in u8

Numeric literals

Numbers can use underscores for readability and different bases:

let million = 1_000_000;
let flags = 0b1010_0011;   // binary
let color = 0xFF_AA_00;    // hex
let permissions = 0o755;   // octal

A bare integer literal defaults to i64 and a bare float to f64, but a literal coerces to fit its context — the annotated type of a binding, a function parameter, or a struct field — as long as the value fits:

let n = 0;                 // i64 by default
let b: u8 = 200;           // literal coerces to u8
fn takes_byte(x: u8) { ... }
takes_byte(255);           // literal coerces to u8 at the call

When a literal has no context to coerce toward — or you want to override the default — pin its type with a suffix:

let mask = 0xFFu8;         // u8, not i64
let ratio = 1.0f32;        // f32, not f64
let zero = 0i64;           // explicit i64

You'll see the suffix form (0i64, 1u8) throughout numeric code. Most of the time the default or a binding annotation is enough; reach for a suffix when inference has nothing to anchor to.

Strings

Kāra has two string types:

  • String — an owned, heap-allocated UTF-8 string.
  • StringSlice — a borrowed view into a string (like Rust's &str).
let greeting = "Hello";              // String
let multiline = """
    This is a
    multi-line string.
""";

String interpolation

Prefix a string with f to embed expressions:

let name = "world";
let msg = f"Hello, {name}!";

let x = 10;
let y = 20;
println(f"{x} + {y} = {x + y}");  // "10 + 20 = 30"

Type conversions

Kāra does not implicitly convert values of one numeric type to another — only literals coerce to context (above). To convert a typed value, use as:

let x: i64 = 1000;
let y: i32 = x as i32;     // widening — always safe

as covers every numeric pair, with semantics worth knowing:

let small: u8 = 7;
let wide = small as i64;   // widening — value preserved (7)

let big: i64 = 300;
let trunc = big as u8;     // narrowing — wraps modulo 2^8 (300 -> 44)

let avg = total as f64 / count as f64;   // int -> float, before dividing
let k = 3.9 as i64;        // float -> int — truncates toward zero (3)

Narrowing can change the value (it keeps the low bits), so cast down only when you know it fits. Going int -> float -> int is the usual way to do real division: cast to f64 first, or integer division truncates the result.

One conversion as won't do is u8 -> char — not every integer is a valid Unicode scalar. Use char.try_from, which returns a Result; see Strings and Bytes.

Shadowing

You can re-declare a binding with the same name. The new binding shadows the old one:

let x = 5;
let x = x + 1;       // x is now 6
let x = x * 2;       // x is now 12

Shadowing lets you transform a value through a series of steps without mut. Each let creates a new binding — the old one is gone.

Naming identifiers

Kāra enforces identifier naming at the compiler level — it's a grammar rule, not a style guide. Every identifier belongs to one of three case classes:

  • Type class — PascalCase. Structs, enums, enum variants, traits, generic type parameters: String, UserAccount, IoError, T.
  • Value class — snake_case, or a leading _ for intentionally-unused bindings. Functions, parameters, fields, modules, and let bindings inside function bodies: read_to_string, user_count, _tmp.
  • Const class — ALL_UPPER with underscores. Module-level let and let mut bindings: MAX_RETRIES, TIMEOUT_MS.
struct UserAccount { ... }                // Type class
fn read_to_string(path: String) { ... }   // Value class
let count = 0;                            // Value class (function body)

fn ReadFile(), struct user_account, and let pi = 3.14 at module scope are all compile errors. One quirk worth knowing: multi-word types treat acronyms as words — HttpClient, not HTTPClient; IoError, not IOError. That keeps the classification unambiguous at a glance.

Note: _ on its own isn't a name — it's a wildcard used in patterns, let _ = expr, pipes, and with _ effects. Only leading _ (like _tmp) is a valid identifier you can read later.

The point of enforcing this is that every Kāra codebase reads the same way — no per-project casing debates, no PR bikeshedding, no re-tuning when you move between libraries.

Module-level bindings

You can declare bindings at the top of a file, outside any function:

let MAX_RETRIES: i32 = 5;
let TIMEOUT_MS: i64 = 60 * 1000;
let APP_NAME: String = "myapp";

Module-level bindings must be initialized with compile-time constant expressions — no function calls, no I/O, no allocations. This is a deliberate restriction: there's no hidden code running before main, no initialization order bugs, no startup effects you can't see.

Values that need runtime initialization (config files, database connections) are constructed inside main and passed down. We'll cover this pattern in later chapters.

Functions

Declaring functions

Functions are declared with fn, parameters are name: Type, and the return type follows ->:

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

fn greet(name: String) {
    println(f"Hello, {name}!");
}
  • The last expression in the body is the return value. No return keyword needed.
  • If a function doesn't return a value, omit the -> Type.
  • Use return for early exits:
fn first_positive(numbers: Vec[i64]) -> Option[i64] {
    for n in numbers {
        if n > 0 {
            return Some(n);
        }
    }
    None
}

Expressions, not statements

Almost everything in Kāra is an expression that produces a value. if/else is an expression:

fn abs(x: i64) -> i64 {
    if x >= 0 { x } else { -x }
}

match is an expression:

fn describe(n: i64) -> String {
    match n {
        0 => "zero",
        1..=9 => "single digit",
        _ => "big number",
    }
}

This means you rarely need temporary variables — you can use control flow inline wherever a value is expected.

Parameter modes: the compiler helps

Here's a function that reads a string but doesn't consume it:

fn char_count(text: String) -> i64 {
    text.len()
}

You wrote text: String, but the compiler notices that text is only read, never moved or mutated. It automatically infers that text should be passed by reference. The caller doesn't make a copy; char_count borrows the string.

You can also be explicit:

fn char_count(text: ref String) -> i64 {
    text.len()
}

Both versions behave identically. The inference just saves you the annotation. We'll cover ownership in depth in Chapter 12.

To take a sequence without copying it — and without locking the caller into one container type — declare the parameter as Slice[T]. A Vec[T] or an Array[T, N] both coerce to a slice at the call, so one signature serves every caller:

fn sum(xs: Slice[i64]) -> i64 {
    let mut acc = 0i64;
    for x in xs { acc = acc + x; }
    acc
}

let v: Vec[i64]    = [1, 2, 3];
let a: Array[i64, 2] = [10, 20];
sum(v);   // Vec coerces
sum(a);   // Array coerces too

This is the standard shape for a function that reads a list of values. See Slices for sub-ranges, mutable slices, and the full story.

Methods

Functions can be attached to types using impl blocks:

struct Circle {
    radius: f64,
}

impl Circle {
    fn area(ref self) -> f64 {
        3.14159 * self.radius * self.radius
    }

    fn scale(mut ref self, factor: f64) {
        self.radius = self.radius * factor;
    }

    fn new(radius: f64) -> Circle {
        Circle { radius }
    }
}
  • ref self — the method borrows the value (reads only).
  • mut ref self — the method borrows mutably (can modify fields).
  • No self parameter — it's an associated function (like a static method). Call it as Circle.new(5.0).

Methods use Universal Function Call Syntax (UFCS). These two calls are the same:

let c = Circle.new(5.0);
c.area()           // method syntax
Circle.area(c)    // function syntax — same thing

Control Flow

if / else

if is an expression — it produces a value:

let status = if score >= 90 { "excellent" } else { "keep going" };

For side effects, use it as a statement:

if temperature > 100 {
    println("Warning: overheating!");
} else if temperature > 80 {
    println("Running warm.");
} else {
    println("All good.");
}

No parentheses around the condition. Braces are always required.

Loops

while

let mut count = 0;
while count < 10 {
    println(count);
    count = count + 1;
}

for loops

for iterates over anything iterable:

let names = ["Alice", "Bob", "Charlie"];
for name in names {
    println(f"Hello, {name}!");
}

With ranges:

// 0, 1, 2, 3, 4
for i in 0..5 {
    println(i);
}

// 0, 1, 2, 3, 4, 5 (inclusive)
for i in 0..=5 {
    println(i);
}

loop

An infinite loop. Use break to exit:

let mut attempt = 0;
let result = loop {
    attempt = attempt + 1;
    if try_connect() {
        break "connected";
    }
    if attempt >= 3 {
        break "failed";
    }
};

loop is an expression — break value sets the value of the whole loop.

break and continue

for i in 0..100 {
    if i % 2 == 0 {
        continue;    // skip even numbers
    }
    if i > 10 {
        break;       // stop after 10
    }
    println(i);      // prints 1, 3, 5, 7, 9
}

match

Pattern matching is one of the most powerful tools in Kāra. At its simplest, it's a better switch:

let day = 3;
let name = match day {
    1 => "Monday",
    2 => "Tuesday",
    3 => "Wednesday",
    4 => "Thursday",
    5 => "Friday",
    6 | 7 => "Weekend",
    _ => "Invalid",
};

But match goes far beyond this — it works with enums, structs, nested data, and guards. We'll cover it fully in Chapter 6.

The pipe operator

Kāra has a pipe operator |> for chaining function calls left-to-right:

let result = data
    |> transform
    |> validate
    |> save;

This is equivalent to save(validate(transform(data))) but reads in the order things happen. It's especially useful for data processing pipelines.

Structs and Enums

Structs

A struct groups related data together:

struct Point {
    x: f64,
    y: f64,
}

fn main() {
    let origin = Point { x: 0.0, y: 0.0 };
    let p = Point { x: 3.0, y: 4.0 };

    println(f"({p.x}, {p.y})");
}

Struct names are Type-class identifiers (PascalCase); field names are Value-class (snake_case). The compiler enforces both — see Naming identifiers in chapter 2.

Methods on structs

Use impl blocks to attach behavior:

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    fn area(ref self) -> f64 {
        self.width * self.height
    }

    fn is_square(ref self) -> bool {
        self.width == self.height
    }

    fn new(width: f64, height: f64) -> Rectangle {
        Rectangle { width, height }
    }
}

fn main() {
    let r = Rectangle.new(10.0, 5.0);
    println(f"Area: {r.area()}");
    println(f"Square? {r.is_square()}");
}

Newtype wrappers

For a lightweight wrapper around a single existing type, use a distinct type:

distinct type Meters = f64;
distinct type Seconds = f64;

These are distinct types — you can't accidentally pass Meters where Seconds is expected, even though both wrap f64. (See Appendix C for #[derive(Arithmetic)], which restores +/-/* on a distinct type.)

Enums

An enum defines a type that can be one of several variants:

enum Direction {
    North,
    South,
    East,
    West,
}

fn describe(d: Direction) -> String {
    match d {
        Direction.North => "going up",
        Direction.South => "going down",
        Direction.East => "going right",
        Direction.West => "going left",
    }
}

Enums with data

Variants can carry data — this is what makes Kāra enums algebraic data types:

enum Shape {
    Circle(f64),                    // radius
    Rectangle(f64, f64),            // width, height
    Triangle { a: f64, b: f64, c: f64 },  // named fields
}

fn area(shape: Shape) -> f64 {
    match shape {
        Shape.Circle(r) => 3.14159 * r * r,
        Shape.Rectangle(w, h) => w * h,
        Shape.Triangle { a, b, c } => {
            let s = (a + b + c) / 2.0;
            (s * (s - a) * (s - b) * (s - c)).sqrt()
        }
    }
}

Option and Result

Two enums are so fundamental they're in the prelude — available everywhere without import:

enum Option[T] {
    Some(T),
    None,
}

enum Result[T, E] {
    Ok(T),
    Err(E),
}

Option represents a value that might not exist. Result represents an operation that might fail. You'll use them constantly:

fn find_user(id: u64) -> Option[User] {
    // returns Some(user) or None
}

fn parse_number(s: String) -> Result[i64, ParseError] {
    // returns Ok(number) or Err(error)
}

We'll cover error handling patterns in depth in Chapter 7.

Shared types

By default, structs and enums have value semantics — assigning or passing them moves or copies the data. For types that need reference semantics (shared ownership, graph structures), prefix with shared:

shared struct Node {
    value: i64,
    children: Vec[Node],
}

A shared struct is automatically reference-counted. Multiple owners can point to the same data without explicit Rc or Arc wrappers. The compiler picks the right reference-counting strategy behind the scenes.

Use shared when your data naturally has multiple owners. Use regular structs (the default) for everything else.

Mutable fields

A field of a shared struct is read-only unless you declare it mut. Because a shared value can have several owners, mutation through a shared reference is opt-in per field — marking a field mut is how you say "this one is meant to change in place":

shared struct ListNode {
    val: i64,
    mut next: Option[ListNode],   // reassignable; `val` is not
}

(Plain value structs don't need this — there you control mutation with a mut binding, let mut p = Point { ... }. The per-field mut is specific to shared, where the binding alone can't decide it.)

A linked list

The recursive shared struct + Option pair is the standard singly-linked list — each node owns the next, and None marks the end:

shared struct ListNode {
    val: i64,
    mut next: Option[ListNode],
}

// Build a list from a slice, tail to head, preserving order.
fn from_slice(xs: Slice[i64]) -> Option[ListNode] {
    let mut head: Option[ListNode] = None;
    let mut i = xs.len() - 1;
    while i >= 0 {
        head = Some(ListNode { val: xs[i], next: head });
        i = i - 1;
    }
    head
}

Walk it by unwrapping each node. if let peels off one Some and recurses on .next; while let does the same thing iteratively, rebinding the cursor until it hits None:

fn sum(node: Option[ListNode]) -> i64 {
    if let Some(n) = node {
        n.val + sum(n.next)
    } else {
        0i64
    }
}

fn length(node: Option[ListNode]) -> i64 {
    let mut count = 0i64;
    let mut cur = node;
    while let Some(n) = cur {
        count = count + 1;
        cur = n.next;
    }
    count
}

Trees are the same shape with two children instead of one (mut left, mut right) — see the TreeNode example in Ownership.

Pattern Matching

Pattern matching is one of Kāra's most expressive features. The match expression lets you destructure data and branch on its shape — and the compiler guarantees you handle every case.

Basic matching

fn classify(n: i64) -> String {
    match n {
        0 => "zero",
        1 | 2 | 3 => "small",
        4..=9 => "medium",
        _ => "large",
    }
}
  • | matches multiple values.
  • ..= matches inclusive ranges.
  • _ is the wildcard — matches anything.

Destructuring enums

This is where match really shines:

enum Message {
    Quit,
    Echo(String),
    Move { x: i64, y: i64 },
}

fn handle(msg: Message) {
    match msg {
        Message.Quit => println("Goodbye."),
        Message.Echo(text) => println(f"Echo: {text}"),
        Message.Move { x, y } => println(f"Moving to ({x}, {y})"),
    }
}

Each variant's data is extracted directly into variables. No casting, no type-checking at runtime — the compiler knows the structure at compile time.

Destructuring structs

struct Point {
    x: f64,
    y: f64,
}

fn describe(p: Point) -> String {
    match p {
        Point { x: 0.0, y: 0.0 } => "origin",
        Point { x, y: 0.0 } => f"on the x-axis at {x}",
        Point { x: 0.0, y } => f"on the y-axis at {y}",
        Point { x, y } => f"at ({x}, {y})",
    }
}

Nested patterns

Patterns compose. Match on the shape of nested data:

fn get_name(user: Option[User]) -> String {
    match user {
        Some(User { name, .. }) => name,
        None => "anonymous",
    }
}

.. ignores the remaining fields you don't care about.

Guards

Add conditions with if:

fn classify_temp(temp: f64) -> String {
    match temp {
        t if t < 0.0 => "freezing",
        t if t < 20.0 => "cold",
        t if t < 30.0 => "comfortable",
        _ => "hot",
    }
}

The variable t binds the matched value, and the if clause adds an extra condition.

Exhaustiveness

The compiler requires that match covers every possible case. This is enforced at compile time:

enum Color {
    Red,
    Green,
    Blue,
}

fn name(c: Color) -> String {
    match c {
        Color.Red => "red",
        Color.Green => "green",
        // compile error: non-exhaustive match — Color.Blue not covered
    }
}

This is especially powerful with enums: if you add a new variant, the compiler tells you everywhere you need to handle it. No silent bugs from forgotten cases.

let patterns

You can destructure in let bindings too:

let Point { x, y } = get_point();
let (first, second) = get_pair();

if let

For when you only care about one variant:

if let Some(user) = find_user(42) {
    println(f"Found: {user.name}");
}

This is cleaner than a full match when you'd just ignore the other cases.

Error Handling

Kāra has no exceptions. No try/catch. Errors are values, handled explicitly through Result and Option.

This sounds strict, but in practice it makes error handling easier — the compiler tracks which operations can fail and makes sure you handle them.

Result and the ? operator

An operation that can fail returns Result[T, E]:

fn parse_port(s: String) -> Result[u16, ParseError] {
    // returns Ok(port) or Err(error)
}

The ? operator propagates errors to the caller:

fn load_config(path: String) -> Result[Config, Error] {
    let text = read_file(path)?;       // if Err, return it immediately
    let config = parse_toml(text)?;     // same here
    Ok(config)                          // success
}

Without ?, you'd need a match at every step. ? keeps the happy path clean.

Option and ?

? also works with Option:

fn first_letter(text: Option[String]) -> Option[String] {
    let t = text?;                     // if None, return None early
    if t.len() == 0 { return None; }
    Some(t.substring(0, 1))
}

Optional chaining

For navigating nested optional values:

let city = user.address?.city?.name;

If any step is None, the whole expression short-circuits to None.

Default values with ??

let name = user.nickname ?? "anonymous";
let port = parse_port(input) ?? 8080;

?? provides a fallback when the left side is None or Err. The fallback is evaluated lazily — only when needed.

unwrap — the escape hatch

unwrap() extracts the value from Option or Result, crashing if it's None or Err:

let value = some_option.unwrap();   // panics if None

This produces the panics effect — the compiler tracks it through your call chain. Public functions that can panic must declare it. For production code, prefer ?, ??, unwrap_or(default), or match.

unwrap() is useful in tests, prototypes, and cases where you've already validated the value can't be None/Err.

Cleanup with defer and errdefer

defer runs cleanup when a scope exits, regardless of how:

fn process_file(path: String) -> Result[Data, Error] {
    let file = open(path)?;
    defer file.close();          // always runs when scope exits

    let data = parse(file)?;     // if this fails, file still gets closed
    Ok(data)
}

errdefer runs cleanup only on the error path:

fn open_connection(addr: String) -> Result[Connection, Error] {
    let conn = Connection.open(addr)?;
    errdefer conn.close();       // only if we return Err below

    register_metrics(conn)?;     // if this fails, close the connection
    Ok(conn)                     // success: errdefer does NOT run
}

Multiple defer/errdefer blocks run in reverse order (last declared, first executed).

The error handling philosophy

Kāra's approach comes down to:

  • Errors are data. They flow through the type system like any other value.
  • The compiler enforces handling. You can't silently ignore a Result.
  • ? makes the happy path clean. Error propagation is one character, not five lines of boilerplate.
  • defer handles cleanup. No RAII gymnastics, no finally blocks — just "run this when we leave."

The result is code where the error paths are visible but not noisy.

Traits and Generics

Traits

A trait defines shared behavior — a set of methods that different types can implement:

trait Area {
    fn area(ref self) -> f64;
}

Types implement traits with impl:

struct Circle {
    radius: f64,
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Area for Circle {
    fn area(ref self) -> f64 {
        3.14159 * self.radius * self.radius
    }
}

impl Area for Rectangle {
    fn area(ref self) -> f64 {
        self.width * self.height
    }
}

Default methods

Traits can provide default implementations:

trait Describable {
    fn name(ref self) -> String;

    fn description(ref self) -> String {
        f"A thing called {self.name()}"
    }
}

Types that implement Describable must provide name, but get description for free. They can override it if they want.

Generics

Generics let you write code that works with any type. Kāra uses [T] syntax — not <T>:

fn first[T](items: ref Vec[T]) -> Option[ref T] {
    items.get(0)
}

This works for Vec[i64], Vec[String], Vec[User] — anything.

Generic structs

struct Pair[A, B] {
    first: A,
    second: B,
}

let p = Pair { first: "hello", second: 42 };

Generic with trait bounds

Constrain what types are allowed:

fn largest[T: Ord](items: Vec[T]) -> T {
    let mut best = items[0];
    for item in items {
        if item > best {
            best = item;
        }
    }
    best
}

T: Ord means "T must implement the Ord trait" — so we know > works. Multiple bounds use +:

fn print_sorted[T: Ord + Display](items: Vec[T]) {
    let mut sorted = items;
    sorted.sort();
    for item in sorted {
        println(item);
    }
}

Why [T] instead of <T>?

No ambiguity with comparison operators. Vec[i32] can't be misread as "is Vec less than i32." No turbofish needed. The tradeoff is that [ does double duty for generics and indexing, but the parser disambiguates by context:

  • Type positions (annotations, return types): Vec[i64] is always generic.
  • Expression positions: arr[0] is always an index. A generic call is recognized by ( after ]: sort[i32](data).

Putting it together

Here's a generic function with a trait bound and a return type:

fn find[T: Eq](items: Vec[T], target: T) -> Option[u64] {
    for i in 0..items.len() {
        if items[i] == target {
            return Some(i);
        }
    }
    None
}

fn main() {
    let names = ["Alice", "Bob", "Charlie"];
    match find(names, "Bob") {
        Some(i) => println(f"Found at index {i}"),
        None => println("Not found"),
    }
}

The compiler infers T = String from the arguments. No annotation needed at the call site.

Collections

Sequence literals

The bare form [1, 2, 3] creates a Vec — growable, heap-allocated, pushed into, returned from functions:

let names = ["Alice", "Bob", "Charlie"];   // Vec[String]
let numbers = [1, 2, 3];                   // Vec[i64]

When you want a different collection, write its name as a prefix:

let xs = Array[1, 2, 3];         // Array[i64, 3] — fixed-size, stack-allocated
let s  = Set[1, 2, 3];           // Set[i64]
let m  = Map["a": 1, "b": 2];    // Map[String, i64]

This form works anywhere a value is expected — function arguments, return values — where a binding's type annotation can't reach.

Vec

The growable array. The most common collection:

let mut numbers = Vec.new();
numbers.push(1);
numbers.push(2);
numbers.push(3);

// Or initialize with values:
let names = ["Alice", "Bob", "Charlie"];

for name in names {
    println(name);
}

println(names[0]);          // "Alice"
println(names.len());       // 3

Capacity — when you know the size, reserve it

A Vec.new() starts empty and grows by reallocation as you push: capacity goes 0 → 1 → 2 → 4 → 8 → …, and each doubling copies every element it already holds to a fresh, larger buffer. On allocation-bound code that builds a bounded-size Vec, that grow chain is the dominant cost.

When you already know how many elements you'll add, reserve the space up front with Vec.with_capacity(n). It allocates once; the first n pushes land in the reserved slots without a single reallocation:

let mut out = Vec.with_capacity(n + 1);   // one allocation, no grow chain
let mut i = 0;
while i < n {
    out.push(compute(i));
    i = i + 1;
}

with_capacity is only a hint — the Vec still grows if you exceed n, so an imperfect estimate can never change your program's output, only its memory use. Reach for it whenever you're building a bounded-size collection in a hand-written loop and the bound is known before the loop starts.

The payoff is real: building a bounded Vec with Vec.new() + a push loop, then switching that same loop to Vec.with_capacity(n), roughly halved the build time on an allocation-bound benchmark — the whole difference was the grow-from-empty tax, not the work itself.

You often don't have to write it, though. Two things already handle the common cases for you:

  • A simple counted push loopwhile i < n { v.push(..) } with a known bound and an unconditional push — is pre-sized automatically; the compiler reserves the trip count for you, so the hand-written form above is only needed when the count isn't a clean loop bound.
  • The collect idiom doesn't need it either. src.iter().map(..).collect() and src.iter().filter(..).collect() build their result with a tight grow loop that has no per-element bounds check, so they already run within a hair of a hand-tuned with_capacity — reaching for a manual reservation there buys nothing (and can even cost you, since a fixed up-front allocation interacts worse with the cache when the source elements are large).

So the rule of thumb is narrow: use Vec.with_capacity for a hand-written loop whose element count you know but that isn't a plain counted push. For counted loops and for collect, write the natural code — it's already fast.

Arrays

Fixed-size, stack-allocated. Size is part of the type — Array[i64, 4] and Array[i64, 5] are different types.

let xs = Array[10, 40, 20, 30];          // Array[i64, 4] — size and type inferred
let scores = Array[0; 4];                // Array[i64, 4] — four zeros via repeat form

// Or declare with an annotation:
let data: Array[i64, 4] = [10, 40, 20, 30];

let mut buf: Array[u8, 256] = [0; 256];  // annotation propagates u8 into elements
buf[0] = 100;
buf[1] = 85;

Map

Key-value pairs:

let mut ages = Map.new();
ages.insert("Alice", 30);
ages.insert("Bob", 25);

// Or initialize with values:
let scores = Map["Alice": 10, "Bob": 7];

match ages.get("Alice") {
    Some(age) => println(f"Alice is {age}"),
    None => println("Not found"),
}

Set

Unique values:

let mut seen = Set.new();
seen.insert("hello");
seen.insert("world");
seen.insert("hello");    // no effect, already present

// Or initialize with values:
let colors = Set["red", "green", "blue"];

println(seen.len());     // 2

Tuples

Fixed-size, mixed-type groups:

let pair = (42, "hello");
let (number, text) = pair;

fn min_max(items: Vec[i64]) -> (i64, i64) {
    // return both at once
    (items.min(), items.max())
}

Nested collections — grids

Collections nest. The workhorse 2D structure is a Vec[Vec[i64]] — a vector of rows, each a vector of cells. Build one by pushing row literals:

let mut grid: Vec[Vec[i64]] = Vec.new();
grid.push([1, 2, 3]);
grid.push([4, 5, 6]);

println(grid.len());        // 2 — number of rows
println(grid[0].len());    // 3 — number of columns
println(grid[1][2]);       // 6 — row 1, column 2

grid[i][j] reads a cell; the same place assigns to one:

grid[0][1] = 20;           // mutate one cell in place

For a grid sized at runtime — the usual starting point for a dynamic-programming table — fill it with zeros up front. Vec.filled(n, value) builds an n-element vector, and nesting it gives an r × c grid:

let mut dp: Vec[Vec[i64]] = Vec.filled(rows, Vec.filled(cols, 0i64));

Each row is an independent copy — writing dp[0][0] = 9 leaves dp[1][0] untouched. (Collections have value semantics; the inner Vec.filled is copied into each slot, not shared.)

Traverse a grid by index. Pull each row out with let row = g[i], then walk its cells — exactly the shape the inner loop wants:

fn cell_sum(g: ref Vec[Vec[i64]]) -> i64 {
    let rows = g.len();
    let mut total = 0i64;
    let mut i = 0i64;
    while i < rows {
        let row = g[i];
        let cols = row.len();
        let mut j = 0i64;
        while j < cols {
            total = total + row[j];
            j = j + 1;
        }
        i = i + 1;
    }
    total
}

The parameter is ref Vec[Vec[i64]] — the grid is borrowed for reading, so the caller keeps ownership. To mutate cells through a parameter, take mut ref Vec[Vec[i64]] (see Ownership).

Rows need not be the same length — a Vec[Vec[i64]] is naturally jagged, which is what you want for adjacency lists and triangular tables.

Strings and Bytes

Kāra strings are UTF-8. A String owns a growable, heap-allocated buffer of UTF-8 bytes; a StringSlice is a borrowed view into one (see Variables and Types). Because the encoding is UTF-8, a character can be one to four bytes wide — and that single fact shapes the whole API.

You can't index a String directly

let s = "hello";
// let c = s[0];   // not allowed

s[i] would have to either return a byte (surprising — you asked for a character) or scan from the start counting characters (a hidden O(n) cost on something that looks like O(1)). Kāra refuses to guess. Instead you pick a view and the cost becomes explicit:

  • Characterss.chars() / s.char_at(i), working in Unicode scalar values.
  • Bytess.bytes(), working in raw u8 with O(1) indexing.

Algorithmic code over ASCII almost always wants the byte view; text that has to respect non-ASCII characters wants the character view.

Substrings — a range index copies

Scalar indexing is out, but a range index is in — and it means something different from range-indexing a Vec. s[a..b] returns a fresh, owned String holding a copy of the bytes in that range (not a borrowed Slice, and not a StringSlice view):

let s = "HelloWorld";
let head = s[0..5];       // "Hello" — a new String
let tail = s[5..];        // "World"
let all  = s[..];         // "HelloWorld" (a full copy)

Every range form works — a..b, a..=b, a.., ..b, ... The method spelling s.substring(a, b) is identical in behaviour to s[a..b].

Two things to keep in mind:

  • The offsets are byte positions, not character positions — the same units as s.bytes(). For ASCII the two coincide; for text with multi-byte characters they don't.

  • A range must land on UTF-8 character boundaries. Slicing through the middle of a multi-byte character panics at runtime (E_STRING_SLICE_NOT_AT_CHAR_BOUNDARY) rather than handing back invalid UTF-8:

    let s = "héllo";         // 'é' occupies bytes 1..3
    let bad = s[0..2];        // panics — byte 2 is mid-character
    

Because the result is an owned String, it outlives the source and can be pushed, returned, or stored freely. When you only need to read a borrowed window without copying, reach for a StringSlice via s.slice(a, b) instead (see Variables and Types).

The character view

for c in s and s.chars() both yield char — one Unicode scalar value at a time:

let mut vowels = 0;
for c in s.chars() {
    if c == 'a' or c == 'e' or c == 'i' or c == 'o' or c == 'u' {
        vowels = vowels + 1;
    }
}

For random access, snapshot the characters into a Vec[char] once, then index that:

let chars: Vec[char] = s.chars().collect();
println(chars[0]);        // 'h'  — a char
println(chars.len());     // 5

s.char_at(i) returns the i-th character as an Option[char]None when i is out of range — and is O(n), since it counts characters from the front. Reach for it for a one-off lookup; if you index repeatedly, collect into a Vec[char] instead.

The byte view — s.bytes()

s.bytes() returns a Slice[u8]: a borrowed, O(1)-indexable view over the string's underlying storage, with no per-call allocation. This is the workhorse for scanning ASCII input.

let bytes = s.bytes();
let n = bytes.len();
let b = bytes[0];         // u8

Byte literals

A b'x' literal is a single ASCII byte — a u8, not a char. Compare and do arithmetic on bytes directly:

let b = bytes[i];
if b >= b'0' and b <= b'9' {
    let digit = (b - b'0') as i64;   // '7' - '0' == 7
}

b - b'0' — the gap between a digit's byte and the byte for '0' — is the canonical "parse one ASCII digit" move. The same range trick classifies letters (b >= b'a' and b <= b'z').

Worked example: Roman numerals

Scanning bytes left to right, subtracting a smaller value that precedes a larger one (IV is 4):

fn value(b: u8) -> i64 {
    if b == b'I' { return 1i64; }
    if b == b'V' { return 5i64; }
    if b == b'X' { return 10i64; }
    if b == b'L' { return 50i64; }
    if b == b'C' { return 100i64; }
    if b == b'D' { return 500i64; }
    if b == b'M' { return 1000i64; }
    0i64
}

fn roman_to_int(s: ref String) -> i64 {
    let bytes = s.bytes();
    let n = bytes.len();
    let mut total = 0i64;
    let mut i = 0i64;
    while i < n {
        let cur = value(bytes[i]);
        if i + 1 < n and cur < value(bytes[i + 1]) {
            total = total - cur;
        } else {
            total = total + cur;
        }
        i = i + 1;
    }
    total
}

Note the parameter is ref String — the function borrows the string to read it, it doesn't take ownership (see Ownership). A string literal passes straight to a ref String parameter, so roman_to_int("MCMXCIV") just works.

Matching on bytes

Because a byte is a plain integer, match arms can be byte literals — handy when one byte maps to another:

fn closer_for(b: u8) -> u8 {
    match b {
        b'(' => b')',
        b'[' => b']',
        b'{' => b'}',
        _    => 0u8,
    }
}

Building strings

You scan with bytes, but you build with characters. Start from an empty String and append:

let mut out = String.new();
out.push('h');            // push a single char
out.push_str("ello");     // append a string

push takes a char, so you build from char literals or from characters you pulled out with .chars():

fn reverse(s: ref String) -> String {
    let chars: Vec[char] = s.chars().collect();
    let n = chars.len();
    let mut out = String.new();
    let mut i = n - 1;
    while i >= 0 {
        out.push(chars[i]);
        i = i - 1;
    }
    out
}

A u8 is not a charb as char is rejected, because not every integer is a valid Unicode scalar. When you need a character computed from a number, go through char.try_from, which returns a Result[char, _]:

fn digit_char(d: i64) -> char {
    match char.try_from(b'0' + d as u8) {
        Ok(c)  => c,
        Err(_) => '?',
    }
}

Which view should I use?

You want…UseCostElement
Scan/parse ASCII left to rights.bytes() then indexO(1) per accessu8
Iterate characters oncefor c in s.chars()O(n) totalchar
Random access by character indexs.chars().collect()Vec[char]O(n) once, O(1) afterchar
One-off i-th characters.char_at(i)O(n)Option[char]
A copied substring (byte range)s[a..b] / s.substring(a, b)O(k) copyString
A borrowed substring views.slice(a, b)O(1)StringSlice
Build up outputString.new() + push / push_stramortized O(1) per push

The rule of thumb: read as bytes, build with chars. Byte scanning keeps the inner loop to single-byte comparisons; character building keeps the output UTF-8-correct without you tracking encoding by hand.

Closures and Iterators

Closures

Anonymous functions that can capture variables from their environment:

let add = |a: i64, b: i64| a + b;
println(add(2, 3));    // 5

let threshold = 10;
let is_big = |n: i64| n > threshold;    // captures `threshold`
println(is_big(15));   // true

Closure parameters take type annotations just like fn parameters. When a closure is passed straight into a combinator like .map(), the element type flows in and the annotation can be omitted (see Iterators below); a closure bound to a let with no other context needs the annotation.

Closures as parameters

Functions can accept closures:

fn apply_twice(f: Fn(i64) -> i64, x: i64) -> i64 {
    f(f(x))
}

let double = |n| n * 2;
println(apply_twice(double, 3));    // 12

Closures and effects

Closures inherit effects from the code they contain. A closure that calls println carries a writes(Stdout) effect. The effect system tracks this through higher-order functions — no surprise side effects hiding in callbacks.

Sorting

The most common place you'll hand a closure to the standard library is sorting. Vec sorts in place, so the binding must be mut:

let mut v = [3, 1, 4, 1, 5, 9, 2, 6];
v.sort();                       // natural ascending order

For any other order, sort_by takes a comparator closure |a, b| ... that returns an ordering. Produce one by comparing two elements with .cmp():

v.sort_by(|a, b| a.cmp(b));     // ascending — same as v.sort()
v.sort_by(|a, b| b.cmp(a));     // descending — flip the operands

a.cmp(b) answers "how does a order relative to b?", so putting b first reverses the direction. The same shape sorts by a derived key — compare the keys instead of the whole elements:

// pairs: Vec[(i64, i64)] — order by the second component, descending
pairs.sort_by(|a, b| b.1.cmp(a.1));

Iterators

Iterators let you process sequences lazily:

let numbers = [1, 2, 3, 4, 5];

let doubled = numbers
    .iter()
    .map(|n| n * 2)
    .filter(|n| n > 4)
    .collect();

// doubled = [6, 8, 10]

Because closures carry the effects of their bodies (see above), iterator chains stay honest about effects too: a map whose closure writes to stdout contributes writes(Stdout) to the enclosing function's effect row. The same map / filter / take combinators apply whether the elements come from an in-memory Vec or an effectful source — one combinator library, no separate Stream type.

Common iterator methods

items.map(|x| transform(x))       // transform each element
items.filter(|x| predicate(x))    // keep elements that match
items.fold(init, |acc, x| ...)    // reduce to a single value
items.any(|x| x > 10)             // true if any element matches
items.all(|x| x > 0)              // true if all elements match
items.enumerate()                  // pairs of (index, value)
items.zip(other)                   // pairs from two iterators
items.take(n)                      // first n elements
items.skip(n)                      // skip first n elements

The pipe operator with iterators

The pipe operator |> chains transformations naturally:

let result = data
    |> parse
    |> validate
    |> transform;

The Effect System

This is the feature that defines Kāra. The effect system tracks what your code does to the outside world — and uses that knowledge to verify correctness, generate better diagnostics, and automatically parallelize work.

The idea

Every interaction with the outside world is an effect: reading a file, writing to a database, sending a network request, allocating memory. In most languages, these are invisible — a function might do anything and the caller has no way to know.

In Kāra, effects are tracked. The compiler knows which functions read from the filesystem, which write to a database, which send network requests. This information flows through the type system and enables powerful guarantees.

Effects = verbs + resources

An effect is a verb applied to a resource:

reads(FileSystem)       — reads from the filesystem
writes(Database)        — writes to a database
sends(Net)              — sends data over the network
receives(Net)           — receives data from the network
allocates(Heap)         — allocates memory
panics                  — might crash (no resource needed)

These six are the resource verbs: reads, writes, sends, receives, allocates, panics. They answer "can these two operations conflict?" — which is what drives the auto-parallelization below.

Resources are user-defined. You declare what exists in your domain:

effect resource FileSystem;
effect resource Database;
effect resource Cache;
effect resource Net;

One verb can name several resources at once — writes(Display, Audio) is shorter than writing writes twice.

Execution verbs: blocks and suspends

The resource verbs say what a function touches. Two more verbs say how it runs — information the scheduler needs to decide where to place a task:

  • blocks — the call may park the OS thread in a kernel wait (a sleep, a synchronous file read, a contended lock). While it waits, that thread can do nothing else, so the scheduler routes blocking tasks to a separate pool.
  • suspends — the call may cooperatively yield: the task steps aside and the thread is freed to run other work, resuming later. This is Kāra's async — there is no async/await, no Future, no function coloring. You write a plain call; the compiler inserts the yield point because the callee is declared suspends.
fn sleep(d: Duration) with blocks { ... }                          // parks the thread
fn http_get(url: String) -> Response with sends(Net) suspends { ... }   // yields while waiting
fn compute(x: f64) -> f64 { ... }                                  // neither — runs anywhere

Execution verbs take no resource — a function either may block/suspend or it may not. They don't take part in conflict analysis (placement is a separate axis from conflict), and like resource verbs they're inferred on private functions but must be declared on public ones: hiding whether a function blocks would defeat the point.

That's the full set: six resource verbs plus these two execution verbs, eight in all.

Private functions: effects are inferred

For internal functions, the compiler figures out the effects automatically:

fn load_data(path: String) -> String {
    read_file(path)     // compiler infers: reads(FileSystem)
}

fn save_report(data: String) {
    write_file("report.txt", data)    // compiler infers: writes(FileSystem)
    println("Saved.");                // compiler infers: writes(Stdout)
}

You write normal code. The compiler tracks what it does. No annotation needed.

Public functions: effects are declared

At API boundaries, you declare your effects explicitly. This is a contract with your callers:

pub fn fetch_user(id: u64) -> Result[User, Error]
    with reads(Database) sends(Net)
{
    let cached = check_cache(id);
    match cached {
        Some(user) => Ok(user),
        None => load_from_api(id),
    }
}

The with clause lists every effect the function may produce. The compiler verifies that the body doesn't exceed the declared effects — if you add a write_file call inside, the compiler will reject it because writes(FileSystem) isn't declared.

This is the key insight: effects are the primary interface of Kāra. They tell callers what a function does to the world. Ownership and layout are implementation details the compiler manages; effects are what you declare and what gets verified.

Why this matters

1. The compiler catches mistakes

If your function claims reads(Database) but you accidentally added a line that writes to it, the compiler tells you. You either fix the code or update the declaration.

2. Automatic parallelization

Two function calls with non-conflicting effects can run in parallel:

fn generate_report(id: u64) -> Report
    with reads(UserDB) reads(OrderDB) reads(Analytics)
{
    let user = fetch_user(id);          // reads(UserDB)
    let orders = fetch_orders(id);      // reads(OrderDB)
    let stats = fetch_analytics(id);    // reads(Analytics)
    build_report(user, orders, stats)
}

The three fetches read from different resources. The compiler can prove they don't interfere with each other and run them concurrently — without you writing any threading code. We'll cover this in Chapter 14.

3. Documentation that can't lie

The with clause is a machine-checked description of what a function does. It can't go stale like a comment. It can't be wrong like a docstring. If the declaration says reads(Database), the function reads from the database and does nothing else that isn't declared.

Effect groups

For common combinations, define groups:

effect group io = reads(FileSystem) + writes(FileSystem) + reads(Env);

Effect group names are Value-class (snake_case) — the same naming class as effect verbs and let bindings. Then use the group in declarations:

pub fn process(path: String) -> Result[Data, Error] with io {
    // can read and write files, read environment variables
}

What's next

The effect system goes deeper — effect polymorphism, parameterized resources, providers, conflict detection. But the core idea is what matters: declare what your code does to the world, and the compiler verifies it. Everything else builds on that.

Ownership Without the Fight

If you've used Rust, you know the ownership system: powerful but demanding. Lifetimes, borrowing rules, the borrow checker rejecting code you know is safe.

Kāra keeps the semantics and drops the lifetimes. Borrows are declared at the signature with a word (ref, mut ref), and the compiler verifies the body matches. RC fills in when a single owner can't be proven.

The three tiers

Every value in Kāra lives in one of three ownership tiers:

  1. Owned — the default. The value has one owner. When the owner goes out of scope, the value is dropped.
  2. Ref (borrowed) — a temporary view of someone else's value. ref T is shared/read-only, mut ref T is exclusive/mutable. Like Rust's &T / &mut T, without lifetime variables.
  3. RC (reference-counted) — shared ownership. Multiple owners, reference-counted. The compiler adds this automatically when needed.

You write the tier at the signature. The compiler infers nothing about the public contract — what the source says is what callers see.

Parameter modes

Every parameter names its mode in the signature. Default (owned) is bare; borrows are written:

fn greet(name: ref String) {
    println(f"Hello, {name}!");
}

fn take_name(name: String) -> String {
    name    // consumed — owned parameter, moves out
}

fn add_suffix(name: mut ref String) {
    name.push_str("!");    // exclusive borrow — mutates in place
}

Three rules, one per mode. The body must match: consuming an owned parameter is fine; consuming a ref parameter is a compile error; writing through a mut ref is fine, consuming it is not.

Receivers

Methods follow the same rule. Bare self is the owned/consuming receiver, ref self is a shared borrow, mut ref self is an exclusive borrow:

impl Builder {
    fn build(self) -> Widget { ... }              // consumes self
    fn peek(ref self) -> i64 { self.count }       // reads self
    fn bump(mut ref self) { self.count = self.count + 1 }   // mutates self
}

No own self — the keyword own isn't written anywhere in a signature. Owned is always the bare form.

karac explain

If you write fn greet(name: String) and only read name in the body, the compiler accepts it — but karac explain reports the "would-be mode" for each parameter, so you can tighten signatures when performance matters. The report is diagnostic, not contractual: callers always see what you wrote.

Call sites

At call sites, mutation gets a marker when the argument is a fresh binding passed to a mut ref T or mut Slice[T] parameter:

let mut v = [3, 1, 4, 1, 5];
sort_in_place(mut v);          // fresh binding → marker required

Inside a function that already holds the binding as a mut ref, you don't repeat the marker — the mutation was announced at the callee's signature:

fn helper(s: mut ref State) {
    update(s.cache);           // field through a mut-ref root → no marker
    reset(s.counter);          // same — forwarded
}

Method calls, field assignment, and index assignment never carry the marker:

v.push(x);                     // method call — silent
s.field = 5;                   // field assignment — silent
v[i] = x;                      // index assignment — silent

ref is never written at call sites — the signature carries the mode. f(ref v) is a parse error.

Move semantics

When a value is moved, the original binding is gone:

let a = Vec.new();
let b = a;           // `a` is moved into `b`
// println(a);       // compile error: `a` has been moved
println(b);          // ok

This prevents use-after-move bugs at compile time. No dangling pointers, no double frees.

RC fallback

Sometimes the compiler can't prove a single-owner model works — the value is shared across data structures, or its lifetime can't be statically determined. In these cases, the compiler falls back to reference counting:

let node = Node { value: 42, children: Vec.new() };
// If `node` ends up shared across a graph structure,
// the compiler automatically wraps it in RC.

You don't write Rc[Node] or Arc[Node]. The source code always says Node. The compiler picks the representation, and karac explain tells you what it chose.

Slices

A slice is a borrowed view into contiguous memory — a pointer and a length, nothing more. Kāra has two:

  • StringSlice — a view into a String.
  • Slice[T] — a view into any sequence of T (usually a Vec[T] or Array[T, N]).

Slices let one function work over many container types:

fn sum(xs: Slice[i64]) -> i64 {
    let mut acc = 0;
    for x in xs { acc = acc + x; }
    acc
}

let v: Vec[i64] = [1, 2, 3, 4];
let a: Array[i64, 3] = [10, 20, 30];

sum(v);         // Vec coerces to Slice at the call boundary
sum(a);         // Array coerces too
sum(v[1..3]);   // a sub-range is also a Slice

You don't write sum(v.as_slice()) — the compiler inserts the coercion when a call expects Slice[T] and the argument is a compatible owned or borrowed container. When you need a slice as a first-class value (stored in a let, captured by a closure), call .as_slice() explicitly.

Mutable slices

For in-place operations, use mut Slice[T] — the same mut modifier Kāra uses everywhere else:

fn sort_in_place[T: Ord](xs: mut Slice[T]) { /* ... */ }

let mut v = [3, 1, 4, 1, 5];
sort_in_place(mut v);          // mutably borrow the whole Vec
sort_in_place(mut v[1..4]);    // or just a sub-range

Why slices matter

Without slices, a function that operates on a sequence has to choose between being too restrictive (ref Vec[i64] — rejects arrays) and too generic (a trait bound — loses O(1) indexed access). Slices give you the middle ground: one signature that works over any contiguous sequence, with full random access.

shared types

For types that are designed for shared ownership, use shared:

shared struct TreeNode {
    value: i64,
    left: Option[TreeNode],
    right: Option[TreeNode],
}

shared struct means: this type always uses reference counting. It's the right tool for trees, graphs, and any structure where multiple parents point to the same child.

The philosophy

Kāra's ownership model: Rust semantics, no lifetimes, one word per mode.

  • Signatures declare the mode with a word: bare for owned, ref / mut ref for borrows.
  • Call sites mark mutation for fresh bindings with mut; forwarded mut-refs and method calls stay silent.
  • The compiler never silently copies expensive data. Moves are explicit in the semantics.
  • When you need to see what the compiler chose (RC flavor, representation), karac explain shows you.
  • Lifetimes never appear in source. The compiler infers borrow scoping below the signature surface.

The goal is Rust-level safety with mainstream-language readability — no <'a>, no turbofish, one unified rule for borrows across free functions, methods, and traits.

Modules and Visibility

File = module

In Kāra, every .kara file is a module. The directory structure defines the module tree — no mod declarations needed:

src/
  main.kara              // entry point
  db/
    connection.kara      // module: db.connection
    pool.kara            // module: db.pool
  auth/
    token.kara           // module: auth.token

The compiler discovers all .kara files automatically. No manifest of modules to maintain.

Module names are Value-class identifiers — always snake_case. This falls out of the identifier case-class rules introduced in chapter 2; db, connection, auth_token are valid, Db or AuthToken as module names are compile errors.

Three levels of visibility

KeywordWho can see it
pubEveryone, including users of your library
(default)All files in your project
privateFiles in the same directory only
pub fn validate(input: String) -> bool { ... }     // public API
fn helper(s: String) -> String { ... }              // project-internal
private fn secret_impl() { ... }                    // same directory only

Why default is project-internal

This will surprise you if you're coming from Rust or Java, where default = private to the module.

In Kāra, modules are directories. If the default were "private to this directory," you'd need pub on almost every cross-directory call within your own project. The current default covers the common case: internal code that your own files need to call.

You only annotate the boundaries:

  • pub for things external users should see.
  • private for helpers that shouldn't leak outside their directory.

Imports

import db.connection.Connection;
import auth.token.Token;

// Multiple items from the same module
import std.collections.{Map, Set};

// Rename an imported item
import std.collections.Map as Dict;

Import paths are absolute from the crate root. Every file writes the same path for the same item, regardless of where it sits in the directory tree.

Re-exports

Libraries can present a clean public surface:

// lib.kara
pub import db.connection.Connection;
pub import db.pool.Pool;
pub import auth.token.Token;

// Users write:
import mylib.Connection;    // not mylib.db.connection.Connection

Reorganize your internals without breaking users.

The prelude

These are available everywhere without imports:

  • Types: Option, Result, Vec, String, StringSlice, Map, Set, and all primitives.
  • Variants: Some, None, Ok, Err.
  • Functions: print, println, eprintln.
  • Builtins: todo, unreachable, dbg, assert, assert_eq.

Project layout

myproject/
  kara.toml             // project manifest (like Cargo.toml)
  src/
    main.kara           // executable entry point
    lib.kara            // library entry point (instead of main.kara)
  tests/
    db_test.kara        // integration tests
  examples/
    basic.kara          // runnable examples

Dependencies go in kara.toml:

[package]
name = "myproject"
version = "0.1.0"

[dependencies]
http = "1.2"
json = { version = "0.8", git = "https://github.com/example/json-kara" }

Concurrency

Kāra's concurrency story is built on a simple idea: if the compiler can prove two operations don't interfere, it can run them in parallel. The effect system makes this possible.

Automatic parallelization

Consider a function that fetches data from three independent sources:

fn build_dashboard(user_id: u64) -> Dashboard
    with reads(UserDB) reads(OrderDB) reads(Analytics)
{
    let profile = fetch_profile(user_id);       // reads(UserDB)
    let orders = fetch_orders(user_id);         // reads(OrderDB)
    let stats = fetch_analytics(user_id);       // reads(Analytics)
    Dashboard.new(profile, orders, stats)
}

The three fetches operate on different resources. The compiler proves they don't conflict and runs them concurrently — zero threading code from you.

This is possible because of the effect system. Without knowing which resources each call touches, the compiler couldn't prove independence. Effects are what make auto-concurrency safe.

Explicit concurrency with par

When you want to be explicit about parallelism:

A par block runs each of its branches concurrently and waits for all of them before control falls through. It's structured concurrency — no dangling tasks, no fire-and-forget. Each branch is an independent statement; they share data through a concurrency-safe type rather than by writing the same plain variable:

par struct Counter { count: Atomic[i64] }

fn bump(counter: ref Counter) {
    let _ = counter.count.fetch_add(1, MemoryOrdering.Relaxed);
}

fn main() {
    let counter: Counter = Counter { count: Atomic.new(0) };
    par {
        bump(counter);
        bump(counter);
        bump(counter);
    }
    println(counter.count.load(MemoryOrdering.Relaxed));   // 3
}

par struct marks a type as safe to share across branches; its Atomic[T] field carries the shared state. The effect system enforces this: if a par branch tried to write an ordinary let mut shared by a sibling, the compiler would reject it and tell you to reach for Atomic, Mutex, or a par struct. No data races slip through.

TaskGroup for dynamic fan-out

par is for a fixed set of branches written out in the source. When the number of tasks is decided at runtime — split an image into workers bands, process N rows — use a TaskGroup: spawn each task, collect its TaskHandle[T], then join each to gather the results.

fn square(n: i64) -> i64 { n * n }

fn main() {
    let mut pool: TaskGroup = TaskGroup.new();
    let mut handles: Vec[TaskHandle[i64]] = Vec.new();
    let mut k = 1;
    while k <= 4 {
        let n = k;                          // a fresh binding per task
        handles.push(pool.spawn(|| square(n)));
        k = k + 1;
    }

    let mut total = 0i64;
    for handle in handles {
        total = total + handle.join();      // wait for each, collect its result
    }
    println(total);                          // 1 + 4 + 9 + 16 = 30
}

pool.spawn takes a thunk (a zero-argument closure) and returns a TaskHandle[T]; .join() waits for that task and hands back its return value. Bind a fresh let n = k inside the loop so each closure captures its own value rather than the shared loop counter. There is no async/await — a spawned task is just a function call that happens elsewhere, with suspends tracking any cooperative yielding (see Effects).

Parallel failure

When one branch of a par block fails:

  1. Sibling branches are cancelled cooperatively.
  2. Each branch's cleanup (defer/errdefer) runs.
  3. The first error is returned.

No orphaned tasks. No silent failures. Structured concurrency means the scope waits for everything to finish before proceeding.

The runtime

Kāra's concurrency runtime uses work-stealing with a thread pool. The details are an implementation choice — your code doesn't depend on them. You write sequential-looking code with effect annotations; the compiler and runtime handle the rest.

Data Layout

Most languages give you no control over how data is arranged in memory. Kāra lets you separate what your data is from how it's stored — without changing the logical API.

Why layout matters

Modern CPUs are memory-bound, not compute-bound. Cache misses dominate performance. How your data is laid out in memory — whether related fields are next to each other, whether you're iterating over dense arrays or chasing pointers — matters more than most algorithmic optimizations.

Layout blocks

A layout block reorganizes the memory of a collection — a Vec[T] or Array[T, N] — without touching the struct definition or the code that uses it. You attach it to a binding, not to the type: layout <name>: Vec[T] { ... }.

struct Particle { x: f64, y: f64, name: String }

// x and y share one contiguous array (the physics hot path);
// name is cold — a separate allocation the hot loop never touches.
layout swarm: Vec[Particle] {
    group hot { x, y }
    cold { name }
}

Now swarm's storage is Structure-of-Arrays: all the xs and ys sit together in the hot group's backing array instead of being interleaved with each name. The logical API is unchanged — you still write swarm[i].x — and a loop that reads only x and y streams through dense memory:

fn drift(swarm: ref Vec[Particle]) -> f64 {
    let mut sum = 0.0;
    let mut i = 0i64;
    while i < swarm.len() {
        sum = sum + swarm[i].x + swarm[i].y;   // touches only the hot group
        i = i + 1;
    }
    sum
}

Three directives go inside the block:

  • group <name> { fields } — the named fields become one contiguous array (the SoA transform). Use several groups to keep fields that are read together on the same cache line.
  • cold { fields } — moves rarely-accessed fields to a separate allocation, out of the hot path. At most one cold section per block.
  • align(N) — forces a group's backing array onto an N-byte boundary (e.g. align(64) for a cache line), the standard fix for false sharing between threads.

Every field must be placed in exactly one group or in cold — the compiler rejects a layout that leaves a field unassigned, so the storage is never ambiguous. There is no soa keyword: grouping a collection's fields is the SoA transform, and the default (no layout block) is plain array-of-structs.

Because a single element's fields are now scattered across the group arrays, no one contiguous region is a whole Particle — so you can't borrow a whole element out of an SoA collection. Reading a field (swarm[i].x) works, and you can always materialize a plain array-of-structs copy of one element:

let e = swarm[1];       // an array-of-structs copy of one element
println(e.name);

When to use layout control

Most code doesn't need layout blocks. Use them when:

  • You have hot loops iterating over large arrays of structs.
  • Profiling shows cache misses dominating.
  • You want SoA layout for SIMD-friendly processing.

For everything else, let the compiler pick the layout. It's implementation freedom — the compiler can optimize within the constraints you give it.

Testing

Kāra has built-in testing support — no external framework needed.

Unit tests

Tests live alongside the code they test, in _test.kara files. Each test is a test "name" { ... } block — a quoted case name and a body. karac test discovers and runs every such block; no attribute or registration required:

// math.kara
pub fn add(a: i64, b: i64) -> i64 {
    a + b
}

// math_test.kara — shares module `math`'s scope, so it calls `add` directly
test "addition works" {
    assert_eq(add(2, 3), 5);
    assert_eq(add(-1, 1), 0);
}

test "addition is commutative" {
    assert_eq(add(3, 7), add(7, 3));
}

A <module>_test.kara file is part of that module, so it sees the module's functions with no import — in fact importing your own module back into its test file is a cycle error. The case name is a string, not an identifier, so it can read like a sentence.

Assertions

Available everywhere as builtins:

assert(condition);              // panics if false
assert_eq(left, right);        // panics if not equal, shows both values

Running tests

karac test                    # run all tests
karac test addition           # run only tests whose case name contains "addition"

The filter is a substring of the case name — the text between test and { — so karac test commutative runs just the second block above.

Server-Side Rendering

A single Kāra program often compiles to more than one target. The classic case is server-side rendering (SSR): the server renders a page to HTML, the browser hydrates it and handles interaction — and you want one component to drive both, not two copies that drift apart.

Kāra does this without #[cfg] chains in your component. The component stays an ordinary, target-agnostic function. What differs between server and client is which provider you bind for its resources.

The full, runnable code for this chapter is examples/ssr_counter.

The shared component

The component renders against an abstract resource, Sink, rather than talking to a concrete HTML buffer or DOM. It has no idea which target it is running on — and no #[target(...)] attribute:

effect resource Sink;

// Target-agnostic: compiles unchanged for the server and the client.
pub fn render_counter(count: i64) with writes(Sink) {
    Sink.heading("Kāra SSR Counter");
    Sink.count(count);
    Sink.parity(count % 2);
}

render_counter issues semantic render calls. Turning those into bytes or DOM mutations is somebody else's job — the provider's.

Two providers, one resource

A provider is just a type whose methods realize the resource. On the server, Sink becomes HTML:

struct StringSink {}
impl StringSink {
    fn heading(mut ref self, title: String) { print(f"<h1>{title}</h1>"); }
    fn count(mut ref self, n: i64) { print(f"<output id=\"count\">{n}</output>"); }
    fn parity(mut ref self, p: i64) {
        if p == 0 { println("<span id=\"parity\">even</span>"); }
        else { println("<span id=\"parity\">odd</span>"); }
    }
}

On the client, Sink becomes DOM mutation. The static heading is already present in the page the server rendered, so hydration leaves it alone — only the dynamic values cross to the host:

effect resource Dom;
host fn dom_set_count(value: i64) with writes(Dom);
host fn dom_set_parity(value: i64) with writes(Dom);

struct DomSink {}
impl DomSink {
    fn heading(mut ref self, title: String) {}  // already in the SSR'd DOM
    fn count(mut ref self, n: i64) with writes(Dom) { dom_set_count(n); }
    fn parity(mut ref self, p: i64) with writes(Dom) { dom_set_parity(p); }
}

The entry points — the only place #[target] belongs

Each target binds its provider with with_provider, then calls the same component. The entry points are the one genuinely per-target part of the program, so they — and only they — carry #[target(...)]:

// Server: render to HTML on stdout.
#[target(native)]
fn main() {
    with_provider[Sink](StringSink {}, || {
        render_counter(42);
    });
}

// Client: hydrate the live DOM. `pub` + a matching target tag exports it
// to JavaScript.
#[target(wasm_browser)]
pub fn hydrate(count: i64) -> i64 with writes(Dom) {
    with_provider[Sink](DomSink {}, || {
        render_counter(count);
    });
    count
}

Build each target from the same file:

karac build ssr_counter.kara                       # ./ssr_counter (server)
karac build ssr_counter.kara --target=wasm_browser # .wasm + .js (client)

The server prints the page body; the browser loads the wasm, supplies the DOM host fns, and calls hydrate. One component, rendered two ways.

The rule

Keep #[target(...)] out of component bodies. The attribute is for entry points and irreducible forks — code that genuinely cannot exist on every target. Everything else is target-agnostic, and per-target behavior comes from the providers you bind.

This is not just style. Because the component is target-agnostic, the compiler type-checks and effect-checks it once per target (see the design notes on cross-target compilation). A user-defined resource like Sink has no target affinity — it lives wherever a provider for it does — so the same component is provably correct on the server and the client without a single conditional.

The effect system also catches target mistakes for you. A function that reaches a browser-only capability (say writes(Display)) cannot be compiled for native; the compiler rejects it at the target gate and points to the call chain — no silent misbuild, no runtime surprise.

FFI and Interop: Kāra as a Library

A new language has a bootstrapping problem: no ecosystem yet, and no team wants to rewrite a working system to get one feature. Kāra's answer is to be additive, not a replacement. Write the hot loop, the parallel kernel, the one function that has to be fast, in Kāra — build it as a linkable library with a plain C ABI — and drop it into a program that keeps everything else. This is the Rust-in-Firefox, Zig-alongside-C playbook, and Kāra is built to be the guest.

This chapter walks one kernel from .kara source into both a C and a Rust host, side by side. Every command and every line of output below is real — the two hosts print the same numbers, which is the whole point.

Calling C from Kāra

Before the main event, the other direction in one screen — importing C functions into Kāra. Declare them in an unsafe extern "C" block and call them inside unsafe:

/// # Safety
/// `sqrt` is pure math from libm; `strlen` reads only the NUL-terminated
/// bytes behind the pointer, which `c"..."` literals guarantee.
unsafe extern "C" {
    fn sqrt(x: f64) -> f64;
    fn strlen(s: *const u8) -> usize;
}

fn main() {
    // Safety: finite input; sqrt has no preconditions.
    let root = unsafe { sqrt(2.0) };
    println(root);                        // 1.4142135623730951
    let msg = c"hello from C land";
    // Safety: msg is a static NUL-terminated literal.
    let n = unsafe { strlen(msg.as_ptr()) };
    println(n as i64);                    // 17
}

This works identically under karac run and karac build — the JIT resolves the symbols in-process, the AOT build links them from libc/libm.

Three rules carry most of the weight:

  • The declaration block is unsafe extern, and every call site is unsafe. The compiler can't check what's on the other side of the boundary, so both the block and each call carry a # Safety / // Safety: comment stating the trust contract — the undocumented_unsafe lint warns when they're missing.
  • C strings cross as *const u8. A c"..." literal is a ref CStr (NUL-terminated, in rodata); hand it to C with .as_ptr(). Declaring an extern parameter as ref CStr directly is not the FFI form — CStr is a Kāra type, not a C one.
  • Effects still apply. An extern fn that writes somewhere declares it: fn puts(s: *const u8) -> i32 with writes(Console); — foreign code doesn't get to dodge the effect system (see Effects).

That's the import direction. The rest of this chapter is the export direction — the one that answers "can I actually use this next to my existing code?"

The kernel

A library has no main. It has an exported surface: the functions a caller is allowed to reach. In Kāra that surface is every pub extern "C" fn. Save this as kernel.kara:

#[repr(C)]
pub struct Stats { sum: f64, count: i64 }

// A simple scalar export.
pub extern "C" fn add(a: i32, b: i32) -> i32 { a + b }

// A little real work — iterative Fibonacci.
pub extern "C" fn fib(n: i64) -> i64 {
    if n < 2 { return n; }
    let mut a = 0;
    let mut b = 1;
    let mut i = 2;
    while i <= n {
        let c = a + b;
        a = b;
        b = c;
        i = i + 1;
    }
    b
}

// A `#[repr(C)]` struct crosses the boundary by value.
pub extern "C" fn stats_mean(s: Stats) -> f64 {
    if s.count == 0 { return 0.0; }
    s.sum / (s.count as f64)
}

Two things worth pointing at:

  • pub extern "C" fn is the whole export declaration. pub makes it visible; extern "C" gives it the C calling convention and an unmangled symbol name, so a C caller reaches add as add, not some mangled string.
  • #[repr(C)] on Stats is a promise about memory layout. A default Kāra struct has no stable physical layout (the compiler is free to reorder fields — see Data Layout); #[repr(C)] pins it to the C order so it can cross the boundary by value.

Building the artifact

Two crate types, two artifacts:

$ karac build kernel.kara --crate-type staticlib
Built: libkernel.a
Built: libkernel.h

$ karac build kernel.kara --crate-type cdylib
Built: libkernel.so
Built: libkernel.h

staticlib produces a .a (a .lib on Windows); cdylib produces a .so (.dylib on macOS, .dll on Windows). Both come with libkernel.h — the C header, generated for you, so a caller #includes it instead of hand-transcribing signatures. The default artifact name is lib<stem>.<ext>, distinct from any executable, so a library build never clobbers a stray binary; -o overrides it.

The .a is thick: it bundles the Kāra runtime. A C program links it and runs with no karac toolchain present — that is the deliverable, a .a + a .h you can hand to a team that has never heard of Kāra.

The emitted header

Here's what karac wrote to libkernel.h (trimmed to the body):

#include <stdint.h>
#include <stddef.h>

/* Runtime lifecycle. Call karac_runtime_init() once before the first
 * exported call, and karac_runtime_shutdown() at host teardown. */
void karac_runtime_init(void);
void karac_runtime_shutdown(void);

struct Stats {
    double sum;
    int64_t count;
};

int32_t add(int32_t a, int32_t b);
int64_t fib(int64_t n);
double stats_mean(struct Stats s);

The #[repr(C)] struct came across as a real C struct, the scalars mapped to fixed-width <stdint.h> types, and two lifecycle functions appeared that you didn't write — more on those next.

The C host

The host includes the header and calls in. Nothing from karac is on the compile line — just cc, the .a, and the .h:

#include <stdio.h>
#include "libkernel.h"

int main(void) {
    karac_runtime_init();

    struct Stats s = { .sum = 30.0, .count = 4 };
    printf("add=%d fib=%lld mean=%.2f\n",
           add(20, 22),
           (long long)fib(20),
           stats_mean(s));

    karac_runtime_shutdown();
    return 0;
}
$ cc host.c libkernel.a -lpthread -lm -ldl -o host_c
$ ./host_c
add=42 fib=6765 mean=7.50

karac_runtime_init() before the first exported call, karac_runtime_shutdown() at teardown — the runtime lifecycle bracket. At v1 they are no-ops, but calling them is the contract: it lets the runtime acquire and release whatever it needs without you rewriting the host later.

The Rust host — with one caveat

A Rust program consumes Kāra the same way it consumes any C library: an extern "C" block declaring the surface. This is the pyo3 / cxx / uniffi pattern, inverted — Rust reaching into Kāra across the stable C boundary. (There is no stable Rust ABI, so C is the durable bridge in both directions.)

#[repr(C)]
struct Stats { sum: f64, count: i64 }

#[link(name = "kernel", kind = "dylib")]
extern "C" {
    fn karac_runtime_init();
    fn karac_runtime_shutdown();
    fn add(a: i32, b: i32) -> i32;
    fn fib(n: i64) -> i64;
    fn stats_mean(s: Stats) -> f64;
}

fn main() {
    unsafe {
        karac_runtime_init();
        let s = Stats { sum: 30.0, count: 4 };
        println!("add={} fib={} mean={:.2}", add(20, 22), fib(20), stats_mean(s));
        karac_runtime_shutdown();
    }
}
$ karac build kernel.kara --crate-type cdylib -o libkernel.so
$ rustc host.rs -L . -C link-arg=-Wl,-rpath,. -o host_rs
$ ./host_rs
add=42 fib=6765 mean=7.50

Same three numbers as the C host. That is the A/B result: one kernel, two languages, identical output.

The caveat is in the build command: a Rust host must link the cdylib, not the staticlib. The Kāra runtime is itself a Rust crate that bundles std, so a .a carries std symbols — rust_eh_personality, the allocator shims — that collide with the Rust host's own std at static-link time, and you get a cryptic duplicate symbol error. A shared library encapsulates those internal symbols; the dynamic linker resolves only the exported entry points. karac prints a note steering you to the cdylib whenever you build a staticlib, and the caveat rides along in the header comment too. C and C++ hosts have no std to clash with and can link either artifact.

What crosses the boundary

The C ABI is honest about what it can carry. The type mapping is a deliberate v1 set:

  • Primitives (i32, i64, f64, bool, …) and raw pointers cross transparently — they are their C equivalents.
  • #[repr(C)] structs cross by value, as you saw with Stats.
  • Owned collections returned by valueString, Vec[i32], and one level of nesting like Vec[String] — are auto-boxed. Kāra returns them as a small {data, len, cap} record, which doesn't match the C struct-return ABI, so the compiler heap-boxes the value and hands C an opaque pointer instead. The header gains a matching struct and a karac_free_<name> destructor; the C side reads the fields and calls the destructor when done. Zero boilerplate on your side.
  • Everything else — an enum, an Option, a plain (non-repr(C)) struct by value — is rejected at build time with a clear error rather than silently miscompiled. If the offender is a user struct, the diagnostic points at the one-step fix: add #[repr(C)].

That last rule is the important one. The compiler will not emit a header that promises a shape the ABI can't actually deliver, so the .a / .so / .h you ship is never quietly wrong.

Ownership across the boundary

The simplest way to hand C a buffer it will own is to allocate it the way C expects — through malloc, imported as an unsafe extern "C" block — fill it with raw-pointer writes, and return the pointer. The caller frees it with free (or a Kāra export that calls free), and no Kāra destructor is ever involved:

unsafe extern "C" { fn malloc(n: usize) -> *mut i64; }

pub extern "C" fn make_squares(n: i64) -> *mut i64 with blocks {
    let p: *mut i64 = unsafe { malloc((n as usize) * 8) };
    let mut i: i64 = 0;
    while i < n {
        unsafe { p.offset(i).write(i * i); }
        i = i + 1;
    }
    p
}

The raw-pointer methods — .offset(i), .write(v), .read() (and _unaligned / _volatile variants) — are the low-level toolkit for this, always inside unsafe. The with blocks on the signature is the effect system at work: calling a foreign function is treated as blocking, and a public function must declare the effects it carries — the compiler tells you exactly which to add if you forget.

When instead you're holding an owned Kāra value and want to release it to the caller without running its destructor, the forget primitive is the move-out. It consumes its argument and suppresses the drop Kāra would otherwise run:

forget(value);   // Kāra will NOT drop `value`; ownership has left the language

Because forget takes its argument by value, the ownership checker and the drop machinery both agree the value left — there is no double-free to reason about. For the auto-boxed return types above, this whole handshake is generated for you; the manual tools here are the escape hatch when you're managing the memory yourself.

Effects at the boundary

An exported function's effects are part of its contract, and the header states them. The boundary is synchronous: a suspends function — one that would yield to the async scheduler — is rejected as an export (E0414), because there is no scheduler on a bare foreign thread to yield to. blocks is fine; panics is contained (a panic can't unwind across the C frame, so it aborts rather than corrupt the caller). The exported surface tells the truth about what it does, up front.

What's next

You've now seen both directions of the C boundary: this chapter produced a library for C and Rust to consume, and the effect and ownership chapters cover calling out to C from Kāra with unsafe extern blocks. The full worked example — kernel, C host, Rust host, and a README — lives in examples/interop/ in the source tree; the specification is design.md § Exported C ABI.

The pitch is simple: you don't have to adopt Kāra all at once. Start with one kernel, link it in, and let it earn the next one.

Handling Secrets

Credentials leak in boring, repeatable ways. A token ends up in a log line because someone println'd a struct for debugging. An API key rides into a crash report because the type derived Serialize. A session compare uses ==, and an attacker times the responses to recover the token one byte at a time. None of these are exotic — they are the default behavior of ordinary types, and that is the problem.

Kāra's answer is std.secret.Secret[T]: a wrapper that makes the leaky paths not compile. You still hold the value, you still use it — but printing it, serializing it, and comparing it with == are compile errors, and the one comparison you're allowed is constant-time.

Wrapping a value

Secret is not in the prelude. You import it explicitly:

import std.secret.{Secret};

fn main() {
    let api_key = Secret.new("sk-live-abc123");
    // ...
}

That import line is deliberate. std.secret is a gated module — code that never asks for it never sees the name — so an import std.secret at the top of a file is itself a signal in code review: this file handles sensitive material. You want that signal loud.

Reading it back

The wrapped value is reachable through exactly one read path, .expose():

import std.secret.{Secret};

fn main() {
    let api_key = Secret.new("sk-live-abc123");
    let raw = api_key.expose();
    println(f"Authorization: Bearer {raw}");
}
Authorization: Bearer sk-live-abc123

.expose() is intentionally ugly and intentionally unique. It is not an operator, not a Deref, not an implicit coercion — it is a method with a name you can grep for. Every place a secret becomes an ordinary value is a .expose() call site, so "where do we touch the raw key?" is a text search, not an audit.

The accidents that no longer compile

The point of the wrapper is what it refuses to do. Comparing two secrets with == doesn't type-check:

let a = Secret.new("x");
let b = Secret.new("y");
println(a == b);
error[typecheck]: type 'Secret<String>' does not implement Eq; add #[derive(Eq)] to use == or !=

The suggestion to #[derive(Eq)] is a dead end on purpose — deriving it is itself blocked:

impl Display for Secret[String] {
    fn fmt(ref self) -> String { "leak" }
}
error[E_SECRET_TRAIT_FORBIDDEN]: cannot implement `Display` for `Secret[T]` — the
wrapper deliberately withholds this trait so a secret cannot be printed,
serialized, or structurally compared by accident (see design.md § Secret Type).
Read the value with `.expose()` where you genuinely need it; for equality use
the constant-time `.ct_eq(...)`, and rely on the built-in `Zeroize`/`Drop` for wiping

The same rejection covers Debug, Display, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Deref, Borrow, AsRef, and Copy — every trait that would let a secret slip out through printing, wire transit, structural comparison, transparent unwrapping, or silent bit-duplication. There is no #[derive] that turns them back on.

Secrets inside structs

Most secrets don't travel alone — they sit in a config or a session struct. Deriving Display on a struct that contains a Secret field is fine; the field just renders as <redacted>:

import std.secret.{Secret};

#[derive(Display)]
struct Config { host: String, token: Secret[String] }

fn main() {
    let c = Config { host: "db.internal", token: Secret.new("hunter2") };
    println(c);
}
Config { host: db.internal, token: <redacted> }

So the reflexive "log the whole config" debugging move is safe by construction: the ordinary fields print, the secret doesn't. This holds everywhere the struct renders — println, .to_string(), an f-string — and through nesting, because each struct re-checks its own Secret fields.

Comparing in constant time

The one comparison a Secret permits is .ct_eq():

import std.secret.{Secret};

fn main() {
    let expected = Secret.new("sk-live-abc123");
    let presented = Secret.new("sk-live-abc123");

    if expected.ct_eq(presented) {
        println("tokens match");
    } else {
        println("tokens differ");
    }
}
tokens match

Why not just ==? Because a normal string compare returns the instant it finds a differing byte. An attacker who can time your token check learns how many leading bytes were correct from the response latency, and walks the secret out one byte per round of guesses. .ct_eq() compares the whole length every time — it accumulates the differences and only then decides — so the timing carries no information about where two values diverge. This is the right primitive for tokens, HMAC tags, and CSRF values, and it's the reason Secret withholds == in the first place.

(Today .ct_eq() covers Secret[String] — the token/HMAC/CSRF case. Byte-array secrets are on the way.)

The posture: wrap at the type, not at the call site

The habit Kāra pushes you toward is to make the secret a Secret[T] at the boundary where it's born — the moment you read the env var, parse the request header, or load the key file — and to keep it wrapped for its whole lifetime. Every downstream function takes a Secret[String], not a String; the type flows through your program carrying its guarantees, and the only holes are the .expose() calls you can enumerate.

The opposite habit — passing a raw String around and being careful at each use — is the one that fails, because "be careful everywhere" is not a property a compiler can check. Wrapping at the type makes the safe path the default and the unsafe path a visible, greppable exception.

A note on zeroization. The language reference (design.md § Secret Type) also specifies that a Secret wipes its bytes when it's dropped, so a freed buffer doesn't linger in memory. That behavior is being wired through the compiler's drop paths and is not complete yet — so don't rely on a dropped Secret's memory being zeroed today. Everything else in this chapter — the wrapper, .expose(), the trait blocklist, <redacted> rendering, and constant-time .ct_eq() — is enforced now.

The 3am Runbook: Debugging Crashes

You have been paged. A Kāra service crashed, and there is a JSON file waiting for you. This chapter is the short version of what to do next — enough to triage the common cases without reading the whole book at 3am.

When a Kāra program panics, its std.panic handler writes a structured crash report — a single JSON file — and prints a short summary plus the file's path to stderr. The default location is /tmp/kara-crash-{pid}-{timestamp}.json (configurable via KARA_CRASH_DIR). The JSON is the load-bearing artifact: it is a stable, versioned wire format that tools can dedupe and group across compiler versions.

First move: render it

When you have a crash file, run:

karac debug /tmp/kara-crash-48213-20260723T024117.json

karac debug turns the JSON into a human-readable report. (Pass - to read from stdin, e.g. curl … | karac debug -.) Everything below is read off that rendered report. The single most useful habit: read the effect set first — it usually tells you which subsystem to look at before you read a single line of application code.

Worked example 1 — a panic that names its blast radius

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Kāra crash report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  panic: index out of bounds
  at src/handlers.kara:42:18  (fetch_user_dashboard)
  index out of bounds: the len is 3 but the index is 99

  effects: reads(UserDB) + sends(Network)

  logical stack:
    ▸ fetch_user_dashboard
        src/handlers.kara:42:18  reads(UserDB) + sends(Network)
    ▸ handle_request
        src/server.kara:88:5  reads(UserDB)
    ▸ main
        src/main.kara:12:3  pure

  parallel context:
    panicked branch: par@spawn_site_42 (worker 3)
    still running: render_sidebar, load_notifications
    cancelled: fetch_activity at sends boundary

  provider stack:
    Db ← PostgresPool   bound at src/main.kara:8:3

  RC fallback:
    `session` became RC at src/handlers.kara:40:9
        reason: captured by closure with subsequent outer use

  ────────────────────────────────────────────────────────────
  kara 0.1.0 (abc1234) · x86_64-unknown-linux-gnu · release · 2026

How to read it, top to bottom:

  • panic: index out of bounds — the kind. An index-out-of-bounds is a logic bug (a bad index), not an outage. You are looking for a length assumption that failed, not a down dependency.
  • at src/handlers.kara:42:18 — go straight here. Line 42 indexed a 3-element collection with 99.
  • effects: reads(UserDB) + sends(Network) — the blast radius. This code path touches the user database and the network. If the bad index came from data, UserDB is the place that data came from.
  • logical stack — the call chain, each frame with its own effect summary. Note main is pure: the effects are introduced deeper in.
  • parallel context — this ran inside a par block. Two siblings were still running and one (fetch_activity) was cancelled by fail-fast. That is expected: one branch panicking cancels the rest.
  • provider stack — the Db resource was bound to a PostgresPool at main.kara:8. If this were a connectivity problem, this is the provider you would check.

Verdict: logic bug at handlers.kara:42. Not a paging-worthy outage — file a bug, patch the index.

Worked example 2 — a crash inside cleanup

The famously-hard case: a panic that happens while the program is already unwinding from another panic, during a destructor. C++ aborts here; Kāra captures it.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Kāra crash report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  panic: panic while unwinding (drop)
  at src/cache.kara:71:5  (Session::drop)
  channel closed: send on a disconnected receiver

  effects: sends(Metrics)

  logical stack:
    ▸ Session::drop
        src/cache.kara:71:5  sends(Metrics)
    ▸ handle_checkout
        src/checkout.kara:118:9  reads(OrderDB) + writes(OrderDB)

  RC fallback:
    `session` became RC at src/checkout.kara:96:13
        reason: captured by a spawned closure with subsequent outer use — RC keeps it alive across the task boundary

  caused by:
  panic: index out of bounds
  at src/checkout.kara:122:20  (handle_checkout)
  index out of bounds: the len is 0 but the index is 0

  effects: reads(OrderDB)

  logical stack:
    ▸ handle_checkout
        src/checkout.kara:122:20  reads(OrderDB)

  ────────────────────────────────────────────────────────────
  kara 0.1.0 (abc1234) · aarch64-apple-darwin · release · 2026

How to read it:

  • panic: panic while unwinding (drop) — the second panic. This one fired in Session::drop while the stack was already unwinding.
  • caused by: at the bottom — the original panic. Read this first: the root cause is the index-out-of-bounds at checkout.kara:122, not the channel error. The drop-time panic is a symptom of the cleanup running during an already-failing request.
  • RC fallback — the report tells you why the Session was still alive to be dropped here: the compiler chose an RC representation for session at checkout.kara:96 because a spawned closure captured it. When a panic crosses an implicit Drop of an RC value, this annotation is how you find it without guessing. The fix for the secondary crash is usually making Session::drop tolerate a closed channel; the fix for the incident is the caused_by bug.

Worked example 3 — one branch takes down a parallel batch

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Kāra crash report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  panic: parallel fail-fast cancellation
  at src/ingest.kara:54:12  (parse_shard)
  unwrap on Err value: ParseError { line: 3, msg: "unexpected end of input" }

  effects: reads(Blob) + panics

  logical stack:
    ▸ parse_shard
        src/ingest.kara:54:12  reads(Blob) + panics
    ▸ ingest_batch
        src/ingest.kara:40:3  reads(Blob) + writes(WarehouseDB)

  parallel context:
    panicked branch: par@spawn_site_17 (worker 2)
    still running: parse_shard[0], parse_shard[1], parse_shard[3]
    cancelled: parse_shard[0] at writes boundary
    cancelled: parse_shard[1] at writes boundary
    cancelled: parse_shard[3] at reads boundary

  ────────────────────────────────────────────────────────────
  kara 0.1.0 (abc1234) · x86_64-unknown-linux-gnu · release · 2026

How to read it:

  • panic: parallel fail-fast cancellation — a branch of a par block panicked, and Kāra cancelled its siblings (fail-fast). The panic itself is the unwrap on Err value at ingest.kara:54.
  • parallel context is the important part here. Worker 2 hit a ParseError on its shard. Three sibling shards were mid-flight and were cancelled — note where: two were cancelled at a writes boundary (they had not yet committed to WarehouseDB) and one at a reads boundary. That tells you the batch did not partially write: cancellation caught the writers before their effect boundary. No cleanup of half-written warehouse rows is needed.
  • Verdict: a single malformed shard (line 3, unexpected EOF) failed the whole batch. Fix the input or make parse_shard return a Result the batch can skip, rather than unwrap.

Common patterns — what the effect set is telling you

The effect line is a triage shortcut. Some patterns worth memorizing:

Effect set on the panic frameWhere to look first
panics + reads(SomeDB)Data from that database — a bad value, an empty result indexed, a failed unwrap on a query.
sends(Network) / receives(Network) on the frameA downstream dependency — timeouts, closed connections, a service that is down. Check the provider stack for which endpoint.
writes(SomeDB) present but the panic is par_fail_fast_cancelCheck the parallel context cancellation boundaries — did any sibling get past its writes boundary? If not, no partial write happened.
drop_during_unwindRead caused_by first — the root cause is the original panic, not the drop.
rc_fallback_borrow, or an RC fallback annotation on the crashA shared value's lifetime crossed a task or closure boundary; the annotation names where the compiler chose RC.

Escalation

If the rendered report is not enough — you want to attach it to a bug, feed it to an AI agent, or diff two crashes — re-emit the structured form:

karac debug crash.json --output=json

This prints the parsed report as pretty JSON. Because the wire format is stable, it is safe to store, diff (karac debug a.json --output=json | diff - <(karac debug b.json --output=json)), or hand to tooling that keys on panic_kind and panic_site for grouping. Tools dedupe on the (panic_kind, panic_site) pair, so two reports with the same kind and site are "the same bug" even across builds.

For the full field-by-field contract — every field, the panic-kind vocabulary, the edge cases (concurrent panics, WASM, GPU) — see the language reference's Crash Report Format section. This runbook is deliberately the short version.

Appendix A: Keywords

The following words are reserved by the Kāra language. You cannot use them as identifiers.

Declaration keywords

KeywordPurpose
fnDeclare a function
structDeclare a struct
enumDeclare an enum
traitDeclare a trait
implImplement a trait or add methods to a type
typeDeclare a type alias
distinctDeclare a distinct (newtype) alias
constDeclare a compile-time constant
modReserved — modules come from the directory tree; a mod declaration is a compile error
useBring a name into scope
importImport an external package
externDeclare a foreign function or type
sharedMark a struct or enum as reference-semantics (RC)
layoutDeclare a physical memory layout for a struct
groupGroup fields within a layout block
effectDeclare an effect system definition
resourceDeclare an effect resource
verbDeclare an effect verb
aliasDeclare that two resource names refer to the same underlying resource

Visibility keywords

KeywordPurpose
pubPublic — visible to external consumers
privatePrivate — visible only within the current directory

(Default visibility — no keyword — is project-internal: visible to all files in the project.)

Control flow keywords

KeywordPurpose
ifConditional branch
elseFallthrough branch for if
matchPattern-matching switch
whileCondition-driven loop
forIterator-driven loop
inSeparator between pattern and iterable in for
loopInfinite loop
returnEarly return from a function
breakExit from a loop
continueSkip to the next loop iteration
deferRun a block when the enclosing scope exits (success path)
errdeferRun a block when the enclosing scope exits via ?-propagated error
asmInline assembly block
global_asmModule-level assembly block

Binding keywords

KeywordPurpose
letDeclare a local binding
mutMark a binding or parameter as mutable

Ownership and borrowing keywords

KeywordPurpose
ownExplicit owned parameter mode (rarely needed — owned is the default)
refBorrow a value (read-only reference)
weakWeak reference into an RC type
lockLock resource

Effect keywords

KeywordPurpose
readsEffect: reads from a resource
writesEffect: writes to a resource
sendsEffect: sends to a resource
receivesEffect: receives from a resource
allocatesEffect: allocates from a resource
panicsEffect: may panic
blocksEffect: may block the calling thread
suspendsEffect: may yield to the scheduler
withIntroduce an effect annotation or effect variable
transparentMark an effect as transparent (not attributed to callers)
stableMark an effect annotation as part of the public API contract
seqSequential block
parParallel block (branches may execute concurrently)
yieldYield a value from a generator

Type system keywords

KeywordPurpose
asType cast or trait disambiguation
whereIntroduce generic bounds or refinement-type predicates
dynDynamic dispatch through a trait object (reserved; not yet implemented in v1)
SelfThe type of the current impl block or trait
selfThe receiver value within a method

Contract keywords

KeywordPurpose
requiresPrecondition contract on a function
ensuresPostcondition contract on a function
invariantInvariant check at the end of every method in an impl block

These contract keywords are reserved and parsed, but contract checking is not yet enforced in v1 — runtime/compile-time verification is planned.

Safety keywords

KeywordPurpose
unsafeMark a block or function as bypassing safety checks

Concurrency and context keywords

KeywordPurpose
providersIntroduce a provider scope
independentDeclare that two resources are independent for conflict analysis

Literal keywords

KeywordPurpose
trueBoolean true
falseBoolean false

Reserved for future use

These words are reserved now; using them as identifiers is a compile error.

KeywordPlanned use
f16Half-precision float (Phase 7+)
bf16Brain-float (Phase 7+)

Primitive type names

These are lexer-level keywords, not identifiers. They are always in scope and require no import.

i8, i16, i32, i64, u8, u16, u32, u64, f32, f64, bool, char, ! (the never type)

Appendix B: Operators and Symbols

Arithmetic operators

OperatorMeaningTrait
a + bAddAdd
a - bSubtractSub
a * bMultiplyMul
a / bDivideDiv
a % bRemainderRem

All arithmetic operators lower to trait-method calls after type checking. Writing a + b where A does not implement Add is a type error that names the missing trait, not the operator.

Integer arithmetic uses trap-on-overflow semantics in app and lib profiles. For explicit two's-complement wraparound, use the named wrapping_* methods (wrapping_add, wrapping_sub, wrapping_mul):

MethodBehavior
a.wrapping_add(b)Two's-complement wraparound

Bitwise operators

OperatorMeaningTrait
a & bBitwise ANDBitAnd
a | bBitwise ORBitOr
a ^ bBitwise XORBitXor
a << bLeft shiftShl
a >> bRight shiftShr

Comparison operators

OperatorMeaningTrait
a == bEqualPartialEq
a != bNot equalPartialEq
a < bLess thanPartialOrd
a <= bLess than or equalPartialOrd
a > bGreater thanPartialOrd
a >= bGreater than or equalPartialOrd

Logical operators

OperatorMeaning
a and bShort-circuit logical AND
a or bShort-circuit logical OR
not aLogical NOT

Kāra spells these as words. The symbolic forms && / \|\| / ! are rejected with a diagnostic that points you to and / or / not.

Assignment operators

OperatorMeaning
a = bAssign
a += bAdd-assign
a -= bSubtract-assign
a *= bMultiply-assign
a /= bDivide-assign
a %= bRemainder-assign
a &= bBitwise-AND-assign
a |= bBitwise-OR-assign
a ^= bBitwise-XOR-assign
a <<= bLeft-shift-assign
a >>= bRight-shift-assign

Range operators

OperatorMeaningExample
a..bHalf-open range [a, b)0..10
a..=bClosed range [a, b]1..=5
a..Range from a to endslice[2..]
..bRange from start to b (exclusive)slice[..4]
..Full rangeslice[..]

Other operators and symbols

SymbolMeaning
?Propagate an Err result early (error shorthand)
a |> fPipe a as the first argument to f
a ?? bNil-coalesce: return a if it is Some, else b
a?.bOptional chaining: access .b only if a is Some
a as TCast a to type T
_Wildcard pattern or unnamed placeholder
..Struct-update spread in struct literals
->Return-type annotation in function signatures
=>Pattern arm separator in match
::Path separator in qualified names
@Attribute prefix
#[...]Attribute on a declaration

Numeric literal suffixes

Force the type of a literal where inference cannot propagate from a binding annotation.

SuffixType
42i8i8
42i16i16
42i32i32
42i64i64 (default for integer literals)
42u8u8
42u16u16
42u32u32
42u64u64
1.0f32f32
1.0f64f64 (default for float literals)

Unsuffixed integer literals default to i64; unsuffixed float literals default to f64. In a binary expression, an unsuffixed literal may be promoted to the type of its suffixed sibling.

String literal prefixes

PrefixMeaning
"..."Plain string literal
f"..."Interpolated string — {expr} inserts the Display value of expr
f"...{expr:?}..."Interpolated with Debug formatting

Appendix C: Derivable Traits

#[derive] is a compiler built-in that generates trait implementations mechanically from a type's fields or variants. You list the traits you want derived in one attribute:

#[derive(PartialEq, Eq, Hash, Display)]
struct GridCell {
    row: i64,
    col: i64,
}

(Note the field types are integers. Eq and Hash require every field to be Eq/Hash, and f64 is neither — NaN != NaN breaks reflexivity — so a struct with f64 fields can derive PartialEq/PartialOrd/Display but not Eq/Ord/Hash. See the per-trait requirements below.)

The compiler resolves derive dependencies automatically regardless of the order you list them. Writing #[derive(Hash)] when PartialEq and Eq are not yet derived causes the compiler to derive them first, in the correct order.


Equality and ordering

PartialEq

Generates == and != by comparing fields pairwise in declaration order. For enums, first checks that the variants match, then compares fields.

No dependencies.

Eq

A marker trait indicating that == is a total equivalence relation (reflexive, symmetric, transitive, and always defined). Adds no method body — it is a promise to the type system.

Requires: PartialEq

PartialOrd

Generates <, <=, >, >= with lexicographic field-order comparison. Returns Option[Ordering] because NaN != NaN for floats; for types without NaN, every comparison returns Some(...).

Requires: PartialEq

Ord

Total ordering: every pair of values is comparable. Generates a complete lexicographic comparison in declaration order.

Requires: PartialOrd + Eq


Hashing

Hash

Generates a hash method that feeds each field into a Hasher. Used by Map and Set as key types.

Requires: Eq (reflects the consistency contract: a == b must imply hash(a) == hash(b))


Display and debugging

Display

Generates a human-readable string representation.

  • Structs: emits TypeName { field: value, ... } for pub fields. Use #[derive(Display(all_fields))] to include private fields.
  • Enums: emits the variant name. Use #[derive(Display(snake_case))] to emit in snake_case instead of the declared PascalCase. For variants with data, appends the fields in parentheses.

Use Display for values shown to end users. Implement it manually to override the generated representation.

No dependencies.

Debug

Generates a developer-oriented representation. Used by {expr:?} in interpolated strings and by the test runner when printing unexpected values.

  • Structs: always includes all fields, regardless of visibility.
  • Enums: includes variant name and all fields.

No dependencies.


Default values

Default

Generates a T.default() method that returns a "zero-like" value for the type. The derived implementation calls .default() on each field in declaration order and constructs the struct. For enums, the first declared variant is used, with each of its fields defaulted.

#[derive(Default)]
struct Config {
    timeout_ms: i64,   // defaults to 0
    retries: i64,      // defaults to 0
    verbose: bool,     // defaults to false
}

let cfg = Config.default();

Requires: every field must also implement Default.


Copying

Clone

Generates a .clone() method that produces a deep copy of the value. For reference-semantics (shared) types, cloning produces a new RC handle, not a new heap allocation.

No dependencies.

Copy

Marks a type as trivially copyable (bitwise copy semantics). Assignment and passing to functions copy the value silently instead of moving it. All primitive types are Copy.

Requires: every field of the type must also be Copy.

Auto-derives Clone: #[derive(Copy)] automatically adds Clone if not already present.


Arithmetic on distinct types

Arithmetic

Available on distinct (newtype) types only. Generates +, -, *, /, % by forwarding to the underlying type's operations and wrapping the result back in the newtype. Without this derive, arithmetic between two values of the same distinct type is a type error (intentional: distinct types are supposed to be incompatible units).

#[derive(Arithmetic)]
distinct type Metres = f64;   // now Metres + Metres → Metres

Only valid on distinct types.


Dependency summary

TraitAuto-derivesRequires
PartialEq
EqPartialEq
PartialOrdPartialEq
OrdPartialOrd + Eq
HashEq
Display
Debug
Clone
CopyCloneevery field is Copy
Defaultevery field is Default
Arithmetictype must be distinct

Appendix D: Attributes

Attributes are metadata attached to declarations. Two syntactic forms are supported:

#[attribute_name]               // marker
#[attribute_name(arg, ...)]     // with arguments
@attribute_name                 // shorthand marker (selected attributes only)

Attributes appear immediately before the item they annotate.


Derive

#[derive(Trait, ...)]

Generates trait implementations for the annotated struct or enum. See Appendix C for the full list of derivable traits and their dependencies.

#[derive(PartialEq, Eq, Hash, Display, Clone)]
struct UserId { value: u64 }

Lint control

#[allow(lint_name)]

Suppress a specific lint within the annotated item. The lint fires nowhere inside the item.

#[warn(lint_name)]

Ensure a lint is at warning level even if it would otherwise be suppressed.

#[deny(lint_name)]

Promote a lint to a hard error within the annotated item.

Available lint names:

Lint nameDefaultWhat it checks
undocumented_unsafewarningEvery unsafe { } block must be preceded by a // Safety: comment
ffi_float_eq / ffi_float_eqwarningComparing an extern "C" float return with == or !=
redundant_suffixwarningLiteral suffix that matches the default type (e.g., 42i64)
mutual_recursion_notenoteNote when the SCC pass detects a mutual-recursion group
module_mut_bindingwarning (lib profile)let mut at module scope
layout_unassigned_fieldswarningFields not assigned to a group in a layout block
repr_c_layout_ignoredwarninglayout block on a private struct (has no FFI effect)
rc_fallbacknoteCompiler chose RC tier to satisfy ownership analysis

Safety

#[noblock] / @noblock

On an extern "C" or extern "C-unwind" function: removes blocks from the default effect set. Use this for pure-CPU foreign functions (math routines, strlen, etc.) that are known not to block.

@noblock
extern "C" fn sqrt(x: f64) -> f64;

Linker control

#[unsafe(no_mangle)]

Use the Kāra identifier as the exported symbol name without any name mangling. Required when a foreign caller (C, linker script, debugger) must reference the symbol by its exact Kāra name. Does not imply extern "C" — the calling convention is independent.

The #[unsafe(...)] wrap is mandatory: disabling name mangling can collide with foreign symbols, an obligation the compiler cannot verify. Bare #[no_mangle] is rejected at parse time.

#[unsafe(no_mangle)]
pub fn kara_entry() { ... }

#[used]

Prevent dead-code elimination for the annotated symbol even if no Kāra code references it. Use for linker-section entries, interrupt vectors, or other symbols that are referenced only from outside the compiler's visibility (linker scripts, hardware, debuggers). Stays plain (no #[unsafe(...)] wrap) — #[used] only suppresses DCE, no soundness obligation.

#[unsafe(link_section(".vectors"))]
#[used]
let interrupt_table: [fn(); 16] = [...];

Place the annotated symbol in a named linker section. Required for embedded targets that map specific sections to specific memory regions (flash, DTCM RAM, etc.).

The #[unsafe(...)] wrap is mandatory: section placement carries layout and aliasing obligations the compiler cannot verify. Bare #[link_section(...)] is rejected at parse time.

#[unsafe(link_section(".dtcmram"))]
let fast_buffer: [u8; 1024] = [0; 1024];

FFI

#[kara_name = "identifier"]

On an extern item: rebinds a non-conforming foreign name to a valid Kāra identifier. The Kāra-visible name must follow the identifier case-class rules; the foreign name may be arbitrary ASCII.

#[kara_name = "GlxFbConfig"]
extern type GLXFBConfig;

Module-level bindings

#[thread_local]

On a module-level let mut binding: gives each OS thread (and each task under the runtime) its own independent copy. The binding's initializer must still be a compile-time constant.

#[thread_local]
let mut request_count: i64 = 0;

Memory layout

#[repr(C)]

On a struct: lay out fields in C ABI order (declaration order, with C padding rules). Required for types passed through extern "C" boundaries.

#[repr(packed)]

On a struct: remove all padding. Fields may be unaligned — use unsafe for pointer access to packed fields.

#[repr(align(N))]

On a struct or as a wrapper type: require at least N-byte alignment.


Functions

#[profile(P1, P2, ...)]

On a function: asserts that its transitive effect set is compatible with the intersection of the listed profiles' constraints — the function must satisfy the strictest constraint from any listed profile. The v1 profile names are default (forbids nothing), embedded (forbids allocates(Heap)), and kernel (forbids allocates(*), panics, blocks, suspends). A forbidden effect in the function's declared or inferred set is error[E_PROFILE_INCOMPATIBLE_EFFECT]; an unknown profile name is error[E_UNKNOWN_PROFILE].

#[profile(embedded, kernel)]
fn scale(x: i64, factor: i64) -> i64 {
    x * factor
}

#[no_effect(VERB, ...)]

On a function: asserts the named effects are absent from its transitive effect set. The arguments are effect verbs in the same grammar a with clause uses. It is the per-function counterpart of #[profile(...)] — that one inherits a target environment's forbidden set, this one names the forbidden effects directly and is independent of any profile, which is what makes it usable on the default profile where allocates(Heap) is permitted.

A bare verb forbids every occurrence of it; a verb with a resource narrows it to that resource, so #[no_effect(allocates(Heap))] still permits allocates(Arena). A forbidden effect in the declared or inferred set is error[E_NO_EFFECT_VIOLATED]. The attribute is fn-only (error[E_NO_EFFECT_INVALID_TARGET] elsewhere), and an empty list is rejected rather than treated as a vacuous guarantee.

#[no_effect(allocates(Heap), panics)]
fn mix(a: f32, b: f32) -> f32 {
    a * 0.5 + b * 0.5
}

#[must_use] / #[must_use = "reason"]

On a type: every binding site where a value of this type would be silently dropped produces a warning. Use for types that must be explicitly handled (e.g., a connection that must be closed).

On a function: the return value must not be silently discarded. Result return values are implicitly #[must_use].

#[must_use = "connections must be explicitly disconnected"]
struct Connection { ... }

Testing

#[test]

Mark a test_-prefixed function as a test case.

#[test(requires = [resource, ...])]

Mark a test that needs a live external resource. When the resource is unavailable, the test is skipped (or fails with reason: "unsatisfied_requires" when karac test --all is used).

#[with_provider(resource_path, constructor_fn)]

Supply an in-memory provider for a test. The provider scope wraps the entire test body. Multiple #[with_provider] attributes are allowed; source order is outer-to-inner.


Tool-namespaced attributes

Multi-segment attribute paths of the form #[TOOL::NAME(...)] are reserved for external tools — formatters, linters, doc generators, IDE plugins, custom analyzers. The compiler accepts them syntactically, stores them on the AST, and otherwise ignores them; semantic interpretation is each tool's responsibility. The full design lives at design.md § Tool-Namespaced Attributes; this appendix entry catalogs the v1-reserved names and the read surface.

#[karafmt::skip]
fn manually_aligned_table() { 0 }

#[karalint::allow(complexity)]
fn complicated_inner_loop(data: ref Slice[Frame]) -> Frame {
    // ...
}

#[acmecorp_security::audit_required(level: "strict")]
pub fn login(username: String, password: String) -> Result[Session, AuthError] { /* ... */ }

The discriminator is structural: a bare-name path (#[derive], #[no_mangle]) must match a known compiler attribute or it is error[E_UNKNOWN_ATTRIBUTE]; a multi-segment path is either a compiler-reserved namespace (#[diagnostic::*] — validated per Appendix D § Diagnostic) or a tool namespace (silently accepted). There is no per-project tool registration at v1; the open-namespace rule applies.

v1-reserved first-party tool namespaces

The Kāra organisation reserves three tool namespaces at v1 for the canonical first-party tools that will ship post-v1. User code may write attributes against them today — they parse and store like any other tool namespace — but their semantics are defined when the corresponding tool ships, and the names will not be reused by any other tool. The reservation is a name-claim, not an implementation commitment.

#[karafmt::*] (post-v1, reserved)

The canonical formatter. Initial members:

  • karafmt::skip — on any item: suppresses formatting for that item.

Until karafmt ships, #[karafmt::skip] is functionally a no-op.

#[karalint::*] (post-v1, reserved)

The canonical lint pack ride-along — separate from the compiler-built-in lints from Appendix D § Lint control. Initial members:

  • karalint::allow(NAME) / karalint::warn(NAME) / karalint::deny(NAME) / karalint::expect(NAME) — same shape as the compiler's built-in lint attributes but scoped to lints that live in the external karalint package.

#[karadoc::*] (post-v1, reserved)

The canonical doc generator. Initial members:

  • karadoc::hidden — on any item: omits the item from generated docs.

Third-party tool namespaces

Any other multi-segment path is also accepted. By convention, third-party tools use a namespace matching their package or organisation name (e.g., acmecorp_security::audit_required, mytool::config(level: 9)) to avoid collision with the reserved names above. The compiler does not enforce this convention; conflict-resolution authority is social — first registered, first served, with the v1-reserved names taking absolute precedence.

Reading tool attributes from outside the compiler

Tools consume tool-namespaced attributes via one of three paths:

  • karac query attributes [--tool=PREFIX] — emits a JSON list of every multi-segment attribute on every item, optionally filtered by first-segment prefix. --tool=karafmt returns every #[karafmt::*]. Without --tool, returns every multi-segment attribute (including #[diagnostic::*]).
  • Language Server Protocol (post-v1) — the IDE-facing surface exposes the same data through workspace-symbol and document-symbol responses.
  • Direct AST access — tools written in Kāra and using the compiler-as-library API read the same Attribute { path, args, span } structures the typechecker stores.