kama language specification (overview)
This is a semantics overview. The grammar is authoritative — see
grammar.bnf (generated from kama.y). Executable examples live in
../tests/ (*.kama with a .expect exit code). The design philosophy — one way to do a
thing, explicit over implicit, no GC / RAII — lives in GOALS.md; this document is the
semantics/feature reference. Status flags below: ✅ implemented, 🚧 reserved (not yet implemented).
Model#
kama compiles to portable C (native + WASM). No garbage collector — object lifetimes are deterministic
(RAII). Calls use named arguments (no positional). Every type declaration is type value (owns nothing,
copies), type resource (owns/has identity, moves, RAII-dropped), type view (a non-owning stack-only
borrow — a slice/span), or type contract (an interface).
Source files are UTF-8, and so are kama.json, kama.local.json and kama_workspace.json. A leading
UTF-8 byte-order mark (EF BB BF, which Windows PowerShell 5.1 and "UTF-8 with BOM" editors write unasked) is
ignored, so line and column positions are the same as without one.
A UTF-16 file is refused with a message that says so, not lexed as bytes: re-save it as UTF-8.
The rest of the file is UTF-8 text throughout, comments included, where no program observes the bytes;
well-formed multi-byte text is welcome in every string form, char literal and comment.
A byte that is not UTF-8 text is refused when the file is read, naming its line, byte column and offset:
a stray 0xFF, an overlong form, an encoded surrogate, a sequence cut short (// caf<E9> in Latin-1 is one),
and a NUL, which is valid UTF-8 but never text: a raw NUL in a string is rejected, and \0 writes one.
A column counts bytes, a tab as one, which is what the language server reports.
Types ✅#
| kama | C |
|---|---|
int8 int16 int32 int64 |
int8_t … int64_t |
uint8 uint16 uint32 uint64 |
uint8_t … uint64_t |
isize / usize |
ptrdiff_t / size_t — platform-varying (8 bytes on x86_64/arm64, 4 on wasm32/thumbv6m) |
clong / culong |
long / unsigned long — platform-varying the other way (4 bytes on Windows, 8 elsewhere); for an extern that mirrors a C long |
cchar |
char — C's byte, as a raw-pointer element only (UnsafeConstPtr<cchar> is const char*; string.cstr() returns one); never a value |
float32 / float64 |
float / double |
bool |
bool |
string |
kama_string (borrowed view or heap-owned RAII string) |
void |
void |
user type value/type resource |
struct (value semantics) |
No raw arrays and no raw pointers — by design (raw memory access is confined to unsafe fn at the FFI
boundary). Collections are generic library types.
Numeric literals#
| form | example | notes |
|---|---|---|
| decimal / hex / octal integer | 42, 0xFF, 0o17, 1_000_000, 0xFFFF_0000 |
a digit separator is an underscore between two digits of a run (1_000, 0xFF_FF, and 1_000.5 in a float); 1__0, 10_ and 0x_F are not literals. A leading 0 on a multi-digit integer is an error, not decimal: C, Go and Node read 0644 as octal 420, and kama's octal is 0o644 |
| based integer | 0b1010_2 |
0b<digits>_<base>, base 2–32; the _<base> is required |
| integer suffix | 42i32, 42ui32 |
u?i(8|16|32|64) — unsigned is ui; there is no bare 42u32 |
| float | 12.5, 12.5e10, 1e10, 1.5e-3 |
digits.digits with an optional exponent, or digits with a required one |
| float suffix | 1.5f32, 1e10f64 |
f32 / f64 |
A float literal's dot needs digits on both sides — 1. and .5 are rejected
(tests/xfail/float_bare_dot.kama); those are the error-prone spellings, .5 reading as a stray member
access and 1. as an unfinished expression. A dotless exponent is fine (1e10), because the exponent
marker already makes it unmistakably a float. This is exactly Swift's and Zig's rule. There is no
normalization requirement: 12.5e10 and 0.5e10 are both literals, not just 1.25e11.
The string type#
There is one string type: lowercase string, a builtin like int32/bool/float64. It lowers to a
fat value that is one of two things, chosen automatically:
- a borrowed literal/view — no allocation, no free (a string literal
"ab", or a view onto another string's storage); - a heap-owned RAII string — allocated on the heap, freed on drop (produced by operations that must
build a new buffer, e.g.
concat).
fn uint8 main() {
string s = "ab"; // borrowed literal — no alloc
string t = s.concat(other: "cd"); // heap-owned, RAII-freed at scope exit
bool eq = s.equals(other: t); isize len = s.length();
return 0;
}
You never spell the borrowed-vs-owned distinction; the type carries it, and RAII frees exactly the owned
ones. There is no separate capital-String collection type.
UTF-8 everywhere. A string is UTF-8 bytes; length() is the byte length (O(1)), and literals
encode \u{…} escapes to UTF-8 — \u{0} is one NUL byte, the same as \0, and a literal's bytes reach the
program exactly as written (C's trigraphs never touch "??=").
An escape names one Unicode scalar value: a surrogate (\u{D800}–\u{DFFF}) is rejected,
and so is a value above \u{10FFFF}, in a string or a char.
Two ways to traverse it, kept distinct by type so bytes and characters never blur:
- bytes —
s[i]returns the i-th byte as auint8(bounds-checked);foreach (uint8 b in s)iterates bytes;s.bytes()hands out aConstView<uint8>over the string's own storage — borrowed, no copy — which is what the byte-taking stdlib takes (sha256(bytes:),base64::encode(bytes:),File.write(bytes:)). It is a second-class borrow like any other view: fine as an argument, and a local needs the windowborrow s.bytes() as v { … }, whichBytesViewable<ConstView<uint8>>grants. There is no writable twin —stringis immutable. Needsstd::collectionsin the program (the view is a stdlib type); without it.bytes()is refused with the import it needs.foreach (char c in s)is a type error — the byte/codepoint distinction is enforced. That is one case of a general rule: aforeachbinding must have the type the collection actually yields, and a mismatch is rejected rather than left to C's implicit conversions (tests/xfail/foreach_char_over_string,tests/xfail/foreach_elem_type_mismatch). - codepoints —
s.chars()is a UTF-8 codepoint iterator (implements Iterator<char>):foreach (char c in s.chars())yields each Unicode scalar value as achar. It's a borrow, valid while the string is.
fn uint8 main() {
string s = "A\u{E9}\u{20AC}"; // "Aé€" — 6 UTF-8 bytes, 3 codepoints
int32 n = 0;
foreach (char c in s.chars()) { n = n + 1; } // n == 3 (codepoints, not bytes)
uint8 first = s[0]; // 65 ('A'), a byte
return 0;
}
char is a distinct primitive — a Unicode scalar value backed by uint32 (not a numeric type, so it
can't silently mix with ints). Literals: 'a', '\n', '\u{1F600}'. Equality + ordering compare
codepoints; cast<int32>(c) / cast<char>(i) convert (arithmetic on codepoints is explicit, the Rust
model). A multibyte source char literal is decoded to its scalar value: 'é' == '\u{E9}' == 233,
'😀' == '\u{1F600}' == 128512. A source byte that is not UTF-8 — '<FF>', once read as 255 — is
rejected with the file, like any other (Model).
Operators & methods. string carries the small, always-available ergonomic surface every language
ships — all compiler intrinsics on the primitive (no import), byte-oriented like s[i]:
+/==/!=—a + bconcatenates (a fresh heap-owned string),a == b/a != bcompare bytes. The compiler special-cases string operands (not a user overload —stringis a primitive); both operands must bestring(string + <number>is a compile error — string interpolation is the one way to mix values into text; see below). An interpolation counts as astringoperand (name == "hi ${x}"). Chains and compose:a + b + c,s.trim() == "x".- slice —
substring(start:, end:)copies the byte range[start, end)into an owned string. A byte range (use.chars()for codepoints), and total with respect to the UTF-8 invariant: it traps on an out-of-range offset and on one that would split a character, so it can never hand back an ill-formedstring. Offsets that come from a search (find,split) are boundary-aligned by construction and cannot trap; an offset computed by arithmetic is the case to guard. - boundary + budget —
floorCharBoundary(at:)returns the greatest character boundary<= at(unchanged whenatalready is one, clamped to0before the start and tolength()past the end). Total — never traps, O(1). It is what makes an arithmetic offset safe at either end of a range:s.substring(start: 0, end: s.floorCharBoundary(at: 80)).truncate(maxBytes:)names the common case on top of it — at mostmaxBytesbytes, never splitting — for a wire field, a column limit or a log cap. Both guarantee valid UTF-8, not visually intact text: a cut at a codepoint boundary can still split a grapheme cluster (ane+ combining accent, an emoji ZWJ sequence, a flag). Segmentation is defined by UAX #29 and needs Unicode tables; it is not shipped, and its home isstd::unicode— see ROADMAP_DETAIL.md §2. - search —
find(substring:)returnsOptional<isize>(the first byte offset,Nonewhen absent — null-safe, no-1sentinel;isize, as every index is);findFrom(substring:, start:)searches from bytestarton and answers an offset still counted from the start of the string (astartpast the end answersNone, a negative one traps as a negative index does);contains(substring:),startsWith(prefix:),endsWith(suffix:)returnbool;isEmpty(). Search matches bytes exactly, so it is right for any UTF-8. - ASCII case-insensitive comparison —
equalsIgnoringAsciiCase(other:)andstartsWithIgnoringAsciiCase(prefix:)foldA–Ztoa–zand compare every other byte exactly, with no allocation: the comparison a protocol makes on field names and tokens (Transfer-Encoding,chunked). The name says ASCII because that is all it folds; Unicode case folding is a different, table-driven operation, and its home isstd::unicode. - transform (each returns a fresh owned string) —
trim()/trimStart()/trimEnd()strip ASCII whitespace;replace(old:, with:)swaps all non-overlapping occurrences;toLower()/toUpper()map ASCII case (bytes ≥ 0x80 untouched — UTF-8-safe). Unicode whitespace/casing are deferred to a later Unicode module. split(separator:)— a lazySplititerator (implements Iterator<string>, like.chars()), yielding each piece as an owned string with no collections import:foreach (string p in s.split(separator: ",")). Gostrings.Splitsemantics (consecutive/trailing separators yield""; an empty separator yields the whole string once). Collect into aDynamicArray<string>explicitly if you need random access.
string path = "/usr/local/bin";
foreach (string part in path.split(separator: "/")) { … } // "", "usr", "local", "bin"
string greet = "Hello, " + name + "!";
if (greet.toLower().contains(substring: "hello")) { … }
match (greet.find(substring: ",")) { case Some(value: i): …; case None: …; };
match (greet.findFrom(substring: "l", start: 3)) { case Some(value: i): …; case None: …; }; // i == 3
if (field.equalsIgnoringAsciiCase(other: "content-length")) { … }
Formatting & string interpolation ✅#
Rendering a value as text goes through one contract, Formattable (prelude, so it works without an import and
survives --no-std) — the display twin of Serializable:
type contract Formattable for value, resource, enum, view, intrinsic { const fn void format(ref Formatter f); }
Every std collection renders when its elements do (Formattable when [T: Formattable]; a map when both its keys
and values do): a sequence or a view as [1, 2, 3], a set as {a, b}, a map as {k: v, k2: v2}, each in iteration
order (a hash map's is its slot order, a PriorityQueue's its heap order), an InlineArray included. A SlotMap
writes its values as a sequence, since a Handle has no text. A hash set's elements must also be Copyable, as its
serialize requires: its keys are read through its map's copying key iterator. A string element is written raw, as
the derive writes a string field, so ["a, b"] and ["a", "b"] print alike; the quoted rendering that tells them
apart is KR-222.
A type writes its pieces into a caller-owned Formatter sink (a growable UTF-8 buffer), so a whole nested
value materializes in one allocation — no O(n²) concat. Formatter has writeStr / writeI64 /
writeU64 / writeF64 / writeF32 / writeBool / writeChar and a finish() -> string. Every primitive
(int8..uint64, float32/64, bool, string) conforms. A float renders as the shortest decimal that reads
back as the same value, the digits Rust, Go, Python, JavaScript and PostgreSQL print: 0.1,
0.30000000000000004, and 0.3 for the float32 0.3f32. The style is %g's, scientific when the exponent is
below -4 or at least 15 (6 for a float32), as PostgreSQL writes float8 and float4. "${x}" is the one way to render a value —
it lowers to exactly this build, so there is no free wrapper beside it. Formattable is infallible (void,
no Result) — an in-memory write can't fail, unlike serialize over an I/O sink.
type value Point implements Formattable {
int32 x; int32 y;
public const fn void format(ref Formatter f) {
f.writeStr(s: "("); f.writeI64(v: cast<int64>(this.x));
f.writeStr(s: ", "); f.writeI64(v: cast<int64>(this.y)); f.writeStr(s: ")");
}
}
Interpolation. A ${expr} hole in a plain string literal splices a value in — lowered at compile time
to a Formatter build (a writeStr per literal chunk, expr.format(ref f) per hole), statically type-checked,
no runtime reflection:
string s = "point ${p} at n=${n}, first=${who[0]}"; // p.format, n.format, who[0].format into one buffer
Holes are restricted to an identifier — or
this— with.field/[index]accessors (${user.name},${items[i]},${this.id}). Anything with an operator or call must be bound first (int32 sum = a + b; "…${sum}") — logic stays out of string literals (Rust RFC 2795's restraint).thisis the only keyword admitted as a head, and it carries the same accessors as any other (${this.inner.n},${this.xs[0]}); a bare${this}formats the receiver, exactly as${p}does for any other value.Escape a literal
${as\${; a lone$(not before{) stays literal.Verbatim strings never interpolate —
@"raw ${x}"is literal (the raw escape hatch).A
char-typed hole renders as its character (${c}→ the glyph) via awriteCharfast-path — the compiler detectscharfrom the hole's type node (charsharesuint32's C type, so it can't hold aFormattableconformance directly).Format specifiers
${expr:spec}render a numeric hole in a chosen form — the vocabulary mirrors Kama's numeric literals rather than printf. Precision is.N(${pi:.2}→3.14) — no printf type-letter, since the hole's type is already known; it requires a float hole. Base reuses the literal prefixes0x/0o/0b, and the leading0you type is echoed, so${n:x}→ff(bare) while${n:0x}→0xff(prefixed — itself a valid Kama literal); the letter's case controls digit case (${n:0X}→0XFF). Base requires an integer hole and shows the unsigned bit pattern of its declared width, so a signed negative round-trips (${x:0x}on-1i8→0xff). A spec on a user-type hole, or a kind mismatch (.Non an int,0xon a float), is a compile error — theFormattablecontract stays spec-less (specs areFormatterfast-paths). A minimum field width right-aligns:${n:6}space-pads,${n:06}zero-pads (the sign stays ahead of the zeros), and on a float it composes with a precision (${pi:8.2},${pi:08.2}) — ideal for zero-padded columns (${h:02}:${m:02}). A leading+forces a sign on non-negatives (${n:+}→+42) and-left-aligns within the width (${n:-6}); both compose with the width/precision. The full spec grammar is[+|-]* [0? width] [.precision] | [-]* [0? width] base. A width and the0/-flags compose with a base:${n:8x}space-pads to 8,${n:08x}zero-pads to 8 (000000ff— the zero flag, NOT the prefix: the prefix is only ever the lone0directly before the letter, so:0xand:08xmean different things, as printf's%#xand%08xdo), and${n:-8x}left-aligns. A precision (.N) or a+with a base is a compile error — an integer has no fraction and a bit pattern no sign. A custom fill character and center-align are not offered: both are presentation the caller composes over the rendered string, and a second fill vocabulary would be a second way to say0.@generate(Formattable)synthesizes a default field-dumpFormattableimpl so a type renders without a hand-writtenformat—Type { field1: v1, field2: v2 }, each field dispatching to its ownFormattableinto the same sink (so nesting composes, one allocation):@generate(Formattable, of) type value Stat { public int32 hp; public bool alive; } fn uint8 main() { Stat st = Stat.of(hp: 30i32, alive: true); string s = "${st}"; // "Stat { hp: 30, alive: true }" return 0; }Strings render raw/unquoted (uniform single-contract dispatch — no special-case). A hand-written
formatwins over the derive;@skipomits a field. Every non-skipped field must itself be a primitive/string or a type thatimplements Formattable; one that is neither is refused, naming it. A generic, a taggedenumand a payload-lessenummay all carry the attribute (see Derives); anOptional-typed field renders because the prelude derivesFormattablefor it, and a collection-typed one renders as its collection does. It is named after the contract (Formattable), notdisplay/debug— Kama has one to-string contract, no Display/Debug split; a quoted, structural rendering (${x:?}) is scheduled as KR-222, for structs and collections together.Tagged strings ✅ — an identifier placed immediately before a string (
html"…",sql"…",stripIndent"…"; no space) makes it tagged: it calls the ordinary function of that name, the TAG, with aTemplateview over the string's trusted literal parts and its holes, kept separate — so a tag is just a function you can define:fn string html(Template t) { /* literals verbatim, holes HTML-escaped — XSS-safe */ } fn SqlQuery sql(Template<SqlParam> t) { /* each hole a typed `?` parameter, never in the query text */ } string page = html"<b>${user}</b>"; // ${user} escaped, <b> kept raw SqlQuery q = sql"… WHERE id = ${id}"; // the value travels out-of-band (injection-safe) string s = stripIndent"…"; // the literal template is dedented; hole values verbatimWhat a hole is, the tag decides. A
Template(Template<string>) hands each hole over as TEXT, rendered throughFormattablewith its format spec (html"${amt:.2}"). ATemplate<C>for a contractChands each hole over as aCvalue, checked againstCwhere the tagged string is written:sql"… ${point}"is a compile error unlessPointimplementsSqlParam, so an emptyOptionalhole is NULL — never the text "None" — and bytes stay bytes. A format spec on a typed hole, a hole that is notC, and aTemplateof anything butstringor a contract are each refused.Templateis a VIEW. The compiler builds it for one call, over holes that live only as long as the statement's scope, so a tag takes it by value, never byref, and it cannot be kept past that scope. (While it was a copyablevalue, a tag that returned it left a heap-use-after-free in safe code.) It exposespartCount()/holeCount()/part(at:)/hole(at:), and the last two BORROW:copywhat a tag keeps. An unknown tag, with no matchingfnin scope, is a compile error.std::fmtshipshtml,stripIndent, andsqlwithSqlQuery,SqlValue(Null,Bool,Int,Float,Text,Bytes) and the contractSqlParam. Its adapters cover the integers that fit anint64, both floats,bool,char,string,DynamicArray<uint8>(bytes) andOptional<T>whenTisSqlParam;uint64andusizeare left out, because not every value fits. A type opts in by naming its one SQL value. Mapping a whole type to a row is an ORM's job, a library above std (ROADMAP_DETAIL §2).
Collections & strings ✅#
Built-in generics, monomorphized per element type and backed by the C runtime (unsafe internals, safe API —
the Rust-Vec model); indexing is bounds-checked (a clean trap, not UB). An indexed element a[i] is
a place (an lvalue): you can write a field through it (a[i].x = v), index it again
(m[i][j] = v), compound-assign it (a[i] += x), or borrow it (ref a[i]) — every form stays
bounds-checked. (Reading a[i] still yields a copy.)
import { std::collections::FixedArray, std::collections::DynamicArray };
@generate(of) type value Point { public int32 x; public int32 y; }
fn uint8 main() {
FixedArray<int32> a = FixedArray.make(size: 4); // fixed buffer, each element int32's default (0)
a[0] = 10; a[1] = 20; // bounds-checked []
int32 first = a[0];
foreach (int32 x in a) { /* ... */ } // iterate (x is a copy)
foreach (ref int32 x in a) { x = x * 2; } // `ref`: mutate each element in place
foreach (const ref int32 x in a) { /* ... */ } // `const ref`: read each in place, write none
DynamicArray<Point> ps = DynamicArray.empty(); // growable
ps.add(item: Point.of(x: 1, y: 2)); isize n = ps.length(); Point q = ps[0];
string s = "ab"; // borrowed literal (no alloc)
string t = s.concat(other: "cd"); // heap-owned, RAII-freed
return 0;
}
A FixedArray's every element is live from construction, so construction builds each one:
FixedArray.make(size:) gives each element T's default, and exists only where when [T: default] holds
(see Collections — the four-ctor matrix); FixedArray.filled(size:, value:) gives each a copy of one value,
and exists only for a copyable element. A move-only element with no default is built in a DynamicArray.
Until 0.9.481 make zeroed the buffer for every T: a FixedArray<File> of 3 closed descriptor 0 three times
when it dropped, a declared field default read 0, and a type with no constructor at all was built.
A bounded list in place — InlineDynamicArray<T>#(N). Capacity N in line, length 0..N, no heap (C++26
inplace_vector, Rust's heapless::Vec): the growable list a type value can hold, written in kama over
InlineStorage. Its kind follows its element's by a kind clause — over a value it is a value, copied as its bytes,
sent as one and held by a type value; over an element that owns something it is a resource that moves, drops
exactly its live elements, and deep-copies with copy when the element is Copyable.
import { std::collections::InlineDynamicArray, std::collections::CapacityFull };
type value Packet { public InlineDynamicArray<uint8>#(64) body; public uint16 port; }
fn void fill(ref Packet p, uint8 b) {
Result<Unit, CapacityFull<uint8>> r = p.body.add(item: b);
match (r) { case Ok(value: _): { } case Err(error: _): { /* full: the byte is handed back */ } };
}
Its surface is DynamicArray's, bounded. Being full is an expected state, not a trap: add and insertAt return
Result<Unit, CapacityFull<T>> and hand the item back (match (give e) { case Item(item: x): … }), and addAll
adds a whole run or none of it (CapacityFull<Unit>, as arrayvec's CapacityError<()>). A discarded growth is
refused like any Result. capacity() is N, with nothing to reserve; N is at least 1. Indexing, remove,
pop, swap, clear, the views, the three foreach forms, contains/indexOf/equals, serde and Sendable
are DynamicArray's, over the live prefix; an index past the length traps though the place exists. Reading more
than N elements is an Err at the first with no place — never a truncation. Every place past the live prefix is
kept zero: a pop or remove clears what it vacates, so a copy, a send or a byte view never carries a popped
element's bytes, and two arrays holding the same elements are the same bytes. A value instance whose layout has no
padding is BytesViewable (InlineDynamicArray<uint8>#(64) is; three int32 places beside an isize length are
not), and never Blittable: written as bytes, its length could claim places that hold nothing. It has no
modification counter, since its places never move: an iterator reads the live length at every step. Over an
owning element it may not sit in a type value, as no resource may.
All collections own their storage and free it via RAII (with element-destructor chaining). Only the
(collection, element-type) pairs the program actually uses are emitted (pay-for-what-you-use). A method
call on an element works directly — list[i].method() borrows the element in place, so a mutating method
mutates the stored element; a const collection allows only const methods on its elements. Elements enter a
collection by the ownership rules below (give to move, copy to duplicate, a value copies).
DynamicArray / FixedArray also offer reserve(n:) (DynamicArray — preallocate to skip incremental growth),
clear() (DynamicArray), and contains(item:) / indexOf(item:) (both — present only when the element is
Equatable, i.e. string or a user type with equals). Removal on DynamicArray returns the removed
element moved out — remove(index:) -> T (shifts the tail; bounds-checked, so always a valid T) and
pop() -> Optional<T> (the last element, O(1), None when empty). Reclaim the element by binding the return,
or discard it to drop (xs.remove(index: i); drops cleanly). This is the one consistent removal shape
across every container — a single method that hands ownership back (matching Deque.popFront/popBack and
Rust's Vec::remove/pop), never a silent drop.
Bulk copies take a view. DynamicArray.addAll(items: ConstView<T>) appends a copy of every element in one
growth, and View.copyFrom(source: ConstView<T>) replaces every element of a window with a copy of the
same-position one — the source must be exactly as long, and a mismatch traps (copy_from_slice's rule). Both
exist only for a Copyable element, and both are ONE memcpy when the element's copy is its bytes, in any
build — they lower to the floor's copyElements,
as every collection's copy, clear and drop lower to the range form of drop.
Inside one buffer, View.copyWithin(from:, to:, count:) copies the count elements at from over the count at
to, and the two runs may overlap — Rust's copy_within, JS's copyWithin, C's memmove — with copyFrom's bound
and its drop-then-copy for an owning element, in pieces no longer than the gap between the runs and in memmove's
direction, so no source is overwritten before it is read; runs that do not overlap are one copyElements. A run past
either end traps. Two windows of one buffer, one written (buf.sliceMut(…).copyFrom(source: buf.slice(…))), are
refused, naming the overlap and this method. capacity() answers how
many elements fit before the next append moves the buffer: reserve(n:) raises it to exactly n, and
add/addAll raise it geometrically (double, or straight to what a large addAll needs).
Slices / spans — View<T> and ConstView<T> ✅#
A View<T> is a non-owning window over a contiguous run of T — a slice / span (zero copy, no
ownership transfer) — and ConstView<T> is its read-only half (Rust &mut [T] / &[T], C# Span<T> /
ReadOnlySpan<T>). Both are a type view (the stack-only-borrow kind; see Type declarations): the
escape check keeps them from being stored, and the window rule below keeps them from outliving their
buffer, so neither can dangle — without a borrow checker, and without lifetimes.
import { std::collections::DynamicArray, std::collections::View, std::collections::ConstView };
fn void uploadToGpu(ConstView<float32> window) { }
fn uint8 main() {
DynamicArray<float32> verts = DynamicArray.empty(); // … fill …
borrow verts.viewMut() as all { // a WINDOW — `verts` is frozen inside it
View<float32> mid = all.slice(from: 2, count: 4); // a sub-range [2, 6) — a derive, no new window
foreach (ref float32 x in mid) { x = x * 2.0; } // mutate-through: writes back to `verts`
isize n = mid.length(); float32 first = mid[0]; // bounds-checked index (a place)
}
uploadToGpu(window: verts.slice(from: 0, count: 3)); // a read-only subrange down — no copy, no window needed
verts.add(item: 1.0); // mutable again: the window has closed
return 0;
}
The window — borrow <place> as v { … } ✅#
borrow names something for a block. A place is second-class in kama — passed (ref/const ref parameters) and
returned (fn ref T), never stored — so there is no ref local; borrow is how a place gets a name, and how a view
gets the extent it is valid for. The extent is lexical: a borrow block is it, one C block plus one initializer
per binding, no runtime cost — no lifetime annotation, and no programmer's promise.
borrow xs[i] as e { … } // an element — `e` IS `xs[i]`
borrow this.rig.joints[k] as j { … } // a field path, and elements inside it
borrow m.getRefMut(key: name) as v { … } // a call returning `ref`/`const ref`, with any arguments
borrow d.view() as v { … } // a view mint: `Viewable<ConstView<T>>` grants `view()`
borrow m.values() as vals { … } // `ValuesIterable<I>` grants `values()` — `Map` has no `view()`
A host is a place or a mint, and nothing else:
A place — an element (
xs[i],grid[r][c]), a field path (o.inner.x), or a call that returnsreforconst ref(m.getRefMut(key: k), a freefn ref), with any arguments. The alias is that place: a write through it writes the place, it passes on asref/const ref, and it isconstwhen the place is read-only (aconst refresult, a field of aconstvalue). The place is named once, when the window opens, so an index changed inside the block does not move it. An argument's temporary (a"key"literal for aconst ref stringparameter) lives for the window, since aconst refresult may point into it.A mint — a nullary member of a
@viewablecontract the receiver's type implements; the alias is the view it makes. The grant names its own member, so nothing is hardcoded: a container alone would not say which view to open (DynamicArraygrants three), and a mint takes no arguments — narrow by deriving off the alias with.slice(…)inside the window, which is free.A place reached through a view that closes its window —
borrow hits.lock().value() as m— is one window: the window makes the view once, holds it, and closes it when the window closes (A view that closes its window, below), so the place is held exactly as long as the view that reaches it. The mint's receiver is frozen as a mint's is. One window closes one view, so a host reaches through at most one, and a place reached this way may not be returned.
Refused, each with its own sentence and no cascade after it: a bare local (borrow a as v — a already names
its place), and a value: a call that returns one, a field of one (mk().x), or a mint on one (mk().view()), which
nothing holds beyond the statement — bind it to a local first. A mint answers to access as any call does: a private one
opens no window outside its type.
Four rules, and together they are what lets the paragraph above say "cannot dangle":
- The host's roots are FROZEN for the extent of the block — no assignment, no non-
constref/outargument, nogiveout of them, no non-const fnreceiver overlapping them. The roots: a field path itself, an element's container, a mint's or a place-returning call's receiver, and everyref/const refargument of that call — the signature lets the result point at any of them, so freezing the receiver alone would be unsound for a freefn ref. A mint on such a call (borrow h.items().viewMut() as v) freezes the call's roots the same way, soh.items().add(…)cannot grow the buffervviews. An element is a place in its container: a window over storagearr[0]owns (borrow arr[0].view() as w,foreach (x in arr[0])) freezesarrand every index of it, sincearr[0]andarr[1]are disjoint only when the indices are — soarr[0].add(…),ref arr[1]andarr[1] = give zare refused there, and so is writing another index ofxswhileborrow xs[i] as enames one. Conflict is a prefix test over places, so a disjoint sibling field stays fully mutable:borrow this.buf.view() as b { this.hits = this.hits + 1; … }is legal, because two distinct fields cannot overlap in storage. Reads are untouched — aconst fncall, an index, aforeachover the frozen host, and (under a view) an element written in place, which moves no buffer —arr[0][1] = 5inside a window overarr[0], orarr[0].add(…)underborrow arr.view(), whose buffer stays where it is. One storage takes one name: two bindings over one container, or a window inside a window over the same storage, are refused —xs[0]andxs[1]are disjoint only when the indices are, which nothing proves in general. A window reached THROUGH an enclosing window's alias is not a second name:borrow tx.conn().transaction() as innerfreezestxfor its block, so one name is usable at a time (a reborrow), and the windows close inside out. - Nothing leaves the block. A view alias may not be reseated; a place alias is no value to store,
givecannot move out of the host it points into (replace(place:, with:)swaps a value in), and afn refresult is judged by what the alias points into — a place inside a local is refused, as returning the local's own place would be. Reading through the alias copies the value out, which is always fine. - A view LOCAL's root must already be lifetime-bounded — a
borrowalias, a by-value view parameter, a derive off either, or a call's result when every view handed to it was bounded and it takes nothing byref: its own return rule let the result root at any of them, soh(w: w, d: arr)returningd.view()is a view ofarr, which this frame can still grow. A view minted from a container you can still name is not bounded, because the next line may grow it, and is an error with the window as its remedy. - A store may not outlive what it stores. A view binding's depth is the innermost window open where
it was declared — the caller's, outside every window — and
x = eneedse's window to enclosex's. Only a binding at the caller's depth is returnable. The rule reads no statement order, so a loop that readsxbefore a later store cannot get past it, and it keeps the shapes a parser is written in: the cursor looprest = rest.slice(from: n, count: …), and an accumulator found in a loop and returned after it. What it refuses isouter = vbfrom a window nested insideouter's, which closes first, andref TwithTa view, which is a view by reference however it is spelled (std::memory::swapover two views). Rust proves the same thing with lifetimes and C# fixes aref structlocal's scope from its initializer; kama reads it off the block.
borrow works the same in every statement position — a function or method body, a loop body left by
break/continue/return, a match arm, a generic body, a spawned child's body, a parallel_for body — and in
a comptime fn, which borrows its own locals' elements and field paths and is checked by the same rules (a call
that returns a place has no compile-time form). A place a spawned child holds by ref cannot be named until its
scope joins it.
A view that closes its window. A type view may declare ~dtor. It frees nothing — a view owns nothing — and
runs when the window that minted the view closes, on every path out of the block (its end, return, break,
continue), after the block's own locals, and once; a window's several bindings close in reverse. So a transaction
is a window that rolls back unless the code committed, with no guard object to store and no closure:
borrow conn.begin() as tx { tx.execute(sql: "…"); tx.commit(); } // ~Tx() rolls back unless commit() ran
Such a view is pinned to its window, because a second one is a transaction no window ends — committing a copy
commits nothing the window sees. A helper takes it ref/const ref; refused are a local of it, a copy of the alias,
a by-value or out parameter, a return from anything but its own constructor or a granted mint, a type argument,
a mint anywhere but a window (conn.begin().commit()), and a foreach over one a window already holds, which would
copy it out of that window. A trap aborts without running it, as for every destructor; an @onPanic region may not
reach one, since recovery skips it — the same rule as for any owner with a destructor. (A database server rolls back
an uncommitted transaction when its connection drops, so an abort loses nothing the server kept.)
A foreach is the other window such a view lives in (KR-263), when it is an iterator: the loop makes it — its
operand is the mint (foreach (Entry<K, V> e in live.drain())), or the container's own iterator()/iterMut()/
iterConst() returns one — holds it for the whole loop, and runs its destructor when the loop is left: run dry,
break (from a match arm too), return (from a nested loop too), after the body's locals and the pass's binding,
once; continue stays in the loop. So Map.drain() empties the map however the loop ends, Rust's HashMap::drain
guarantee, with no guard to name. The operand is a borrow host's kind of mint — granted, nullary, on a receiver
something names, reaching through no other closing view — and it is a call like any other: a non-const mint is
refused on a read-only receiver and inside another loop over the same host. Refused too: a loop over a view a window
already holds, in all three loop forms, and returning a ref binding of such a loop, or a field of one, whose place
the view's destructor ends.
Why a window and not a ref local. ref Joint j = this.rig.joint(name: n); is safe only if its host stays
frozen from the declaration to j's last use — a region nobody wrote. Rust infers that region (lifetimes and a
borrow checker); C++ does not check it (auto& j = … dangles when the vector grows); C#'s ref locals lean on a GC
and cannot point into a List<T>; Swift has none. kama has no lifetimes, so the frozen region is the visible block,
the rule a view window already used — one construct for both. A ref local is a parse error that points here.
The rule covers every type view, not just View<T>: an iterator that borrows is a view. That is only
expressible because the mint is read from the grant — Map has no view(), so before that,
MapValueIter<int32> mi = m.values(); had no window it could open at all.
foreach and parallel_for are the same window under a different spelling. Each holds a borrowing
iterator over its operand for the extent of the body, so the operand is frozen there by rule 2 —
foreach (int32 x in d) { d.add(item: 9); } is a compile error, not the runtime panic the growable
containers' mods counter used to raise. The operand roots through its receiver, so foreach (v in m.values()) freezes m, and an element operand is iterated where it lives and freezes its container:
foreach (x in arr[0]) { arr.add(…) } is refused. The loop calls its container's mint
itself, and a non-const one (iterMut(), an iterator() that writes) is a write to the container: refused over a
read-only place, and inside another loop over the same container, as d.iterMut() written out would be. Reads stay free: a const fn call on the operand, an element write through a
ref binding (that is what foreach (ref …) is for), and any disjoint container or sibling field. The
mods counter remains as defense in depth for the unsafe/FFI paths that no static rule sees.
An iterator that owns its source — a resource, as std::io::Lines is — is advanced where it is named:
foreach (string ln in ls) moves ls itself on, so what it recorded is still there after the loop (ls.error()), a
second loop resumes where the first stopped, and ls is dropped once, at its own scope's end. It is frozen for the
body and may not be read-only, since the loop writes it. One the operand MAKES (foreach (… in Lines::<File>.make(src: give f))) belongs to the loop, which drops it when it is left. A value or view iterator
is copied into the loop, as every value is.
So is a match arm that binds a view of a lookup. A container may return an Optional view (public const fn Optional<ConstView<int32>> find(int32 k)), and match (s.find(k: 1)) takes it apart: an arm that binds the view
holds the subject's roots — here s — still for that arm, its binding takes the arm's depth (rule 4), so it is
stored nowhere outside the arm, and it is as returnable as the subject (a lookup on a ref parameter, yes; on a
local, no). An arm that binds no view holds nothing, since the subject is reachable only through an arm's bindings, so
lookup-or-insert is one match:
match (s.find(k: key)) {
case Some(value: v): { use(v: v); } // `s` is held still here
case None: { s.put(v: fresh); } // and may grow here
};
Rust's NLL accepts the same shape (match map.get_mut(&k) { Some(v) => …, None => { map.insert(…); } }). A subject
behind a Shared gets the arm the runtime access a window gets.
Obtain one from a container:
DynamicArray/FixedArray/InlineArrayexposeview()(whole) andslice(from:, count:)(bounds-checked sub-range), bothconst fn, minting the read-onlyConstView<T>— so aconst refcontainer can open a window — andviewMut()/sliceMut(), minting the writableView<T>.View<T>hasslice(writable),length(),isEmpty(),operator[](a mutate-through place),swap/reverse, anditerator()/iterMut();ConstView<T>hasslice(read-only),length(),isEmpty(), aconst ref T operator[]— the read-only place, which indexes any element, copyable or not, without copying it, so there is noget(index:)—iterator(), anddataPtr()(theUnsafeConstPtr<T>for C).InlineArrayis built in, so itsview()/viewMut()exist only in a program that has the view types; without them the call is refused with the import it needs. A view a call returns indexes directly —s.bytes()[1],d.viewMut()[0] = 7— as does any value: an array returned by value, or a member of one.A view flows down the call stack as a by-value parameter. A callee that only reads takes a
ConstView<T>, and aView<T>argument narrows to it implicitly at every sink — an argument, an initializer, an assignment, areturn— the direction kama already takes forconst refparameters andUnsafePtr<T>→UnsafeConstPtr<T>; a genericConstView<T>parameter infersTfrom aView<T>argument through the same pair. The reverse is refused, namingviewMut(), and a write through aConstView<T>—cv[0] = x, aforeach (ref …), aparallel_for— is refused as a write through aconst refplace;viewMut()on aconst refcontainer is refused like any non-const fncall. A view may not be stored in a field of anything but another view, a collection element, astatic, or a box (Owned/Sharedof a contract), each of which outlives every window — a type that holds a view is a view itself (Views compose, below). It may be returned only when it borrowsthisor aref/view parameter (soarr.slice(...)on aref/thisreceiver is fine; a view over a local is rejected); aforeach (ref …)or borrowing-matchbinding is as returnable as what it aliases, and a view's own by-value storage is what holds the view, not what it borrows. To hand back data you own, copy into aDynamicArray.A view may not be passed by
ref/out— it is already a borrow, and the only thing the extra indirection adds is the power to reseat the caller's view at storage the caller never named. Pass it by value (it is two words) or return one. The ban covers contract members too, where a bodiless signature would otherwise propagate the spelling to every implementer.A view argument may not root in another argument's mutable place, nor in the receiver of a non-
const fn:bad(d: ref d, v: d.view())andb.eat(v: b.view())hand the callee a window over storage it may grow. A view roots through its receiver, so a chained derive roots where its receiver roots — and a call's view result roots at its receiver, at every view it was handed and at everything it takes byref, sog(v: firstHalf(xs: a.viewMut()), d: ref a)is refused as the unwrapped call is, and a ternary or amatchexpression roots wherever any arm does. A view into an element and a mutable argument naming that element's container, or another index of it, are refused the same way:g(v: arr[0].view(), d: ref arr).foreachneeds a place to iterate from.foreach (x in d.view())has nowhere to put the view and nothing holding the buffer still — open a window and iterate the alias. An operand that is its own iterator ("ab".chars(),m.valuesMut()) is copied by value and stays legal.A view is minted by the type it views, and by nothing else. A view is a bidirectional relationship — it does not exist without a type to view — so a
type view's constructor is private while the view holds a raw pointer, at any depth, and writingpublicon it is then a compile error rather than a silent downgrade (the same rule aviewfield already obeys). A view that holds no raw pointer creates no borrow — it only re-holds views it was handed — so it may declare a public constructor, whose result roots where its view arguments root (Views compose). Two places may call it: the view itself, which is what makesView.slicework; and the type being viewed, which declares that relationship by implementing a member of a contract marked@viewable. Implementing such a member lets that member's body mint the view it returns — the return type self-selects, so a marked contract cannot over-grant, and a member returningint32mints nothing. No other body may: not a free function, nor the viewed type's own destructor or other members.@viewable type contract Viewable<V> for resource, value, view { fn V view(); } // the read-only window @viewable type contract ViewableMut<V> for resource, value, view { fn V viewMut(); } // the writable one type resource DynamicArray<T, A> implements …, Viewable<ConstView<T>>, ViewableMut<View<T>> { public unsafe const fn ConstView<T> view() { return ConstView::<T>.over(at: this.data, count: this.len); } public unsafe fn View<T> viewMut() { return View::<T>.over(at: this.data, count: this.len); } }Two contracts rather than two members of one, because a grant is read by member name:
borrow d.viewMut()andparallel_foreach look for exactly their own mint, and a type that can only ever be read grantsview()alone.View<T>itself implementsViewable<ConstView<T>>— itsview()is the narrowing, and the one route outsideConstViewto that view's private constructor.The marked contracts in tree are
Viewable<V>/ViewableMut<V>,Iterable<T>/IterableMut<T>(prelude) andValuesIterable/ValuesIterableMut/EntriesIterable(std::collections). A@viewablecontract is a mint protocol, not a value: it declares who may hand out a view, so boxing one would erase the very identity the grant is about. It emits no C type at all — no vtable, no fat pointer — and naming one as a local, parameter, field or return type is an error. Use it in animplementsclause or as a generic bound.borrowandparallel_forare nominal on it too: a host whose method was never granted is rejected, though resolution stays structural, so the emitted call is still direct.parallel_forwants one specific grant —viewMut()— because it needs contiguous storage it can write;borrowaccepts any.What the mint buys is auditability and generality; the window is what buys soundness. A view is minted only by a type that claims to own the memory — but that type's own
view()can still return a truthful pointer with a false length, and no type system without lifetimes can tell. That residual is named and small: the trusted set is theunsafe fnbodies of the types that own the memory, and it is greppable at declarations. What safe kama can no longer do is hold a correctly-minted view across a mutation of the thing it views.A borrowing iterator is itself a view. Every collection iterator —
ViewIter/ViewIterMut,DynamicArrayIter,MapValueIter,BitSetIter, thestringiteratorsChars/Split, all of them — is declaredtype view, so it obeys the same escape rules as theView<T>above: a local or a by-value parameter, returnable from the container's owniterator()/values()(it borrowsthis), and never a field of anything but a view, a collection element, a box or astatic— an enum carrying one is a view itself — and it obeys the window rule, so an iterator local is bounded exactly as aView<T>local is. That closes the laundering path, where aView<T>could not be stored in a field but its iterator, holding the same pointer, could. It costs nothing at the use site:foreachresolvesiterator()/iterMut()structurally and emits direct monomorphized calls, so a view-kind iterator is never widened to a contract value. The mods-counter fail-fast the growable containers carry is unrelated and was never a substitute — it points into the container too, so reading it after a free is the use-after-free.
Views compose — a type that holds a view is a view ✅#
A zero-copy parser keeps views into its input. A type view may hold views, and an enum with a view
payload is a view — Optional<V>, Result<V, E>, a user type enum with a view case — under every rule
above: stack-only, never a field of anything but a view, never a collection element, returnable only where it
borrows what the caller holds, and a local of it bounded by a window. So absence is in the type, not a
sentinel:
import { std::collections::ConstView };
type view Tokens {
ConstView<uint8> rest;
public ctor over(ConstView<uint8> input) { this.rest = input; } // no raw pointer: a public constructor
public fn Optional<ConstView<uint8>> next() { // an Optional of a view is a view
isize n = 0;
while (n < this.rest.length() && this.rest[n] != 32) { n = n + 1; }
if (n == 0 && this.rest.length() == 0) { return Optional::None; }
ConstView<uint8> word = this.rest.slice(from: 0, count: n); // a derive off `this`
isize skip = n < this.rest.length() ? n + 1 : n;
this.rest = this.rest.slice(from: skip, count: this.rest.length() - skip);
return Optional::Some(value: word);
}
}
fn uint8 main() {
string text = "zero copy";
int32 words = 0;
borrow text.bytes() as b {
Tokens t = Tokens.over(input: b); // bounded by the window `b` came from
while (true) { match (t.next()) { case Some(value: w): { words = words + 1; } case None: { break; } }; }
}
return cast<uint8>(words); // 2
}
Tokens is the view's pointer and length — 16 bytes on a 64-bit target, on the stack — and
Optional<ConstView<uint8>> is that plus its tag. Nothing is allocated and no byte is copied.
- A public constructor is allowed when the view reaches no raw pointer at any depth (through a value's
fields, an
Optional, anInlineArray, a bare type parameter), and every parameter that carries its borrow must be a by-value view: arefcontainer is storage the caller can still grow. - A view read from a composite's field roots where the composite roots, and
thisin a view's method is the caller's. Storing into such a field obeys rule 4:this.rest = this.rest.slice(…)is a derive off the same holder. So a composite's non-constmethod may be handed only views that outlive its receiver —t.reset(other: vb)withvbfrom a window nested insidet's is refused at the call. - Not held: a view that closes its window (it lives only as its
borrowalias, which runs its destructor), as a field or a payload; and a view field handed out as a mutable place (fn ref ConstView<uint8> …), through which it could be reseated where rule 4 cannot see. - An enum of a view passes by value only, as a view does, and a
matchbinds its payload as a view rooted where the subject roots — copied out, never read in place. Over a subject nothing else bounds, a lookup such asmatch (s.find(k: 1)), an arm that binds the view is a window (The window, below). Its other cases may own:Result<ConstView<uint8>, Owned<Error>>is kama's own error convention, and it is moved and drops itsErrexactly once, as any enum does. A user enum with a view case is no collection element, and its stores obey rule 4.
A structure that must outlive the parse — an AST kept in a collection — keeps offsets into an owned buffer
instead. That is what rustc itself does, and it is the honest limit of a language without lifetimes. Rust stores a
borrow anywhere and proves it with lifetimes; C# lets a ref struct hold Span<T> and ref fields, and Swift a
~Escapable struct hold Span, with Optional of one accepted since SE-0465 — kama takes the second road, with
no annotation.
The bytes of a plain value — @generate(BytesViewable) / @generate(Blittable) ✅#
A plain value that a program sends, hashes or uploads as-is wants its own in-memory bytes, not a serialization
pass. The type hands them out itself, as string hands out s.bytes():
import { std::collections::ConstView, std::collections::View };
type enum Phase : uint8 { Lobby, Play }
@generate(BytesViewable) // read: numbers, `bool`, `char`, an enum with a declared integer type
type value Round { public uint32 tick; public Phase phase; public bool live; public uint8 spare0; public uint8 spare1; }
@generate(Blittable) // read and write: numbers only
type value Snapshot { public uint32 tick; public InlineArray<float32>#(64) pos; }
fn uint32 sum(ConstView<uint8> b) { uint32 s = 0; for (isize i = 0; i < b.length(); i++) { s = s + cast<uint32>(b[i]); } return s; }
fn void receive(View<uint8> into) { }
fn void step(ref Round r, ref Snapshot snap) {
uint32 h = sum(b: r.bytes()); // an argument needs no window
borrow snap.bytesMut() as w { receive(into: w); }; // received in place
}
bytes() is a ConstView<uint8> over the value's storage and bytesMut() a View<uint8>; both are minted by the
type, so the window rule applies as to any view. Two facts make them sound, and the compiler proves both:
- Every byte belongs to a field. Padding holds whatever the memory held before, so two equal values could hash
differently and a send would leak it. A type with padding is refused, naming the gap — "
Hdrhas 3 bytes of padding aftertag" — so the fix is a field there or another order. The check assumes natural alignment, so "no padding" holds on every target, at both pointer widths forusize/isize; the C compiler then asserts each field's offset for the target being built. Blittable: every bit pattern of every field is a valid value. Aboolbyte of 2, a surrogatecharor an enum tag outside its variants is not, so a writable view takes numbers only; such a type still reads as bytes.
A field qualifies when it is a number, a bool/char/declared-width enum (read only), an InlineArray or
Simd of those, or a value that itself declares the view. An enum with payloads, an undeclared-width enum, a
pointer and anything that owns or borrows memory are refused, naming the field; so are an enum, a resource and
a type that already has a bytes(). On a generic type the view is conditional, as every derive is: each
instance gets the strongest level its fields allow, and a use that wants more says why.
A run of plain elements is plain bytes too: an InlineArray, a ConstView<T> and a View<T> over such a T give
bytes() (and over numbers bytesMut()), len * sizeof(T) bytes of the same buffer — borrow list.view() as v { send(bytes: v.bytes()); }. BytesViewable and Blittable<R, W> are the prelude contracts a bound names;
Blittable refines BytesViewable.
The bytes are in host layout — a mirror, not a portable format; kbin is the portable one — and byte equality
is equality of representation (-0.0 and 0.0 differ). A lone number has no byte view (a non-goal): a method
on a number receives a copy, so there is no storage to view; std::num::byteswap* and kbin put a number on the wire.
Hash maps & sets (std::collections) ✅#
Map<K, V, H: Hasher = DefaultHasher, A: Allocator = GlobalAllocator> (open-addressing, linear-probing,
tombstoned, grows at 0.75 load) and Set<K, H: Hasher = DefaultHasher, A: Allocator = GlobalAllocator> (a
thin wrapper over Map<K, Unit, H, A>), over a key K: Hashable + Equatable (the trailing A is the custom
allocator — see "Custom allocators" below). These two key contracts are in the prelude:
type contract Hashable for value, resource, enum, intrinsic { const fn uint64 hash(); }
type contract Equatable<T is This> for value, resource, enum, intrinsic { const fn bool equals(const ref T other); }
Keys are hashed and compared by content, so a lookup key built any way (a concat, a fresh
construction) finds the stored entry. string and every integer width satisfy both out of the box, via
pure-kama type adapter blocks that ship in the prelude (universal — no std::collections
import; no compiler blessing). hash() returns a cheap CONTENT hash
— identity (cast<uint64>(this)) for an integer, FNV-1a over the UTF-8 bytes for a string — and the
avalanche/mixer is a separate, pluggable step the Map/Set apply via their H: Hasher type
parameter: slot = H::finish(k.hash()) & (cap-1). H defaults to DefaultHasher (splitmix64, strong
avalanche, DoS-agnostic — see below) so Map<K, V> is unchanged; Map<K, V, FastHasher> swaps in a
cheaper single-multiply mixer for trusted, well-distributed keys (the Hasher contract + both hashers live
in std::collections). string declares Equatable with an empty body, satisfied by its built-in equals; each
integer's is scalar (a conformance on a primitive — this is the scalar itself). Floats get Equatable
only (exact ==) — intentionally not hash-keyable. A third prelude contract,
type contract Comparable<T is This> for value, resource, enum, intrinsic { const fn Ordering compareTo(const ref T other); } (returning the prelude enum
Ordering { Less, Equal, Greater }), gives every int/float/string a total order through the same pure-kama
type adapter blocks — the bound for PriorityQueue and the sorted containers. A user key declares implements Hashable, Equatable<This>
and provides the two methods. Bounds are nominal: the implements is required (a coincidental equals
is not enough), the same rule as foreach.
import { std::collections::Map, std::collections::Set };
fn uint8 main() {
Map<string, int32> counts = Map.empty();
counts.put(key: "a", value: 1);
counts.put(key: "a", value: 2); // overwrite (drops the old value)
int32 v = match (counts.get(key: "a")) { case Some(value: x): x; case None: 0; }; // 2
counts.remove(key: "a"); bool has = counts.contains(key: "b"); isize n = counts.length();
Set<string> seen = Set.empty();
seen.add(key: "x"); bool member = seen.contains(key: "x");
return 0;
}
foreach (K k in map) / foreach (K k in set) iterates the keys (a by-value key iterator, present for a
Copyable key; it guards against a mid-iteration put/remove like the DynamicArray iterator).
foreach (V v in m.values()) iterates the values by copy (present for a Copyable value), and
foreach (ref V v in m.valuesMut()) borrows every value in place to mutate it — the value analogue of
iterMut(), and the way to walk a map whose key isn't Copyable — and foreach (const ref V v in m.valuesConst()) reads every value in place, from a const fn, so a function that only borrows a map
(const ref Map) walks values that are not Copyable. copy m deep-copies a whole map
(independent clone) — present only when both key and value are Copyable, gated by a multi-condition
when [K: Copyable, V: Copyable]. foreach (Entry<K,V> e in m.entries()) iterates the key-value pairs
by copy (e.key() / e.value()), present only when both key and value are Copyable — the pair analogue
of iterator() (keys) and values(). Entry<K,V> is a named pair (the language has no tuple, by decision: a named pair is greppable and self-describing — explicit over implicit, GOALS 5 — and a one-line type value is the spelling); a key is never
mutated in place (that would corrupt the table), so there is no entriesMut() — mutate values via
valuesMut() / getRefMut.
drain() moves every pair out (KR-263; Rust's HashMap::drain), with no Copyable asked of either half:
foreach (Entry<string, Session> e in live.drain()) { // live is empty after the loop, its table kept
match (give e.intoParts()) { case Parts(key: k, value: s): { archived.put(key: give k, value: give s); } };
}
Each pair is moved once, as the owned Entry the loop binds, and taken apart by name with give e.intoParts() →
EntryParts::Parts(key:, value:) (kama has no tuple). The map is empty after the loop however it ends — run
dry, break, return — because the drain is a view that closes its window: the foreach holds it and its
destructor drops what the loop did not take (A foreach is the other window). The table is kept for reuse. The
loop freezes the map, so a put/remove in the body is refused, and a read is legal and sees exactly the pairs
not yet taken. Map drains in table order, OrderedMap oldest first, SortedMap smallest key first; Set,
OrderedSet and SortedSet drain their members the same way (foreach (string k in seen.drain())), and
ConcurrentMap.drain() moves every pair into one Map, shard by shard under each shard's lock. Consuming a map is
drain() and then dropping it, so there is no second, consuming spelling. The grant is the @viewable contract
Drainable<I> (std::collections), as the other iterations are granted.
Map is move-only: it owns its keys and values (dropping the key + handing the value back on remove,
dropping both on overwrite/clear/end of life — ASan/UBSan-clean for owning keys and values, e.g.
Map<string, DynamicArray<string>>). Lookups borrow the key (ref K), so they don't consume a key you're
holding. Three value accessors form a consistent trio: get(key:) -> Optional<V> hands back a deep copy
(present only when V is Copyable); getRef(key:) -> const ref V borrows the stored value in place, read-only, and getRefMut(key:) -> ref V is its writable twin (any V —
the accessor that makes a Map of move-only values like Owned/Shared/a collection first-class rather than
write-only; panics on an absent key, so guard with contains first, as a map lookup is partial);
remove(key:) -> Optional<V> moves the value out (None when absent — reclaim it or discard to drop).
A key that is an inline rvalue — a string/number literal or a user-type ctor — is materialized into a temp
automatically, so m.get(key: 5) / m.get(key: Point.make(x: 1, y: 2)) work without binding a local first.
std::collections also carries Deque<T> (a growable ring buffer — O(1) push/pop at both ends) and
PriorityQueue<T: Comparable> (a binary heap). The queue is a min-heap by default (bare ctor or
PriorityQueue.minHeap() — smallest out first, the fit for A* / event scheduling); PriorityQueue.maxHeap()
inverts it. push(item:) and pop() -> Optional<T> are O(log n), peek() -> Optional<T> (copy, Copyable
element) / peekRef() -> const ref T (read-only borrow, panics when empty; peekRefMut() to mutate in place) read the root O(1). It orders via the element's
Comparable.compareTo, is move-only (deep-copies only for a Copyable element), and — since heap order isn't
meaningful — isn't iterable; drain it with pop. (It's backed by a DynamicArray, which gained an O(1)
swap(i:, j:) in-place element exchange.)
SlotMap<V> is a generational slot map (Rust's slotmap; the ECS entity / asset registry): insert(value:) -> Handle hands back a stable, Copyable Handle, and get(handle:) -> Optional<V> (copy, Copyable) /
getRef(handle:) -> const ref V (read-only borrow, panics on a stale handle; getRefMut(handle:) writes) / remove(handle:) -> Optional<V> (moves out)
all reject a stale handle — one whose slot was removed, or removed and reused for a different value —
returning None (or panicking on getRef) instead of aliasing the new occupant. That's a per-slot generation
counter (odd while occupied, bumped on every insert/remove), so a handle can safely outlive the value it names
and a dangling handle is caught, not a use-after-free. contains(handle:), length/isEmpty, clear, and
values()/valuesMut() (iterate the live values, copy or borrow) round it out.
A Handle survives a serde round trip — save a slot map, load it, and every handle a caller stored
elsewhere still resolves to the same value, which is the only reason to hold handles rather than indices.
That is what the wire format is for: it preserves the slot LAYOUT — the per-slot generation array
including vacant slots, then the live values in ascending slot order, then the free list as a stack whose
last entry is the next slot reused ({"slots":[1,2,1],"values":[10,30],"free":[1]}) — so a hole costs a
generation and no payload, and the size is proportional to the high-water slot count rather than to the live
count. The free list is written because its order is state: it picks the slot the next insert reuses, so a
map loaded and stepped is the map saved and stepped, which replay and rollback rely on. A free list that does
not name every vacant slot exactly once is refused as Malformed. Only len is derived on read.
The compact alternative (write only the live values, rebuild dense) was rejected for failing silently: a
stale handle could then coincidentally match a rebuilt slot and alias the wrong value, which is the exact
accident the generation check exists to prevent. Handle is itself Serializable, so the handles a caller
stored travel beside the map; its index is an isize, which has no wire format, so the wire carries a
fixed-width int64 and a handle written on one target reads back on another.
SortedMap<K: Comparable<K>, V> / SortedSet<K: Comparable<K>> are the ordered map/set — a B-tree
(min-degree 6; peer: Rust BTreeMap, C++ std::map) keyed by Comparable.compareTo, not a hash. They mirror
Map/Set (put/get(copy)/getRef(in-place borrow, panics absent)/remove -> Optional<V>/contains/
length/isEmpty/clear/deep copy/JSON serde), but keys stay sorted, so they add the ordered queries a
hash map can't answer: first()/last() (min/max key), floor(key:)/ceil(key:) (nearest ≤ / ≥), range (from:, to:) (a half-open key window), and keys()/values() (resp. SortedSet.elements()) yielding a fresh
DynamicArray of copies in ascending order (serde likewise writes its entries in ascending order). Each node backs
its keys, values, and child boxes with DynamicArray (reusing its move-out / shift / RAII) and holds children
as Owned<BTreeNode> heap boxes, so a subtree moves as one owned pointer and the splits (insert) / borrows +
merges + predecessor-swaps (remove) relocate move-only keys and values with ownership intact. getRef
forwards an in-place borrow up the tree via the escape checker's chained-ref-return rule (a fn ref T may
return a place-returning method call whose receiver roots at this). SortedSet<K> wraps SortedMap<K, Unit>,
as Set wraps Map.
OrderedMap<K: Hashable + Equatable<K>, V> / OrderedSet<K> keep the third order a map can have: insertion
(Python's dict, Java's LinkedHashMap, .NET 9's OrderedDictionary). Lookup is O(1) as in Map, and every walk —
foreach, values()/valuesMut()/valuesConst(), entries(), serde — goes in the order keys were first put; a
put on a present key replaces the value and keeps the place. They mirror Map/Set member for member (H and
A included) and add what order answers: first()/last() (the oldest and newest key, O(1)) and moveToEnd(key:)
(an entry becomes the newest, its key and value relocated, not copied) — together an LRU: moveToEnd on a hit,
evict first() at capacity. The layout is Python's compact dict, entries in order plus a hashed index of their
positions. remove is O(1) and never moves an entry: it leaves a hole iteration skips. Holes are reclaimed in
exactly two places, so a program chooses when their O(n) cost is paid: an insert that finds the entries full
rebuilds them (in place when at least half are dead, growing otherwise — inserts stay amortized O(1)), and compact()
does it on demand. compact() is reserve()'s mirror — one moves a growth, the other a compaction, out of the
put that would pay it — so a frame or an audio callback pays neither. There is no positional access (at(index:)):
with holes it would be O(n). Serde writes insertion order too: a text-keyed map is a keyed object in the order its
keys were put (where Map sorts them), a repeated key in a document keeps its first position and takes its last
value, other keys write Map's pair array, and a text-keyed OrderedMap merges per key, keeping its order.
Custom allocators ✅#
A collection's memory source is a trailing type parameter A: Allocator = GlobalAllocator. Because it
defaults (default type parameters), DynamicArray<T> / Map<K,V> / bare BitSet are unchanged; the
allocator is opt-in. Every container that manages its own heap buffer carries it: DynamicArray, Map,
Set, Deque<T, A>, FixedArray<T, A>, BitSet<A> (its first type parameter, so a plain BitSet is now
the all-defaulted instance), SlotMap<V, A>, OrderedMap<K, V, H, A>/OrderedSet<K, H, A>, and PriorityQueue<T, A> (which owns no buffer itself — it
threads A to its embedded DynamicArray<T, A>). A stateful allocator arrives via a named ctor:
withAllocator(allocator:) for the growable containers, withAllocator(allocator:, size:) and
filledWithAllocator(allocator:, size:, value:) for the eager FixedArray, and withAllocator(allocator:, maxOrder:) for PriorityQueue. The ordered containers thread it
too: SortedMap<K, V, A> / SortedSet<K, A> push A through the B-tree — the node contents (inner arrays) and
every node box, the root included, draw from A (via the placement new(allocator:) BTreeNode below), so
arena.reset() reclaims the whole tree.
An Allocator is a copyable value handle (the C++ std::pmr::polymorphic_allocator / Rust &Bump / Zig
std.mem.Allocator model), a three-method contract. It is foundational, so the Allocator contract and the
default GlobalAllocator live in the global prelude (beside Comparable/Hashable) — both the collections
and the smart pointers name them, and they survive --no-std. The concrete strategy types Arena/BumpAllocator
stay in std::collections.
type contract Allocator for value {
fn Optional<UnsafePtr> allocate(usize bytes, usize align); // None on OOM/exhaustion — fallible seam (never panics)
fn Optional<UnsafePtr> reallocate(UnsafePtr pointer, usize bytes, usize newBytes, usize align);
fn void deallocate(UnsafePtr pointer, usize bytes, usize align);
}
reallocate resizes a block the allocator handed out and keeps its first min(bytes, newBytes) bytes. It
may move the block, and the allocator does the whole job: in place when it can, otherwise a new block, the bytes,
and its old block back. So None means what it means for allocate, out of memory, and the original block is
then untouched and still the caller's. That is C's realloc contract, and Zig's remap. GlobalAllocator is
realloc. An arena's BumpAllocator grows the block it handed out last where it stands, by moving its offset,
and otherwise bumps a fresh block and copies. An allocator with nothing smarter to do allocates, copies and
deallocates. A growing DynamicArray calls it instead of doing those three steps itself, because a resize can
extend a block or remap its pages where a copy cannot. On Linux the difference was 5× on a megabyte-scale
append (bench bulk): freed blocks went back to the kernel, and every round faulted them in again.
Both halves carry the block's layout — its size and its alignment (a power of two) — the model Rust's
Layout and Zig's aligned allocation use. allocate must return storage aligned to align, since a type holding a Simd field
needs 16 and an @align(64) type needs 64; deallocate is handed back exactly the layout the block was
allocated with, so an allocator may trust it (a size-class pool keeps no per-block header). The compiler and
the stdlib always pass sizeof/alignof of what they place, and the object's own layout
(sizeof(ptr:)/alignof(ptr:)) when a base handle may hold a derived one. The container stores A alloc by
value and routes every buffer through this.alloc.allocate/reallocate/deallocate; dispatch is a direct monomorphized
call (no vtable), so a GlobalAllocator (a zero-size handle onto the runtime's allocation funnel, the system
heap) costs nothing. A stateful allocator is a small handle pointing into a
caller-owned Arena (one heap buffer, bump-allocated, reset() bulk-frees in O(1)); the arena must
outlive the container — a documented contract, not a borrow-checked one (a raw UnsafePtr isn't escape-checked
and there is no lifetime tracking). Since Kama has no constructor overloading, a stateful allocator arrives via
a named ctor (DynamicArray.withAllocator(allocator:)), which assigns alloc on the value it builds. Allocator/GlobalAllocator are prelude (global, no import); Arena and BumpAllocator ship in
std::collections:
import { std::collections::DynamicArray, std::collections::Map, std::collections::Arena, std::collections::BumpAllocator };
fn uint8 main() {
Arena arena = Arena.make(capacity: 1 << 16); // caller-owned; drops last
DynamicArray<int32, BumpAllocator> xs = DynamicArray.withAllocator(allocator: arena.handle());
Map<int32, int32, A: BumpAllocator> m = Map.withAllocator(allocator: arena.handle()); // named arg skips H
// ... fill/use; xs and m draw from the one arena; their deallocate is a no-op; the Arena frees the buffer.
return 0;
}
Fallible seam (✅ shipped, MCU step 5). allocate returns Optional<UnsafePtr> — None on OOM/exhaustion,
never panics. Infallible new and the direct-malloc containers (DynamicArray, Map, Set, Deque,
FixedArray, BitSet, SlotMap, PriorityQueue) plus the boxed SortedMap/SortedSet B-tree keep the
pre-step-5 panic-on-OOM behavior (they unwrap the Optional via the prelude unwrapPtr, panicking on
None; try new takes the same pointer through ptrOrNull, the sibling that answers with a null pointer
so the failure can become a None). The ONE non-panic construction entry is try new (below); user code that calls allocate
directly can match on None (e.g. a bump/arena that stops at exhaustion instead of trapping — the
game-engine frame-allocator / real-time pattern, not only MCU).
try new — non-panic construction ✅ (MCU step 5)#
try new T.make(...) yields
Optional<Owned<T>> — None when the allocation fails, instead of panicking. It is the single fallible
construction entry (new stays the infallible sugar that unwraps-or-panics); there is no parallel
tryAllocate.
try accepts exactly what new accepts — they share one grammar rule, so the two spellings cannot
drift: the placement form (try new(allocator: a) T.make(…)), the on-type turbofish
(try new T::<A>.make(…)), and an interface-element handle (Optional<Owned<Contract>>,
Optional<Shared<Contract>>) all work. Like try cast, it needs a declared destination to read
Some's payload type from — a local, an argument, or the function's return type — which is what makes a
fallible factory the ordinary shape.
Optional<Owned<Box>> b = try new Box.make(v: 7);
match (b) { case Some(value: x): use(box: ref x); case None: { /* OOM — recover, don't trap */ } };
fn Optional<Owned<Mesh>> load(string path) { return try new Mesh.parse(path: give path); } // return position
// Placement: the block comes from the arena, and `None` is how an EXHAUSTED arena answers — no trap.
Optional<Owned<Shape, BumpAllocator>> s = try new(allocator: arena.handle()) Circle.make(r: 5);
Every allocation on this path answers None, including a Shared handle's control block. A bare
try new into a box whose allocator is anything but GlobalAllocator is refused, because that block would be released through
the wrong allocator. An arena-backed box is built with the placement form
(tests/try_new_arena.kama,
tests/try_new_iface.kama,
tests/xfail/try_new_stateful_bare.kama).
No-heap subset ✅ (MCU step 5)#
A per-region @noheap function attribute and a whole-program --no-heap build flag make every
emitter-visible heap allocation a compile error — new/try new, parallel_for/spawn argument
boxing, error-boxing into Owned<Error>, and string interpolation's Formatter buffer all funnel through
one gate. Target-independent (composes with --target embedded).
The two answer different questions. @noheap proves a BODY, so it refuses an allocation where it is
written. --no-heap proves the PROGRAM, and a program is what its entry points reach: main, every
expose fn, and every @foreignEntry body. A body nothing reaches is not part of it. An allocating helper
nobody calls builds, and so does a stdlib module whose error paths allocate, merely imported.
What a no-heap object promises follows: the reached program never allocates. A build compiles only that
reached program, so an unreached body is not in the object at all (docs/packages.md); a kama transpile
output, which keeps everything, may still name malloc in one. Every no-heap and bare-metal compile gets one
section per function, so the board's link drops unreached code with --gc-sections (docs/targets.md).
@noheap is transitive, which is what makes it a proof rather than a lint: a @noheap body may not
call anything that allocates, however many calls away it is, and the diagnostic names the chain
(tick -> mix -> grow). Nothing has to be annotated for this — where the compiler can see the callee it
infers, so an ordinary un-annotated helper is fine exactly when it is allocation-free
(tests/noheap_chain.kama). Reachability includes destructors: owning a
local whose ~T() allocates allocates, even though the body contains no call. That holds for the drops the
compiler writes itself as well: owning a type whose FIELD frees, or an Optional whose payload does, reaches
that free. The call graph is read from the C the build emits, so every call the compiler writes is an edge,
whether or not any source line spells it.
The chain ends at libc, and GlobalAllocator is the leaf, unless the program declares a global allocator (below): a container or box drawing from it is
rejected inside a no-heap region, including merely owning one (dropping it frees; free can block on the
allocator's lock exactly as malloc can). The whole-program --no-heap flag applies the same leaf, so
a no-heap build cannot reach malloc through a container either. A reached allocation is reported where it is
written when that is in a body you wrote. Otherwise the diagnostic anchors on the innermost function you
wrote, the frame holding the call you can change, and never on a prelude or std:: body, which would name code
the author did not write and cannot edit. The same container over a bump allocator is fine and needs no
annotation: A is a type parameter, so DynamicArray<T, BumpAllocator> is a different monomorph reaching
a different allocate (tests/noheap_arena.kama) — which is the idiom a
real-time region is expected to use. A new is judged the same way, by the allocator it draws from: a
placement new(allocator: a) takes its block from a, so it is legal exactly when a is, while a bare
new is refused as the libc allocation it is (tests/noheap_new_bump.kama). Under the whole-program flag the region itself must not come from the heap
either: Arena.make draws its buffer from GlobalAllocator, so a --no-heap program backs a BumpAllocator with storage it owns.
The runtime's own C is READ, not declared. kama ships the runtime headers, so the compiler scans them the
way it scans the C it emits: every static inline body is a node in the same call graph, and what a runtime
extern does with the heap is DERIVED. # lines are skipped, so both arms of every #if are read — which
is what makes a no-heap verdict the same on every target, mechanically rather than by anyone remembering to
say so. The scan bottoms out in two leaves. The allocation funnel (kama_alloc/kama_realloc/kama_free) is an edge
into the declared pool's entries when a program declares a global allocator, and a heap fact when it does not
— so a pool-backed program may build a string, format a number and read its arguments, all from storage it
owns. A foreign allocator — one that hands back a block the program must release through a route the
funnel never served, such as getaddrinfo — is a fact always, pool or not.
Because that verdict is derived rather than declared, it reads the seam as written — so std::fs's
PATH calls allocate on no target and a no-heap program may use them: exists, stat, rename,
remove, createDir, removeDir and File.open in every OpenMode, with no pool declared. Windows
converts UTF-8 to UTF-16 into a caller-owned stack buffer to earn that (the heap conversion it replaced
was a fact on Linux and wasm too, since both arms are read). The directory and whole-file calls are
not, for reasons of their own and on every target: readDir owns a DIR*/cursor, readFile and
removeDirAll own a growing buffer, and createDirAll takes a substring for each parent.
C the compiler cannot read still declares itself. An extern fn into C kama does not ship is marked
@heap, and a call to it is then an allocation fact like any the compiler writes: @heap extern fn UnsafePtr vendor_block_alloc(usize n);. The mark is on the SYMBOL, so a redeclaration without it does not unmark the
call. An unmarked user extern is unchecked C, as it always was — unless its C names an allocator the
compiler can see: a reached extern fn calloc is refused whether or not anyone marked it, because the call
the compiler writes spells the name. @heap is refused on anything but an extern fn: a body's allocations are seen without
it, and a fnptr has no C of its own. It is also refused on a symbol kama's own headers define — there
the answer is derived, and a mark would not agree with it but override it: a mark is unconditional, so one
placed on kama_fmt_i64 would refuse a formatted string whose bytes came from the program's own pool.
string is the one intrinsic that mints heap, and it is immutable, so every method that "changes" one
returns a NEW owned string: a + b, concat, substring, trim*, replace, toLower/toUpper,
truncate, split and a deep copy are all rejected in a no-heap region. So is foreach (string s in xs),
which yields each element by value and deep-copies to do it — foreach (ref string s in xs) borrows and
is legal. Reading a string is always legal (length, indexing, the predicates), and a string literal is
a borrowed view that allocates nothing, so string tag = "voice"; belongs in a real-time body.
tests/noheap_realtime_ok.kama is the standing control for all of this:
a literal, an InlineArray<T>#(N) with a borrow window and an in-place sortUnstable, and a borrowed heap
object — the whole of it legal, with no annotation anywhere but the one attribute.
Where the compiler cannot see the callee, the target must declare the promise, and the call is
otherwise rejected rather than assumed harmless: a fnptr or bound function pointer has no knowable
target at all. A contract member or virtual method is different. It is dispatched through a slot, but a
slot holds only a function some vtable the program builds installed there, and a build sees every one of them, so
a call through it is an edge to every implementer the program defines — the class-hierarchy analysis that
whole-program C++ compilers and Java JITs use. The call is legal exactly when each of those is, and the refusal
names the slot it went through and the implementer that allocates: `total` -> `Area.area [Blob]` -> `Blob::area` -> …. The proof is over the program as built, so adding an implementer that allocates
refuses every @noheap call through that slot, naming it. Marking the contract member @noheap states the
promise once instead: every implementation is then checked against it, exactly as const fn already works
on a contract (tests/noheap_contract.kama). A generic body bound by the
contract (fn measure<T: Area>(T x)) calls each instance directly, and its chain names the instance as
written, measure<Blob>. A virtual call the compiler devirtualizes (a final class, a final fn, or a
method nobody overrides) is a direct call. One limit is outside any kama build: a host calling an
expose fn can hand it a contract value whose vtable it built in C, and that vtable's functions are
unchecked C, like an unmarked extern fn.
A destructor is a slot like any other. Dropping an Owned/Shared/Weak handle over a contract or
a virtual base runs the concrete object's ~T() through the vtable, so the drop is an edge to every
implementer's or subclass's destructor, and it is refused in a no-heap region when any of them frees to
the system heap — even when the handle's own storage is an arena: `Shape.dtor [Circle]` -> `Circle::dtor`.
Two declarations state the promise instead of leaving it to the implementers of the day:
@noheap ~Base(), which every subclass destructor is then held to — a subclass that owns a field which
frees must say so, and is refused when it cannot — and a contract declared for value, whose
implementations own nothing and therefore have no destructor to dispatch
(tests/noheap_drop_box_ok.kama). A handle over a concrete type is
unaffected: its destructor is a direct call the analysis already reads. With a declared pool
(@globalAllocator) every implementer's destructor frees into it, so a boxed error builds under
--no-heap (tests/noheap_pool_serde.d).
The fnptr seam has the same escape, spelled on the signature: @noheap fnptr int32 Op(int32 x);
makes the promise part of the named type, so every function bound to it must itself be @noheap — in
every bind position, a local initializer, an assignment, a call argument, a field and a module
static alike — and a call through such a slot is then legal inside a no-heap region
(tests/noheap_fnptr.kama). One direction, as on a contract: a @noheap
function may be bound to a plain signature, which constrains only itself. BindableFunctionPtr<Sig>
inherits it, because its Sig is an fnptr type. Without this a callback — the shape a real-time
system is built from — had no legal spelling in a @noheap region at all.
@noheap marks a body, so it goes on any declaration that has one: a free fn, and equally a
method, ctor, destructor or operator. The member forms matter because a real-time entry point is
naturally a method — synth.fill(…) — and a destructor is what runs at the end of a real-time scope.
It is rejected where there is no body to gate: on a module static, and on the bodyless extern forms
— an extern "<h>"; and an extern fn, whose bodies are C and cannot be checked at all. A fnptr is
the exception, and by the same property rather than in spite of it: it declares a TYPE for someone else's
body to satisfy, so the promise has somewhere to land (above). @interrupt and @section remain rejected
on all three, because they attach to emitted code and a fnptr emits only a typedef. @interrupt stays
free-function-only (a vector table reaches its handler by symbol, and a member's C name is mangled and
takes a receiver); @section(".name") is legal on a free fn and a member. The other
attribute a bodyless form accepts is @linkName("…") on an extern fn — it names a symbol, not a
body, and an extern fn is the one bodyless form that has one (FFI — calling C); on a fnptr it is
rejected, as it is on an ordinary fn, whose C name is kama's to choose.
@noheap fn int32 tick(int32 n) { /* new / "${x}" / spawn here is a compile error */ ... }
type value Mixer { // ...and the same gate on a member
@noheap public fn int32 fill(int32 n) { ... } // the natural spelling for an audio callback
}
Global allocator — @globalAllocator ✅#
Every heap block a kama program's C obtains, resizes or releases goes through one funnel, the runtime's kama_alloc/kama_realloc/kama_free,
and that includes each box, container buffer, string, error box and spawn bundle. It defaults to the platform
allocator. A program replaces that default with a declaration:
import { std::concurrent::Atomic };
@globalAllocator type resource Pool implements GlobalHeap {
InlineArray<uint64>#(4096) storage; // storage the program owns
Atomic<int32> lock; // every field starts at zero
Atomic<usize> bumped;
Atomic<usize> head;
public unsafe fn Optional<UnsafePtr> allocate(usize bytes, usize align) { … }
public unsafe fn Optional<UnsafePtr> reallocate(UnsafePtr pointer, usize bytes, usize newBytes, usize align) { … }
public unsafe fn void deallocate(UnsafePtr pointer, usize bytes, usize align) { … }
}
GlobalHeap has the same three members as Allocator, and it is a separate contract because the two are different
things. An Allocator is a handle: there are many of them, stored in containers and copied. GlobalHeap is
the heap: there is exactly one, and only the funnel calls it. The default A, GlobalAllocator, is a handle
onto the funnel, so it follows the declaration with no change. A bare DynamicArray<T> draws from the pool.
One instance, process-wide. The compiler emits Pool kama_global_allocator = {0}; once, in the entry
translation unit, and every translation unit and every isolate calls into it. A block allocated in one isolate may
be freed in another (tests/global_allocator_isolates.kama). The
declaration is held to rules that follow from that:
- At most one per program.
- A
type resource, not generic, implementingGlobalHeap. The heap has identity and must not be copied, and the contract is what promises the funnel its three members. - No constructor and no field initializer. The instance is a C global, and nothing runs before
mainto construct it (an MCU has no hook for that either). It starts as zero bytes, so a pool is designed to begin at zero: an empty free list, and nothing bumped yet. - Fields follow the sharing-seams rule, because every isolate writes the same object. Each field is an
Atomic, a deeplyimmutablevalue, raw storage (UnsafePtr), or anInlineArray/Simdof scalars. A plainusizewould be written without synchronization. - A pool may not reach the funnel. Once a pool is declared, the funnel is the pool, so a pool that draws from
GlobalAllocator, or from anything that allocates through it, calls itself. It is refused in every build, and the diagnostic names the cycle.
Reaching the instance — globalHeap::<Pool>(). The pool is the only object that knows what the program has
allocated, so a program can ask it: globalHeap::<Pool>() is a place (ref Pool) naming the one instance.
@globalAllocator type resource Pool implements GlobalHeap {
Atomic<isize> live; // blocks out and not yet returned
public fn isize liveCount() { return this.live.load(); } // …published by the pool, like any method
…
}
isize n = globalHeap::<Pool>().liveCount();
The type argument names the declared allocator and is checked against it, so the dependency is written at the
call site rather than resolved silently. Naming a type that is not the declared one is refused,
and so is the call in a program that declares none.
The result is the pool's ordinary surface: a private field stays private through it.
Pool itself obeys the file rung like any other name, so a pool read from another file is exported and
imported there. A fn ref Pool of the program's own may return the place — it roots in static storage
(see Writing a collection in kama, place-returning methods).
No-heap. @noheap and --no-heap keep one meaning: never reaches the system heap. With a declaration, the
funnel is the pool rather than the system heap. Every allocation the no-heap gate would refuse becomes a call into
GlobalHeap::allocate, and the pool's own body decides. A pool over storage it owns therefore makes boxes,
containers, strings and error boxes legal under both — and, because the runtime's own C is read rather than
marked, that reaches the runtime too: a pool-backed program may format a number and read its arguments.
A pool that calls a @heap extern is refused, and the chain runs through the pool
(tick -> GlobalHeap::allocate -> Pool::allocate). --no-heap still refuses whatever the declaration does not
cover: a @heap extern reached from anywhere, a slot call it cannot see through, and a foreign allocator,
which no declaration can excuse — the pool never handed that block out, so it cannot take it back.
The sanitizer leg's layout check (KAMA_ALLOC_CHECK) wraps whichever implementation is active, so a declared pool
is held to the same promise as the default: every deallocate receives exactly the layout its block was allocated with.
Foreign entry points — @foreignEntry / @callerThread ✅#
A function handed to C as a callback may run on a thread kama did not create: an audio render callback,
a completion port, an RTOS ISR. Every module static is per isolate (_Thread_local, see Module
statics), so on such a thread a static holds only its declared initialiser — never a value some
isolate assigned — and a mixer that keeps its buffer in a static reads silence. The program compiles and
runs. That is the failure this pair exists to make a compile error, and it is a declaration, not an
inference, because the compiler genuinely cannot know: kama_run_loop is while (tick(state)) { } and
calls back on the calling thread, CoreAudio's render callback does not, and nothing in either signature
says which. The threading contract of a C API is knowledge only the seam author has.
- Every
fnptrtype handed to anextern fnmust carry one of the two.@callerThreadsays the C API calls back on the calling isolate —qsort's comparator, a run loop's tick — and nothing changes.@foreignEntrysays it may run on a thread kama did not create. A bare signature at the crossing is refused, so is a bare function name handed over with nofnptrtype to carry the contract at all, and a signature claiming both is a contradiction.@callerThreadon an ordinary body describes no crossing and is refused; it belongs on afnptrtype or anexpose fn. - A callback FIELD of a
type extern valueis a crossing too, and it is judged at the bind — wherever the kama function lands in the field, through the type's constructor or by field name — because a C-layout type exists for one reason, to match a C header, so the construction route cannot matter. This is the shape real C APIs use: WebGPU, CoreAudio and miniaudio all take their callback inside a descriptor struct, which is exactly where a callback runs on a thread kama did not create. At the bind rather than at the field's declaration, because a descriptor a program only ever RECEIVES from C holds no kama function, and an annotation that drives no check is worth nothing. Anfnptrfield on an ORDINARY type is a kama callback table, invisible to C, and declares nothing. - Every route into C is asked, not just the argument. An ordinary kama type carrying a callback is
judged where it CROSSES, since the bind into it was legitimate — the walk reaches the callback through
fields and through a raw pointer, whose pointee is named. And a callback handed BACK — an
expose fnreturning one, bare or inside a value — crosses as surely as one passed in, with no call site for the argument check to watch, so theexposeboundary asks. Its PARAMETERS are not asked: a callback arriving from C is C's own function pointer, and C owes kama no contract for it. @foreignEntrynames a region, and the region is checked. Every function bound to a@foreignEntrysignature — at any bind position — and every body that carries the attribute itself is a root, and the region is the root plus everything it reaches, over the same call graph the no-heap proof walks. Inside it, reading a modulestaticthat is assigned anywhere outside the region is an error, reported at the read with the chain from the root: that read can only ever see the initialiser. Assigning a static inside the region is fine (per-thread scratch), reading acomptimeis fine (a Cstatic const, one object for every thread), and a@callerThreadcallee is not a region at all. The state a callback needs travels through the pointer the C API hands it —UnsafePtrin, oneunsafe fnat the seam to turn it into aref, and everything downstream is safe and typed,Atomic<T>fields included.- The attribute on a body is the declared escape for a pointer handed to C by a route no bind can
see — placed in raw memory, registered later — the same role
@noheapon afnptrplays for allocation. A descriptor struct is no longer such a route: it is a bind, and it is checked (above).
Recoverable regions — @onPanic(recover: <literal>) ✅#
A panic — a bounds miss, a narrowing or arithmetic fault, division by zero, a bad shift, a float cast,
panic, assert, out of memory — terminates the process, and in a real-time callback it does so from a
thread the player cannot see: the mixer dies and the game goes silent. @onPanic names a region that
recovers instead: the fault leaf still writes its message (a glitch stays visible on stderr), then
control returns to the region's prologue, which returns the value the region declared. The mixer glitches
and degrades.
@foreignEntry @noheap @onPanic(recover: 1) // a TickFn returns 1 to keep the loop running
fn int32 tick(UnsafePtr state) { … } // any panic inside, at any depth, returns 1
- The region names its own value, because no default is right: a
TickFnreturns1to continue, so a zero would end the very loop the region was meant to save.recover:takes a literal; avoidregion takes none, and a value-returning one must give it. @noheapis mandatory beside it, and the region may own no destructible local — in the root or in anything it reaches, walked transitively over the same call graph the no-heap proof uses. Recovery is a longjmp from the fault to the prologue, which frees nothing and runs no destructor; the gate is what makes it sound rather than merely fast. Keep the region's state behind the pointer the callback receives.- No park inside a region: a recovered panic is a longjmp, and one across a fiber switch would land on another
fiber's stack, so a region that reaches a
join, aFiber's drop or ascope's close is refused with the chain (KR-3 decision 15), and so, in a program that runs fibers, is one that makes a socket call or waits on a channel. In a program that runs none neither is a park, and a region may make one. - Regions nest (each saves the previous slot), and the slot is per thread, so a foreign audio
thread's region never sees another isolate's. A region is a body: on a
fnptrtype, a constructor or a destructor it is refused. - The process-level panic hook does not run for a recovered panic: the region's declared recovery is
the handling, and
setPanicHandler's hook keeps its contract of running only when the process is about to terminate. - The mechanism is
setjmp/longjmp.<setjmp.h>is hosted-only, so the runtime includes it — after its feature-test macros, so a region never costs a Linux build the OS seam — and builds its recovery path only for a program that declares a region; every other program is untouched. On wasm this rides emscripten'ssetjmpsupport; on--target embeddedthe toolchain must supply the header (newlib does).
Allocator-aware new / Owned<T, A> / Shared<T, A> / Weak<T, A> ✅#
Heap-boxed objects draw from an allocator too: Owned<T, A: Allocator = GlobalAllocator>,
Shared<T, A>, and Weak<T, A>. A bare new T.make(args) is unchanged (A defaults to GlobalAllocator → the
system heap); a placement form new(allocator: a) T.make(args) draws the block from a and stores the handle in
the box, so its dtor releases through the same allocator — letting a boxed object live in a caller-owned
arena and be bulk-reclaimed on reset():
import { std::collections::Arena, std::collections::BumpAllocator };
type resource Node { int32 v; public ctor make(int32 v) { this.v = v; } }
fn uint8 main() {
Arena arena = Arena.make(capacity: 1 << 12); // drops last (outlives the box)
Owned<Node, BumpAllocator> n = new(allocator: arena.handle()) Node.make(v: 42);
Shared<Node, BumpAllocator> s = new(allocator: arena.handle()) Node.make(v: 7); // pointee AND ctrl from the arena
// n/s dtor deallocate() is a no-op; the objects live in the arena; the Arena frees the region.
return 0;
}
The allocator must be spelled on the box type (Owned<T, A> / Shared<T, A>, explicit over implicit — a
new(allocator: BumpAllocator) into a box spelled Shared<T> is a compile error). Any A but GlobalAllocator
requires the placement form. A bare new draws from the global allocator while the box releases through A,
so it is a compile error even for a stateless A, since the two families would meet only by accident. For
Shared/Weak, both the pointee and the shared control block are drawn from A, and every handle carries
its own copyable A value (copied through copy()/downgrade()/tryUpgrade()), so whichever handle observes
strong == 0 && weak == 0 — even a Weak that outlived its Shared — frees the ctrl through the right
allocator; arena.reset() reclaims a whole ref-counted graph. With allocator-aware new,
SortedMap/SortedSet (whose B-tree nodes box through new/Owned) thread A through their full
SortedMap<K, V, A> / SortedSet<K, A> form (every node box, the root included, placement-new from A). Interface-element boxes (Owned/Shared/Weak<Contract, A>, e.g. Shared<Shape, BumpAllocator>) draw from the allocator the same way: the type-erased fat handle ({obj, vtbl[, ctrl]}) grows a
by-value A alloc + pointee objsize, so the pointee and control block are drawn from A and freed through it
— completing allocator coverage for every box (concrete and contract-erased). Default-GlobalAllocator
interface boxes are byte-identical to before (they keep the plain intrinsic macros). One design limit:
object-graph serialization (@generate Shared/Weak/Owned edges) is GlobalAllocator-only — a graph
edge spelling a stateful A is rejected at compile time (deserialize has no allocator on the wire).
Smart pointers ✅ (triad → prelude/built-in ✅ — embedded, always in scope, no import)#
The smart-pointer triad Owned/Shared/Weak is prelude / built-in — always in scope, no import.
RAII-over-GC is the language (every new T.make(args) already targets a HeapOwner, and the compiler
special-cases the triad throughout: HeapOwner/Deref, never-null checks, drop insertion, ctrl-block layout),
so the ownership triad is as fundamental as int or UnsafePtr and shouldn't require an import. "Built-in" means
always-available, not rewritten in C: they stay kama-defined (RAII resources over Deref/HeapOwner,
refcounting in kama), loaded as part of the prelude like the primitive Hashable/Equatable conformances.
The compiler adds only what a library can't express: the type-erasure (fat pointer + vtable) that makes
Owned<Shape>/Shared<Shape> over a contract work, new T.make(args) heap placement into any HeapOwner<T>,
and — because it is their emitter — direct manipulation of their internals (ctrl blocks, shell-adopt,
ownership transfer) in the emitted C of the serialization graph lowering, so that machinery leaks no public
__-methods onto the triad.
Owned<T> — unique heap ownership (= Rust Box / C++ unique_ptr), zero overhead, move-only,
auto-deref, RAII-freed. The kama surface stays pointer-free; the raw pointer is confined to the library.
Use it for heap objects, recursive data structures, and polymorphic ownership.
Owned<Counter> c = new Counter.make(start: 40); // `new` heap-boxes the ELEMENT type
c.bump(); int32 n = c.get(); // auto-deref: . reaches the pointee
Owned<Counter> d = c; // MOVE: c is now empty (moved-from)
fn Owned<Node> make(int32 v) { return new Node.make(id: v); } // inline `new` in return/arg position — factory, moves out
Move-only: copying/initializing/returning an Owned transfers ownership and invalidates the source, so the
pointee is freed exactly once (RAII, with the pointee's destructor).
The foreign boundary — adopt in, release() out. HeapOwner<T>'s ctor adopt(UnsafePtr<T> raw) is
how ownership enters kama from a host API (Rust's Box::from_raw); unsafe fn UnsafePtr<T> release()
is how it leaves (Box::into_raw): it hands back the raw block and empties the handle, so the
destructor frees nothing. That is the one sanctioned way for a value to outlive the frame that made it —
hand it to the host's own userdata slot in one unsafe fn, borrow through it in the callback via a
ref T parameter, and adopt it back to destroy. It is not a way to own through a raw pointer inside
kama: a static will not hold a resource and a raw element will not be dropped or called through (see
The raw seam). An Owned<T, A> over a custom allocator releases the same block; the host must give
it back to adoptIn with that allocator, or free it as the allocator would. Shared<T> has no release():
its control block makes the ownership a count, not a pointer, so there is nothing a host could hold.
Where an adopted block comes from, and where it goes back. A bare new T.make(…) into any HeapOwner<T> —
the stdlib's or one you write — draws the block from GlobalAllocator with T's layout, so the owner's
destructor returns it there, with the object's own layout read before the drop: usize n = sizeof(ptr: this.p); usize a = alignof(ptr: this.p); drop(ptr: this.p); GlobalAllocator.make().deallocate(pointer: cast<UnsafePtr>(this.p), bytes: n, align: a);. Not libc free: kama's heap goes through one funnel that a
program may replace, and the sanitizer leg checks every release against the layout its block was allocated with.
type resource World { int32 x; public ctor make() { this.x = 0; } }
unsafe fn UnsafePtr<World> install(Owned<World> w) { return w.release(); } // the host's userdata now owns it
unsafe fn void teardown(UnsafePtr<World> raw) { Owned<World> back = Owned.adopt(raw: raw); } // dropped here
Ownership hand-off — give / copy. When a named owned value is handed off — in an initializer,
assignment, argument, or return — an explicit marker states the intent, uniformly in all four positions:
give moves (invalidates the source), copy retains (Shared/Weak) or duplicates. Every owning
kind is movable; what varies is whether it is also Copyable and what a bare hand-off defaults to.
Owned is move-only — a bare hand-off moves, and copy Owned is an error (it's unique). Shared/Weak
are Copyable and declare bare: copy, so a bare hand-off retains (refcount++); copy is the explicit
retain, and give still moves the handle — the ref transfers and the source is consumed (this is how a
Shared returns from a factory without a spurious retain/drop). A value/primitive just copies. A fresh
new/constructor/call result needs no marker. Smart pointers also pass by value: the callee owns the
argument and drops it at function end — fn int32 use(Owned<T> p) consumes it (use(p: give x)), fn int32 peek(Shared<T> s) retains it (peek(s: x), x stays valid).
Owned<Counter> b = give a; // explicit move (a consumed)
Shared<Counter> t = s; // copy/retain (default) — both valid
Shared<Counter> u = copy s; // explicit retain (same as bare); `give s` moves the handle (s consumed)
(copy of a collection is a deep copy — a fresh buffer, element-wise: a bitwise-copyable element is copied
memberwise, a Copyable-resource element is deep-copied via its own copy ctor. A resource element that is not
Copyable is rejected. give of a collection moves the buffer.)
Reading a value after give moved it is a compile error — including out of a declaration's initializer,
and including an element-slot store v[i] = give s,
and a variant payload E::V(f: give s).
Those three are spelled out because "uniformly" above is a claim about the hand-off SHAPE as much as about
the type, and it was true of string in only some of the shapes until 0.9.436 — with nothing here able
to tell, since the fixtures tested the site and tested the type but never the intersection.
this is a named value. In a method it names the receiver the CALLER owns, so the method may hand off
copy this (into a local, an argument, a return, a payload), and a bare hand-off takes the type's bare
default, but it never moves the receiver: give this, or a bare hand-off that would move, is refused.
In a named ctor this is the value under construction, the ctor's own, and give this hands it back.
A ternary hands off one arm, so each arm is the hand-off. c ? a : b over a type that owns something
chooses between values; it never bit-copies one. Where it is handed off, a named arm carries the marker the
same value would carry alone: string label = useName ? copy name : copy fallback;. A fresh arm, such as a
literal, a call or a constructor, needs no marker: useName ? copy name : "anonymous". A bare named arm takes
its type's bare default when that default leaves the source alive: a Shared retains, and a
Copyable(bare: copy) resource copies. Otherwise the arm is refused, and so is a give in any arm.
A ternary runs one arm, so a move there would leave the value moved on one path and live on the other. That is
the "moved on some paths" error an if gets. A move nested deeper in an arm, such as f ? wrap(s: give a) : …
or the right side of &&/||, is judged the same way.
Where the ternary is borrowed instead of handed off, it is a place: a receiver, a ref/const ref argument,
a match subject, an operand. (useName ? name : fallback).length() reads the chosen string where it lives
and copies nothing. A fresh arm there is held in a temporary, which is dropped only if that arm ran, and a
marker there is refused as on any other sub-expression.
A value-producing match hands off one arm the same way. A named owning value in an arm carries its
marker, whether it is a local from outside the match or the arm's own binding: case A: copy t;,
case Some(value: x): give x;. A bare named arm takes its type's bare default only when that default leaves
the source alive, as a ternary arm does; otherwise it is refused.
Unlike a ternary arm, an arm may give: every arm's moves meet at the end of the match, as an if's
branches meet, so a value given in one arm and not another is the "moved on some paths" error an if gets.
A value-producing match may also be assigned to an existing binding or field of any type, string included.
Move-only resource values + the Copyable contract. A type resource value (it owns something, or
has identity) is move-only: a bare named hand-off moves (the source is consumed, its destructor
suppressed), so its heap is freed exactly once — a silent copy is never emitted (that would double-free).
give is optional emphasis; copy is an error unless the type opts in. A resource opts into copy
nominally — implements Copyable<This>(bare: …) (the prelude contract Copyable<T is This> { ctor copy(ref T source); })
plus a public copy constructor (a lone copy ctor without the implements does not make a type
copyable). It is a ctor because a copy is a new object — the same reason a self-returning static fn
is rejected as a disguised constructor; the source is borrowed (ref This), since copying never consumes
it — and borrowed mutably, not const ref, because a retaining copy bumps a refcount reached through
the source and const is deep (the write is refused through a const ref, raw pointer or not). The copy
ctor must spell that ref itself: a parameter's ref-constness is part of the signature (below, const ref),
so ctor copy(const ref This source) does not conform even when it never writes through the source. That costs
nothing at the call: a Copyable resource element still copies out of a const ref container, since the one
__copy funnel casts the const place. Copying never consumes
it. Opting in requires declaring the bare-hand-off default: Copyable<This>(bare: give) (a bare hand-off moves)
or Copyable<This>(bare: copy) (a bare hand-off deep-copies). A marker (give x / copy x) always
overrides the default; there is no "ambiguous — must annotate" error. Because copy/give are markers only
in expression position, they're contextual keywords — usable as member names, so the opt-in ctor is
literally named copy.
import { std::collections::DynamicArray };
type resource Res implements Copyable<This>(bare: copy) { // a bare hand-off deep-copies
DynamicArray<int32> items;
~Res() { }
public ctor make() { this.items = DynamicArray.empty(); }
public ctor copy(ref Res source) { this.items = copy source.items; } // the Copyable ctor
}
fn uint8 main() {
Res a = Res.make();
Res b = copy a; // deep copy — a stays valid, b has its own buffer
Res c = give b; // move — b consumed
Res d = a; // bare — follows the declared default (here: deep-copy)
return 0;
}
Auto-deref — the Deref<T> / DerefMut<T> contracts. The standard smart pointers forward member
access to their pointee (ptr.method()/ptr.field reach the held T). Any type can opt into the same
auto-deref by implementing the prelude contracts — the implements is the explicit gate:
type contract Deref<T> for value, resource { const fn const ref T deref(); } // the read-only place
type contract DerefMut<T> for value, resource { fn ref T derefMut(); } // the writable place
When a member isn't found on the wrapper itself, it resolves on the pointee T and is called through a
place into the pointee: derefMut() when the type implements DerefMut<T> and the receiver is
mutable, else deref(), which is a const fn returning a const ref T — so a const ref Owned<T>
reaches its pointee's const fns, and a type implementing only Deref<T> forwards reads and refuses a
write or a non-const call through it. Resolution is recursive, so deref chains. Both places root at
this, so they are bound by the same second-class-borrow rules as ref T operator[] (no lifetimes
needed). This is how a smart pointer is written as an ordinary resource (RAII + move-only come free)
rather than a compiler intrinsic; Owned/Shared implement both halves.
type value Point { public int32 x; public int32 y; public const fn int32 sum() { return this.x + this.y; }
public ctor make(int32 x, int32 y) { this.x = x; this.y = y; } }
type value BoxP implements Deref<Point>, DerefMut<Point> {
Point inner;
public ctor make(Point p) { this.inner = p; }
public const fn const ref Point deref() { return this.inner; }
public fn ref Point derefMut() { return this.inner; }
}
fn uint8 main() {
BoxP b = BoxP.make(p: Point.make(x: 30, y: 12));
int32 s = b.sum(); // auto-deref -> Point__sum(BoxP__derefMut(&b)) (42)
int32 x = b.x; // auto-deref -> BoxP__derefMut(&b)->x (30)
b.x = 1; // a write goes through `derefMut()`; with only `Deref<Point>` it is refused
return 0;
}
The give/copy behavior matrix. Every owning kind is movable; a bare hand-off follows the kind's
default (a Copyable type must declare it with bare:), and an explicit marker overrides. The rule is
uniform across all four hand-off positions — initializer, assignment, argument, return — and a fresh
rvalue (new/constructor/call result) never takes a marker.
| kind | bare hand-off | give |
copy |
|---|---|---|---|
primitive / value |
copy (cheap) | copy (a value's move is a copy) | copy (redundant, allowed) |
Owned<T> (unique) |
move | move (emphasis) | ⛔ "is unique" |
Shared<T> (ref-counted) |
retain (strong++) | move (transfer the handle) | retain (explicit) |
Weak<T> (weak ref) |
retain (weak++) | move (transfer the handle) | retain (explicit) |
collection (FixedArray/DynamicArray/string) |
⛔ marker required | move (buffer) | deep copy (fresh buffer) |
plain resource (move-only value) |
move | move (emphasis) | ⛔ "opt into Copyable" |
Copyable resource (has a copy ctor) |
its declared bare: default |
move | deep copy via copy |
collection of Copyable elements |
⛔ marker required | move | deep copy (element-wise copy) |
enum whose payload owns |
move | move (emphasis) | deep copy when every payload copies (variant-wise), else ⛔ naming the payload |
A marker on a fresh rvalue is an error. Move tracking is compile-time: reading a moved value, moving out of a
field/element, and a conditional move that is still live at scope exit are all rejected — there is no runtime
drop flag. Assigning a moved local gives it a value again, and every later use is sound; the old value is
dropped only if it is still live, so a give before the assignment, or one on its right side
(r = wrap(r: give r)), leaves nothing to drop:
one.add(item: give t); // t is moved…
t = Tree::Node(kids: give one); // …and holds a value again
A local moved on only some paths cannot be assigned: whether to drop its old value would be a run-time question.
Moving, inside a loop, a value declared outside it is allowed only when every path back to the loop's start — the
end of the body, and each continue — assigns it again; otherwise the next pass would move it a second time. A
break carries its state out of the loop, so a local moved before a break and live at the loop's own exit is
moved on some paths only. A while or for condition runs before every pass, so a value it moves is held to the
same rule, and since the loop ends on a failed test it is moved after the loop (while (eat(b: give w)) { w = …; });
a do … while condition is a pass's last step, so nothing can give a value it moved a new one before the next.
Shadowing is a compile error — kama has none. A binding may not take the name of a parameter, an
enclosing-scope local, or an in-scope field of the enclosing type (C#-aligned; one name = one binding within
any live scope — keeps both name resolution and move tracking unambiguous). Every binding is covered,
not just a declaration —
a for counter, a foreach variable and a match payload binding are each refused the same three ways.
Nor may a binding take the name of a function, type, module static or module constant in scope —
declared in its file or imported into it — and with nothing implicit in scope (KR-87) that needs no exception. "In scope" is what a use is held to, so a
sibling file's private name, a sibling's export this file does not import, and a dependency's file-private
extern are free to take: a call to any of them from here is refused as not visible. A field is a binding too: it is
reachable bare in its type's methods, so it is held to the same rule, and may not share a name with a
method of its own type. The language's names — Optional, Result, Owned, Ordering, the core
contracts, every name the prelude declares — are in every scope like string, so no binding and no
declaration may take one.
A pattern's label names the variant's field and is unrestricted (case V4(a: o1)); it is the binding
beside it that must be fresh.
Sibling scopes may reuse a name freely (they never coexist), whatever binds it — two sequential for loops
over i are ordinary, because the counter dies with its loop.
A parameter sharing a field's name — the this.x = x constructor idiom — is
allowed; a static method has no this, so a local there can never shadow a field, and neither exemption is
narrowed by the rule above.
Shared<T> — ref-counted shared ownership (= C++ shared_ptr / Rust Rc). Copyable: each copy retains
(refcount++), each drop releases, and the pointee is destroyed when the last handle goes away.
Shared<Tex> a = new Tex.make(id: 7);
Shared<Tex> b = a; // retain — a and b share one Tex (both valid)
b.use(); int32 n = a.id;
// a, b drop in RAII order; the Tex is freed exactly once, with the last handle
Weak<T> — a non-owning weak reference to a Shared<T>'s pointee. It does not keep the pointee alive, so
it breaks reference cycles that Shared alone would leak. You can't dereference a Weak (it may be
dead) — upgrade it with the checked tryUpgrade(), which returns an Optional<Shared<T>> you must
match on, so the dead case is impossible to ignore:
Weak<Tex> w = s.downgrade(); // make a weak ref from a Shared (does not keep Tex alive)
int32 id = match (w.tryUpgrade()) { // -> Optional<Shared<Tex>>
case Some(value: up): up.id; // alive: use the upgraded Shared
case None: -1; // dead: the cycle-safe path
};
A value being dropped is already dead to its Weaks. While the last Shared's destructor runs, tryUpgrade()
answers None — so a destructor that upgrades a weak back-edge to its own value (an observer, a parent pointer) can
never hand out a Shared to memory about to be freed. Rust's Weak::upgrade answers the same way.
Two handles to one value cannot pull its storage out from under each other. Two Shared handles to one value are
two names the place rules cannot tell apart, so kama checks at run time what they check statically, with Swift's model.
A conflict traps, in every build, naming both sites:
Shared<Core> b = a;
borrow a.first() as x { // a window: an access to the value for its extent
b.grow(); // TRAP at grow's `this.items.add` — "made at …:2 … (core.kama:9)"
}
What the model counts:
What an access is to. An access is to a stored field of an object a
Sharedmay hold: theTof aShared<T>, a class boxed into aShared<Contract>, and their bases and subclasses. The object may be reached throughthis, aref, or a handle. A field's own internals, such asitems' buffer, are covered by the access on the field.Instant accesses. A field write is an instant access.
Accesses that last a call. A call handed a field's interior is an access for the call's length: a non-
constcall on a field, areforconst refargument, and a view minted for the call. Arguments are evaluated before it begins, sothis.items.add(item: this.items.length())is legal.Accesses that last a window. A window (
borrow,foreach, a borrowingmatch) is an access for its extent. Through a handle, it is to the whole value, because a place a method returns is opaque at the call.The conflict rule. Two accesses conflict when three things hold:
- they overlap;
- one of them may change the storage;
- the new one is not the live one's own work, which means an object inside the part the live one handed out (its receiver, its argument, its alias).
Mutations versus reads. A window holds its storage against mutation only, so reading inside one stays legal, as it is statically. Opening a writable window is checked as a mutation, so two writable windows through two handles trap.
Calls. A call on a value through a handle is no access of its own; its field accesses are. So
b.setTitle(t: …)duringforeach (o in this.observers)runs. A contract handle's value is checked the same way.Cost. Every lasting access is recorded per thread, in a node in its own frame, with the span of every open one kept beside the list. A mutable
Sharednever crosses an isolate, so one thread's record sees every access to its value. What is paid where:- Outside the span, a check is two compares.
- A mutating method of a payload class asks once, on entry, whether its object is clean, and then checks none of its own writes. A callee's accesses close before it returns, so the answer holds.
- A call on a field holds the field only when the callee can reach the value by a path it was not handed: a mutable handle, an indirect call, or a foreign function. The compiler reads that off the program's call graph, as Swift's optimizer does.
- A frame's own local is never a payload, so it is never checked.
Shared<immutable T>has nothing to check.- A program with no
Sharedis unchanged.
@onPanic. A region's recovery restores the record as the region found it, so a region may hold a borrow.Fibers. On an isolate that runs fibers — any code its entry,
mainincluded, reaches once it reaches aspawn fiber— a lasting access may not reach a park, a call that lets another fiber run: ajoin, aFiber's drop, ascope's close, a socket call that would block — a read, a write, an accept, a connect (Sockets on a fiber) — anyFileread or write, a standard stream's,Process.wait()andCommand.run()(Pipes and children on a fiber; whether aFileholds a pipe is a run-time fact, so every one counts) — a channel's wait — a receive with nothing queued, a send into a full channel (Channels) — or asleep. Fibers share the thread's record, which ends accesses in the order they began; a fiber that parked inside one would let another change what it holds. It is a compile error naming the chain, for a window and a call that holds a field alike, and so is a call through afnptrinside one, whose target could park. A call that only blocks the thread, such asprintln, is no park: no other fiber runs. A spawn is none either, so a window may spawn into ascopeopened outside it, and an isolate that runs no fibers is not held to the rule. Take what is needed out of the window and wait after it; a broadcast takes the handles out of the list first. Rust'sRefCellheld across an.awaitpanics at run time, and a lint (await_holding_refcell_ref) is all that warns.
Rust's Rc forbids mutation without a RefCell, whose borrow flag panics on a conflicting borrow_mut for the whole
value. Swift traps an overlapping access to a class property at run time, as kama does here.
.downgrade() is the only way to get a Weak, for a handle over a contract and over a concrete type alike.
A Shared given where a Weak is wanted (Weak<Tex> w = s;, an argument, a return, a field) is refused, naming s.downgrade().
Boxing and upcasts change what a handle owns; this would change whether it owns, which is written down. An Owned
has no weak handle.
No null (safe surface) — see GOALS §3b. A value, Owned/Shared, ref/out borrow, or contract value
is always valid: there is nothing to null-check. null is only for UnsafePtr<T> at the FFI boundary, and
that holds in both directions and for every other type — a safe type can neither be compared to
null (== null / != null is a compile error; the C habit checks the wrong thing here) nor set to it.
int32 x = null;, Thing t = null;, a string field defaulted to null, x = null and x == null on
any of them are all rejected, and so is null == null, which asks nothing; model absence with Optional<T>, or use a zero value. The rule reads the
declared type, so it covers primitives — and it does not care whether you are inside an unsafe fn,
which changes what may be dereferenced, not what may be null. An unresolved or FFI type name is left
alone, since a C typedef for a pointer is a legitimate null target. A Weak<T>'s liveness is obtained through tryUpgrade() -> Optional<Shared<T>>,
whose result forces you to handle the dead case.
Passing a smart pointer: borrow it by passing ref T — the borrow names the object (ref T,
storage-agnostic; a ref may not name the smart pointer itself), which auto-derefs to the held object; or
transfer by value, where the callee owns the argument and drops it at function end (Owned moves in,
Shared retains). The pointee is a value/resource or a contract — Owned/Shared/Weak<Shape>
own a concrete implementer behind a fat handle and dispatch polymorphically (see Contracts below). A smart
pointer works as a field, return, and a collection element — DynamicArray<Shared<Shape>> stores and drops
each handle in RAII order and dispatches polymorphically through it. See Generics below.
Handing out what a place holds — replace and swap ✅#
A field or an element cannot be given: that would leave its owner holding a moved-from value. std::memory
has the two moves that keep the place full instead. replace(place:, with:) hands back what place held
and leaves with there, and swap(a:, b:) exchanges what two places hold. Nothing is ever moved-from, so the
move state has nothing new to track. Unlike the triad, they are an ordinary import, and take is
replace(place: x, with: T.default()).
import { std::memory::replace, std::collections::DynamicArray };
type resource Batch {
DynamicArray<int64> items;
public ctor make() { this.items = DynamicArray.empty(); }
}
type resource Builder {
Batch batch;
public ctor make() { this.batch = Batch.make(); }
public fn Batch finish() { return replace(place: this.batch, with: Batch.make()); } // a fresh batch stays
}
fn uint8 main() { Builder b = Builder.make(); Batch done = b.finish(); return 0; }
Functions ✅#
fn int32 add(int32 a, int32 b) { return a + b; }
fn uint8 main() { return cast<uint8>(add(b: 20, a: 10)); } // named args; reordered to declared order
Arguments are evaluated in the order they are written, left to right, whatever order the parameters are
declared in: add(b: f(), a: g()) calls f first. An enum variant's payload is evaluated the same way. So
both(a: copy s, b: give s) copies s before it moves, and a give s written BEFORE a copy s leaves
nothing for the copy to read, which is refused.
C leaves a call's argument order unspecified; kama does not.
ref and out parameters both pass by pointer, but they are different promises:
ref T x— a read-write borrow of a value that is already live. The marker at the call site is optional (f(x: ref v)andf(x: v)are both fine), because nothing is riding on it.out T x— the callee must assign it on every path before returning, and may not read the incoming value. The call site must sayout(divmod(a: 17, b: 5, q: out quotient, r: out rem)). The marker is mandatory because both forms lower to the sameT*: without it neither a reader nor the caller's definite-assignment analysis could tell a borrow from a fill.outis what lets aslotbe filled by a callee and counted as assigned afterwards.
fn void divmod(int32 a, int32 b, out int32 q, out int32 r) { q = a / b; r = a % b; }
fn uint8 main() {
slot int32 quotient; slot int32 rem;
divmod(a: 17, b: 5, q: out quotient, r: out rem); // 3, 2
return 0;
}
const ref T x is a read-only borrow, and it is what a literal or a temporary may bind to. Neither
has an address, so the compiler materialises one into a temp — which is what lets m.get(key: 5),
readFile(path: "some/path") and useShape(a: Square(3)) be written without binding a local first. Any
other value works the same way: take(v: a + 1), take(v: count()), take(v: -a), take(v: mk().y),
each held in storage of the parameter's type. That
temp dies at the end of the statement — before the next one runs, as a call receiver's does — so a non-const ref/out cannot take one: the callee's
write would land in storage nothing can read back. The repair is whichever the callee meant — bind a
local if it really writes, or say const ref if it never did. Prefer const ref for any parameter you
only read; it is what makes the borrow usable at a call site.
No borrow in a call overlaps a mutable one. Two ref/out arguments naming overlapping places hand the
callee two mutable borrows of one object, and so does a ref/out argument overlapping the receiver of a
non-const fn. A const ref argument is a borrow too: overlapping a mutable argument or such a receiver, it
lets the callee change or free what it reads (two(a: o, b: ref o), b.absorb(other: b)). Each is refused at
the call. The test is the window's prefix test over places, so disjoint fields stay legal:
f(a: ref p.lo, b: p.hi). A receiver that is an element is compared by what holds it, so an argument that
CONTAINS the receiver is refused too: this.items[0].touch(owner: ref this) would let the method grow
this.items and move its own this. A receiver behind a contract — an Owned<Handler> element — is judged
by the contract member's signature, the only one in view.
"Every path" is a real flow merge, not "assigned somewhere": an if/else in which both arms assign
counts, a lone if does not, and an arm that ends in return/break/continue never reaches the join
and so owes nothing to it.
A non-void function must return on every path. Reaching the closing brace without a value is a
compile error in kama itself — not a C-compiler diagnostic against generated code, which the language
server could not see. A path satisfies it by RETURNING or by DIVERGING, so all of these are accepted:
an if/else where both arms return; a match where every arm does (a match is exhaustive by
construction); a tail call to panic; and a loop that cannot exit (while (true) / for (;;) with no
break). void functions may fall off the end. The analysis is deliberately one-sided — it reports only
what it can prove, so a construct it does not model costs a diagnostic, never a false rejection.
(Fixtures: tests/return_paths.kama for what must be accepted, tests/xfail/missing_return for what
must not.)
FFI — calling C ✅#
extern fn Ret name(params); declares a C function's call signature (name + named params for lowering); the
C prototype comes from the header you extern "<header.h>"; — kama never emits a prototype for an
extern function (so there's no redeclaration conflict, and a missing include is a plain C error). Link
libraries with --link. The FFI boundary is the language's only "unsafe" seam (explicitly extern):
extern "<stdlib.h>"; // every C function comes from an explicit header
extern "<math.h>";
extern fn UnsafePtr malloc(usize n); // UnsafePtr = void* (opaque pointer/handle); usize = size_t
extern fn void free(UnsafePtr p);
extern fn float64 sqrt(float64 x); // libm auto-links when a program `extern "<math.h>";`s (pay-for-use)
unsafe fn int32 demo() { // calling an `extern fn` is the unsafe seam
UnsafePtr p = malloc(n: 64);
if (p == null) { return 1; } // hold / null-check / compare — but no deref yet
free(p: p);
return cast<int32>(sqrt(x: 1764.0)); // 42
}
fn uint8 main() { return cast<uint8>(demo()); } // safe code may call an `unsafe fn`
The FFI rule (one sentence): declare C types/functions by extern-including their header. An extern
declaration is purely kama's call-signature (name + named params, so it can lower the call) — the actual C
prototype comes from the header you include with extern "<header.h>";. kama never emits a C prototype for
an extern function, so there are no redeclaration conflicts; and the runtime hides its own libc dependencies
(block-scope declarations), so no C function (not even malloc) is available without its header — a
missing include is a plain C error, never a silent guess.
An extern is a file-private declaration, like any other. A file names an extern it declares, or one it
imports from a file that exports it — a type extern value and an extern fn alike, keeping its literal C
spelling either way. A file that names one it neither declares nor imports is refused.
Repeating the declaration in another file is still legal — every declaration of one C symbol must agree
(tests/xfail/extern_disagree), and a file in a loose build root, with no module to import from, has no other
way. Exporting an extern widens nothing: CALLING one requires an unsafe fn wherever it is imported; wrapping
it in an ordinary fn is how a module offers a SAFE surface instead. tools/check-extern-rung.sh holds all of it.
@linkName("symbol") binds an extern fn to a C symbol spelled differently from its kama name —
Rust's #[link_name]. The kama name is what the file calls; the string is what the call emits. It is
how a C function whose name is a kama keyword gets a binding at all, and how a binding takes a
name the API's own is not:
extern "adder.h";
@linkName("match") extern fn int32 cMatch(int32 a, int32 b); // C's `match` — a kama keyword
@linkName("kama_test_add") extern fn int32 plus(int32 a, int32 b);
The string is a C symbol — letters, digits and _, not starting with a digit — and not a C keyword;
it takes exactly one string literal, like @section.
One C symbol has one kama binding in a program: extern fn abs in one file and
@linkName("abs") extern fn magnitude in another is an error, because the agreement rule below compares
declarations by kama name and two names for one symbol would let them disagree in silence.
(tests/linkname_extern.d/.) Repeating extern fn UnsafePtr malloc(usize n); in each file that
calls malloc is the idiom, not a smell — it is what a C header does, and a declaration is not a
definition. A file that would rather not repeat it wraps the extern in an ordinary fn and exports that;
the wrapper costs nothing, because --release folds the program into one translation unit and a
pass-through compiles to the same instructions as the direct call.
On a type extern value itself, @linkName names the C struct — a header's vec2_t bound as kama's
Vec2. kama code writes the kama name, imports and exports it by that name, and sees it in diagnostics; the
emitted C spells the struct's own name. (core::PanicInfo is the runtime's kama_panic_info this way.)
On a field of a type extern value, @linkName names the C member — the same attribute, for a struct
field a header spelled with a kama keyword or a name the binding would rather not take:
extern "settings.h"; // struct Settings { BaseSettings base; int match; float mass; }
type extern value BaseSettings { public int32 up; public int32 slope; }
type extern value Settings {
@linkName("base") public BaseSettings baseSettings;
@linkName("match") public int32 matchCount;
public float32 mass;
}
kama code writes the kama name (s.baseSettings.up = 1, Settings.of(baseSettings: …)); the C reads the
member. It marks only an extern value's fields, since every other type's fields are kama's to spell, and one
member of the header's struct has one kama field.
Every declaration of one C symbol in a program must agree — same return type, same parameter names,
same parameter types. They are one entry: kama emits no prototype, so nothing downstream could catch a
mismatch, and calls are lowered by named argument, so two declarations differing only in parameter order
would silently reorder one file's arguments (memcpy(dst:, src:) emitting memcpy(src, dst, n)). The same
holds for a type extern value — matching field names and types. A disagreement is an error naming both
files.
UnsafePtr is void*; UnsafePtr<T> is T* — an opaque carrier (hold, pass to/from C, null-check, compare;
no dereference in kama outside an unsafe fn). UnsafeConstPtr<T> is the read-only twin, T const*
(bare, const void*): same carrier, no store through it, no conversion back to UnsafePtr<T> (see
unsafe fn). usize/isize map to size_t/ptrdiff_t. Names beginning
kama_ are reserved (runtime-provided).
Pointer arithmetic is not in the language — p + n on a raw pointer is an error, in both the bare and
the typed form, and so are -p, ~p and null as any arithmetic operand. An offset is addr(of: p[i]), which scales by the element type (a bare UnsafePtr
takes a cast<UnsafePtr<uint8>>(…) first), and an address that is genuinely being computed becomes a
number with cast<usize>(p). Carrying + as well would be a second spelling of the same step, and on a
bare UnsafePtr a byte-stepping one that disagrees with the typed form. Comparisons stay: a carrier is
null-checked and compared, and the check is written out — a raw pointer is not a condition, so C's if (p)
is if (p != null).
A cast converts between scalars and pointers, on both sides — an aggregate is neither, so cast<UnsafePtr>(someInlineArray) is refused exactly as a cast TO an aggregate always was.
An aggregate's storage address is addr(of: arr[0]), and an element's address is an ordinary usize
(cast<usize>(addr(of: arr[0]))) — the shape an allocator's own bookkeeping is written in.
Math (std::math) ✅#
Engine Tier-0 linear algebra — concrete float32 value types: Vec2/3/4, Mat2/3/4, Quat, plus a
full scalar surface over libm. import { std::math::Vec3, std::math::Mat4, std::math::sqrt, std::math::sin, … };.
One name per scalar operation, at BOTH float widths — the width is inferred from the argument, so
sqrt(x: 1.0) is a float64 call and sqrt(x: 1.0f32) a float32 one. That matters because a bare 1.0
literal in kama is a float64, so a float32-only module made sin(x: 1.0) a type error for the most
obvious thing a reader would write. kama has no overloading, so the usual answers were unavailable (C
suffixes every float32 entry point, Go and Java ship one width and make you convert, C# adds a second
class MathF); the mechanism used instead is kama's own — a contract with a type adapter impl per
width, exactly how Comparable reaches every primitive. Each operation is one generic free function over
Real, and the per-width libm call lives in the impls: sqrt cbrt sin cos tan asin acos atan exp log log2 log10 floor ceil round trunc abs (one argument) and pow fmod atan2 hypot (two).
Real is exported, so a user type can join in — type value MyFixed implements Real<This> { … } and every
function above works on it. Its methods carry the same names as the free functions (as Rust's
Float::sqrt and Swift's squareRoot() do), so an implementer writes public fn MyFixed sqrt().
atan2 keeps C's (y, x) meaning, but the arguments are named, so the classic mix-up cannot happen
silently. The engine helpers that have no libm counterpart stay float32 under their kama names:
pi/tau/halfPi/epsilon/radians/degrees/lerp/signf — constants are
zero-arg functions because a zero-argument generic has nothing to infer from. The seam is kama_math.h:
a kama function cannot share a name with the extern it calls, so the binding is renamed rather than
the API (the same rename is now spelled in kama as @linkName("sqrt") extern fn … cSqrt(…); the header
predates it).
A generic call infers from any argument whose type is known, including another generic call's result:
abs(x: sin(x: t)) is abs<float64>, because the inner call is solved first. A field, an element and a
?: infer the same way, and so do this and its fields in a member body (cos(x: this.yaw)).
One limit worth knowing: a ref parameter may not name a smart pointer, so a contract instantiated at
Owned<T> — e.g. Order<Owned<T>> — is not expressible; sort or compare the resources themselves.
Methods + operators (one operator* per type: matrices/quaternions compose, vector transform / rotate
are named methods — no overloading). Matrices are column-major with the column-vector convention
(result = M * v, GPU/WebGPU-native); perspective/orthographic/lookAt target WebGPU 0..1 depth,
right-handed. Quat is a unit quaternion (fromAxisAngle/fromEuler, Hamilton *, rotate, slerp/
nlerp, toMat3/toMat4). All literals are f32-suffixed (a bare 1.0 is float64). SIMD needs no
explicit vector types or intrinsics: the value types have a SIMD-ready contiguous layout (Vec4 = 16 B,
Mat4 = 4×Vec4), and in a --release build the C backend auto-vectorizes the elementwise ops (Vec4
+/-/scale, Mat4.transform, Mat4*Mat4) to NEON on aarch64, SSE on the x86-64 baseline, and v128 on
wasm (the wasm build passes -msimd128; see targets.md Platform notes) — landing
hot math at C parity, and often better than a hand-written vector type would: clang de-interleaves the
array to SoA registers and computes four dots or four transforms at once. This relies on
the ops inlining into the caller, which release builds guarantee (see Building & debugging — release
compiles as one translation unit). Results are bit-identical to the scalar path (the exact-value semantics are
unchanged; SIMD is a pure throughput property). Quat's Hamilton product is intentionally left scalar — its
shuffled ± pattern makes a hand-vectorized version slower than the 16 pipelined scalar FMAs on measured
hardware (ARM64), which is the same effect that makes the auto-vectorized path win generally.
⚠️ One limit, measured. An arbitrary shuffle or a lane mask as a value has no spelling in kama at
all, at any target: those are what an explicit SIMD surface would add, and auto-vectorization cannot produce
them from scalar source — see Explicit SIMD below. (The other limit this paragraph
used to carry — that a wasm build got no vector instructions at all — was real and is fixed: the driver now
passes -msimd128 on every wasm build. Both halves of the claim above are held down by guards that read
real machine code, tools/check-simd-native.sh and tools/check-simd-wasm.sh; it went wrong twice for
want of them.)
Numbers (std::num) ✅#
Numeric type limits as zero-arg functions — int8Min/Max … int64Min/Max, uint8Max … uint64Max,
float32Max/float32MinNormal/float32Epsilon (signed min is -max - 1) — and per-width integer
operations — min/max/clamp, ONE generic each over Comparable<T> (every integer width, float32/float64
under their total order, string, a user type implementing Comparable<This>; min/max answer a on Equal,
clamp panics on lo > hi), plus per-width absI32/signI32 (+ I64: no contract names an integer that can be
negated) — and explicit wrapping arithmetic wrappingAddI32/wrappingSubI32/wrappingMulI32/
wrappingNegI32 (+ I64) for intentional overflow. import { std::num::int32Max, std::num::min, std::num::wrappingAddI32, … };. The minI32/minI64/minf ladder that spelled the width in the name is gone:
a literal beside a typed sibling now binds T (generic inference defers an
unsuffixed literal), so min(a: n, b: 9) with isize n is the whole spelling.
The wide product — mulWideU64(a:, b:) -> Wide64 { hi, lo }, mulHighU64 (the top 64 bits, Lemire's
multiply-shift in one call) and the signed mulWideI64 -> WideI64 — is the answer to "kama has no int128":
a native 128-bit width is a non-goal (__int128 exists in clang and gcc on 64-bit targets only, so it would be
a numeric type that exists on some targets, which the fixed-width position forbids), while the two things that
reach for one — a Fixed<int64> backing's intermediate product, an unbiased random range — need exactly this
product, which is four 32-bit limb multiplies in plain unsigned arithmetic on every target.
A number at a byte offset, in a stated order — loadU16/loadU32/loadU64(from: ConstView<uint8>, at:, order:)
and storeU16/storeU32/storeU64(into: View<uint8>, at:, value:, order:), with ByteOrder { Little, Big } the
format's order (KR-270): loadU32(from: hdr.view(), at: 8, order: ByteOrder::Little) reads a glTF header's length.
The order is one typed argument, as Go's binary.LittleEndian and Zig's .little are, not a suffix on twelve names;
signed widths go through bitcast; bytes past the view's end trap, naming the caller's line, as an index does.
byteswap* stays the value transform; host-order helpers stay out, since a stated order needs no knowledge of the host.
Sorting & searching (std::collections) ✅#
import { std::collections::sort, std::collections::sortUnstable, std::collections::binarySearch, std::collections::lowerBound, std::collections::isSorted, std::collections::Order, … };.
Free functions over a View<T>, not methods on each container. One implementation therefore serves
DynamicArray, FixedArray and any sub-range — sort(items: xs.sliceMut(from: 1, count: 4)) orders a
window and leaves everything outside it untouched, which a per-container xs.sort() could not express.
View<T> gained swap/reverse to support this: a view is second-class in escape, not in mutability
(it already writes through its place-returning operator[]), and putting the raw move there keeps every
algorithm above it safe. Descending order needs no API — sort, then reverse.
Two guarantees, deliberately both. sort is stable and sortUnstable is an in-place introsort
(median-of-3 quicksort, insertion-sort cutoff, heapsort depth fallback, so the worst case stays
O(n log n)). Stability is what makes sorting by a minor key and then a major key produce the intended
answer; an in-place sort is what an MCU or an audio callback can afford. The stable form sorts an index
permutation and applies it with swaps, which keeps it O(n log n) and free of a Copyable bound, so
move-only elements sort stably too — the cost is the int32 buffers, and therefore the heap.
sort/sortWith are @compileFor(!NOHEAP), so a --no-heap build does not silently reach the
allocator: they simply do not exist there, and the diagnostic says so.
Ordering comes from a contract, never a function pointer. Comparable gives the natural order;
Order<T> supplies any other, through sortWith/sortUnstableWith/binarySearchWith/lowerBoundWith.
A comparator is an object, so it may carry state (a key index, a direction, a collation table) — which
is what stands in for a capturing closure, since kama has none. It is also the faster choice: a
C: Order<T> bound monomorphizes to a direct, inlinable call, where an fnptr is an indirect call the C
compiler cannot inline (the reason qsort trails std::sort). fnptr could not express it in any case —
a function-pointer type takes no type parameters (ROADMAP_DETAIL §2).
The same object is kama's closure — the generic functor. Anything a capturing closure would hold is a
field: declare the callback signature as a contract (type contract Handler<E> for resource { fn void call(ref E e); }), write one type resource per handler with its captured state as fields, and either
store it — an event table is a DynamicArray<Owned<Handler<Click>>>, dispatched through the contract —
or lend it for one call, the non-escaping case where Rust would borrow locals: the functor is passed
ref, never moved, and the caller reads its state back afterwards. The lent form is spelled as a generic
bound, fn void each<T, V: Visitor<T>>(ref DynamicArray<T> xs, ref V v), because a concrete value cannot be
passed to a ref parameter of contract type (mutable references are invariant — see Contracts), and the
bound monomorphizes to a direct call. There is no prelude Callable family: without variadic generics it
would be an arity ladder, and a callback signature is the library's own contract, as Order<T> is. What a
closure would add is syntax only — the handler type, its ctor and the conformance line written for you; not
borrowed captures, which no lifetime tracking means kama would not have in either spelling. It is sized and
not scheduled (ROADMAP_DETAIL §2).
Because a ref parameter may not name a smart pointer, Order<Owned<T>> is not instantiable: sort a
container of the resources themselves. Searching splits what Rust folds into Result<usize, usize> —
kama's Result<T, E> constrains E to Error, so binarySearch returns Optional<int32> (the first
index of an equal run) and lowerBound returns the total insertion point.
Rust's sync::{Mutex, RwLock} are std::concurrent's Locked and ReadWriteLocked, whose value is reachable only
inside a lock window that cannot wait (see Concurrency). Once has no counterpart, and that is a stance, not a
gap: a module static is per-isolate and takes a compile-time initializer, so there is nothing to initialize once.
Parsing (std::fmt) ✅#
import { std::fmt::parse, std::fmt::parseRadix, std::fmt::ParseError }; — the exact inverse of this module's intStr/f64Str
side (std.fmt.parseInt is Zig's placement too).
import { std::fmt::parse, std::fmt::ParseError };
fn Result<int32, ParseError> readCount(string text) {
Result<int32, ParseError> r = parse::<int32>(s: text);
return r;
}
A parse fails, it does not come up absent, so the result is Result, not Optional — GOALS #3d draws
exactly that line — and ParseError separates Empty / InvalidDigit / OutOfRange, because "not a
number" and "too big for this type" want different messages. Rust, Zig and Go all keep that distinction;
only the boolean and optional shapes discard it.
One generic spelling, no parseI32/parseI64 ladder. The mechanism is the serde one — a marker
contract (Parseable) plus a per-type type adapter impl supplying a fallible ctor, reached as
T.fromStr(...).
The turbofish is required because nothing in the arguments mentions T. Covers int8…int64,
uint8…uint64, float32/float64 and bool (exactly "true"/"false"). parseRadix adds bases
2..36 for the integer widths, case-insensitive, with no 0x/0b prefix — the base is already an
argument. Parsing is strict, as in Rust: no whitespace is trimmed and a trailing byte is an error, so
" 7" and "7x" both fail. A float is read in Rust's spelling — a sign, then digits with an optional
fraction and exponent, or inf/infinity/nan in any case — so a hex float and a NaN payload fail too,
and it is converted once, by strtod or strtof. Only overflow is OutOfRange; a value too small for the
type rounds to zero or a subnormal, as a literal does. A JSON number into a float field follows the same
rules, and one out of range or malformed fails the document.
ASCII (std::ascii) ✅#
import { std::ascii::isDigit, std::ascii::isAlpha, std::ascii::isSpace, std::ascii::toLower, … }; — isDigit, isHexDigit, isAlpha,
isAlnum, isSpace, isUpper, isLower, isPunct, isControl, isAscii, toLower, toUpper,
digitValue, all over char.
Named ascii rather than char for two reasons: char is a keyword, so std::char cannot be a module
path; and the name states the limit in every import line instead of a footnote. This is the same boundary
Zig draws with std.ascii; text beyond ASCII is std::unicode (below). Every predicate is false for a non-ASCII codepoint rather than guessing, and
toLower/toUpper return one unchanged — so they can never corrupt one. Free functions rather than
methods because char and uint32 share a C type and the conformance registry cannot hold both.
Unicode normalization (std::unicode) ✅#
import { std::unicode::normalize, std::unicode::NormalizationForm }; — normalize(s:, form:) returns s in one
of UAX #15's four forms, and isNormalized(s:, form:) asks whether it already is. One text can be spelled several
ways in code points (é precomposed or as e + a combining accent; the ligature fi; a full-width A), and
normalizing picks one spelling, so two strings that mean the same compare equal byte for byte. Nfd/Nfc are the
canonical forms, which keep the text exactly, decomposed or recomposed. Nfkd/Nfkc also fold compatibility
variants into plain letters — normalize(s: "Afi", form: NormalizationForm::Nfkc) is "Afi" — which is what
SASLprep applies to a password before SCRAM hashes it, and what an identifier comparison or a search index usually
wants. A string in and a string out: UTF-8 both ways. The tables are the Unicode Character Database at one pinned
version, generated (tools/gen-unicode) and held to the UCD's own NormalizationTest.txt, every line of it, by
tools/check-unicode.sh. They are read-only data in a program that imports this module and in no other. Unicode
casing, whitespace and grapheme segmentation are the module's to add (ROADMAP_DETAIL §2).
Fixed-point — Fixed<B> comptime(int32 F). A signed binary fixed-point type value in the same module, for
FPU-less targets and for exact fractional arithmetic: + - * / through operator overloading (multiply and
divide widen through int64 and re-scale), fromInt/toInt/fromFloat/toFloat, and saturating
satAdd/satSub/satMul. The base operators trap on overflow like every other integer op above; the
sat* forms clamp. Pure library, no compiler support.
Both halves of the format are parameters. B is the backing integer, bounded by the FixedBacking<B>
contract (int8/int16/int32; int64 is not one today, because wide() widens into an int64 — a
native int128 is a non-goal, and std::num::mulWideI64 is the 128-bit product an int64 backing would
widen through, an additive change if a consumer wants it), and F is the fraction count as a comptime parameter — so Fixed<int32>#(16) is
the classic Q16.16 and Fixed<int16>#(8) is Q8.8. The backing is passed, not computed from a bit count:
kama has no type-level computation — a type is passed, never computed, which is the simplicity line GOALS 4 draws — and Rust's fixed and C++'s fixed_point<Rep, Exponent> pass storage
explicitly for the same reason. Pairing a fraction with a backing too narrow to hold it (Fixed<int8>#(16))
is a compile error, from one comptime assert in the type's
own body reading sizeof(B) — not a rule the compiler knows about this type. See
MCU_READINESS.md for the no-FPU story it belongs to.
Arithmetic on two values of one type yields that type. uint8 + uint8 is a uint8, at every width —
kama's own rule, the one Rust, Swift and Go have, and not C's integer promotion, which would make the
result an int and every sub-int expression a narrowing on the way back out:
fn uint8 hexDigit(uint8 v) {
if (v < 10ui8) { return 48ui8 + v; } // `uint8 + uint8` : uint8 — no cast, nothing to convert
return 97ui8 + (v - 10ui8);
}
Two consequences worth stating, because C answers both differently:
- The result wraps into its own type rather than surviving at
intwidth.uint8 a = 200ui8, b = 100ui8;makesa + bequal to44, and it is 44 everywhere —cast<int32>(a + b),(a + b) > 250ui8and(a + b) / 2ui8all read the wrapped value. C would carry 300 until something narrowed it, so the two agree only where the value is immediately stored into auint8. - A shift takes its type from its LEFT operand alone. The right one is a count, not a co-operand, so
the two need not agree:
int64 x; x << someInt32is fine, as is(c >> 4ui8)on auint8.
C's promotion is not part of kama's surface, which is the point: a reader should not have to know it to predict which lines need a cast. The emitted C carries an explicit narrowing so the two agree.
There is no implicit numeric conversion. If two numeric types differ, the conversion is written down — the Rust/Swift/Go rule. It applies in two places:
- Wherever a value crosses into a destination of a stated type — a local, a field, an assignment, a
return, amatcharm, an argument, an enum payload.int8 a = big;is an error wantingcast<int8>(big). Every declaration's initializer is such a crossing — aconstorcomptimelocal, a modulestaticorcomptime, a type'sconstfield orcomptimeconstant — socomptime int32 W = sizeof(int64);wantscast<int32>(…)exactly asint32 w = sizeof(int64);does. - Between an operator's two operands.
int32 + uint8does not compile, and neither doesint32 < usize. That second one is the point: C answers-1 < 1u32with false, and a rule that covered assignments but not comparisons would leave the sharpest edge in place.
It is every crossing, not just narrowing — widening, a signedness flip and int/float in either direction are all conversions. What is not a conversion, and needs no cast:
| a literal, typed by its destination — or by the other operand | int8 a = 100; · float32 f = 3; · v < 10 on a uint8 |
arithmetic over literals, which is still the literal — including a shift of an unsuffixed literal whose value fits an int32 |
int8 a = 2 + 3; · isize n = 1 << 16; |
| arithmetic on one type, which yields that type | a + b on two uint8s |
| a shift, whose count is a count and not a co-operand | x << someInt32 on an int64 |
a literal handed to a generic T, which takes the width its typed siblings bind |
pick(a: 0, b: n) on an isize n |
| a literal OPERAND of a generic argument, typed by the other operand | sqrt(x: 1.0 + a) on a float32 a is sqrt<float32> |
a match whose every arm is a literal, typed by its destination |
return match (k) { case A: 0; case B: 1; }; from an isize function |
a bitcast literal, typed by the target's width |
bitcast<uint64>(1.0) reads 1.0 as a float64 |
A named constant is not a literal: comptime int32 N = 5; states a type, so int8 x = N; wants a
cast. A constant that does not fit its destination is rejected for that instead (int8 a = 300;).
A float literal is typed the same way. Beside a float32 — an operand, a ternary's other arm, a destination,
a cast<float32> — an unsuffixed 0.319 is a float32, so x == 0.319 compares two float32s and x * 0.1
is float32 arithmetic, exactly as x == 0.319f32 and x * 0.1f32 are. Its value is the decimal rounded once, to
float32, and compile-time code computes it the same way (tests/comptime_float32_literal.kama). One past the
range it is typed to is rejected: float32 f = 1e39;, like 1e39f32 and 1e999, has no value to give.
A ref parameter is a destination like any other. A literal has no address, so one bound to a
const ref is materialised into a temp — and that temp is the parameter's storage, typed by the
parameter: a.contains(item: 2) on a DynamicArray<int64> stores an int64, and the same call on a
DynamicArray<isize> stores an isize. It reads as a detail of the lowering and is not one. Typing the
temp from the literal instead handed the callee a four-byte slot to read eight bytes out of, and the
container answered false for a value it held (tests/constref_literal_width.kama, fixed in 0.9.138).
isize is the size type; usize is the C ABI#
A length, a count and an index are an isize — every collection's length()/count(), every
operator[], every index parameter, string/Fixed/View included. usize is reserved for quantities
crossing into C: sizeof, an allocation size, an extern fn mirroring a size_t.
isize/usize are platform-varying (ptrdiff_t/size_t — 8 bytes on x86_64/arm64, 4 on wasm32/thumbv6m),
which is why they keep size in their names: the name is what says a crossing to a fixed width needs a
cast, and isize → int32 is a genuine narrowing on a 64-bit host. The other two platform-varying types
are clong/culong, C's long/unsigned long — 4 bytes on Windows, 8 elsewhere, so an extern
mirroring a C long must say clong, never isize. All four take the same refusals: bitcast is refused
(a reinterpret that holds on one target breaks on the next), and there is no wire format. A compile-time
sizeof of one is this build's number, as a run-time one is: 8 on x86_64 Linux, 4 on Windows or wasm32.
cchar is C's char, and never a value. kama's char is a 32-bit codepoint; C's is a byte that is a
third type beside int8 and uint8, and a libc prototype spelled const char* warns on either. So cchar
exists only as what a raw pointer points at — UnsafeConstPtr<cchar> is const char* (what string.cstr()
returns), UnsafePtr<cchar> is char*, nested and in a type extern value field alike — and every bare use
is a compile error: a local, a field, a parameter or return outside a pointer, a generic argument, a cast
target, sizeof, and reading p[i] through one; the bytes are a cast<UnsafePtr<uint8>> away. A raw
pointer's char meaning C char was the alternative, and is a non-goal: DynamicArray<char>.dataPtr()
would declare a 1-byte stride over a 4-byte buffer.
The size type is signed, which is the part that is easy to get wrong. The intuition says a length
cannot be negative, so make it unsigned — but unsigned does not prevent the invalid state, it makes it
unrepresentable, so an erroneous negative becomes an enormous positive instead of an obvious -1:
usize len = 0; usize last = len - 1; // 18446744073709551615 — silently
isize len = 0; isize last = len - 1; // -1, which fails `< length` and trips a bounds check
kama traps signed overflow in every build and lets unsigned wrap (it is defined), so usize would put the
most common length expression, len - 1, in the one arithmetic domain with no protection. The collections
already relied on signedness: operator[] bounds-checks i < 0 || i >= len, a test that cannot be written
against an unsigned index. Go's len() -> int, Swift's Int, Python's Py_ssize_t (PEP 353) and C++20's
std::ssize() all landed in the same place; the unsigned camp (C, C++, Rust, Zig) predates the lesson.
They carry the four value contracts, and deliberately not the two wire ones. isize/usize implement
Formattable, Hashable, Equatable and Comparable, so a length can be interpolated, be a Map/Set key,
be contains-searched and be sorted — the four a size type needs, given that every length(), count()
and index is one. They implement neither Serializable nor Deserializable, and they are the only
primitives that do not: a stream is read by a program that is not the one that wrote it, so a field whose
width is ptrdiff_t would be 8 bytes written natively and 4 read on wasm32. A platform-varying width has
no wire format. Give a serialized field a fixed width (int64/uint64) and cast at the boundary;
@generate(Serializable) over an isize field is an error naming the field.
The same line divides everything else about them: bitcast is refused (an equal-width reinterpret that
compiles on one target is a different program on the next), while Formattable's widening cast<int64> and a
compile-time sizeof — this build's number, like the run-time one — are not. What decides is whether the
operation needs ONE width on every target.
Bare int is not a kama type. It was an alias for int32 carrying no information of its own, and a
reader coming from C or Go would expect a platform width from the name — the opposite of what it meant.
Write int32 for a fixed 32-bit integer, or isize for a size. double is gone the same way — it
aliased float64, and every kama float states its width. Neither uint nor float ever existed, but both
get the same diagnostic, because a C or Go reader will try them and "unknown type" would send them hunting
for a missing import instead of a different spelling.
The rule holds through a binding. A foreach element and a match-arm payload are typed values like
any other, so both of these are errors wanting a cast — they are not a hole the rule quietly skips:
foreach (int64 x in xs) { int8 n = x; } // error, not 44
int8 n = match (big()) { case Some(value: c): c; case None: 0i8; }; // error, not 44
Where kama cannot be certain of a type it still says nothing rather than guessing — a type parameter, a
comptime parameter, an extern fn result, an intrinsic with no declared return type, and a
value-producing match seen before its arms are bound. Silence there is deliberate: a rule built on a
classifier that confuses "this is a primitive" with "I have no idea" is either silent on every primitive or
fires on every unresolved name.
Two types are two types, even when they share a representation#
The rule above is about width. This one is about identity: a value must already have its destination's type, and sharing a machine representation does not make two types one. It reaches the three kinds of type that a C backend would otherwise let blur together.
A plain enum is not an integer, and not another enum. Its values are named variants, so:
type enum A : uint8 { A0, A1, A2 }
type enum B : uint8 { B0, B1, B2 }
A a = A::A2;
B b = a; // error — two distinct enums, whatever tag width they share
uint8 n = a; // error — an enum is not its underlying integer
A back = n; // error — an integer names no variant until it has been checked
A z = 0; // error — an enum value is written by name: `A::A0`
A c = a + A::A1; // error — the result would be an `A` that is no declared variant
bool q = (a < A::A1); // error — ordering is `Comparable`: `@generate(Comparable)` on `A` gives it one
(tests/xfail/identity_enum_cross_enum.kama, identity_enum_to_int, identity_int_to_enum,
identity_enum_int_literal, identity_enum_arith, identity_enum_order, identity_enum_cmp_cross.)
The two directions have different doors, because they are not symmetric. Enum → integer is total, so
it is an ordinary cast: cast<int32>(Code::Bad). Integer → enum is fallible by construction — an
arbitrary integer names no variant — so it is try cast<Color>(x), yielding Optional<Color>, and the
None arm is where a byte off a wire gets handled. ==/!= between two values of the same enum is
the one operator that stays; everything else goes through match.
char is one Unicode codepoint, not a number. s[i] is a uint8 (a byte); .chars() yields
codepoints. The two never cross implicitly, in either direction:
char c = 'a'; uint32 u = 65ui32;
uint32 n = c; // error — cast<uint32>(c)
char d = u; // error — cast<char>(u)
char e = 65; // error — write the character: 'A'
That last line is where contextual literal typing stops. float32 f = 3; is accepted because 3 is a
float32; 65 is not a codepoint, it is an integer standing in for one.
(tests/xfail/identity_char_to_int.kama, tests/xfail/identity_char_literal.kama.)
A fnptr signature type is nominal. Two signatures with the same shape are still two types, and
assigning one signature-typed value into another is an error — including, and especially, when the shapes
differ, since calling through a mismatched function pointer is undefined behavior that neither the C
compiler nor the sanitizers will report here. Assigning a function to a signature is checked
structurally and is unaffected.
(tests/xfail/identity_sig_cross_sig.kama, tests/xfail/identity_sig_arity.kama.)
A user class is not another user class. Two type value/type resource declarations are two types
however alike their fields, and a value of one is never accepted where the other is declared — in a
local initializer, an assignment, an argument, a return, or an operator's operand:
type value Mat4 { … } type value Vec4 { … }
Vec4 v = …;
Mat4 m = v; // error — unrelated types; convert explicitly, or take a contract both implement
Mat4 p = m * v; // error — `Mat4 * Vec4` needs an operator declared for that pair
The rule holds inside a generic body too: fn f<T>(T x) { Mat4 m = x; } is judged per instantiation,
and is an error exactly when T is bound to something other than Mat4.
(tests/xfail/class_identity_value.kama, class_identity_operator, class_identity_generic_fn,
class_identity_generic_type.)
What this rule does not touch: a contract destination (which admits every kind by design, so
Hashable h = someInt32; keeps working), an inheritance upcast, Owned/Shared/Optional promotion,
and any hand-off whose type kama cannot resolve — the same silence the width rule keeps. Every exemption is
pinned, running, by tests/identity_exemptions.kama. Binding a value to a contract it does not
implement is a separate error (tests/xfail/contract_arg_nonconforming.kama and its two siblings).
No undefined behavior in arithmetic (Rust's model). Every integer operation is defined — never C's UB:
- Signed overflow (
+/-/*, and-xonTYPE_MIN) traps in debug builds (catches the accidental-overflow bug during development —kama: arithmetic overflow -- int32 + overflowed,tests/trap/signed_overflow,neg_min) and wraps two's-complement in release (-fwrapv, zero-cost, defined — not UB). The debug check is the compiler's own (KAMA_ADD/SUB/MUL/NEG, orKAMA_ADD_T& co. where the operand type is known — the overflow builtins under!NDEBUG, the plain operator underNDEBUG), so a debug overflow prints which operation overflowed, runs the panic hook and is recoverable inside an@onPanicregion. A compound assignment and++/--follow the same rule through place operators that take the place by address —a[idx()] += 1evaluates its index exactly once,i++hands back the old value — which is also what givesint8 x += ythe range check C's+=never had (tests/trap/compound_overflow_i8,tests/arith_compound_place_once). Intentional signed wrapping is opt-in:std::num'swrappingAddI32/wrappingSubI32/wrappingMulI32/wrappingNegI32(+ theI64set) always wrap and never trap (computed in the unsigned type), or just use unsigned math directly. Unsigned overflow always wraps (as C already defines). - Divide by zero and
INT_MIN / -1trap in every build — always bugs, never UB. Both are emitted by the compiler (KAMA_DIV/KAMA_MODin the runtime header: one branch per fault, then a cold leaf that printskama: division by zero, runs the panic hook and, inside an@onPanicregion, recovers). TheTYPE_MIN / -1check is on the operands, sinceINT64_MIN / -1overflows the width a result check would use (tests/trap/div_zero,mod_zero,intmin_div,intmin_div64);TYPE_MIN % -1is0, where C traps computing it. - Shift ≥ the promoted width, or negative, traps (
kama: shift by 40 is outside the 32-bit width—tests/trap/shift_width); a signed left shift into the sign bit (1 << 31) is defined (computed in the unsigned type — a defined bit pattern), so bit-twiddling is safe. - Out-of-range or NaN
float → inttraps (tests/trap/float_cast_oob); in-range truncates toward zero. - A narrowing
cast<T>(x)whose value does not fitTtraps, in every build — the integer sibling of the line above, and the same policy for the same reason.castpreserves the value, so a value that does not fit is not a conversion but a different number:int32 big = 300; int8 a = cast<int8>(big);aborts rather than binding 44. A constant that does not fit is rejected at compile time instead. The escapes are explicit and cost nothing:truncate<T>(x)keeps the low bits,try cast<T>(x)hands backOptional<T>. A widening, a same-type cast, and one whose operand provably fits emit no check at all, and where the check remains its bounds are compile-time constants — so the comparison that cannot fail folds away. - The signed-overflow rule holds at EVERY width —
int8andint16behave exactly asint32andint64do. This used to be a documented exception, and it is worth recording why, because the cause is not obvious: C promotesint8/int16operands toint, so100i8 + 100i8is computed as 200 where-fsanitize=signed-integer-overflowsees nothing at all, and kama's own narrowing of the result back toint8(the D-arith rule above) is a conversion, which the overflow check does not watch either. The value silently became-56whileint32 MAX + 1trapped. The compiler now emits the check the sanitizer cannot:+ - *on a signed sub-intare range-checked against the operand type, trapping in debug and truncating in release, and/is checked in every build because the only division that can overflow isTYPE_MIN / -1, which the rule above promises unconditionally (tests/trap/arith_overflow_i8,arith_overflow_i16,arith_div_i8_min). Shifts are deliberately exempt —<<takes its type from the left operand alone and a signed left shift into the sign bit is defined (above), so3i8 << 7i8is-128, not an overflow (tests/arith_same_type). Unsigned is untouched at every width: wrapping is defined and stays silent.
Enforced by the compiler's own checks in the emitted C (KAMA_DIV/KAMA_MOD/KAMA_SHL/KAMA_SHR,
kama_f2i_chk, the narrowing checks) + -fwrapv — so a kama program can't hit arithmetic UB whether
built debug or release, and every fault it can raise takes ONE path: a message, the panic hook, recovery
inside an @onPanic region. Until 0.9.160 division by zero, a bad shift and a float cast were
-fsanitize-trap — the same compare-and-branch, lowered to a bare __builtin_trap that printed nothing,
ran no hook and could not be recovered from; and any -fsanitize at all is what made emscripten refuse
-sWASM_WORKERS, so there was no audio thread in the browser.
⚠️ The two tiers differ in exactly one macro definition, and that is load-bearing rather than
incidental. -fsanitize=signed-integer-overflow used to supply the debug trap, and it was not reliably
suppressed by -fwrapv (Apple clang does not suppress it; Ubuntu clang and gcc do), so passing it in
release made overflow trap on one platform and wrap on another and cost a compare-and-branch on every
signed add and multiply — against an invariant that calls release arithmetic C-parity. Since 0.9.161 no
tier passes it: the debug check is KAMA_ADD & co. under !NDEBUG, and under NDEBUG the same macros
are the plain operator, so the release tier relies on -fwrapv alone with the one case it does not
define (TYPE_MIN / -1) checked explicitly. tools/check-release-arith.sh asserts both the semantics
and the zero cost, on a tier the fixture suite cannot build.
import { std::math::Vec3, std::math::Mat4 };
fn uint8 main() {
Mat4 vp = Mat4.perspective(fovyRad: 1.0472f32, aspect: 1.777f32, near: 0.1f32, far: 100.0f32)
* Mat4.lookAt(eye: Vec3.of(x: 0.0f32, y: 2.0f32, z: 5.0f32),
center: Vec3.zero(), up: Vec3.unitY()); // method chaining
Vec3 p = vp.transformPoint(p: Vec3.of(x: 1.0f32, y: 0.0f32, z: 0.0f32));
return cast<uint8>(p.length());
}
Standard I/O (std::io / std::fs / std::net / std::process) ✅#
A native, single-binary I/O foundation — library over FFI, no new language surface beyond the prelude's
enum Unit (the empty Result<Unit, E> payload — one error convention for void-fallible ops). std::io
gives IoError + error classification; std::fs gives a RAII File (fd closed by its destructor; opened
Read, Write — create/truncate — Append, ReadWrite — create, never truncate — or CreateNew, which is
AlreadyExists if anything is at the path, in the same step as the create), with random access: seek
(a SeekFrom — Start, Current or End), readAt/writeAt at an offset, which leave the cursor where it was on
every OS (Windows moves it for a positioned call, so kama puts it back, as Go does), and setLength; a pipe has no
position and answers InvalidInput; a lock on the whole file — lock/lockShared
wait, tryLock/tryLockShared answer WouldBlock when it is held elsewhere, unlock and closing release it, and so
does the holder's death, so a lock file needs no pid check (Rust's File::lock; flock on Linux and macOS, advisory;
LockFileEx on Windows, mandatory — another handle's read or write fails while it is held; none on wasm, Other); plus free readFile/writeFile/readText/writeText/stat/readDir/remove,
createDir/createDirAll/removeDir/removeDirAll/rename/exists, symlink and hardLink
(original:, link:; on Windows a symbolic link wants Developer Mode or a privilege, past which it is PermissionDenied, and
wasm's filesystem has no hard links), and a Metadata of size,
kind, modified (a std::time::Timestamp — nanoseconds where the filesystem records them, whole seconds
on Windows), changed (the change time — st_ctim, Windows' ChangeTime — which no user tool can set back, as cp -p
and touch -r set modified), id (a FileId, equal for two reads of one file through any path, kept by a rename: a
device and inode on Linux and macOS, a volume serial and a 128-bit file id on Windows; Equatable and Hashable, so a
cache keys on it — git's index keys on the same) and permissions (the nine bits the file records, not an access check — below). kind is a FileKind
— File, Dir, Symlink, Fifo, CharDevice, BlockDevice, Socket, Other — with isFile(), isDir() and
isSymlink() for the common questions; stat(path:) follows a symbolic link and symlinkStat(path:) describes the
link itself, and File.metadata() asks an OPEN file, so a check and the read after it are about one file.
An open File does not hold its path: while it is open, the path can be renamed, removed, created again or replaced
by a rename, and the file reads on through all of it — POSIX's rule, kept on Windows by opening every file with delete
sharing, as Rust's std does.
owner and group come from the same stat, so they describe the same file as permissions (libpq accepts a
group-readable key only when root owns it). On Linux and macOS they are a std::process::UserId and GroupId, the
stat's st_uid/st_gid. On Windows they are an Optional of each: the SIDs the file's security descriptor names,
which the stat reads for permissions anyway, and None where this account may not read that descriptor —
another account's private file. Rust and Go expose no owner on Windows, and Python and Node report 0.
std::net gives RAII TcpListener/TcpStream (blocking TCP),
UnixListener/UnixStream (Unix-domain) and UdpSocket. All fallible calls return Result<…, IoError>, consumed by match.
Sockets on a fiber. A socket call that would block — a read or a write, an accept, a connect, a datagram's send or
receive, a sendFile, descriptors passed over a Unix socket — parks only its fiber on an isolate that runs fibers, and
the core runs the others until the socket is ready (KR-3, docs/design/fibers.md decision 7). The same call blocks the
thread on an isolate that runs none, so a function is written once for both. Underneath, kama makes such a socket
non-blocking and registers it with the isolate's scheduler at its first wait; neither is the program's to see:
- A socket the program made non-blocking keeps answering
WouldBlock, on a fiber too, for a program that drives its ownPoller. - A socket moved to another isolate is taken off the first isolate's scheduler by whichever isolate next uses or drops it; on an isolate that runs no fibers it is a blocking socket again.
intoDescriptor()hands the descriptor back in the mode the program asked for, off any scheduler;adoptreads the mode a descriptor arrives in on POSIX, and takes it as blocking on Windows, which cannot read it back.rawHandle()lends the descriptor as it is, non-blocking on a fiber. AduplicateDescriptor()shares the open socket's mode, so one used on a fiber is non-blocking; kama's own data calls there passMSG_DONTWAIT(POSIX), so a duplicate set back to blocking cannot stall the core.- On Linux a Unix-socket connect to a full accept queue is made again after a pause until there is room, as a
blocking connect waits; the program's own non-blocking connect answers
WouldBlockthere. - A socket's first wait on a fiber allocates its registration, and so does a pipe end kama made, so in a program
that runs fibers
@noheapcode may make no socket call and noFileread or write (KR-245); a standard stream's wait lasts the one call and allocates nothing, and since a timed park grows the scheduler's timer heap, no channel wait or join either. A program runs fibers when its code spawns one: importingstd::concurrentfor a channel or an isolate is not running fibers, and such a program compiles none of this. std::time::sleepparks too, and so doesPoller.waiton epoll and kqueue, on the Poller's own descriptor, so a fiber that drives its ownPollerdoes not stop the isolate's others. A WindowsPollerhas no descriptor to park on, and there a fiber's wait still blocks the thread until the offload pool (KR-3 step 7).
Pipes and children on a fiber. A File over a pipe, a FIFO or a terminal parks only its fiber while it waits, as a
socket does, and so do StdIn, StdOut and StdErr, Process.wait() until the child exits, and Command.run()
while it captures; off a fiber each blocks its thread as before. Go and tokio put a child's pipes on their poller the
same way, and Rust's std blocks. Which descriptors kama switches is the rule that keeps the parking invisible:
Only a pipe end kama made is made non-blocking:
Command'sStdio::Pipedends, whichProcess.stdout()lends andtakeStdout()hands out. The child's end is its own open file and never sees the mode.Any other descriptor is never switched: stdin and the other standard streams, a FIFO opened by path, a descriptor
File.adopttook. Its mode belongs to every process sharing it (Node's libuv switched inherited stdio and left the parent shell's terminal non-blocking). On a fiber its call waits for readiness, parked, and is then made blocking, which readiness makes safe for one reader; a write then takes at mostPIPE_BUFbytes, which a ready pipe takes whole. A descriptor the OS will not watch,/dev/null, is read as off a fiber.A pipe end kama made is blocking again once it is handed out, by
intoDescriptor()orduplicateDescriptor(), and for good: a child given it, or the duplicate, reads a blocking stream, and theFile's own later calls on a fiber still park, as an inherited pipe's do. Go'sFile.Fd()and tokio'sChildStdoutinto aStdiodo the same; a socket's duplicate keeps the mode instead, since its calls passMSG_DONTWAITand a pipe'sreadhas no such flag.rawFd()lends the descriptor as it is.A child's exit is watched without a process-wide handler: a pidfd on Linux 5.3 and later,
EVFILT_PROCon macOS and the BSDs, and elsewhere, or where seccomp refusespidfd_open, a look withWNOHANGafter a pause that doubles from 1 ms to 64 ms. No SIGCHLD handler is installed for a host or a library to collide with.Windows blocks the thread for all of these until KR-3 step 8, which makes
Command's piped ends overlapped named pipes read by completion, as Rust's std and libuv make them.A regular file's read waits on the disk, not on a peer: it is the plain system call, and goes to the offload pool with KR-3 step 7.
On Linux the scheduler runs on io_uring wherever the kernel allows it (6.1 or later, and not refused by a container's seccomp, SELinux or
kernel.io_uring_disabled), and on epoll everywhere else;KAMA_IO_URING=0picks epoll (targets.md). Neither changes what a program does. A stream read or write that waits is handed to the kernel with the caller's buffer, and the fiber leaves the wait only once the kernel has let go of it: a read whose deadline passes loses no byte, and a write whose deadline passes has sent exactly the count it reports. On x86-64 every write is handed to the kernel, and elsewhere a write makes its system call first and is handed over only when the socket is full: a measured delta (benchmarks/servers.md, io_uring).
Deadlines. setReadDeadline(at:) and setWriteDeadline(at:) on a stream or a datagram socket, and
setAcceptDeadline(at:) on a listener, take an absolute Instant and keep it until it is reset; Optional::None
clears one. A call made once it has passed fails with Err(TimedOut) at once, even with bytes waiting, and so does a
call still waiting when it passes (Go's model, KR-3 decision 11). One deadline therefore bounds an exchange of several
calls — a request read in pieces, a writeAll — where a per-call timeout (Rust's set_read_timeout, Java's
setSoTimeout) restarts on every call, so a peer that sends a byte at a time holds it open for ever; set it before
each call for a per-call limit. A connect's deadline is ConnectOptions.deadline, through connectWith, as
ListenOptions goes through bindWith. On a fiber the call parks until the deadline; on a thread a socket with one
waits in a poll for the time left, and a socket with none costs the one system call it always did.
An IoError is a kind and, when the OS reported it, the OS's code — the two facts every mainstream library keeps
apart. e.kind() is an IoErrorKind (NotFound, AlreadyExists, PermissionDenied, WouldBlock, ConnectionRefused, …,
Interrupted, Other), which a caller branches on with match (e.kind()). e.rawOsError() is the POSIX errno or,
on Windows, the Winsock or Win32 code, and e.message() quotes the system's own words for it:
"not found (os error 2: No such file or directory)"; e.osMessage() is those words alone, as strerror gives
them, or None when no OS code backs the error. So two failures of one kind stay distinguishable to whoever
reads the log — EPERM and EACCES are both PermissionDenied, and say "Operation not permitted" and "Permission
denied" — and an unclassified one is Other with its code and text rather than a bare "i/o error". An error kama
raises itself, IoError.of(kind: IoErrorKind::InvalidInput), has a kind and no code. Interrupted is a signal
arriving during a blocking call; Poller.wait(timeoutMs:) absorbs it and waits out the time that is left.
TooManyOpenFiles is Rust's name with its scope: the process (EMFILE, Winsock's WSAEMFILE, Win32's
ERROR_TOO_MANY_OPEN_FILES) or the whole system (ENFILE) at its limit on open files and sockets. That is an
accept that cannot take the connection, an open or a socket. NotConnected is Rust's name too: a socket with
no connection (ENOTCONN, Winsock's WSAENOTCONN), such as the peer of a UDP socket that never connected.
InvalidData is Rust's name for someone else's bytes that the operation cannot accept: text that is not UTF-8, a
compressed stream that is corrupt or fails its checksum, an archive header that does not parse. InvalidInput is the
caller's own argument. LimitExceeded is a bound the caller set being reached, such as a decoder's limit on its output.
A write to a peer that has gone is an error, not the end of the program. Writing to a socket whose peer
closed, or to the stdin of a child that exited, returns an error of kind BrokenPipe (or ConnectionReset or NotConnected, as the
kernel reports it: macOS answers ENOTCONN to every send after the first, once that one was buffered before the reset landed) — POSIX raises SIGPIPE there instead, whose default ends the process. A program ignores that
signal from the start of main, as Go, Rust, Python and Node do, and every socket is also made not to raise it
(MSG_NOSIGNAL, and SO_NOSIGPIPE on Apple) — TCP and Unix-domain alike — so a --shared kama library is covered without touching its
host's signals. Two things keep the Unix convention: stdout and stderr still end the program by SIGPIPE
when the reader goes away — prog | head -n 1 stops prog with exit 141 — and a child starts with SIGPIPE
at its default (tools/check-sigpipe.sh holds both). Both hold whatever the program inherited: a parent that
leaves SIGPIPE ignored — the GitHub Actions runner does — used to make prog | head write its whole loop into a
closed pipe, and pass the ignore on to every child, until 0.9.478. Until 0.9.471 the first write after a peer
hung up killed the process.
Permissions are the nine Unix bits — read, write, execute for the owner, the group and everyone else —
on every platform. Hand-written code names them, Permissions::OwnerRead | Permissions::OwnerWrite (the
constants are comptime values of the type itself); has(p:) tests, mode() gives the number, and ${p}
renders rw-r--r-- as ls -l does. A mode number from outside the program goes through
Permissions.fromMode(mode:), which returns Err(InvalidInput) for any bit outside 0o777 — setuid, setgid,
the sticky bit and a raw st_mode's file-type bits are not permissions kama models (they mean nothing on
Windows), and nothing drops them silently. Metadata.permissions replaced the lone readOnly bit in
0.9.447: !m.permissions.has(p: Permissions::OwnerWrite) asks the same question. On Windows the bits are
read back from the file's access list — the owner's entries, the group's, and Everyone's (with Authenticated
Users and Users, the every-user groups) — and the DOS read-only attribute clears the write bits; a list this
process may not read falls back to what the C runtime reports. An EPERM ("not permitted", what changing
another user's file answers) is of kind PermissionDenied, as EACCES is, and its code and message still say which.
Setting permissions. File.openWith(path:, mode:, permissions:) opens for writing with EXACTLY the
permissions given — Write creates or truncates, Append creates or appends, and Read, which creates
nothing, is Err(InvalidInput). createDirWith(path:, permissions:) is createDir's form, and
setPermissions(path:, permissions:) changes a path's (following a link, as chmod does). The permissions are
applied at creation, never by a create and then a change — the race in which a secret sits readable. EXACT
means what it says, on every platform: the umask does not narrow an explicit request (a plain create is what it
narrows, below), and a file that already exists is given the permissions before it is truncated or written, so
a secret never lands in a file someone else could already read — the gap Go documents and leaves open, since
O_TRUNC keeps the old mode. A file that already holds exactly the permissions asked for is left as it is, so
a group member may write a shared file it does not own; changing a file this process does not own is
PermissionDenied, as chmod is. Each call reads back what the filesystem kept and fails when it differs, so a
volume that cannot hold permissions (FAT, some network mounts) never yields a file left open, and a file or
directory the call made is removed again. What no permission can revoke is a descriptor another process opened earlier; for a secret,
write a fresh name and rename it into place. On Windows the bits become an access list — Cygwin's mapping:
an entry for the owner (which always keeps the right to change and delete its own file), for the file's group
and for Everyone only when they are granted something, SYSTEM always, deny entries first where an owner has
less than the others, and protected from inheriting anything wider. A private file is therefore owner + SYSTEM,
the set Win32-OpenSSH accepts for a private key; Administrators get no entry and can take ownership of any file,
as root reads any file. An owner's list is written whole there, even over bits that already match — an entry no
bit expresses, an Administrators grant say, does not survive the owner's openWith or setPermissions — so
"left as it is" is the non-owner's case. The group maps weakly there (a file's group is usually "None", which
every local user belongs to). The wasm target's virtual filesystem stores and reports the bits.
A new file's permissions. A plain create — File.open with Write or Append, writeFile, writeText —
asks for rw-rw-rw- and lets the process umask narrow it (rw-r--r-- under the usual 022, rw-rw-r--
under a group-shared 002), and a directory asks for rwxrwxrwx the same way: the default of C fopen, Go,
Rust, Python, Node, Zig, Java and .NET. Until 0.9.446 a file was a hardcoded rw-r--r--, which under 002
stripped the group-write bit the user's own umask granted. On Windows a plain create inherits its directory's
access list, as in every language; the wasm target's virtual filesystem applies the umask as POSIX does.
removeDirAll does not follow a symlink — a link inside the tree is unlinked, never descended into
(the CVE-2022-21658 shape) — and says in its doc comment what it still cannot promise (atomicity against
a racing writer, which needs openat). exists returns a bool, and is a snapshot: to use a file, open
it and handle the error.
Text files (readText / writeText). The one-call form for a file that holds text, beside the
byte-level readFile/writeFile that keep working unchanged: readText(path:) -> Result<string, IoError>
and writeText(path:, text:) -> Result<isize, IoError> (the byte count, and it create/truncates like
writeFile). writeText copies nothing on the way in — text.bytes() is a borrow of the string's own
storage.
Both whole-file writes are all or an error: Ok means every byte was written, and a write the OS cut short
(a full disk, a file-size limit) is the Err that stopped it, never Ok with fewer bytes — File.write is the
partial-write primitive, and std::io::writeAll is the loop over it that both use.
⚠️ readText validates, and so does every other path that builds a string out of foreign bytes —
std::io::readAll and BufReader.readLine, which are where the reading actually happens. Invalid UTF-8
is Err(InvalidData), not an ill-formed string: string's UTF-8 invariant is what .chars() assumes
and what substring traps to preserve, and until 0.9.433 readAll was a way around it (a binary file
read as text yielded bogus codepoints from entirely safe code). Bad input is an error, the same way
parse returns one; bad output written into a StringWriter is a caller bug, so finish() carries a
release-stripped debugAssert naming takeBytes() instead. readFile still accepts any bytes — "these
bytes are not text" is a fact about the file, not a reason to refuse it.
The checked conversion itself is std::encoding::utf8: decode(bytes:) -> Result<string, Utf8Error>
and validate(bytes:) -> bool over a ConstView<uint8>, with Utf8Error::Invalid(at:) carrying the byte
offset where the text stopped being well-formed (Rust's valid_up_to). Rejection is full RFC 3629 — a
non-lead byte, a truncated tail, an overlong encoding, a UTF-16 surrogate, and anything above U+10FFFF.
There is deliberately no encode: a string is UTF-8 bytes, so that direction is s.bytes().
For text shown to a person — a log line, a file name — decodeLossy(bytes:) -> string
replaces each ill-formed sequence with U+FFFD, one per maximal subpart (Unicode §3.9, WHATWG's decoder, Rust's
from_utf8_lossy): ED A0 80 is three, a four-byte sequence cut short is one. It is named apart on purpose,
because substituting corrupts data; decode stays strict.
Names (std::net). resolve(host:, port:) -> Result<DynamicArray<SocketAddr>, IoError> asks the
system resolver for every address a name has, IPv4 and IPv6, in the resolver's own (RFC 6724) order and
unfiltered (on macOS localhost is ::1 first); resolveOne is the first of them. TcpStream.connectHost(host:, port:) resolves a name and tries each address in that order, keeping the first that connects and reporting the
last failure otherwise, so a server bound to 127.0.0.1 alone is reached by name although ::1 is tried
first. (One after another, not raced: RFC 8305 happy eyeballs is not offered.) connect(host:, port:) keeps
taking numeric text and performs no lookup — a call that reads like a syscall must not make a network round
trip behind the caller's back, so a name is resolved by a function that says so, and a name handed to connect
is Err(InvalidInput). parseIp is the strict numeric parser under every text-taking call: exactly four
decimal octets, no leading zero on a multi-digit part (C's inet_addr reads 010.0.0.1 as octal and 127.1
as a packed number — the CVE-2021-29922 shape). inet_addr is gone from the socket seam, so connect(host: "010.0.0.1"), which reached 8.0.0.1 before 0.9.375, is now Err(InvalidInput).
Sockets and families (std::net). A socket's family is its address's, for its whole life. The text forms
(TcpStream.connect/connectNonBlocking, TcpListener.bind, UdpSocket.bind/connect) each have an address
form (connectTo, connectToNonBlocking, bindTo, connectTo) taking a SocketAddr, which is the only
spelling that reaches a link-local address, whose interface is its scopeId. "" keeps its meaning: every
IPv4 interface to bind, IPv4 loopback to connect. "::" is dual-stack: IPV6_V6ONLY is turned off on every
OS (Windows defaults it on), so one socket serves both families and an IPv4 peer reads as ::ffff:a.b.c.d.
setTtl sets the unicast hop limit of either family. A send to an address of the other family is an error
from the socket, not a silent drop.
TCP options and the two ends (std::net). A TcpStream takes the options a long-lived client needs, one
setter per option, each returning Result<Unit, IoError>: setNoDelay(on:); keepalive — setKeepAlive(on:)
and its three knobs setKeepAliveIdle(idle:), setKeepAliveInterval(interval:), setKeepAliveCount(count:) —
so a peer that vanished without a FIN (a NAT that forgot the flow, a failed-over server) is noticed; and
setUserTimeout(timeout:), how long written data may go unacknowledged before the connection is dropped. A
duration is positive and rounds up to what the stack takes — whole seconds for the keepalive knobs,
milliseconds for the user timeout on Linux (TCP_USER_TIMEOUT, which also bounds the keepalive probes there)
and whole seconds on macOS (TCP_RXT_CONNDROPTIME) and Windows (TCP_MAXRT) — so a timer never fires earlier
than asked; zero, a negative duration or a zero count is Err(InvalidInput), and an option to leave at the
OS's default is simply not set. shutdown(how:) closes one half: Shutdown::Write sends a FIN, so the peer
reads end-of-file while this side still reads its reply, and a later write here is BrokenPipe.
localAddr() on a TcpListener is how a program learns the port the OS picked for bind(port: 0) — every
socket fixture binds that way, so no two can collide (tools/check-fixture-ports.sh) — and a TcpStream has
localAddr() and peerAddr(), the peer being how a server learns who an accepted stream belongs to. A
UdpSocket has both too, its peer being the one connect fixed. Asking a socket where it is changes nothing, so
every socket's localAddr()/peerAddr() is a const fn: a const ref to the socket is enough to ask.
Sending a file (std::net). TcpStream.sendFile(file:, offset:, count:) sends up to count bytes of a File
from offset, and answers the count sent. It is a partial transfer, like write, so a caller loops and advances
offset. On a non-blocking stream nothing sent is WouldBlock, and 0 means offset is at or past the end. The file's
own cursor never moves, as for readAt. On Linux and macOS the bytes go from the page cache to the socket with no copy
through the program (sendfile; on macOS, about 40% fewer cycles than a read and write loop). Where the OS cannot do
that, it copies through a small buffer with the same result: on Windows, wasm, or a file the kernel has no such path
for. Windows' TransmitFile would block a non-blocking socket for the whole count. A peer that has gone is an error,
never a signal, even in a --shared library. Python's socket.sendfile, .NET's Socket.SendFile and Java's
transferTo spell it the same way; Go and Rust hide it inside their generic copy.
Listener options (std::net). TcpListener.bindWith(address:, options:) takes a ListenOptions, one type on
every OS and each its own OS's. Every one carries backlog, the accept queue's length (128 by default, what bind uses;
the OS caps a larger one at its own maximum, and a negative one is InvalidInput). Where a kernel lets several
listeners share a port, the type names what that kernel then does, because the OSes differ:
- Linux:
balance. The kernel spreads new connections across every listener on the port (SO_REUSEPORT), so each of several isolates accepts on its own listener. - macOS:
handOff. Every new connection goes to the listener bound last, which is a graceful restart rather than a balancer. - Windows and every other target: the backlog alone.
Every listener on a shared port must ask for it. A port another listener holds is otherwise AddrInUse on every OS.
Linux and macOS set SO_REUSEADDR so a restarted server rebinds a port whose old connections linger in TIME_WAIT.
Windows rebinds one by default, and there the option would let a second listener take a live port, so kama does not
set it (Go and Rust set it on Unix only). @compileFor gates a declaration, never a statement, so code that sets
balance is a function gated to Linux.
Sharing a UDP port (std::net). A plain UdpSocket.bind/bindTo shares nothing: a port another socket holds is
AddrInUse on every OS, as Rust's and Go's are. (Until 0.9.785 kama set SO_REUSEADDR on every UDP socket, and Linux
then let a second program's plain bind take a live port's datagrams.) A socket that shares its port asks for it through
UdpSocket.bindWith(address:, options:), whose UdpBindOptions.share is a UdpPortShare, one type on every OS and
each its own OS's. Every socket on a shared port must ask, and an Alone socket's port stays its own (on Windows,
whose SO_REUSEADDR lets a later socket take a held port, an Alone socket is bound SO_EXCLUSIVEADDRUSE):
Group, on every OS: every socket on the port receives each multicast and broadcast datagram, which is what several programs listening to one group want. A unicast datagram reaches one of them, the one bound last on Linux and macOS. Go hasListenMulticastUDPfor this, JavaMulticastSocket.Balance, Linux only: the kernel spreads unicast datagrams across the sockets by their sender (SO_REUSEPORT), so each of several isolates serves its own socket, asListenOptions.balancedoes for TCP.
Readiness (std::net::Poller). A server waits once for whichever of its sockets is ready, and the wait costs what
is ready, not what is watched. Each OS's own mechanism stands behind the one interface:
- Linux: epoll.
- macOS and FreeBSD: kqueue.
- Windows: AFD polling over an I/O completion port, the technique mio (tokio's), wepoll and Java's selector since
JDK 17 use. It gives readiness, with no
FD_SETSIZEceiling. kama's code is its own: a runtime that embeds into every program carries no third-party notice. The same port will carry overlapped I/O's completions. - Any other POSIX target:
poll(2).
It is native only: in a browser the event loop is the selector.
add(fd:, interest:, token:)watches a socket'srawHandle()forInterest::Read,WriteorReadWrite. It is reported under auint64token the caller chooses, typically an index into its own table of connections. Adding a watched descriptor again replaces its interest and its token.remove(fd:)stops watching it, and isNotFoundwhen it was not watched. Remove a descriptor before it closes: epoll keeps watching one that another process (or adup) still holds, under its old token.addandremoveareResults, because the OS can refuse. On Linux, epoll cannot watch a regular file (PermissionDenied).wait(events:, timeoutMs:)fills a caller-ownedEvents.Events.withCapacity(capacity:)allocates it once, with the OS's own array beside it, so a wait allocates nothing. EachEventis{token, readable, writable}:readablecovers hangup and error, so the read that follows meets EOF or the error, andwritableon a connecting socket means the connect resolved. A signal during the wait (Interrupted) resumes it with the time that is left.
One event per descriptor per wait, on every OS. kqueue reports reading and writing as two events, and its backend merges them, so code tested on Linux never meets a second event for a connection it has just closed. More ready descriptors than the list holds are reported by the next wait. It is level-triggered: a socket with unread data is reported again by every wait, so a reader may stop part way.
Waking a loop from another isolate. p.waker(token:) answers a Waker (std::concurrent). Its wake(), from
any isolate, ends the Poller's wait, which reports an Event carrying that token, readable. It is the one place a
loop handles events, so the loop learns what woke it there.
- Copyable, and shared. A
WakerisSendableandCopyable(bare: give):copy wis another handle to the same wake source. The state lives until the last handle and thePollerhave both let go, so a waker that outlives itsPollerwakes nothing, safely. - Coalesced. Only the first of several wakes before a wait makes a system call, and the wait reports one event for them all. The wait clears the flag before it reports, so a wake that lands while the loop drains is reported again at the next wait, never lost. A busy producer pays no system call per message (Go, libuv and Java do the same).
- Each OS's own mechanism: an eventfd under epoll;
EVFILT_USERunder kqueue, with no descriptor of its own; a packet posted to the completion port under AFD; a pipe underpoll(2). - Infallible. It fails only when the OS has run out of the resources to take the signal, which panics, as running out of memory does. A full eventfd or pipe is already set.
A channel the loop waits on. rx.wakeOnSend(waker:) gives a Receiver's channel a waker. A send that takes the
queue from empty to non-empty wakes the loop, and so does the last sender's close. Items already queued wake it at
once, so a late registration misses nothing.
- The loop drains with
tryRecvuntilEmpty(orClosed). - A send to a queue that already holds items costs nothing more, and the wake is made after the channel's lock is released.
- So an isolate that owns a socket and serves other isolates' requests waits for both in one
wait. Before, it had to alternate a channel wait and a socket wait, each with a timeout. Wakerlives beside the channel, so a program that only uses channels compiles nothing more for it.
The ready list is its own value, not the Poller's, because foreach freezes what it walks. A loop over Events can
therefore add the connection it just accepted. Raising the descriptor limit first (raiseOpenFileLimit) lets a
loop hold thousands, and Windows, which bounds sockets by memory, past the 1,024 its select() held. Measured on 0.9.717: one ready descriptor among N idle Unix-domain pairs, the best of seven
interleaved rounds of 2,000 waits. Linux ran in the arm64 container.
| N | poll(2), Linux |
epoll | poll(2), macOS |
kqueue |
|---|---|---|---|---|
| 100 | 4.0 µs | 0.16 µs | 14.4 µs | 0.18 µs |
| 1,000 | 36.7 µs | 0.14 µs | 177 µs | 0.18 µs |
| 10,000 | 1,288 µs | 0.14 µs | 7,267 µs | 0.18 µs |
Unix-domain sockets (std::net). A byte stream between two processes on one machine, named by a path:
UnixStream.connect(path:) and UnixListener.bind(path:) + accept(), on Linux, macOS and Windows 10 1803+.
UnixStream is a ReliableStream like TcpStream, with shutdown(how:), localAddr()/peerAddr() (a
UnixAddr: a path, or unnamed, which a connecting client's own end is) and peerCredentials() — who is on
the other end: its processId(), user(), group() and groups() (every group, the primary first and each
once — Linux itself lists only supplementary ones), the same members on every OS (Linux reads
what the kernel recorded at connect, macOS the peer's credentials, Windows the peer process's access token,
which a more privileged peer may refuse to show). A path is checked before the OS is asked: empty, holding
a NUL, or longer than sun_path less its terminator (103 bytes on macOS, 107 on Linux and Windows) is
InvalidInput. bind over an existing path is AddrInUse — a live listener's or a stale file, which is
never removed behind the caller — and a listener removes the socket file it made when it drops, so a clean
restart just works (a crash still leaves the file). SIGPIPE is handled as for TCP (above).
connectNonBlocking(path:) / connectToNonBlocking(address:) never wait, for a caller holding a deadline: a
local connect completes at once, and a listener whose queue is full answers at once too — WouldBlock on Linux,
which started nothing (connect again), ConnectionRefused on macOS. Where an OS reports a connect in flight, the
stream is returned and checkConnected() resolves it once it polls writable, as for TCP. Windows reports every
connect in flight, and its queue does not fill (all 400 connects to a listener nobody accepts from are made). A
path with nothing at it is NotFound on every OS: Windows answers it as it answers a path with no listener,
ConnectionRefused, so kama looks.
A connected pair with no path. UnixStreamPair.open() makes two connected UnixStreams and no file — the shape
a parent uses to talk to a helper both ways: keep one end, hand the other to a child as its stdin and stdout
(Stdio::Handle(descriptor: give b.intoDescriptor())) or to an isolate. Each end is lent in place, first() and
second(), as Process.stdin() lends its pipe, and the pair comes apart into two owned ends with give pair.split()
(match … { case Ends(first: a, second: b): … }, KR-261); an end still in the pair closes when the pair drops. Both ends are close-on-exec, so a child holds only the end it is handed, and
each end's peer is this process. POSIX makes it with socketpair; Windows has none, so there the pair is a private
listener in the temp directory, connected to once and accepted, its path removed as soon as the connection is queued,
and refused unless both ends name this process as their peer — a stranger who connected first is not paired. A
socket is no child's stream on Windows (Stdio says why), so there a pair serves isolates. On macOS the
SO_NOSIGPIPE every kama socket carries travels into a child with its end, so a child writing to a parent that has
gone gets EPIPE there where Linux raises SIGPIPE. UnixDatagramPair is the same over datagrams (Linux and
macOS): each send arrives whole at the other end, and neither needs an address.
A connected pair in memory. MemoryStreamPair.open(capacity:) makes two connected MemoryStreams that hold no
descriptor and run on every target, wasm included: the test pipe for protocol code written over ReliableStream. Each
direction holds at most capacity bytes (at least 1), so a partial write happens at the size the test chose on every
OS, where the kernel sizes a UnixStreamPair's buffers. A write takes what fits and is WouldBlock when nothing does,
and a read of an empty direction is WouldBlock. After the far end's shutdown(how: Write) or drop, a read is 0 once
drained, and a write after this end's own shutdown(how: Write) is BrokenPipe, as is a write the far end shut its
Read against. Always non-blocking, as a stream on the web is: setNonBlocking changes nothing. The ends are lent in
place, first() and second(), as UnixStreamPair's are, or taken apart with give pair.split(), and are not Sendable: they share their buffers through a
Shared, so the pair is one isolate's. tokio's io::duplex(max_buf_size) is the model; Go's net.Pipe is unbuffered.
Passing open descriptors. UnixStream.sendDescriptors(bytes:, descriptors:) sends open files, sockets
and streams to the process on the other end, with at least one byte to carry them, and
recvDescriptors(into:, descriptors:) receives them (UnixDatagram has the pair too). What travels is a
std::io::Descriptor, an OWNED descriptor that closes when it drops, so none can leak — one received and
never adopted is closed like any value, and a message carrying more than 253, or more than the kernel kept, is
MessageTooLong with every one that did arrive closed. Every handle type converts both ways: X.adopt(descriptor:)
(the receiver says what a descriptor IS by the type it adopts it into), give x.intoDescriptor(), which consumes x
(a use after it is a compile error, KR-261), and x.duplicateDescriptor(). A pipe end a Process lends leaves it
with takeStdout() (and takeStdin(), takeStderr()), which leaves an empty File in its place. Sending CONSUMES the list — each is closed once the peer holds its own — so a
sender keeps a file by sending a duplicate. A raw number from outside kama becomes a Descriptor through ONE named
door, Descriptor.adopt(raw:) / adoptSocket(raw:) — adopt, as every ownership transfer in std is spelled — and
the per-type public make(fd:) doors are private since 0.9.485, so grep finds every place a program claims a
number it did not get from kama. It is not checked: adopting a number something else owns (0, stdin) still closes
it twice, and kama's unsafe marks a dangerous body, never a caller, so it is no marker here. Received
descriptors are close-on-exec. POSIX passes them as SCM_RIGHTS, which reaches any program; Windows has no such
message, so each handle is put into the peer process (DuplicateHandle, WSADuplicateSocketW) and a frame ahead
of the bytes tells the peer where — a convention between two kama programs there, where POSIX's is the kernel's.
A Windows file descriptor and socket close differently, so adopting one as the other is InvalidInput there.
Each OS keeps what is its own. UnixDatagram (Linux, macOS) sends whole messages between local processes —
systemd's notify socket, syslog's /dev/log: unbound(), bind(path:)/bindTo(address:), sendTo(bytes:, to:),
recvFrom(into:) → RecvFrom<UnixAddr> { count, from }, connect/send/recv, and it removes a file it bound
when it drops. It is UdpSocket's shape over a UnixAddr, and both implement DatagramSocket<A>, the contract
over the address type A a datagram's peer is named by: UdpSocket, WtConnection and RtcConnection are
DatagramSocket<SocketAddr>, UnixDatagram is DatagramSocket<UnixAddr>, and recvFrom answers RecvFrom<A>. So a
syslog client that sends to /dev/log or to UDP 514 by configuration is one generic path — fn emit<S: DatagramSocket<A>, A>(ref S s, ConstView<uint8> line, const ref A to) — and A is inferred from the socket where no
argument names it. The address is always spelled, never defaulted: it is what kind of socket this is, not a policy
knob. An empty into is InvalidInput from every recvFrom: it cannot hold a datagram, and answering would invent
a sender. Windows' AF_UNIX is stream-only, so the type is not declared there. On Linux
UnixAddr.fromAbstract(name:) names the abstract namespace — no file behind it, nothing a crash leaves, gone
when the last socket holding it closes, and, with no file, no file permissions either: any process in the
network namespace may connect — read back by abstractName() and rendered @name. An API one OS lacks is a
compile error on that OS, never a run-time one (tools/check-target.sh builds both for every target); a
portable program keeps its use of one in a file gated to where it exists, with a twin answering the same call
elsewhere (tests/net_unix_datagram.d), because an import of a name its build excludes is refused even when
every use is gated.
Multicast (std::net). A UdpSocket joins a group per interface, and the families name an interface
differently, so each has its own call, as in the C API: joinMulticastV4(group:, interface:) takes an address
the interface holds (0.0.0.0 = the OS's choice), joinMulticastV6(group:, interfaceIndex:) its index (0 = the
OS's choice), with leaveMulticastV4/leaveMulticastV6 beside them. interfaceIndexOf(name:) turns lo0 or eth0
into an index, and a name that is no interface is Err(NotFound). On Windows the name is the NDIS name
(loopback_0, ethernet_32769), and the friendly name (Loopback Pseudo-Interface 1) is Err(NotFound). setMulticastInterfaceV4/V6 choose the
interface sends leave by (without it the routing table picks, and a loopback-bound V4 socket's send fails on a
host with a default route); setMulticastLoop and setMulticastHops apply to either family. A group or
interface of the other family is Err(InvalidInput) before the OS is asked.
Addresses (std::net). IpAddr is V4(uint8 a, uint8 b, uint8 c, uint8 d) or
V6(InlineArray<uint8>#(16) octets), both in network order, and match on one names both arms. parseIp
reads IPv6 text as RFC 4291 writes it — :: once, and an optional dotted IPv4 tail under the same strict
octet rules — and an IpAddr renders RFC 5952's canonical form: lowercase, no leading zeros, the longest
run of two or more zero groups as ::, and an IPv4-mapped address dotted (::ffff:1.2.3.4). A mapped
address stays V6. A zone (fe80::1%lo0) is refused by parseIp: the scope names an interface of this
machine rather than part of the address, so it is SocketAddr.scopeId, the C sin6_scope_id (0 when there
is none; SocketAddr.of(ip:, port:) leaves it 0 and SocketAddr.scoped(ip:, port:, scopeId:) sets it). A
SocketAddr renders 127.0.0.1:80, [::1]:8080, or [fe80::1%4]:22.
Lines and the standard streams (std::io). BufReader<R>.readLine() -> Result<Optional<string>, IoError> is the text-framing primitive over the byte substrate: Ok(None) is end of input, the
terminator is stripped, CRLF is one terminator and a lone \r is not, and a final unterminated line
is still a line. Lines<R: Reader> is the foreach form — Lines::<File>.make(src: give f) — and since
an Optional-yielding iterator cannot tell a read failure from a clean end, it records the failure for
error() after the loop; readLine's Result is the spelling that cannot be ignored. stdin(),
stdout() and stderr() are three non-owning Reader/Writer values (no-op destructors — the
descriptors belong to the process) that compose with pump, BufWriter and serde; they do not replace
core's print family (import { core::println };), which FLOOR.md explains.
Subprocesses (std::process). A Command builder — argv vector (never a shell string, so
injection-safe by construction; Command.shell(line:) is the explicit sh -c opt-in) with cwd/env/
envClear and one Stdio per stream — Inherit, Piped, Null, Handle(descriptor:) (any owned handle:
a file, a socket, a pipe end, another child's stdout, so a pipeline is two Commands) or, for stderr alone,
Stdout (2>&1) — spawns an owned, move-only type resource Process via start(), or captures via the
one-shot run() -> Output { status, stdout, stderr }, which drains stdout and stderr concurrently (so a child
that fills both pipes can't deadlock the parent). A Handle goes to the child: the parent's copy is closed once
the child holds it, so a reader of the other end sees end-of-file, and it serves one start() (a second is
InvalidInput). run() captures only what was left unset — stdout and stderr; an unset stdin is the null
device — honours every stream that was set, and leaves the Command as it found it. Process gives wait() (blocking reap → an ExitStatus, below),
tryWait() -> Optional<ExitStatus> (non-blocking), kill/terminate/signal, and — with processGroup(group:)
— a child in a group of its own, signalled whole by killGroup/terminateGroup/signalGroup so a supervisor
stops it with everything it started. ProcessGroup keeps each OS's own: POSIX has Inherit, New (setpgid),
Join(group:) (a pipeline's children share one) and NewSession (setsid — no controlling terminal); Windows has
Inherit and New, the child's own console group plus a Job Object that ends with its last handle, so the group
never outlives the Process that owns it. A child in the parent's group has none to signal (InvalidInput), and
a group with nobody left in it is IoErrorKind::ProcessNotFound; and the piped streams as
std::fs::Files (stdout()/stderr() read, stdin()+closeStdin() feed-then-EOF). Dropping a Process
never blocks: it reaps a already-exited child (no zombie) or detaches it (the OS reparents to init) —
explicit wait() is how you get the status. On a fiber wait() and run() park only their fiber (Pipes and children
on a fiber). A child inherits its three streams and nothing else of
its parent's: every descriptor kama makes is close-on-exec on POSIX (atomically where the OS can, and on macOS under a
gate a fork waits on) and non-inheritable on Windows, where the spawn also names exactly the three handles the child
may take — so no child keeps a parent's listening port, file or pipe end open (Rust, Go and Python default the same).
POSIX and Windows both ship: process.kama is byte-identical
across platforms, with the whole difference behind kama_os.h (fork/execvp/pipe/waitpid vs CreateProcess),
and run()'s two-pipe drain sits behind one kama_capture2_parked seam (poll on POSIX, a reader thread per pipe on
Windows, a park on both pipes on a fiber) so every platform takes the same code path. wasm has no process model.
An ExitStatus is each platform's own type, as main's status is. On POSIX a child either exits, with the one byte
the platform keeps, or is killed by a signal: @nonExhaustive type enum ExitStatus { Exited(uint8 code), Signaled(int32 signal) }. On Windows it only exits, with all 32 bits: Exited(uint32 code). Portable code reads a
code with case Exited(code: c): … beside a case _:, and success() asks the same question on both. Neither
needs a sentinel: a signalled child used to report code = -1.
Where a process works (std::process, std::fs). currentDirectory() reads what a relative path is relative
to and setCurrentDirectory(path:) changes it — process-wide, so it races every isolate resolving a relative path
at the time (set it at startup, or pass absolute paths), and MAX_PATH-bound on Windows. std::fs::canonicalPath(path:)
resolves ., .. and every link into the one absolute spelling of a file that exists (NotFound otherwise). On
Windows neither answer carries the verbatim \\?\ prefix the OS hands back, except where the plain path would
name another file (a trailing . or space, a device name such as CON); kama re-adds the prefix itself past 248
characters, so nothing is lost.
Who a process runs as (std::process). currentUser(), currentGroup() and currentProcessId() answer
for this process, and UnixStream.peerCredentials() (below) for the one on the other end of a socket. The
interface is the same on every OS and each OS keeps what is its own — the rule for every platform
difference in std: one primary interface, and the deltas in the types it returns, never a platform cut down to
the common subset. UserId and GroupId compare with ==, name their account with name() (NotFound when
no account has the id) and render with ${…}; on Linux and macOS they are the uid and gid (raw(),
of(raw:)), on Windows security identifiers (sid() renders S-1-5-21-…, fromSid(bytes:) checks what it
is given, and domain() names the account's domain). Windows adds AccessToken, its own process identity:
current(), ofProcess(id:), and its user(), primaryGroup() and every one of its groups(). Each twin is
a whole type gated by @compileFor, since a member cannot be (Conditional compilation).
UserId.homeDirectory() is the home the account database records — the passwd entry on Linux and macOS, the
profile Windows records for the SID — and homeDirectory() is the current user's: $HOME (%USERPROFILE% on
Windows) when it is set and not empty, else the account's, as Rust's home_dir and libpq read it.
Another process: alive, and the same one? (std::process). A pid alone is not a process — the OS reuses it — so
processStamp(id:) answers a ProcessStamp: an Equatable, Hashable value made only of what the kernel recorded
once — on Linux and macOS this boot's UUID (boot_id, kern.bootsessionuuid) and the process's start (the ticks
since boot in /proc/<pid>/stat; p_starttime), on Windows its creation time — with a text form a lock file or pid
file writes ("${stamp}") and ProcessStamp.parse(text:) reads back. A wall-clock start is not an identity: Linux's
btime and macOS's kern.boottime are now less the time since boot, recomputed when the clock is set, so after an NTP
step a live holder's would no longer match (psutil's (pid, create_time) moves that way). processExists(id:) is the
cheap check (kill(pid, 0); on Windows WaitForSingleObject), processStarted(id:) and bootTime() answer the wall
clock for display, and processGroupExists(group:) is POSIX's, beside ProcessGroup. An exited child not yet reaped
still exists on Linux and macOS; on Windows an exited process is gone. A pid stays an int32, as everywhere in
std::process. A lock file itself is File.lock, which dies with its holder; the stamp says who holds it.
The limit on open descriptors (std::process, POSIX). openFileLimit() answers an OpenFileLimit, the
RLIMIT_NOFILE pair:
softis what the process can have open now; past it, a call fails withTooManyOpenFiles.hardis as far asraiseOpenFileLimit()can takesoft. That call raises soft to hard, never lowers it, and answers the limit then in force.
Both are what can actually be opened. macOS enforces kern.maxfilesperproc whatever setrlimit accepted: measured on
Darwin 25, it takes any soft value up to RLIM_INFINITY and reads it back. A shell's 1,048,576 there is a number no
program reaches (61,440 on the measuring machine), so both values are bounded by it, which is Go's cap too. No limit at
all reads as the largest uint64, which compares as one.
kama never raises the limit implicitly; Go does at startup, as the JVM and Node do. The low default protects C code
that uses select(), which breaks above descriptor 1023, and a csources library may be such code, so raising it is
the program's decision. Windows bounds sockets by memory and has no such limit, so the type is absent there rather
than faked. A portable program reaches it from a file gated to POSIX, with a twin.
The streaming byte substrate. std::io also defines two contracts that unify every byte source/sink:
type contract Writer (the partial-write primitive write(ConstView<uint8>) -> Result<isize, IoError> +
flush) and type contract Reader (read(View<uint8>) -> Result<isize, IoError>, Ok(0) = EOF). Buffers
are always views (a non-owning span — zero-copy sub-slicing, no charset assumptions: binary-native, text
backends layer UTF-8 on top): the read-only ConstView<uint8> where the callee only reads (write,
send), the writable View<uint8> where it fills (read, recv). Write-all looping, pump (Go io.Copy), and readAll are free helpers over the
primitive (contracts carry no default methods: a contract is purely an interface, so conformance is total and every body lives in the implementing type — helpers compose over the primitive); StringWriter/SliceReader are the in-memory impls and
BufWriter<W>/BufReader<R> the buffering layer (each owns its inner sink/source by value — kama forbids
stored borrows). A drop abandons, a consuming finish completes: a BufWriter dropped with bytes buffered writes
nothing and reports nothing, since a drop has no way to report an error, and give bw.intoInner() flushes and hands
the sink back, or hands the writer itself back in Unfinished beside the error, so a WouldBlock can be finished
again; the writer is gone after the call (KR-261). give r.intoParts() hands a BufReader's, or a Lines', source
back with the bytes it read ahead, and give sw.finish() and give sw.takeBytes() move a StringWriter's buffer out
without a copy. std::fs::File implements both, and a reliable network stream is
type contract ReliableStream for resource implements Reader, Writer (refinement) + setNonBlocking — so TcpStream and
the web WsConnection are drop-in Reader/Writers. The upshot: the serde backends and fmt stream over a
file or a socket with no transport-specific code (deserializeJsonStream<T>(from: someReader)), and unbounded data moves
in bounded memory. (Datagram endpoints — UdpSocket, WebTransport — are message-oriented, not byte streams, so
they take the same view buffers but do not implement Reader/Writer.)
import { std::fs::writeFile, std::collections::DynamicArray };
fn uint8 main() {
DynamicArray<uint8> data = DynamicArray.empty();
data.add(item: 104ui8); // "h" — a file is bytes
match (writeFile(path: "out.txt", bytes: data)) {
case Ok(value: n): {}
case Err(error: e): { return 1; }
};
return 0;
}
Compressed streams (std::compress). DeflateReader<R> (RFC 1951), ZlibReader<R> (RFC 1950) and GzipReader<R> (RFC 1952) are Readers of
the decoded bytes. Each takes its source as a BufReader<R> and takes exactly the stream's bytes from it, handing back what it read ahead, so streams
back to back (a gzip file's members, a packfile's objects) decode one after another. limit, a bound on the decoded bytes, is required, since a few
kilobytes can decode to gigabytes: up to limit bytes are delivered, and the read that would deliver one more answers LimitExceeded. A stream that breaks
its format or fails a checksum is InvalidData and one cut short is UnexpectedEof, and either is final; an error from the source passes through and
changes nothing, so the read after a WouldBlock resumes where it stopped. A read of nothing is not the end.
A checksum is verified by the read that answers Ok(0). give d.finish() reads nothing, so it cannot block or time out: it hands the BufReader back at
the first byte after the stream, and an Ok from it says the stream was read to its end, where Go's Close and Rust's into_inner hand a source back
whether it was or not. Before the end it answers InvalidInput, and after a failure that failure, each with the reader back whole in Unfinished, and
give d.abandon() takes the source back as it stands, nothing verified.
DeflateWriter<W>, ZlibWriter<W> and GzipWriter<W> are Writers of the bytes to compress, each taking level: DeflateLevel, which is Store,
Level1 to Level9 or Frozen, and is @nonExhaustive. flush is a sync flush, so a peer can decode everything written so far. finish writes the end
of the stream and hands the sink back, flushing nothing of its own; over a stalled sink the writer comes back whole in Unfinished, and finishing it again
writes no second end. Dropping a writer abandons its stream, which then never verifies. The bytes depend only on the input and the level, never on how the
writes were split, on every target, for one kama version; DeflateLevel::Frozen's never change across versions, and are what kama publish writes, so a
package's sha256 can be rebuilt by std.
GzipReader.make takes members: GzipMembers, with no default: All reads a file's members as one stream, and limit counts across them, while One reads
the first and leaves the rest in the BufReader. What follows a member under All is another member or nothing: zeros and junk are InvalidData, as in Go,
where gzip(1) warns and exits 2, and Python skips zeros. header() reads as far as the header's end and gives the member most recently begun as a GzipHeader:
mtime(), os(), and name(), comment() and extra() as raw bytes, empty when absent. RFC 1952 says a name is Latin-1 and gzip(1) copies a file name's
bytes as they are, so nothing in a file says which it holds, and the caller decides. A name or a comment past 65,535 bytes is InvalidData, since neither
has a length field. GzipWriter writes one header for every file, with no flags, no name and no time. A zlib stream that names a preset dictionary is InvalidData.
Archives (std::archive::tar). TarReader<R> reads ustar, pax x and g records, GNU long names and v7, and hands back every entry as written, since
listing a hostile archive is how one audits it: next() gives each TarHeader or None at the end, skipping what was left of the last body, and read
gives its body. TarHeader.path() is raw bytes, because a tar promises nothing about its names; kind() is a TarKind carrying what only that kind has
(File(size), Directory, Symlink(target), HardLink(target), CharDevice(major, minor), BlockDevice(major, minor), Fifo, Other(flag, size)), and is
@nonExhaustive; mode() is the archive's own bits; mtime() is an Optional<Timestamp>, None for a time a Timestamp cannot hold; owner() is TarOwner::None
or Named(uid, gid, uname, gname). make takes endMarker: TarEndMarker, with no default: NotRequired reads what GNU tar, bsdtar and Python read, where the source
ending between entries or the first zero block ends the archive, and Required asks for what none of them offers, two zero blocks and nothing less. A header
whose checksum fails, a number that does not parse or a malformed pax record is InvalidData, and a source that ends inside a header or a body is UnexpectedEof. A
path, a link target or an owner name past 1 MiB is InvalidData, since each is held; a pax record of any other key is skipped without being held, however long.
finish and abandon are the decoders', and finish hands R back exactly after the end, since a reader never reads ahead; over a GzipReader that leaves the
gzip trailer to the caller, whose own finish then insists on it.
TarWriter<W> writes ustar: begin(header:), exactly the size declared through write, and finish. What no ustar field holds goes in a pax record. Too long
a body is InvalidInput with nothing taken, and too short is InvalidInput from the next begin or finish. Over GzipWriter at Frozen, an archive of regular
files with kama publish's fields is publish's bytes.
extractInto(archive: ref t, dir: "out") writes an archive under one directory and never outside it, and never traverses a link. The destination must be new or empty.
Each entry's parents are asked of the operating system one at a time, so a parent that is a link is refused however it is spelled, and files are opened CreateNew,
so nothing is written through a final link, and the same name twice is refused, where GNU tar, bsdtar and Python leave the last. It refuses, as a TarExtractError
naming the entry and the TarRefusal: an absolute path, a .. component, a NUL, a name that is not UTF-8, a device, a FIFO or a type it does not know, a link whose target is
absolute or has a .. anywhere but at its start or past the destination, a hard link to anything but an earlier regular file of the archive, and on Windows alone a
drive, UNC path, stream, reserved name or trailing dot. The setuid, setgid and sticky bits and group and other write are dropped, nothing is chowned, and a directory keeps
its owner's access. A hard link on a filesystem with none is a copy. What was written stays after a refusal, under a directory that was new or empty.
Every platform difference lives in one bundled C bindings header, kama_os.h (pulled in only when a
module extern "kama_os.h";s it — pay-for-what-you-use), which keeps OS aggregates (struct stat,
sockaddr_in, dirent) opaque behind static inline accessors — the standard FFI boundary (Rust libc /
Zig @cImport), forced by "an extern struct emits the literal C name." POSIX (Linux/macOS/iOS/Android) and
Windows (Winsock + CRT) both ship; under wasm the virtual FS works, sockets need a host proxy. Sockets link
-lws2_32 on Windows (pay-for-use, like -lm for <math.h>). examples/httpd/ is a ~200-line static-file
HTTP server built on these three modules.
FFI data — all controlled; the only unsafe fn is the one every C call needs:
extern "<stdlib.h>"; // a C #include
type extern value div_t { int32 quot; int32 rem; } // bind an external C struct (not re-emitted)
extern fn div_t div(int32 numer, int32 denom);
extern fn float64 frexp(float64 value, UnsafePtr<int32> exp);
unsafe fn int32 demo() { // calling an `extern fn` is the unsafe seam
div_t r = div(numer: 17, denom: 5); // r.quot=3, r.rem=2 (field access on a C struct)
int32 e = 0;
frexp(value: 1764.0, exp: addr(of: e));// addr(of: x) = &x — controlled out-param
return r.quot + r.rem + e; // 5 + 11 = 16
}
fn uint8 main() { return cast<uint8>(demo()); }
type extern value Foo { ... } is an external struct provided by an included header / linked code —
kama uses its fields (all public, the C layout) but never re-emits it (so no redefinition), and its name is
the literal C name. It may declare ctors, methods, operators and static fns — the struct is the header's,
but a member is an ordinary kama function, emitted like any other — and it is built by a constructor like
every kama type: bind a struct-returning C fn (div(...) above), call one of its ctors, or opt into
@generate(of) (memberwise) / @generate(zero) (a C struct's fields are public, so it is a data bag). The
nameless div_t(quot: 3, rem: 2) is refused. A field a ctor does not
assign is zero, not an error: the header owns the layout and kama may declare only the fields it uses — so
partial init of a large descriptor is WGPUBufferDescriptor bd = WGPUBufferDescriptor.zero(); bd.size = 64;.
The fields may be repeated in another file that declares the same struct; the members have one body each, so
they are declared once and the type is exported from there. (tests/extern_value_init.kama.)
The field list is a claim about the header, and the build checks it: each field must match the header's in
size and in kind (integer, floating, bool), so int32 level over a C short level is a build error naming
the field rather than a silent truncation. Order does not matter (every read is by name) and a binding may name
a subset of the header's fields. Signedness is not compared — a C enum field is int or unsigned int by
implementation, and int32 is how a binding spells it. The check is a C11 _Static_assert, because the C
compiler, not kama, reads the header's layout — so it fails at kama build, not at kama check.
addr(of: x) takes the address of a
real local (out-params, descriptor pointers) — a controlled op, though its result is a raw pointer and so lives in an unsafe fn. s.cstr() yields an
UnsafeConstPtr<cchar> — C's const char*, read-only (cchar is C's char, a pointee only — see Numbers).
A struct crossing by value. A struct passed or returned BY VALUE through an extern fn or an expose fn needs
a layout both sides agree on, and the marker on the type says who owns that layout — exactly as extern fn
and expose fn say who owns a body:
type extern value— a C header owns it, and kama binds it (checked field by field, above). It crosses either kind of function: anextern fn's prototype is the header's own, and a host calling anexpose fnincludes the same header.type expose value— kama owns it, and the generated host header publishes it (Exposing to a host). It crosses only anexpose fn, where kama owns the function too. On anextern fnit is refused: that prototype comes from a C header that declares its own struct, so kama's would be a second, incompatible type.
| by value | extern fn (kama calls C) |
expose fn (C calls kama) |
|---|---|---|
plain type value, a resource, an enum with payloads, a generic instance |
refused | refused |
type extern value |
✅ | ✅ |
type expose value |
refused | ✅ |
a plain enum |
refused | refused |
type extern enum |
✅ | ✅ |
type expose enum |
refused | ✅ |
A plain type value is refused because nothing states its layout, and marking one is what makes a layout
a promise: an unmarked value stays free to change, which is what keeps every other value type's layout kama's
own. What passes unmarked is what is not a kama composite — a primitive, a pointer (UnsafePtr<T>, ref), an
fnptr, a name the header itself declares — and kama's own intrinsics,
whose layout is its ABI: string (the runtime header's kama_string, on an extern fn), InlineArray/Simd,
and View/ConstView. An InlineArray lays its elements out inline, so its element answers the same question:
an array of a plain value is refused. Fixture: tests/extern_value_crossing.d/, both directions against a
real C file.
An enum crossing to C answers the same question for its VALUES, with the same pair. A plain enum numbers its
variants itself, so inserting one silently renumbers the rest — it is refused at the boundary: in a signature,
as a field of a type extern value or type expose value, or as an InlineArray element.
type extern enum— an enum a C header defines. Each variant IS the C constant's name, verbatim, and kama writes no values; the emitted C uses those constants, so the numbers are the header's by construction. Binding a C enum is naming it:extern "vulkan.h"; type extern enum VkFormat : int32 { VK_FORMAT_UNDEFINED, VK_FORMAT_R8G8B8A8_UNORM } cfg.format = VkFormat::VK_FORMAT_R8G8B8A8_UNORM; // C: `cfg.format = VK_FORMAT_R8G8B8A8_UNORM;`Writing a value is refused, since the header owns them. The width is stated (
: int32— C leaves an enum's size to its compiler) and the build holds it to the C type's size; a constant the header does not define is refused by the C compiler, by name. Both are build failures, since only the C compiler reads the header.try cast<E>(n)tests membership against the C constants, andmatchcovers them like any enum. Its values cannot be folded at compile time — they live in the header, which kama never reads — but its size can:sizeof(VkFormat)is the stated width, which the build already holds to C's. Like atype extern valueit is a module symbol (export/import) under its literal C name, and may be declared again where it is used only if the declarations are identical. Fixture:tests/extern_enum.d/.type expose enum— values kama owns, which the generated host header publishes (Exposing to a host). Every value is written out, so a variant cannot be renumbered under a host that compiled against it.
Either marker requires the width (: int32) and a payload-less enum: a tagged union has no C spelling. Neither
takes both markers — one side owns the values. Either may declare methods, a contract
and @generate like any enum: a payload-less enum is its integer whatever it declares, so what crosses is
unchanged. A C enum declared in several files takes its members on one of
those declarations. A type expose enum crosses an expose fn only, as a
type expose value does.
A named C constant — extern const T NAME; binds a constant a header defines, by name, the way type extern enum
binds an enum's: kama writes no value, a use emits the C name, so the number is the header's by construction. One
spelling covers both shapes a C API uses — a typed static const (webgpu.h's bit flags) and a #define (GLFW's
keys) — and bit flags compose with the ordinary integer operators:
extern "webgpu.h";
extern const uint64 WGPUBufferUsage_Uniform; // static const WGPUBufferUsage WGPUBufferUsage_Uniform = 0x40;
extern const uint64 WGPUBufferUsage_CopyDst;
bd.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; // C: `bd.usage = (WGPUBufferUsage_Uniform) | (…);`
- The type is stated, and the build holds it to the header's — its size and its kind (integer, floating,
bool), the rule atype extern valuefield follows. A macro has a type too:#define KEY 256is anint, so it binds asint32. A mismatch is a C11_Static_assertnaming the kama declaration, and a name the header does not define is refused by the C compiler, by name — both build failures, since only the C compiler reads the header. - A number or
boolonly. A constant crosses by value, and only a primitive has a kind to check: a string macro is achar[N]and a braced*_INITmacro is not an expression. Bind those through a C function. - Never folded. Its value lives in the header, which kama never reads — and a C
static constis not even a C11 constant expression — so it cannot initialize acomptimeconstant or size an array; state the number in acomptimeof your own there. A modulestaticneeds a compile-time initializer, so it cannot take one either. - Read-only, and safe to read. It is never a write target, and reading one is not a call into C, so it needs no
unsafe. Aconst refborrow of one gets a temporary, as for any constant. - A module symbol like every extern — file-private,
exported andimported, repeated in another file only if the declarations agree, and@linkName("SYMBOL")binds a C spelling that is not the kama name; one C symbol has one kama binding in a program. Fixture:tests/extern_const.d/.
Time (std::time) ✅#
import { std::time::Duration, std::time::Instant, std::time::Timestamp, std::time::Date, std::time::Weekday, std::time::TimeError, std::time::monotonicNow, std::time::unixNow, std::time::sleep }; —
two clocks, and choosing between them is the only thing the module asks of a caller, plus the calendar
the wall clock reads in.
| type | read with | for | |
|---|---|---|---|
| monotonic | Instant |
monotonicNow() |
measuring a span: a timeout, a frame time, a benchmark. Never runs backward; has no relation to any date. |
| wall | Timestamp |
unixNow() |
stamping an event: a file's mtime, a log line, a protocol field. Signed nanoseconds from the UNIX epoch (1677-09-21..2262-04-11). Can jump either way (NTP, a user setting the clock), so durationSince may be negative — that is the clock being honest, not an error. |
Duration is the signed span both produce (fromMillis/asSecsF/… and + - <); all three implement
Equatable/Comparable/Hashable/Sendable, so a stamp is a SortedMap key without a comparator and
crosses an isolate.
The calendar is the Postgres split: an instant and a day.
| kama | Postgres | is |
|---|---|---|
Timestamp |
TIMESTAMPTZ |
an instant. It holds no zone and no offset; it reads its fields in UTC. |
Date |
DATE |
a day with no time: a birthday, a due date. Years 0000–9999, the range RFC 3339 text can write. |
import { std::time::Date, std::time::Timestamp, std::time::unixNow };
fn uint8 main() {
Date due = match (Date.make(year: 2026, month: 9ui8, day: 16ui8)) { case Ok(value: d): d; case Err(error: e): { return 1; } };
Timestamp t = unixNow();
Date today = t.date(); // plus hour() minute() second() nanosecond() weekday(), in UTC
int64 left = due.daysSince(earlier: today);
string text = "${t}"; // 2026-09-16T14:03:07.123Z
return 0;
}
- One timestamp type. There is no field-holding
DateTimebesideTimestamp: two types for one instant is what Rust has inSystemTimeand chrono'sDateTime<Utc>, and it makes every program pick which to store.Date.at(hour:, minute:, second:, nanosecond:)goes from a day to its instant. - No local time, and no zoneless wall time (Postgres
TIMESTAMP, JavaLocalDateTime). A wall time names no moment until a zone pins it, and zones are a database — a package, aschrono-tzis, and so is locale-aware formatting. Dateis always a real day. Its fields are private andDate.makeis the one door: a month outside 1–12, a day past the month's length (Feb 29 only in a leap year) or a year outside 0000–9999 isErr(TimeError::OutOfRange), and so is anaddDaysthat leaves the range.Date::isLeapYearandDate::daysInMonthanswer the rules directly;Weekdayruns Monday–Sunday, ISO order.- Proleptic Gregorian, POSIX seconds. Every day is 86,400 seconds, so there is no leap second: second 60
is
OutOfRange. A stamp before 1970 reads the day it falls on — one nanosecond before the epoch is 1969-12-31T23:59:59.999999999Z.
Text is RFC 3339, the strict profile of ISO 8601, plus exactly what PostgreSQL writes. "${t}" prints YYYY-MM-DDTHH:MM:SS, a fraction only
when there is one (3, 6 or 9 digits, the fewest that are exact, so every stamp parses back to itself), then
Z; a Date prints YYYY-MM-DD. Timestamp.parse(text:) and Date.parse(text:) return
Result<_, TimeError>, the Uuid.parse shape (Parseable is a primitive-only contract):
- the zone is
Zor an offset, and the offset is applied and dropped —2026-09-16T16:03:07+02:00parses to the instant that prints2026-09-16T14:03:07Z, which is what Postgres does with TIMESTAMPTZ input; - a Postgres text column reads directly: a space may stand for
T(RFC 3339 §5.6 allows it), and an offset may be Postgres's±HHor±HH:MM:SSas well as±HH:MM— so2026-09-16 16:03:07+02and a historical1900-01-01 00:19:32+00:19:32both parse (every form measured against PostgreSQL 17); TandZmay be lowercase, and the fraction has 1–9 digits;- nothing else parses: no week or ordinal dates, no compact
20260916T140307Zor+0200, no surrounding whitespace, nothing trailing. A malformed byte isTimeError::InvalidCharacter(at:)at its offset (the text's length when it ends early); a field that does not exist, an offset past 23:59:59, or a value these types cannot hold isTimeError::OutOfRange— outside int64 nanoseconds, and Postgres'sinfinity,-infinity,BCdates and years past 9999.
Postgres keeps microseconds, so a stamp written with finer digits comes back rounded and no longer
compares equal. For a binary format, Date.fromUnixDays(days:) and date.unixDays() are the day number
Arrow, Parquet, Avro and BigQuery store; Postgres's binary DATE is that minus 10957, and its TIMESTAMPTZ is
microseconds from 2000-01-01 — Timestamp.fromUnixMicros plus a constant.
Both types serialize as that text on every backend, as a Uuid does, and text that does not parse is
DeErrorKind::Malformed.
sleep(d: Duration) waits at least d — the calling thread, or on an isolate that runs fibers only the calling
fiber, while the core runs the others (KR-3; Sockets on a fiber). POSIX resumes nanosleep across a
signal (without which any program that also uses std::process wakes early when a child exits), and a
zero or negative span returns at once — so sleep(d: deadline - now) past its deadline is a no-op.
⚠️ On the web target it busy-waits: without ASYNCIFY (a whole-program
cost kama will not pay for one function) a wasm function cannot yield to the host, so it spins on the
monotonic clock, burns a core, and freezes a page on the browser's main thread. On an event loop,
schedule instead of sleeping.
It sleeps for the span you asked for, not the platform's scheduler tick. Windows Sleep() rounds to
the next tick — ~15.625 ms by default — so a 1 ms and an 8 ms nap cost the same and a frame loop paced
with sleep ran at half rate there while running correctly everywhere else. kama uses a
high-resolution waitable timer instead, per call, falling back to Sleep where the OS declines it
(the flag wants Windows 10 1803+). Deliberately not timeBeginPeriod, which raises the tick for
the whole process — a library may not spend a caller's power to fix its own resolution. Measured
before and after on one machine: 1 ms → 13.4 ms then 1.6 ms, 8 ms → 15.6 ms then 8.7 ms. The at
least guarantee is what this preserves, not what it changes.
Application loop (std::app) and web transports (std::net::web) ✅#
std::app is the frame loop a game or an interactive tool runs, written once for both hosts:
run(tick: t, state: s) calls t(state) until it returns 0 — a plain while natively, and on wasm
emscripten's main loop, which yields to the browser between ticks so asynchronous I/O makes progress (a
busy while on the web never sees an event). quit(code:) ends the program correctly on both; a plain
exit is ignored on the web while the loop keeps the runtime alive. TickFn is @callerThread — the tick
runs on the thread that called run, so a module static it touches is the caller's. On the web run
does not return.
std::net::web is the browser half of std::net, for a wasm program talking to a server:
WsConnection— a WebSocket,connect(url:), withstate()(WsState { Connecting, Open, Closing, Closed }). It is aReader/Writer, so a serializer orpumpstreams over it as over aTcpStream;writesends one binary message andreadreturns the next message's bytes. Non-blocking only — the browser is event-driven — soreadisErr(WouldBlock)until data arrives andOk(0)once the peer closed. Closes on drop.WtConnection— WebTransport datagrams,connect(url:),state()(WtState), andsend/recv/sendTo/recvFromin theUdpSocketshape: message-oriented, so not aReader/Writer. Where WebTransport is unavailable (Safari/iOS, Node),WsConnectionis the reliable fallback.RtcConnection— a WebRTC DataChannel, peer to peer between two browsers:offer(iceServers:, delivery:)opens it (RtcDelivery::Unreliable— unordered, never retransmitted, the UDP-like channel — orReliable) andanswer(offer:, iceServers:)receives it; each side'slocalDescription()(Noneuntil ICE gathering is done) travels to the other through signalling the program provides, and the offerer applies the answer withacceptAnswer. The ICE servers are a required list ofIceServer.stun(url:)/IceServer.turn(url:, username:, credential:)— an empty one is the explicit same-machine-or-LAN choice, and no server is built in. A message is atomic: one bigger than the buffer isMessageTooLongand stays queued;sendbefore the channel opens isWouldBlock;state()(RtcState) has a transientDisconnectedbesideClosed. It implementsDatagramSocket<SocketAddr>asWtConnectiondoes.
Paths (std::path) ✅#
import { std::path::join, std::path::parent, std::path::fileName, std::path::stem, std::path::extension, std::path::isAbsolute, std::path::separator }; — six pure functions over string,
no I/O, and no Path type: two string-ish types is what GOALS #4 warns against, and Rust's Path
earns its keep through OsString encoding concerns kama does not have. The names are the ones every
modern successor API converged on (Rust, pathlib, C#), not the shell tools' dirname/basename.
import { std::path::join, std::path::parent, std::path::fileName, std::path::extension };
fn uint8 main() {
Optional<string> dir = parent(path: "/a/b/c.txt"); // Some("/a/b")
Optional<string> name = fileName(path: "/a/b/c.txt"); // Some("c.txt")
Optional<string> ext = extension(path: "/a/b.tar.gz"); // Some("gz") — the LAST dot, WITHOUT the dot
string full = join(path: "/srv", with: "www"); // "/srv/www"
return 0;
}
Five deliberate answers, each a place a mainstream implementation surprises someone:
joinnever discards the base. Rust's and Python'sjoinreplace the whole path when the right side is absolute — the classic traversal footgun.join(path: "/srv/root", with: "/etc/passwd")is/srv/root/etc/passwdhere. This is the one place kama disagrees with its comparator on purpose.- A dotfile has no extension:
extension(path: ".bashrc")isNone. - The extension excludes the dot (Rust/Go/Zig, not Node's
extname). - Missing is
None, never"":parent(path: "file.txt")isNone(Rust saysSome("")), and so isfileNameof a root or of./... - There is no
normalize: resolving..lexically is wrong across a symlink; the honest version needs the filesystem and belongs instd::fs.
Separators are / everywhere and \ as well on Windows (both accepted on input; join writes \),
and isAbsolute knows the drive (C:\) and UNC forms. The variance is two @compileFor pairs, not a
runtime branch.
Random (std::random) ✅#
import { std::random::Rng, std::random::range, std::random::shuffle, std::random::entropy }; — a
seeded generator first, OS entropy as the convenience, and neither one is a secret.
import { std::random::Rng, std::random::range, std::random::shuffle, std::collections::DynamicArray };
fn void demo(ref DynamicArray<int32> xs) {
Rng g = Rng.seeded(seed: 42ui64); // the same stream on every platform, forever
uint64 raw = g.next(); // xoshiro256**
int32 die = range(rng: g, lo: 1, hi: 7); // a value in [1, 7), unbiased — T from the bounds
isize pick = g.below(n: xs.length()); // an index in [0, n)
borrow xs.viewMut() as v { shuffle(items: v, rng: g); } // Fisher–Yates over any View, move-only safe
Rng h = Rng.fromEntropy(); // seeded by the OS — a different stream each run
}
Seeded is primary, because a deterministic stream is what most callers want: a game replays a match
from its seed, a spectator catches up by re-running it, a test pins its inputs. The generator is
xoshiro256** (Blackman & Vigna) seeded through splitmix64 — the mix DefaultHasher already finalizes
with — and the first outputs of two seeds are pinned against the reference C implementation, so a
change to the algorithm is a test failure rather than a silent replay break.
Not cryptographic. An observer who sees a few outputs can recover the state and predict the rest.
entropy(into: View<uint8>) fills a buffer from the operating system's own generator (getentropy,
BCryptGenRandom, crypto.getRandomValues under wasm) and is the one draw here fit for a key, a nonce
or a token; everything above that is a libsodium package's job, not std's. An entropy source that
refuses is an environment fault, so it panics rather than returning a Result (Go 1.24 drew the same
line).
Rng is a resource — move-only, handed around as ref Rng. A copy would silently fork the stream
into two identical ones, the bug Rust's &mut Rng, Go's *rand.Rand and C#'s class all rule out the
same way. It is Sendable, so an isolate can own one.
Two integer draws, two jobs. range(rng:, lo:, hi:) is one generic spelling over every numeric
width — int8…int64, uint8…uint64, isize/usize, float32/float64 — through the same
marker-contract mechanism as parse::<T> (Ranged). It is half-open, unbiased (rejection sampling, kept
because it is already unbiased; std::num::mulHighU64 is Lemire's multiply-shift if the branch ever matters), and the signed span is computed in wrapping unsigned
arithmetic so range(rng: g, lo: int64Min(), hi: int64Max()) draws without trapping. T comes from
the bounds: a typed one binds it and an unsuffixed literal follows (range(rng: g, lo: 0, hi: xs.length())
is an isize draw — generic inference defers an unsuffixed literal until its typed siblings have bound
T, and a method call on a typed local answers with its declared return type); two bare literals are
int32, 0.0/1.0 are float64.
g.below(n:) stays beside it because an index and a value are different jobs.
An empty range (hi <= lo) panics rather than answering lo.
nextFloat() is a float64 in [0, 1) from the top 53 bits, chance(p:) is true with probability p,
and a float range interpolates convexly so two finite bounds cannot overflow.
shuffle permutes through View.swap, so it works for a move-only element type, allocates nothing, and
covers a DynamicArray, a FixedArray or a sub-range alike — the sort shape. The platform seam is
kama_random.h; on a Windows target the driver links -lbcrypt beside -lws2_32.
Digest (std::digest) ✅#
import { std::digest::sha256::Sha256, std::digest::sha256::sha256, std::digest::sha1::sha1 }; — three
submodules, sha1, sha256 and sha512, each exporting a streaming hasher (make(), update(ConstView<uint8>),
finish()), a one-shot function of the same name as the module, its HMAC one-shot (hmacSha1, hmacSha256,
hmacSha512), and DIGESTBYTES/BLOCKBYTES. The digest is an InlineArray<uint8>#(N) — 20, 32 or 64 bytes on
the stack, no allocation anywhere, so all three are present in a --no-heap build.
std::digest itself holds what is written once over any of them. Each hasher implements the BlockDigest
contract (update, finishInto(digest:), digestBytes(), blockBytes()), and over it: Hmac<H>, the
streaming HMAC (RFC 2104) — Hmac.make(hash: Sha256.make(), key: k), update, finishInto(tag:) — keyed once,
so a copy MACs another message under the same key; and pbkdf2(hash:, password:, salt:, iterations:, into:)
(RFC 8018), which fills into with the derived key. Together they are SCRAM-SHA-256, PostgreSQL's default
authentication — tests/digest_scram_rfc7677.kama reproduces RFC 7677's client proof and server signature from
them. constantTimeEquals(a:, b:) checks a received tag (or a bearer token, a signed cookie) against the
expected one in a time that does not depend on where they first differ. == stops at the first byte that differs, so
its time says how many leading bytes matched. Unequal lengths are false at once, since a length is not the secret, and
the comparison reads both inputs through volatile in C, so the compiler cannot turn it back into an early exit (Go's
subtle.ConstantTimeCompare, Python's hmac.compare_digest). MD5 is not here: it serves only deprecated protocols,
and a package that needs one carries it.
import { std::digest::sha256::sha256, std::digest::sha1::Sha1, std::encoding::base64::encode as b64Encode,
std::collections::DynamicArray, std::collections::ConstView };
fn string demo(ConstView<uint8> v, ref DynamicArray<uint8> key, ref DynamicArray<uint8> guid) {
InlineArray<uint8>#(32) d = sha256(bytes: v); // one shot
Sha1 h = Sha1.make(); // streaming: any number of updates, one finish
h.update(bytes: key.view());
h.update(bytes: guid.view());
string accept = b64Encode(bytes: h.finish().view()); // RFC 6455's Sec-WebSocket-Accept
return accept;
}
Why a digest is in std when ciphers are not. Ciphers, key exchange and signatures are a package,
@kama/sodium, because they carry the constant-time burden and that is libsodium's job. TLS is a package
too, and a different one — libsodium has no TLS: a C TLS stack behind a ReliableStream wrapper (the planned
@kama/tls, over Mbed TLS), or TLS terminated at a reverse proxy in front of a plain TcpListener. A digest
is what NON-cryptographic protocols need — the WebSocket handshake, git object ids, content addressing,
ETags, the registry's own sha256-… integrity strings — and every peer with a batteries stdlib (Go,
Zig, Python, .NET, Java, Node) ships one; only Rust leaves it to a crate, and the sha1/sha2 crates
being among its most-downloaded is the argument against copying that.
SHA-1 is legacy — not collision-resistant — and its header says so; it exists for the protocols that
still require it. Anything new is sha256. Both are pure kama, spell their loads and stores big-endian
by hand, and give the same bytes on every target; the fixtures pin the FIPS 180-4 vectors (including the
million-a message, streamed in odd-sized pieces), RFC 2202 and RFC 4231 for HMAC, RFC 6070 and RFC 7914 for
PBKDF2, and the RFC 6455 example. ⚠️ Comparing a received MAC with == leaks through timing how many leading bytes
matched; a verifier compares every byte before deciding.
UUID (std::uuid) ✅#
import { std::uuid::Uuid, std::uuid::UuidError }; — RFC 9562 identifiers as a 16-byte value, made
two ways: v7, time-ordered and the default for a key, and v4, for an id that must not say when it was
made.
import { std::uuid::Uuid, std::uuid::UuidError, std::collections::Map };
type value Account { public int64 balance; }
fn uint8 main() {
Uuid id = Uuid.v7(); // later ids compare greater
Uuid tok = Uuid.v4(); // 122 random bits, no timestamp
string s = "${id}"; // "01932c07-a4b2-7c3e-8f1a-5b6c7d8e9f00"
Result<Uuid, UuidError> r = Uuid.parse(text: s); // either case in, strict 8-4-4-4-12
InlineArray<uint8>#(16) raw = id.bytes(); // network order: a binary column, a wire field
Optional<int64> ms = id.unixMillis(); // Some for a v7, None for anything else
Map<Uuid, Account> byId = Map.empty(); // Hashable, Comparable, Sendable, serde-ready
return 0;
}
Why v7 is the default. A v7 is a 48-bit Unix-millisecond prefix, 12 bits of sub-millisecond time and
62 random bits, so ids made later sort later and a B-tree or LSM index appends instead of splitting pages
at random. A v4 indexes like noise, and that is also its use: RFC 9562 §6.12 names the id whose creation
time must not leak. .NET 9, Python 3.14, Ruby 3.3 and PostgreSQL 18 all added v7 for the same reason;
Go, Rust and Zig leave UUIDs to a package, and the ubiquity of that package is the argument for std.
Six deliberate answers:
- A type, not a
stringconvention. Two big-endian words, so<is exactly the RFC's byte order (not Java's signed-longorder),bytes()is network order (not .NET's mixed-endianToByteArray), and nothing allocates. - Text is canonical and parsing is strict.
"${id}"writes lowercase8-4-4-4-12;Uuid.parsereads either case and nothing else — braces,urn:uuid:, 32 bare digits and whitespace areInvalidLengthorInvalidCharacter(at:), thehexstance: the caller strips a wrapper. It is aResultbecauseParseableis a primitive-only contract. - Strictly increasing within an isolate (RFC 9562 §6.2 Method 3). The 12
rand_abits carry the clock's sub-millisecond fraction, and a tick that does not exceed the previous id's becomes previous + 1 — under a coarse clock, inside one tick, and when the wall clock steps backward, where the embedded time runs ahead until the clock catches up (PostgreSQL 18'suuidv7()does the same). - Per isolate, not process-wide. The last tick is a module
static. Ids from two isolates interleave to within a tick and never collide, since each carries 62 bits from the OS generator; a cross-thread order is not something a reader of the ids could observe. - Every id draws from the OS generator (
std::random::entropy), because the random bits are only worth having unguessable (§6.9), and no pool keeps key-grade bytes in memory to save the syscall. An id is still a name, not a credential: a v7 dates itself, and anything that grants access by knowing an id wants a secret beside it. - On the wire a
Uuidis its canonical string, on every backend. It is serialized to meet a database or an HTTP API, and text that is not a UUID fails the read asDeErrorKind::Malformedrather than decoding to some id.
Encoding (std::encoding) ✅#
import { std::encoding::base64::encode, std::encoding::base64::decode, std::encoding::base64::encodeUrl, std::encoding::base64::decodeUrl, std::encoding::hex::encode as hexEncode, std::encoding::hex::decode as hexDecode }; — two submodules, base64 and hex, each exporting encode(ConstView<uint8>) -> string and
decode(string) -> Result<DynamicArray<uint8>, DecodeError>. That is the shape Go (encoding/base64 +
encoding/hex), Rust (base64 + hex), Zig and Python all converged on; only C# prefixes
(Convert.ToBase64String). Both export the same two names, so a file that wants both renames at the
import — the same as any colliding pair uses.
import { std::encoding::base64::encode, std::encoding::base64::decode, std::encoding::base64::encodeUrl,
std::encoding::base64::DecodeError, std::encoding::hex::encode as hexEncode,
std::collections::DynamicArray, std::collections::ConstView };
fn void demo(ConstView<uint8> v) {
string t = encode(bytes: v); // "Zm9vYmFy" — RFC 4648 § 4, padded
Result<DynamicArray<uint8>, DecodeError> b = decode(text: t);
string u = encodeUrl(bytes: v); // § 5 alphabet, UNPADDED — what a JWT carries
string h = hexEncode(bytes: v); // "666f6f626172", lowercase
}
Five deliberate answers:
- Two named pairs, not flags.
encode/decodeis the standard alphabet with=padding;encodeUrl/decodeUrlis the URL-safe alphabet without it. A call site reads which wire format it speaks. (The RFC's padded URL-safe form isurlSafeplus a stripped=tail; a third named pair would be a second spelling of two that exist.) - Decoding is strict, as in Rust and Go and unlike Python: a byte outside the alphabet is
InvalidCharacter(at:)with its position;=is accepted only where padding belongs and is not a character of the URL alphabet at all (InvalidPadding/InvalidCharacter); a length the encoding cannot produce isInvalidLength; and the unused bits of the last character must be zero (InvalidTrailingBits), so exactly one text decodes to a given byte string. - Whitespace is not skipped. Line-wrapped MIME input is the caller's
replace; a decoder that drops some bytes silently would have to decide which, and that decision is not its. - Hex writes lowercase and reads either case. An odd digit count is
InvalidLength. This is byte encoding, not integer formatting —${x}andparseRadixcover the number. - A decode fails, it does not come up absent —
Result, neverOptional, theparseline.
Both directions allocate their result, so they are @compileFor(!NOHEAP) and absent from a --no-heap
build, as sort is. The byte substrate is ConstView<uint8> in and DynamicArray<uint8> out; a string's
bytes reach encode through a DynamicArray<uint8> built by foreach (uint8 b in s).
Command-line arguments + environment ✅#
A program reads its own command-line arguments and environment through module core, imported by
name like any other module's symbols (import { core::args, core::envOr };). core is embedded in the
compiler, so it survives --no-std. It is not a std:: module because it cannot be one: arguments
enter through the compiler-synthesized main wrapper (which stashes argc/argv into a runtime global
before kama_main runs), so a --no-std user could not reimplement them. The user's fn uint8 main()
signature is unchanged.
import { core::args, core::Args, core::programInvocation, core::programName, core::programPath,
core::env, core::envOr };
// arguments (argv[0] is excluded — see programPath())
foreach (string a in args()) { /* each user arg, in order */ }
int32 n = args().count(); // number of user args
string first = match (args().get(at: 0)) { case Some(value: v): copy v; case None: ""; };
// program identity — three separate accessors, not part of args()
Optional<string> inv = programInvocation(); // argv[0] verbatim, e.g. "./myapp" (exact launch string)
Optional<string> name = programName(); // basename of argv[0], e.g. "myapp" (usage text / dispatch)
Optional<string> path = programPath(); // OS-resolved absolute exe path (find files / re-exec)
// environment — a keyed lookup, not a list
Optional<string> home = env(name: "HOME"); // None when unset
string term = envOr(name: "TERM", dflt: "dumb"); // value, or the fallback
args() -> Argsyields the user arguments excluding argv[0].Argsis a single value handle that bothforeach-iterates (implements Iterator<string>) and supports random access (count()+get(at:) -> Optional<string>) — one handle covers both, because the floor cannot hand back astd::collectionstype (collections are collected after the prelude). Each yielded / returned string is a fresh owned copy (giveit onward, or read it in place).programInvocation() -> Optional<string>is argv[0] verbatim — the exact string the program was launched with (./myapp,/usr/bin/myapp, or a baremyapp), unmodified. For logging fidelity or code ported from Go (os.Args[0]) / Rust (args().next()).Noneonly where there is no argv[0] (firmware).programName() -> Optional<string>is the basename of argv[0] — the name the program was invoked as (/usr/bin/app→app), what usage/error messages print and what a busybox-style multi-call binary dispatches on. Kept separate fromargs()(it is not a user argument). Spoofable (it is whatever the caller put in argv[0]);Noneonly where there is no argv[0] (bare-metal firmware).programPath() -> Optional<string>is the OS-resolved absolute path to the running executable — reliable for locating sibling files or re-exec'ing (queried from the OS via/proc/self/exe/_NSGetExecutablePath/_get_pgmptr, not argv[0]).Nonewhere there is no such notion or no portable query: wasm (a JS/browser host), bare-metal firmware, or an unsupported platform (a console port adds its own branch). TheOptionalreturn is what makes the MCU/wasm "not available" honest.env(name) -> Optional<string>is the primitive (a keyed lookup — the environment is exposed as a by-name query, not an enumerable list — the keyed lookup is the portable primitive; enumeration isenviron-shaped and absent on bare metal);envOr(name, dflt) -> stringis the common fallback wrapper.- Embedded (
--target embedded). There is no argv/environ on bare metal, sokama_args_initand the accessors are no-op stubs behind#if KAMA_TARGET_EMBEDDED(args()empty,env()/programName()None) — the surface still compiles, with zero libc linkage (getenvis a block-scope extern, elided). kama run -- <args>now delivers arguments end-to-end (the passthrough was inert before this landed).
Logging (std::log) ✅#
Leveled, tagged diagnostics — the configurable logger, a library over a small runtime seam, no new language
surface. Module core gives print/eprint (raw console output) and the language gives assert/panic (fatal checks);
std::log is the tier above: filterable, taggable, redirectable output that keeps running (a warn is a
log level, never an abort). Import it — the module is the discovery unit; it is not scattered as floor globals.
import { std::log::logInfo, std::log::logWarn, std::log::logError, std::log::logDebug, std::log::logTrace, std::log::logEnabled, std::log::setLogSink, std::log::LogLevel };
@generate(Formattable, of) type value Mix { public int32 voices; public float32 gain; }
fn string dumpState() { return "voices=8 gain=0.5"; }
fn uint8 main() {
string version = "1.2.0";
Mix state = Mix.of(voices: 8, gain: 0.5f32);
logInfo(tag: "boot", msg: "starting ${version}"); // tag may be "" (untagged)
logWarn(tag: "net", msg: "returning");
logDebug(tag: "audio", msg: "mix ${state}"); // state.format() runs ONLY if the record passes (v2)
if (logEnabled(level: LogLevel::Debug, tag: "audio")) { // logEnabled remains — for guarding a whole block
string dump = dumpState(); // a hole takes no call, so bind it first
logDebug(tag: "audio", msg: "mix ${dump}");
}
return 0;
}
Two axes, either suppresses a call. enum LogLevel { Error, Warn, Info, Debug, Trace } (ordered — Error
most severe, Trace most verbose) is the level; a free-text string is the tag. A call at level L
prints when L <= threshold(tag); the default threshold is Info (so Error/Warn/Info print, Debug/Trace are
suppressed until raised). Both are reconfigurable at runtime on a shipped binary — the QA/live-debug win
most compile-time loggers discard.
Configuration (runtime). One grammar, warn,audio=debug,net=trace — a leading bareword is the global
threshold, each tag=level overrides one tag (levels error/warn/info/debug/trace, plus off). Two
sources, --log primary, KAMA_LOG env secondary:
KAMA_LOG=debug ./app # env: global debug
./app --log warn,audio=debug # flag: global warn, but the "audio" tag at debug
KAMA_LOG=info ./app --log=off # the flag wins (overrides the env) → silence
The config source is the process-global env: every translation unit / isolate reads KAMA_LOG into its own
module-scoped static and gets a consistent answer (argv is a module-scoped static, so the --log flag is
bridged into KAMA_LOG once in main — see kama_log_init_args; only programs that import std::log emit
that call).
Baked project default (kama.json). A shipped binary has no kama.json beside it, so a project's default
filter is compiled in. The manifest gains a log section — a JSON object mirroring the same level vocabulary:
{ "log": { "level": "warn", "tags": { "audio": "debug", "net": "trace" } } }
The compiler translates it to the canonical spec (warn,audio=debug,net=trace) and seeds it into KAMA_LOG
in main only if the env is unset — so the full precedence is --log > KAMA_LOG env > baked kama.json
default > the built-in info floor. Invalid level names are a manifest error at build time.
Swappable sink. The default sink writes [LEVEL] tag: msg to stderr (kept off stdout so a CLI's real
output stays clean), colored on a tty. Install your own — the filter runs upstream, so a sink only ever sees
enabled records:
import { std::log::setLogSink };
fn void mySink(int32 level, string tag, string msg) { /* route to a file / engine console / telemetry */ }
fn uint8 main() {
setLogSink(s: mySink); // set once at startup, before spawning isolates — like setPanicHandler
return 0;
}
Modeled on setPanicHandler (a runtime-held slot), not a stored Logger object: a kama resource can't be
a module-static and an UnsafePtr to an interface isn't dispatchable, so the facade calls the extern
kama_log_dispatch, which invokes the C-held slot (or the built-in console default). On --target embedded
output routes through the same weak kama_log_sink as print (freestanding, no libc); config falls back to
the Info default (no argv/env on bare metal).
Zero-cost when filtered (v2 lowering). The compiler recognizes the five facade calls and lowers each to a guard with the message built inside it — so a filtered-out record never assembles its (possibly expensive) message:
logDebug(tag: "audio", msg: "mix ${state}"); // state.format() runs ONLY if the record passes the filter
Two axes reach zero cost. Level, compile-time: under --release a Debug/Trace call is stripped
physically (like debugAssert — gone at any -O; Error/Warn/Info stay). Level(above the floor) + tag,
runtime: an inlined kama_log_enabled(level, tag) guard (the same filter, reading the process-global config)
wraps the message build. So logEnabled is no longer a manual necessity — it stays available, but the guard is
now automatic at every recognized call site. To make a whole subsystem physically absent regardless of runtime
config, gate its declarations with @compileFor(FLAG). This is an AST lowering, not a preprocessor — typed,
hygienic, one grammar (the "${x}"/assert/print shape), exactly like Rust log/tracing.
unsafe fn — raw pointer memory access#
The only place kama can touch arbitrary memory through a raw pointer. Raw UnsafePtr<T> / UnsafeConstPtr<T> index/store is a
compile error outside an unsafe fn — so the entire dangerous surface is explicit and greppable, and
greppable at the declaration (grep -rn 'unsafe fn') rather than buried in a body. Everything else
(collections, smart pointers, FFI structs/handles/out-params, addr) stays safe.
unsafe marks the BODY, not the caller. This is C#'s meaning of the word, not Rust's: it says this
function does dangerous things inside, so calling an unsafe fn is unrestricted and its visibility is
ordinary. public unsafe fn is the common shape, not a contradiction — the function's signature is the
safe boundary, so a caller needs no permission. There is no propagation and no caller obligation. (Rust's
unsafe fn means the opposite — calling this is dangerous, the caller must uphold an invariant — which is
what forces containment there. kama does not take that meaning.)
It is markable wherever a body exists: a method, a ctor, a destructor, an operator, and a free function.
It is rejected where no body exists — on a type, on a field, on an abstract method, and on a contract
member — because there is nothing there to be unsafe. A contract member is a conduit: the implementation
whose signature names UnsafePtr must itself be an unsafe fn, and a caller cannot invoke the member
without holding an UnsafePtr. That is what keeps A: Allocator a perfectly safe bound while
allocate/reallocate/deallocate stay uninvocable outside an unsafe fn.
import { std::collections::FixedArray };
unsafe fn int32 sum(UnsafePtr<int32> p) {
p[0] = 10; p[1] = 32; // raw store (p[0] is *p)
return p[0] + p[1]; // raw read
}
unsafe fn int32 answer() { // no raw pointer in its signature, so a safe caller may call it
InlineArray<int32>#(2) buf = [0; 2];
return sum(p: addr(of: buf[0]));
}
fn int32 caller() {
return answer(); // calling an unsafe fn needs no ceremony — the signature is the boundary
// UnsafePtr<int32> p; // ERROR here: "local `p` is a raw pointer, so it requires an `unsafe fn`"
}
unsafe fn void upload(const ref FixedArray<float32> verts) {
UnsafeConstPtr<float32> data = verts.dataPtr(); // a `const fn` — Rust's as_ptr; `dataPtrMut()` is as_mut_ptr
usize n = cast<usize>(verts.length()) * sizeof(float32); // byte count: there is no `byteLen()` — one way, `length() * sizeof(T)`
// ... pass (data, n) to a C upload fn
}
A pointer to a struct is read the same way, through its element: p[0].x. The pointer itself has no fields, so
p.x is refused with that spelling — there is no ->, since [0] already is the one way to reach a pointee.
A method is reached the same way: p[0].get(), and p.get() is refused with that spelling.
(*p) and p->x, the C spellings, are refused with a note naming p[0].
The raw seam is a seam, not a second ownership system. An element p[i] of a raw UnsafePtr<T> is
untyped to ownership: p[i] = v is a plain store (the old bytes are overwritten, no destructor runs),
and nd[i] = od[i] is a bitwise relocate — which is exactly what a collection's own buffer needs, and why
the emitter does not resolve a class for a raw element in a store. A method call on a raw element
(p[0].m()) is not a store: it borrows the element in place, so it resolves, through a local pointer as
through a field one or a module static one. Nor is a member of the element (p[0].x, p[0].inner.x):
it names the pointee's field, spelled the way the pointee's type spells it — a type extern value's fields
are the header's — and private to that type as it is anywhere else. What stays
refused is a drop of the element: it takes the POINTER, not the element — drop(ptr: p) destroys *p and
drop(ptr: addr(of: p[i])) destroys one element — so the thing being destroyed is named rather than
inferred from an untyped slot. To take the value out or
release it instead: borrow it through a ref T parameter (fn f(ref T x), called as f(x: ref p[0])), or
own it — keep it in an Owned<T>, and release() it to a foreign API's userdata slot when it must
outlive a frame (see Smart pointers). A raw pointer is the foreign boundary, never general-purpose escape
(GOALS §3a/§3e). And addr(of:) takes the address of a place, never of a temporary: an element or
field whose root is a by-value call result (addr(of: f.view()[0]), a view minted by the call and dropped
at the end of the statement) is refused — bind the value to a local first — while a place-returning call at
the root (addr(of: b.at(i: 0))) is storage the callee still owns and is fine.
What requires an unsafe fn — the decision table#
One rule, and it keys on the TYPE, not the spelled token: an expression, declaration, or binding whose
type IS or CONTAINS UnsafePtr<T> or UnsafeConstPtr<T> may only occur inside an unsafe fn. Plus one more: calling an
extern fn requires one too.
| construct | example | verdict |
|---|---|---|
declare an UnsafePtr field |
UnsafePtr<T> data; |
legal — every container and every type extern value depends on it |
module static of UnsafePtr type |
static hardware UnsafePtr<uint32> gpio; |
legal — the MCU path depends on it |
| read or write such a field or static | this.data |
unsafe fn only |
| a local of raw-pointer type | UnsafePtr<T> p = …; |
unsafe fn only |
a signature naming UnsafePtr |
fn UnsafePtr<T> dataPtr() |
legal iff the function is unsafe |
| a contract MEMBER naming it | ctor adopt(UnsafePtr<T> raw) |
legal, no marker — bodiless; implementer and caller are each forced by their own types |
| bind one without spelling it | match (a.allocate(…)) { case Some(value: p): … } |
unsafe fn only — the rule reads the type |
| a call whose result is raw | kfree(p: make()) |
unsafe fn only |
addr(of: x) |
gpio = addr(of: led); |
unsafe fn only — it produces a raw pointer |
cast<UnsafePtr<T>>(…) |
cast<UnsafePtr>(0x40021000) |
unsafe fn only; stays possible for MMIO |
compare against null |
if (this.handle != null) |
unsafe fn only — it reads a raw-typed place |
declare an extern fn |
extern fn int32 abs(int32) |
no marker — bodiless |
call an extern fn, scalar-only included |
kama_close_socket(fd: fd) |
unsafe fn only |
inline asm(…) |
unsafe fn only |
The read-only raw pointer — UnsafeConstPtr<T>. C's const T* (bare, const void*), lowered
east-const as T const* so that UnsafeConstPtr<UnsafePtr<uint8>> is uint8_t* const* — a pointer to a
const pointer, which is what it says. It obeys the containment rule above exactly as UnsafePtr<T> does,
reading through it is the same unsafe read, and it differs in three ways: a
store through it — q[i] = x, q[i].f = x, a ref/out borrow of q[i] — is rejected;
a non-const fn call on q[i] is rejected as a call on a const receiver; and it
does not convert to UnsafePtr<T> — at a local's initializer, an assignment, an argument, a return —
while UnsafePtr<T> → UnsafeConstPtr<T> is implicit, as in C, and cast<UnsafePtr<T>>(q) inside an
unsafe fn is the explicit launder. It is what a const fn may honestly hand out: addr(of: …) on a
const root yields one, and so do dataPtr() on every contiguous container and string.cstr() (the
writable halves are dataPtrMut() and addr(of:) on a mutable root), so a buffer held const reaches
a const T* C API with no cast and no mutable borrow — Rust's *const T, Swift's UnsafePointer<T>, the stdlib's own get/getRef split applied at
the FFI seam. A const UnsafePtr<T> parameter is unchanged: a const binding of a mutable pointer,
still lowered const T*, which is why it accepts either pointer.
| call an unsafe fn | | unrestricted |
| unsafe on main | unsafe fn uint8 main() | rejected — see below |
Two of these are worth their own sentence, because the obvious weaker version of each is wrong.
The rule reads the type because a token rule leaks. match (a.allocate(bytes: n)) { case Some(value: p): … } binds an UnsafePtr and never spells the word — the payload's declared type is the template's
T. Before the type-keyed rule existed, that shape compiled into a double free with zero unsafe tokens
in the function.
A generic is judged as written. That match is the program's own code, so the type it binds is read with
the instance's arguments in. Inside the generic itself they are not: in DynamicArray<T>, T is never a raw
pointer, so DynamicArray<UnsafePtr<int32>> instantiates and its safe methods stay safe. The raw pointer is
the instantiator's: the local, the field read and the match binding that hold one need an unsafe fn, as
anywhere. What a generic spells for itself is checked all the same, by an instance over other arguments or,
when there is none, by the walk every uninstantiated generic gets.
extern has no scalar exemption. extern fn int32 kama_close_socket(isize fd) names no pointer and is
a double-close primitive by effect; roughly half the stdlib's extern declarations are pointer-free. Danger at
this boundary is a property of the callee's effect, which kama cannot see, not of its signature, which it
can — so a type-based carve-out would look like a rule and behave like a hole.
main returns the process's exit status, in the platform's own type. fn uint8 main() is the one portable signature — the one byte Linux and macOS keep — and on Windows fn uint32 main() is accepted too, for the 32 bits it keeps; a program that wants them writes it as a @compileFor(OS_WINDOWS) twin.
Any other return type is refused, and fn int32 main() names the fix: an int32 status was truncated by the OS at exit with no word, so return 256; exited 0.
fn uint32 main() is refused off Windows, where the status keeps one byte.
A literal status that does not fit is refused where it is written.
A computed one narrows explicitly: cast<uint8>(n) traps in every build when n does not fit, and truncate<uint8>(n) keeps its low bits on purpose.
Rust (ExitCode from a u8) and Zig (fn main() u8) put the range in the type the same way; every other language
inherits C's int and the truncation. std::process's ExitStatus is the parent's side of the same rule, and
std::app::quit takes the same types.
main may not be unsafe. It encloses the whole program, so the marker would put every line in
the trusted region and stop marking anything — the same shape as an extern fn call that needed no
marker at all. Since calling an unsafe fn from safe code is unrestricted (above), the fix is always
available and always better: move the raw work into a helper unsafe fn and call it from main, so
the marker names the region that actually needs it.
Definite assignment inside an unsafe fn: locals relax, out parameters do not. The split is
load-bearing. Relaxation exists because a raw store is invisible to the definite-assignment walker, so an
unsafe body's own initialization dance would otherwise read as a use-before-assign. Filling an out, by
contrast, is a contract with the caller — and since safe code may call an unsafe fn freely, relaxing it
would hand every caller a hole full of uninitialized stack. A raw fill still counts: addr(of: dst) marks
its target assigned, which is how a type-erased C call satisfies the rule.
a.dataPtr() bridges a collection's buffer to C (safe to call; the returned UnsafePtr is valid only
while the collection is alive + unmodified, and dereferencing it requires an unsafe fn). An unlowered construct
(including a safety-gate violation) is a hard build error — kama never emits incomplete C and claims
success.
Moving an owned value into a raw slot uses give: buf[i] = give w; (inside an unsafe fn) stores the bytes and
consumes w (its scope-drop is skipped — a use-after-move is a compile error), the one marker that
carries ownership across into unsafe manual storage. An unmarked slot[i] = x is a plain bitwise store
(the untracked raw-relocate a container uses internally, e.g. moving elements between buffers). Getting a
value back out is manual (bitwise-copy into a local, take responsibility) — there is no give-out of a
raw slot; a safe Slot<T>/MaybeUninit wrapper for both directions is a tracked design spike.
That move-out direction has one name: std::ptr::relocate(from:, at:, into:), which bitwise-moves
from[at] into an out parameter. std::ptr is the raw-pointer module, kept apart from std::memory
(the owning handles Owned/Shared/Weak) and imported explicitly. The source is left stale — the
bytes are still there — so the caller must vacate it (tombstone the entry, decrement the length) or the
value is dropped twice. Every container's move-out goes through it, which is what keeps addr(of: …) on a
slot out of the call site.
The module's other member is the honest reading of a C pointer that may be null: std::ptr::nonNull(p:)
returns Optional<UnsafePtr> — Some(p) or None, decided once at the FFI seam, so the fact reaches a
match instead of a == null test someone can forget. An extern fn cannot return an Optional (its
prototype comes from the C header), so this is how the stdlib reads every extern that genuinely returns null
(kama_diropen, kama_poller_create, the process argv/envp builders); readDir on
a missing directory is the Err that path produces. It is the inverse of the prelude's unwrapPtr/ptrOrNull.
Inline assembly — asm("...") ✅#
Some operations have no C-level equivalent: wfi/wfe (idle-sleep), cpsid i/cpsie i
(interrupt-masked critical sections), dsb/dmb/isb (memory barriers), cycle-exact delays. asm(...)
emits them directly.
unsafe fn void idle() {
asm("wfi"); // -> __asm__ __volatile__("wfi" : : : "memory");
asm("cpsid i\n\tdsb"); // multiple instructions in one \n-separated string
}
- A statement taking exactly one string literal (no interpolation — the text must be literal).
- Requires an
unsafe fn. Inline asm is the ultimate raw operation, so it lives in the same explicit, greppable seam as raw-pointer access.asm(...)outside one is a hard build error ("inlineasm(...)must be inside anunsafe fn"). - Always volatile + a memory clobber. Every
asm(...)lowers to__asm__ __volatile__("…" : : : "memory")— never optimized away or reordered, and also a full compiler memory barrier, socpsid i/dsb/dmbare correct by default (without the clobber the compiler could hoist memory ops across them — a silent footgun). Anopdelay with a memory clobber is harmless. There is no non-volatile / no-clobber form (one way, safe default). - Portability is yours. The text is target-specific; like FFI,
asm(...)breaks "runs anywhere C runs." It is allowed anywhere inside anunsafe fn(not gated to--target embedded). - Curated named helpers (
wfi(),disable_interrupts(),barrier()) are an ordinary library built on this primitive — the unsafe-core / safe-API-as-library model. Extended asm with operand constraints and@nakedfunctions are KR-83.
Conditional compilation — @compileFor(FLAG) ✅#
Tag a whole declaration; the compiler keeps or drops it for the active build. There is no
in-body branching — no static if, no #ifdef/comptime-if soup. Build-mode (DEBUG/RELEASE)
and platform (OS_WINDOWS/ARCH_WASM32/…) are the same primitive: a decl-level keep/drop gate.
@compileFor(DEBUG) fn void traceState(int32 s) { ... } // gone entirely in a release build
@compileFor(!RELEASE) static int32 assertsRun; // present in any non-release build
@compileFor(OS_WINDOWS, TELEMETRY) fn void ping() { ... } // comma = AND (both flags active)
⚠️ A platform flag is DERIVED FROM THE TRIPLE, and a built-in target NAME is not a flag. The
derived set is ARCH_<arch>, OS_<os>, ABI_<abi>, the synthesized HOSTED and SIMD128, plus the
name of a target the project declared. So NATIVE, WASM, EMBEDDED and WINDOWS are not gates
— they are undeclared flags, and this is deliberate: EMBEDDED is a
shortcut for a triple family, so gating on it would gate on how the build was spelled and would
silently stop applying the moment a real board triple (xtensa-none-elf) was used instead.
OS_NONE is the fact, and it holds for both. ⚠️ Getting this wrong fails silently in a loose
build, which reads no manifest and treats an undeclared flag as simply inactive — so the
declaration is dropped and the only symptom is a missing symbol somewhere else, or nothing at all if
both sides were gated. A manifest build rejects the name outright. These four spellings were in this
document's own examples until 2026-09-01, and a program built on kama nearly shipped them off
these pages; tools/check-compilefor.sh now greps the docs for them, because the half of that guard
which proves the compiler rejects such a name is exactly what made the docs drifting invisible.
⚠️ A triple component must be one some target kama KNOWS can produce — the built-in catalog, a
target the manifest declares under select.TARGET, or this build's own --target triple. Anything
else is an error naming the nearest known spelling: @compileFor(OS_WINODWS) is refused with "did
you mean OS_WINDOWS?". It holds in every mode — a loose file as much as a manifest build,
because OS_/ARCH_/ABI_ are reserved namespaces and therefore kama's names to validate rather
than a project's to declare — and wherever the name appears, negated included: !OS_WINODWS is
satisfied by every target there is and is the same typo, silently keeping the declaration
everywhere instead of dropping it. Until 2026-09-18 the prefix was a free pass: any spelling
validated, and the gate quietly deleted the declaration from every build, while @compileFor(WINODWS)
one level down was refused — one rule holding in one position and not its sibling.
- Where — any top-level decl:
fn,type,enum, modulestatic, and the three bodyless forms —extern "<header.h>";,extern fnandfnptr. Gating the extern pair is what lets two platform backends each own their own C header: a gated-outextern "<h>";emits no#includeand contributes no driver link hint (-lm,--js-library), so an emscripten build never reaches for<CoreAudio/CoreAudio.h>. A bodyless form accepts@compileForand nothing else —@noheapgates allocation in a body and@interrupt/@sectionattach to emitted code, and a declaration with no body has neither. ⚠️ A class member is NOT a gate site, and saying so is a hard error — not a silent no-op. The prune pass walks top-level declarations only, so a member's@compileForwould never be evaluated and never stripped: the conditional would fail open on every build. Gate the wholetype(which drops every member with it), or split the member into two types. (Per-member and per-implementsgating is a later stage.) - Logic — flag-set membership, a leading
!(negation), and comma = AND. Full&&/||/parens are deliberately out (this is tagging, not an expression language). - Drop is literal — a gated-out decl's symbol never exists; no
#ifdefreaches the emitted C, the Kama compiler does the selection. A reference from kept code to a dropped decl is a normal unresolved-symbol error — gate both sides, or provide a same-named fallback for the complementary flag. - Platform via the contract seam — a platform-agnostic
contract+ per-target@compileFor-gatedtypeimpls; exactly one survives per build. This is the tag-type abstraction boundary — one mechanism, not a second platform system.
type contract Clock for value { fn int32 tick(); }
@compileFor(!ARCH_WASM32) type value NativeClock implements Clock { ... } // native build keeps this
@compileFor(ARCH_WASM32) type value WasmClock implements Clock { ... } // wasm build keeps this
A flag and its negation, rather than two positive names, because that is what makes the pair both
exhaustive and mutually exclusive — exactly one impl survives on every target, including ones
nobody has built yet. (HOSTED would not serve here: emscripten has a libc, so it is hosted too.)
The working fixture is tests/compilefor_platform.kama.
The file gate — file @compileFor(FLAG); ✅#
The same primitive applied to a whole compilation unit, written as its first line:
file @compileFor(!ARCH_WASM32); // this file is not part of a wasm build at all
import { std::fs::exists };
fn int32 platformValue() { return 1; }
It exists because a package build compiles every .kama under its source root, whatever the import
graph — which is what lets the manifest alone prove there is no unreachable module, and what left a
native-only file with no way to sit in a project that also builds for wasm. Gating declarations was not
enough: the file still had to analyze on every target, so an extern over a platform header or a type
built on one sank a build that was never going to run the code.
- Excluded, not deactivated — a gated-out file is never admitted to the compilation. Its declarations,
its
exportlist, itsimports and its contribution to its module do not exist for that build. It is still parsed (that is how the gate is read), so a syntax error in it is an error on every target — deliberately, so gating a file does not quietly stop checking it. - First line only, ahead of
import { … };. The gate decides whether the file is in the build at all, so nothing may precede it. @compileForand nothing else —file @noheap;is a hard error, not a silent no-op, for the reason a bodylessexterngives:@noheapgates allocation in a body and@interrupt/@sectionattach to emitted code, and a file is neither.- A file the CLI names is a direct request — if its own gate excludes it, that is an error rather than a build that quietly produces nothing. A file the build collected (a manifest's source root, the language server's workspace) is skipped silently; selecting files is what collection is for.
- Both sides, as always. Gate a file and its complement (
FLAG/!FLAG) so exactly one is admitted on every target. Importing a module whose files are all gated out is an error that says so, rather than an unresolved name later in the file that did the importing.
file is a contextual keyword: one only in this position, an ordinary name everywhere else.
tests/file_gate.d/ is the working example — two implementations of one
function, one gated per platform, returning the same value on the native and wasm legs.
kama check covers every gate ✅#
A gate is a keep/drop, so the code it drops is never analyzed — which means code for a target, a build
type or a flag you do not build every day rots in silence, and the first report comes from whoever does
build it. So kama check owes every gate in your own files one analysis in which that gate is active:
$ kama check kama.json
src/impl_wasm.kama:11:0: error: unknown type `Zork` in a parameter — … [--target WASM]
kama: kama.json FAILED (2 configurations, 1 error)
It analyzes the configuration it was given, then adds configurations until each @compileFor and each
file gate has been active in one, and tags a diagnostic from any other configuration with the flags
that reproduce it. No cross toolchain is involved: this is kama's own analysis, and a --target WASM
check on a Mac needs no emscripten. The obligation is over the gates the program contains, not over the
configurations it admits — the latter is a product (TARGET × BUILD_TYPE × OUTPUT × every group × the
powerset of flags) and cannot be enumerated, while a gate is a conjunction of possibly-negated names
with no ||, so a covering set is computable and is what "this code is checked somewhere" means.
- Any explicit configuration flag means exactly that configuration —
--target,--select,--define,--undefine,--release/--debug,--no-heap,--shared. That is both the fast inner loop and the way a tag is reproduced: the tag is the command. - The cover does not follow the host.
kama checkreaches every target in kama's catalog on every machine — both architectures, not just the one that asked — and tries them in one written order: declared targets, thenLINUX,MACOS,WINDOWS,WASM,EMBEDDED, then the catalog's arch-pinned triples. So a gate is judged under a hosted target whenever one activates it, and as bare metal only when none does (OS_NONE,!HOSTED):!OS_MACOSchecked on a Mac is judged as Linux. The default analysis is still the host's own, so which gates need a cover differs per host (!OS_WINDOWSis active by default on a Mac). Building follows the host and its toolchain. Until0.9.759the built-in names were tried alphabetically,EMBEDDEDfirst, so!OS_WINDOWSchecked on Windows was judged as bare metal and refused aspawn fiberthat macOS and Linux passed. Until 2026-09-18 a built-in target name resolved its arch to the host's, so a type error behind@compileFor(ARCH_AARCH64)was found on an arm Mac and reported OK on an x86_64 box, andARCH_X86_64the other way round: half of the commonest arch pair in systems code went unchecked on every machine, and a different half per machine. - A gate no known target can activate is an error, and the error says what to do about it:
"no target kama knows has
ARCH_RISCV64— declare one underselect.TARGETin kama.json to write code for it". Declaring the target is what building for that platform needs anyway, and it brings the code into the cover. ⚠️ SPEC said the opposite until 2026-09-18 — that such a gate is stub code for a platform you do not support yet, compiling for nobody and breaking nobody. The allowance is lifted: a rule that cannot tell that case from a misspelling (ARCH_AARCH46) buys silence in both, and it left a category of code that compiles for nobody and is analysed by nobody. There is no longer one. - A gate that can never be active is an error —
@compileFor(DEBUG, RELEASE)names two values of one single-select group,@compileFor(X, !X)contradicts itself, and@compileFor(OS_LINUX, OS_WINDOWS)names two values of one triple component; no configuration compiles what any of them gates. The refusal is in the prune pass itself, where every gate passes, so every verb says it —kama buildand the language server included. A file gate that can never be active is refused the same way. - Only your own files owe the cover. A dependency's gates are that package's obligation, checked by its own CI.
kama buildis unchanged — one artifact, one configuration, judged fully.checkis the verb whose job is judgment, so the cover is its.
Your editor does the same for one file: a source the editor's configuration gates out is analyzed under a configuration that admits it, with its diagnostics tagged the same way — see editors.md.
The guard for the cover itself is tools/check-compilefor-cover.sh:
every axis, both tags, and the corpus swept; the editor half is section J of tools/check-lsp.sh.
Flags are reproducible — from the explicit build invocation, never ambient environment. They come
from two places: single-select groups (pick one value; its name becomes a flag) and the
multi-select flags bag (any number on at once).
TARGET— the platform this runs on, selected with--target. Its value is a<arch>-<os>-<abi>triple, and each component becomes a flag:ARCH_AARCH64,OS_LINUX,ABI_GNU, plusHOSTEDfor anyos != none. Built-ins:HOST(the default — this machine),MACOS,WINDOWS,LINUX,WASM,EMBEDDED. A project adds its own; anything containing-is taken as a bare triple, so--target aarch64-linux-gnuneeds no config at all.BUILD_TYPE—DEBUG(default) /RELEASE, selected with--release/--debug.OUTPUT—EXE(default) /SHARED/STATIC/OBJECT.- User flags — repeatable
--define NAME/--undefine NAME, declared inkama.json.
Gate on the derived flag rather than a target name: @compileFor(OS_NONE) holds for EMBEDDED
and for a real board triple like xtensa-none-elf, whereas a built-in name describes only how the
build was spelled — which is why built-in target names are not flags at all.
kama.json (a user-project file, named as the build's operand — kama build kama.json)
declares the valid user-flag universe and any extra groups, and turns on strict validation — an
undeclared @compileFor/--define name is then rejected (typo protection). A build that names .kama
files instead is a loose build: it reads no manifest at all, so it is permissive (an undeclared flag is
simply inactive) and bare single-file builds need no config. There is no auto-discovery and no
--config flag — the operand says which of the two you meant, which is the rule everywhere in the CLI.
{ "name": "myapp", "version": "0.1.0",
"select": {
"TARGET": { "RPI": { "triple": "aarch64-linux-gnu", "cc": "aarch64-linux-gnu-gcc" } },
"BUILD_TYPE": { "FAST": { "inherits": "RELEASE" } },
"CONSOLE": { "XBOX": { "default": true }, "PS5": {} }
},
"flags": { "TELEMETRY": { "default": true }, "PROFILING": {} } }
A single-select group takes exactly one value — --select CONSOLE=PS5 --select CONSOLE=XBOX is an
error — and inherits pulls the base in with it, so FAST activates RELEASE and gets its
optimization/stripping behavior without redeclaring it. A value may also carry build flags — cflags, ldflags,
cxxflags, objcflags, the names a target entry takes — added after the project's and the target's, base
first along inherits, to every translation unit (a dependency's included). That is where a sanitizer
build lives: "ASAN": { "inherits": "DEBUG", "cflags": ["-fsanitize=address"], "ldflags": ["-fsanitize=address"] } is a build type of any target, --select BUILD_TYPE=ASAN, beside --target. kama.local.json overrides defaults per
machine. Precedence: CLI > kama.local.json > kama.json > the built-in default. The practical
toolchain setup is in targets.md.
kama.json is the project manifest — modules, dependencies, targets and build settings
(packages.md, targets.md). It is parsed by the compiler driver (C++), not by the
language's own JSON library — the compiler is not self-hosted, so its build-time config can't run kama-level
code.
Writing a collection in kama — sizeof, panic/assert, place-returning methods ✅#
The above pieces (a place-returning operator[], UnsafePtr<T> + unsafe, generics, RAII) let a Vec/matrix
be written in the language rather than baked into the compiler. Three builtins complete the kit:
sizeof(T)/alignof(T)— the byte size / alignment of a type (ausize); both monomorphize, somalloc(n: n * sizeof(T))works in a genericVec<T>, andalignof(T)(→ C_Alignof) serves aligned DMA buffers / register-block layout asserts. Both are compile-time values for every type but atype extern value— they drive acomptimeinitializer, a comptime argument and anInlineArraylength (InlineArray<uint8>#(sizeof(Header))), including through a bound type parameter (sizeof(T) * 8inside a generic). A fixed-width scalar's size is the language's (int8…int64,uint8…uint64,char,float32,float64, on any target); everything else —usize,bool,string, an enum, a struct, a generic instance, a contract handle — is laid out by the C compiler, so kama folds it from a model of this build's target: the pointer width, C'slong, every scalar aligned to its size (anint64orfloat64member at 8), a Cenumat 4, a vector's alignment capped per arch, and the struct kama emits for the type (the vptr or embedded base first, then the fields in order,@packed/@alignapplied). The C compiler checks every answer: each baked type gets a_Static_assert(sizeof(T) == N && _Alignof(T) == A, …)in the emitted C, once per type, so a model that disagrees with the toolchain fails the BUILD, never the program. The model covers x86_64, aarch64, riscv64, wasm32, wasm64, 32-bit ARM, riscv32 and xtensa; on any other arch (i386 puts anint64member at 4) the fold is refused, naming the arch, and atype extern value— whose layout is the C header's, which kama never reads — is refused naming the field that holds it. Both keep working everywhere a run-time value works, and acomptime assertover them goes to the C compiler, which knows. The values are per build:sizeof(usize)is 8 on a 64-bit target and 4 on wasm32, sokama check --target WASMjudges a layout claim for wasm, and C written bykama transpileis for the target it was transpiled for.The three conversion verbs, told apart by what each PRESERVES. One conversion needs one spelling, so the verb is chosen by intent and the compiler holds you to it:
verb preserves width rule can fail? cast<T>(x)the value any yes — traps truncate<T>(x)the low bits target narrower or equal no bitcast<T>(x)all the bits same width no cast<T>(x)traps on a value that does not fitT, in every build — see No undefined behavior in arithmetic.truncate<T>(x)is the wrapping form and is required, not a convenience: masking first cannot express it (cast<int8>(x & 0xFF)yields 0..255, which is itself outsideint8, so it would trap in turn), and every language that traps ships a named truncating form — SwifttruncatingIfNeeded:, Zig@truncate, C#unchecked. A provably wideningtruncateis rejected: there are no high bits to drop, socastis what was meant.try cast<T>(x)is the fallible form, yieldingOptional<T>—Noneexactly wherecastwould trap. Liketry newit needs a declaredOptional<T>destination, because that is where its result type comes from — a local (Optional<int8> r = try cast<int8>(n);), an argument (f(x: try cast<int8>(n))), or the function's return type (fn Optional<int8> narrow(int32 n) { return try cast<int8>(n); }) — and nowhere else (tests/try_cast_value_position.kama, tests/xfail/trycast_no_destination.kama).truncateis a contextual keyword: a keyword only before<T>(, sostring'struncate(maxBytes:), any member of that name and a binding namedtruncateall read as names.An
enumis not acasttarget —try cast<E>(x)is the one door in. The table above is about numbers, whose valid values are a range fixed by width; an enum's are a set of names the author chose, so an integer arriving from outside has no reason to name one. That makes the infallible verb dishonest at both ends: when the value is known the variant already has a name (Color::Green), and when it is not the conversion is fallible by construction. Socast<Color>(n)is a compile error naming both answers, andtry cast<Color>(n)yieldsOptional<Color>—Nonewhen the value names no variant, tested against the declared discriminants rather than a range, so a value between two of them ({ Ok = 3, Bad = 200 }given 4) isNonetoo. Theenum→ integer direction is untouched:cast<int32>(Color::Blue)is total. Rust draws the line in the same place —200u8 as Coloris rejected, and so is0u8 as Color, because validity does not enter into it; Swift'sColor(rawValue:)likewise hands back an optional. The payoff is that an enum holding a value that names no variant is unrepresentable in safe kama, somatchnever meets one: the invalid byte is handled as aNonearm, by the same construct that reads the enum. Fixtures:tests/try_cast_enum.kama,tests/xfail/cast_enum_rejected.kama.bitcast<T>(x)— a same-width bit reinterpret of a numeric scalar, distinct fromcast<T>(a value conversion):bitcast<uint32>(f)exposes afloat32's IEEE-754 bits,bitcast<float64>(u)builds a double from auint64. Source and target must be equal-width numeric scalars (int8..int64/uint8..uint64/float32/float64); a width mismatch, a non-scalar, or an operand whose scalar type isn't statically known (bind it to a local first) is a compile error. Lowers to a no-UB ISO-C11 union type-pun. It is the safe-surface primitive for binary formats / hashing / endianness (std::numbyteswapF32rides it); raw-memory reinterpret of composites stays behindunsafe/UnsafePtr.assert(cond:, msg:)/debugAssert(cond:, msg:)/panic(msg:)— a clean trap (writes the message +file:lineto stderr, thenabort()— not UB, the user-facing form of the built-in bounds trap).msg:is mandatory (empty string allowed); a failedassertalso auto-appends the condition's source text (assert(cond: x > 0, msg: "")→assertion failed: x > 0 (game/f.kama:12)). Thefileis package-relative: the owning package's name, then the path under itssourceroot (game/f.kama,std/num/ops.kama,@kama/sodium/sodium.kama,<prelude>/global.kama; a loose file is named under the folder its build spans). It is the file the code was WRITTEN in, wherever it was instantiated, and no build machine's path reaches the binary (tools/check-panic-locations.sh). Every trap names the same location — a bounds miss, a narrowingcast, an overflow, a divide or shift fault, a string offset:kama: index 8 is out of bounds for length 2 (main.kama:7), in every build, and a panic handler is handed it (info.file(),info.line()).@trackCalleron a function, a method or an operator makes every trap andpanicin its body name its CALLER's line instead — Rust's#[track_caller]; inside another@trackCallerbody the location passes through, so a chain reports where it was entered. std's indexing, slicing and lookups carry it (DynamicArray,FixedArray,Deque,View,Map/SortedMap/SlotMapgetRef,SlotMap.getRefMut,PriorityQueue.peekRef,Template.part; agetRefMutthat implementsMergeByKeycannot, below), soxs[9]past aDynamicArraynames the line that indexed. It adds two hidden C parameters every direct call fills, which is why it is refused where no direct call reaches: a function bound to afnptr, a contract member's implementation, avirtualmethod, anexpose fn(a host calls it), a destructor (nothing calls one) and a constructor (built into storage too — a constructor whose input can be wrong returnsResult). It takes no arguments.debugAssertis identical but stripped under--release(dev-only checks);assertis always-on. For a premise that can be settled before the program runs, usecomptime assert— same arguments, checked at build. For a bug that can't continue; recoverable errors useResult<T, E>. A custom fatal handler (for a shipped game/GUI with no terminal) installs viasetPanicHandler(handler:)(modulecore) — it runs for cleanup/exhibition, then the runtime still terminates. It is handedconst ref PanicInfo info:info.message()is the text stderr shows afterkama:(panic: boom), andinfo.file()/info.line()the location,""/0when none is known — all built on the panicking thread's stack, so nothing allocates on the way to the abort. One thread runs the hook: another that panics meanwhile waits for it, and a panic inside the hook hard-aborts. A handler written to the old argument-less signature is refused, naming the one it must match. (kama aborts on panic — no stack unwinding; ≈ Rust'spanic=abort. The one exception is a declared@onPanicregion, which recovers before the hook and never reaches it — see Recoverable regions.) The full always-in-scope surface is catalogued in FLOOR.md.drop(ptr: p)— destroy the POINTEE of anUnsafePtr<T>(a no-op for a non-destructibleT). It is one leg of the manual-memory triad:Allocator.allocatehands out raw bytes → a value is placed in them →dropdestroys the value →deallocatereturns the bytes. Without it you could free the bytes but never destroy what lived in them. So it reaches exactly the places RAII cannot: a heap pointee behind a raw pointer (drop(ptr: this.p)— whatOwned/Shareddestructors do) and an element in a hand-managed buffer (drop(ptr: addr(of: this.data[i]))— what the containers do). It needs no rule of its own to stay insideunsafe: anUnsafePtrexpression already requires anunsafe fn, sodropsits behind the same gate as its two siblings, which is where it belongs. A destructor mutates what it runs on, so anUnsafeConstPtr<T>is refused. ⚠️ To end a LOCAL's life early, give it a scope —{ T x = …; }. A local is already owned, so running its destructor explicitly would run it again at scope exit. That is whydroptakes a pointer and not a place: the double drop is unspellable rather than diagnosed. The olddrop(value: place)form allowed it, and shipped that double free in six stdlib sites until0.9.290.The range forms —
drop(ptr: p, count: n)andcopyElements(ptr: d, from: s, count: n). The triad, a run of elements at a time:dropdestroys thenpointees frompon, andcopyElementsplaces copies ofs[0..n)into the storage atd, which nobody owns — whatever was there is overwritten, not dropped. The compiler decides per element type, exactly as it does fordrop(ptr:): an element with no destructor is dropped by doing nothing at all, an element whose copy is its bytes (a number, avalue, an enum of those) is copied by ONEmemcpy, and anything else takes its own destructor or its owncopyper element (astring's buffer, aCopyableresource'scopyctor, aSharedby one more reference). That holds in a debug build as much as a release one: 256 MiB ofuint8is one ~22 msmemcpyin both, where an element loop is 1.3–2.2 s at-O0. This is what the collections are built on —DynamicArray.addAll(items:),View.copyFrom(source:), every collection'scopy,clearand drop — and why they need no knowledge of their element type the language would otherwise have to expose; a type query a generic could branch on was declined, as the first step to thestatic ifkama does not have. A raw pointer has no length, so the count is written here and only here; the safe surface above takes a view, which carries one.from:may be either pointer of the pair;ptr:must be anUnsafePtr<T>of the sameT, andcount:anisize. Each element must copy ascopy xwould (Ownedcannot, a non-Copyableresource cannot). A negative count, or ranges that overlap, trap in every build, before anything is written: an overlap would overwrite source elements that are still live.sizeof(ptr: p)/alignof(ptr: p)— the size and alignment of the object anUnsafePtr<T>points at: the pointer forms ofsizeof/alignof, labelled likedrop(ptr:). They are what the triad's last leg must be given:deallocate(pointer, bytes, align)promises the layout the block was allocated with, and a base pointer can hold a derived object, sosizeof(T)would hand back a smaller block than was given out, and a pool that trustsbyteswould be corrupted. For a class with virtual members they read the most-derived layout through the object's vtable; for every otherTthey are exactlysizeof(T)/alignof(T). Read them beforedrop(ptr:), which ends the object's life:usize bytes = sizeof(ptr: this.p); usize align = alignof(ptr: this.p); drop(ptr: this.p); this.alloc.deallocate(pointer: cast<UnsafePtr>(this.p), bytes: bytes, align: align);That is
Owned/Shared's destructor. They take a pointer and nothing else, and never fold, since the answer is the object's, not the type's:sizeof(ptr: x)on a value is an error, as is any label butptr.addr(of: place)— the address of a place (a field/local/element) as anUnsafePtr<T>, or as anUnsafeConstPtr<T>when the place roots in aconstbinding (a const local or parameter,thisinside aconst fn). Taking an address is safe (either pointer is safe to hold); dereferencing staysunsafe. Lets a library type hold a live back-pointer to another's field (e.g. an iterator to its container's mutation counter).A place-returning method —
public fn ref T at(usize i) { … }returns a place, exactly likeoperator[], sov.at(i) = xworks. Aref Tresult must borrowthis, arefparameter, or static storage — a modulestatic, or the@globalAllocatorinstance viaglobalHeap::<Pool>(). Those three outlive the call; a place rooted in a local is refused, because it would dangle. A static is the least conditional of the three —thisand arefparam outlive the call only because the caller holds them — sostatic Counters g; fn ref Counters counters() { return g; }is legal, and a local that merely shares a static's name is not. Aforeach (ref …)binding and a borrowingmatchbinding are notrefparameters: each borrows its operand, so a place returned through one is judged by what the loop or the subject roots in, through any depth of nesting —foreach (ref e in this.rows)may handeback,foreach (ref e in local)may not. A place is second-class (used in-place, never stored): there is noreflocal and noreffield, and a value is read where it is or copied out. Its read-only form isconst ref T— the place aconst fnmay return (§const fnbelow).
foreach over a user type — the iterator protocol. A user container is foreach-able (not just the
built-in InlineArray) via a small iterator protocol — not indexing, so it works for any shape (list, tree,
map, range). It's zero-cost: monomorphized to direct calls (no vtable), and the container is
borrowed, not consumed. The container hands out an iterator via a nullary factory method, and that
iterator must implements the matching prelude contract — foreach is nominal: a type with the
right method shape but no implements is rejected (explicit over implicit).
- value —
foreach (T x in v):vprovidesfn <Iter> iterator()whose iteratorimplements Iterator<T>(fn Optional<T> next()—Someper element,Noneat the end); orvis the iterator (implements Iterator<T>+ a nullarynext()). - mutable —
foreach (ref T x in v):vprovidesfn <IterMut> iterMut()whose iteratorimplements IteratorMut<T>(fn bool hasNext()+ a place-returningfn ref T next()— Rust'siter()/iter_mut()split;Optionalcan't carry a place, so mutable is a parallel iterator). - read-only —
foreach (const ref T x in v):vprovidesconst fn <IterConst> iterConst()whose iteratorimplements IteratorConst<T>(fn bool hasNext()+ a read-only place,fn const ref T next()). It reads each element where it lives — a move-only element, or a big one, with no copy — and writes none: the binding is as deeplyconstas aconst refparameter. It is the loop a read-only collection has (aconst refparameter, aconstlocal, aconst fn's fields), since its factory is aconst fn; arefloop over one is refused and names this form, because arefbinding writes. C++'sfor (const auto& x : v), C#'sforeach (ref readonly var x in span). Every container withiterMut()hasiterConst(), and each keyed one'svaluesMut()hasvaluesConst(). Astringis immutable and has neither: its bytes bind by value. - A borrowing iterator holds an
UnsafePtrcursor (its ownunsafeinternals); theforeachsurface is safe. - The by-value and read-only factories are
const fn(iterator(),values(),entries(),iterConst()), so a borrowed collection — aconst refparameter — is looped like an owned one, and a collection a call RETURNS (foreach (int32 k in sm.keys())) is kept in a temporary for the loop and dropped after it. A view still needs itsborrowwindow, and arefloop over a temporary is refused, its writes landing in a dropped value.
The prelude contracts (type contract Iterator<T> for value, view, resource { fn Optional<T> next(); } and
IteratorMut<T>) are ordinary monomorphized generic contracts, so they double as a static bound —
fn sum<I: Iterator<int32>>(I it) (zero-cost, direct Concrete__next) or a dynamic fat-pointer value
Iterator<int32> it (vtable). foreach uses the same implements, checked nominally.
Iterator safety: growing a collection (add) while iterating it would be a use-after-free when the
buffer reallocates. The library DynamicArray guards against this at runtime (C#-style): a modification
counter is bumped on every structural change (add), each iterator snapshots it, and next()/
hasNext() panics if it changed — before the stale cursor is dereferenced. In-place element writes
(foreach (ref x in list) { x = … }) don't touch the counter and are fine — that's the point of ref.
The guard lives in DynamicArray's own kama source (not the compiler), so it's a stdlib policy: a hand-rolled
container chooses whether to pay for it. FixedArray/InlineArray are fixed-size and can't reallocate, so they
need no guard. (The iterator's back-pointer to the counter uses the addr(of: place) builtin — the
address of a place as an UnsafePtr<T>; safe to take, unsafe to deref.)
An InlineArray's kind follows its element's. Over a value — a number, a type value, a plain enum — it is a
value: copied bitwise, with nothing to drop, and it may sit in a type value or a module static. Over an element
that owns something — a string, a resource, an owning enum, an owning array — it owns its elements, as a
DynamicArray does, and drops each one at the end of its scope.
A hand-off of one says give (move) or copy (deep, element by element); a bare b = a is refused.
foreach (T x in a) hands each pass a copy the pass drops, and is refused when the element does not copy (borrow with const ref).
[v; N] copies v into every place, and is refused when the element does not copy.
Each element of a literal is handed off as xs.add(item: …) would take it, so a bare named string there is refused.
Such an array owns memory, so it is refused in a type value and as a module static.
A contract element is refused: it has no layout to store in place.
Copies are independent: writing one never reaches the other.
InlineStorage<T>#(N) is N places for a T in line that the compiler never constructs, copies as elements or drops.
It is the in-place half of "unsafe core, safe API": what a container written in kama keeps a live prefix in, as an
allocator's block is the heap half. It is laid out and aligned as T[N], so its sizeof/alignof are compile-time
facts; it is zero-filled at construction, InlineStorage<T>#(N) s = InlineStorage.empty(); or as a field; and its kind
follows T's — a value over a value, copied bitwise, and move-only over an element that owns something, though it
never drops anything either way: which places hold a T is the holder's knowledge.
Its only way in is dataPtr()/dataPtrMut(), whose pointer only an unsafe fn may hold.
It has no element operations: no index, no foreach, no operators.
Over an owning element it is refused in a type value, and copy does not apply to it.
It has at least one place, and a contract, with no layout, is refused as its element.
InlineStorage.empty() is its one constructor, and takes its instance from where the value goes.
An InlineArray is Equatable exactly when its element is, and compares element by element: ==, != and
a.equals(other: b), a T: Equatable<T> bound, a field of a @generate(Equatable) type, a collection's contains.
Each element goes through its own equality — == for a number, so -0.0 == 0.0 and a NaN is unequal to itself; the
element's equals for anything else — never a memcmp, which would compare padding. Rust's [T; N], C++'s
std::array and Go's arrays compare the same way; a digest compared to a secret wants std::digest::constantTimeEquals.
An element with no equality is refused, naming it, and an InlineArray has no other operator.
FixedArray has equals too, as DynamicArray always had.
Every contiguous container hands out the same two bridges, InlineArray included. dataPtr()
(a const fn) returns an UnsafeConstPtr<T> for C and dataPtrMut() the writable UnsafePtr<T> —
both safe to obtain, unsafe to dereference; string.cstr() is the same read-only bridge — and view() returns
the read-only ConstView<T> and viewMut() the writable View<T> for kama — which is what reaches borrow,
parallel_for (viewMut()) and every view-taking algorithm in the stdlib (sort, sortWith,
binarySearch). This matters most for InlineArray<T>#(N), the one
container that is stack-allocated, fixed-size and allocation-free — so the one a @noheap region is
obliged to use, and until it carried these two it was the one locked out of all of the above.
view() is also what lets a single signature serve every size: N is part of an
InlineArray<T>#(N)'s type (and of any contract that mentions one), while a View<T> carries its
length as a value, so fn void fill(View<float32> block) works for a 480-frame call and a 512-frame
call alike.
⚠️ A view over an InlineArray is bounded by the same rules as any other, and needs no extra
care despite the storage being on the stack: the escape checks key on the view, not on where the
buffer lives. Returning one over a local is rejected, and a view local still needs a borrow window
(tests/inline_array_view.kama shows both accepted spellings). View<T> is a stdlib type, so a
program that never imports it has no view to mint — the diagnostic says exactly that rather than
claiming the method does not exist. dataPtr() is unconditional.
Explicit SIMD — Simd<T> comptime(int32 N) ✅#
A lane batch: N numbers the CPU operates on as one value. It is an intrinsic value type, monomorphized
per (T, N), lowering to a C vector_size typedef — so a + b is one machine instruction, not a loop.
fn uint8 main() {
Simd<float32>#(4) a = [1.0f32, 2.0f32, 3.0f32, 4.0f32]; // an array literal — lanes written out
Simd<float32>#(4) k = [10.0f32; 4]; // the fill form IS a splat
Simd<float32>#(4) c = a * k + a; // elementwise; C's own operators
float32 x = c.lane(index: 2); // a bounds-checked lane read
Simd<float32>#(4) p = c.abs();
Simd<float32>#(4) lo = c.min(rhs: k); // also `max(rhs:)`
Simd<float32>#(4) rt = c.sqrt(); // also `floor()` / `ceil()` — FLOAT lanes only
InlineArray<float32>#(4) back = c.toArray(); // back to addressable memory
return 0;
}
sqrt/floor/ceilexist on float lanes only. They are libm on each lane (kama_math.h, which the compiler includes beside a float batch; the runtime header stays freestanding), folded to the vector instruction by both C compilers —sqrtunder-fno-math-errno, which the driver passes on every C compile. An integer batch has no such method; the diagnostic says so rather than inventing a per-lanesqrtf(int). The other operations are the same on every lane type (tests/simd_basic).Integer lanes obey kama's arithmetic rules, lane by lane. A signed
+ - *overflow traps in debug and wraps in release, exactly as the scalar of that type does, and a<<into the sign bit is defined the same way. ⚠️ None of that comes from the toolchain:-fsanitize=signed-integer-overflowdoes not instrument vector arithmetic, so the compiler emits the check (tests/trap/simd_lane_overflow). Unsigned lanes wrap at every width, as unsigned scalars do. Bitwise& | ^ << >>are C's own vector operators (tests/simd_int_lanes).sizeof(T) * Nmust be exactly 16, and the element must be a numeric primitive. 128 bits is the only width every kama target has — SSE2 on the x86-64 baseline, NEON on aarch64, wasm128 — and wider needs a-march-style CPU-tuning flag that does not exist yet. Both are compile errors naming the arguments, checked where the instantiation happens.Construction is the array literal, the same spelling
InlineArrayuses: one way to write "N values of T".[v; N]splats, which is the operation every other SIMD API names separately.No
foreach, nov[i], noset. Taking a lane batch apart one lane at a time is what the type exists to avoid;lane(index:)is the single read andtoArray()is the way out to memory.A target with no vector unit is not an error. The operations lower to correct scalar code — which is the whole reason kama exposes a type rather than per-CPU intrinsics.
⚠️ Reach for it only for what auto-vectorization cannot express. An ordinary loop over
Vec4s already compiles to SIMD on every target kama ships, and better: measured, a hand-written lane batch was 65% slower than the scalar source for a cross product, because the compiler de-interleaves an array of structs and does four at once.Simdis for the operations that have no scalar spelling at all, which are these three:
fn float32 lanes(Simd<float32>#(4) a, Simd<float32>#(4) b) {
Simd<float32>#(4) rev = a.shuffle(pattern: [3, 2, 1, 0]); // an arbitrary permutation
Simd<float32>#(4) mix = a.blend(rhs: b, pattern: [0, 5, 2, 7]); // two vectors: 0..3 from a, 4..7 from b
Mask<float32>#(4) gt = a.greaterThan(rhs: b); // a lane mask, as a VALUE
Simd<float32>#(4) pick = gt.select(ifTrue: a, ifFalse: b);
float32 total = a.reduceAdd(); // also reduceMul / reduceMin / reduceMax
return total;
}
- A shuffle pattern must be a literal, and that is the hardware talking:
__builtin_shufflevectorneeds integer constant expressions because the CPU encodes the permutation in the instruction. A constantwzyxis two register ops; the same permutation from a runtime value spills the vector to the stack and rebuilds it with four scalar loads. - An out-of-range lane index is an error —
shufflenames one vector (0..N-1),blendnames two (0..2N-1, where the upper half selectsrhs). Mask<T>#(N)carriesT, not justN, because a comparison's lane width follows its operand's: afloat32compare yields 4-byte lanes and anint16compare 2-byte ones, identically on gcc and clang. A bareMask#(4)would have no C type. Its lanes are all-ones/all-zeros bit patterns, so it carries no arithmetic operators — combine withand(rhs:)/or(rhs:)/not(), test withanyTrue()/allTrue(). That, andselectliving on the mask so it cannot be handed a data vector, is why it is a distinct type rather than aSimd<bool>#(N).Simdis notInlineArrayand notVec4. AnInlineArrayis a container — indexed, iterated, lanes meaning whatever you decide.std::math'sVec4is geometry — lanes namedx/y/z/w, meaning different things, laid out for a GPU vertex buffer. ASimd's lanes are interchangeable.
SIMD128 — are the lanes real? A derived @compileFor flag, read off the resolved target like
ARCH_AARCH64 / OS_LINUX / HOSTED. It is not "this target can compile a Simd" — every target
can, by degrading to scalars. It says the target has 128-bit vectors in its baseline (mandatory SSE2 on
x86-64, mandatory NEON on AArch64, wasm with -msimd128), so a library can pick a different algorithm
rather than hoping the fallback is fast enough (tests/simd128_flag):
@compileFor(SIMD128) fn int32 sum4(InlineArray<int32>#(4) a) { … Simd<int32>#(4) … }
@compileFor(!SIMD128) fn int32 sum4(InlineArray<int32>#(4) a) { … a scalar loop … }
Prefer it to a target name: SIMD128 states the capability, and a name only implies it. (Gating on a
target name is legal — names are ordinary flags through the TARGET group, and that is how platform
implementations are selected — so this is guidance about which question you are asking, not a rule the
compiler enforces.)
The codegen — that this really becomes a machine vector rather than four scalars — is asserted by
tools/check-simd-type.sh, because a value fixture passes just as happily against a scalar fallback.
Function pointers — fnptr ✅#
kama has no naked function pointers. fnptr declares an explicit, named function-pointer type
(independent of any user type) — zero-cost (a bare C function pointer, no wrapper). It is non-null
(must be bound; no null, no null-check at the call, and cb == null is refused), and binding a free function is
signature-checked.
(A bodiless fn is not a function pointer — a forgotten body is a clear error, never a silent type.)
fnptr int32 Comparator(int32 a, int32 b); // an explicit function-pointer TYPE
fn int32 cmp(int32 a, int32 b) { return a - b; }
fn uint8 main() {
Comparator c = cmp; // bind by name (positional, type-checked) — used directly
int32 r = c(a: 9, b: 2); // named invoke through the pointer
return 0;
}
A bare function name used as a value is its function pointer (Rust-like), so binding and passing need no
operator — c = cmp and f(cb: cmp) just work. That is the only place a function is a value: where an fnptr
(or a BindableFunctionPtr promoted from one) is wanted. Anywhere else — an operand (a * pi), a float32
local or argument — the name is refused, naming the call that was meant (pi()). An fnptr can also be a parameter (fn run(Op op, …) { op(…) } — the core callback shape), and it can be stored and invoked later — in a field or a module
static — which is the callback-registry shape: install a handler now, dispatch through it on a later
call (tests/fnptr_stored.kama). Binding is checked in every one of those
positions, not only in a local's initializer: a shape-mismatched bind elsewhere used to compile, and the
call through it then passed the wrong number of arguments — an indirect call through a mismatched
function-pointer type, which is undefined behavior.
Any value of an fnptr type binds another of that type — a field, an element, a module static, a call's
result — and a call through any of them has the signature's return type, so the numeric, kind and identity
rules judge its result like any call's. The callee of such a call is the value, never a function that happens to
share its name: a parameter decode is called through, whatever decode the program compiles elsewhere
(tests/fnptr_call_named_like_fn.kama).
Any expression of an fnptr type is callable: an element (handlers[i](x: 3)), a call's result
(pick()(x: 1)), a parenthesized operator ((ready ? onOk : onErr)(x: 1)). The callee is evaluated first, then
the arguments left to right, so handlers[0](x: reinstall()) runs the handler that was there before reinstall did
(Rust's, Go's and Java's order; C leaves it unspecified). Two function names chosen by an operator have no type
until a destination names one, since fnptr types are nominal (below), so bind the choice first: Op h = ready ? twice : thrice;.
An fnptr type is a type argument like any other, which is what makes a registry a collection:
DynamicArray<Op>, Map<string, Op>, a user Box<Op>, a generic function over one, and Optional<Op>, the one
maybe-a-callback (Rust's Option<fn>, Swift's (…)?, Zig's ?*const fn). It has no default, because there is no
null function to fill with, so a fixed array of them is FixedArray.filled(size:, value:), never make. A bare
function name gives a generic T nothing to infer from: write the type argument, id::<Op>(v: twice).
An fnptr is Equatable and Hashable, so == and the bound agree and a handler can be a Map key, be found
with contains, or be removed from a registry by identity. Two values are equal when they call the same code:
a function has one definition, so it always equals itself, and two different functions compare equal only when a
linker folded identical machine code into one, in which case calling either does the same thing. Equality is not
"same name". The hash is the address. Both methods are reached through a bound, as a primitive's are; == compares
two directly. (C, Zig, Rust and C# compare function pointers the same way; Go and Swift refuse it, because their
function values are closures carrying captured state, which a kama fnptr never is.) Constraint: a generic
function's instance is emitted per file, so one could have two addresses; if an instance ever becomes an fnptr
value, it must first get one.
A signature may carry @noheap, which makes non-allocating part of the type: @noheap fnptr int32 Op(int32 x); requires every function bound to it to be @noheap too, and in exchange a call through the
slot is legal inside a no-heap region — the one slot whose target no build can see. See No-heap subset.
Unbound method references — Type::method (zero-cost). A method lowers to Class__method(Class* self, …), so it's a function pointer whose first parameter is the receiver; the object is passed explicitly:
@generate(of) type value Vec2 { public int32 x; public int32 y; fn int32 dot(ref Vec2 o) { return this.x*o.x + this.y*o.y; } }
fnptr int32 DotFn(ref Vec2 self, ref Vec2 o); // receiver is an explicit first param
fn uint8 main() {
Vec2 u = Vec2.of(x: 1, y: 2); Vec2 v = Vec2.of(x: 3, y: 4);
DotFn d = Vec2::dot; // unbound (`::` = no instance, no binding) — zero-cost
int32 n = d(self: ref u, o: ref v);
return 0;
}
BindableFunctionPtr<Sig> — a callable that captures a receiver so you don't pass it each call. Unlike
the zero-cost fnptr, it carries an object (opt-in cost) and is RAII-managed. It's constructed like any
other object — by its constructor, BindableFunctionPtr.bind, whose signature comes from the local it initializes
(not new: a bindable is a value, and its object is already on the heap behind the pointer you hand in) — and
the ownership model follows the pointer type you hand in — no separate keyword:
fnptr int32 Compare(int32 a, int32 b); // NB: receiver is HIDDEN here (the inverse of an unbound fnptr)
type value Scaler { int32 k; public ctor make(int32 k){ this.k = k; }
public fn int32 apply(int32 a, int32 b){ return (a - b) * this.k; } }
fn int32 sub(int32 a, int32 b) { return a - b; }
fn uint8 main() {
Owned<Scaler> s = new Scaler.make(k: 3); // (constructed as Owned)
BindableFunctionPtr<Compare> c = BindableFunctionPtr.bind(obj: s, method: Scaler::apply); // MOVE-in (sole owner)
Shared<Scaler> s2 = new Scaler.make(k: 2);
BindableFunctionPtr<Compare> c2 = BindableFunctionPtr.bind(obj: s2, method: Scaler::apply); // RETAIN (shared owner)
BindableFunctionPtr<Compare> c3 = sub; // free-function PROMOTION (no object) — so this type "accepts either"
int32 r = c(a: 9, b: 2); // -> Scaler::apply(boundObj, 9, 2) = (9-2)*3 = 21
return 0;
}
An Owned obj: moves in (the bindable becomes the sole owner); a Shared obj: is retained
(refcount; the object lives while any owner holds it). Either way the object is released by the box it
came from: the bindable rebuilds that Owned/Shared and runs its destructor, so the object's own drop, its
allocator and its size are decided in one place. That rebuild uses the allocator's default value, so the
box must draw from a stateless allocator (GlobalAllocator, or any Allocator with a default ctor) — a box
in an arena (BumpAllocator) is refused, since a default one would point at no arena. The obj: is a box
over a concrete type, because the method is named T::method. A bare fnptr / free
function promotes in with a null object — so a BindableFunctionPtr<Sig> parameter accepts both free and
bound callables, while fnptr stays the zero-cost free-only form. It is move-only (it may uniquely own
its object): returning one from a factory transfers ownership; the captured object's destructor runs
exactly once when the bindable finally drops.
Because a bound one owns its receiver, handing one bindable to another is an ownership transfer and
takes the same marker every other owning type takes: give, and only give. A bare b = a is
refused — it would leave two handles releasing one object — and copy is refused outright: the
handle is type-erased, so whether its receiver is unique or refcounted is only known at run time, and the
compiler cannot decide which of the two a duplicate would need (Owned refuses copy for the same
reason). Promoting a free function is not a hand-off — it transfers nothing — so it takes no
marker, and a give/copy there is refused rather than ignored. Reading a bindable after it was
given is a use-after-move like any other, including calling it.
FFI: an extern fn may take an fnptr type as a param; passing it hands C the raw pointer. C requires an
exact function-pointer-type match (incompatible fn-pointer types are a hard error), so when the C callback
signature is one kama's fnptr doesn't spell identically — most commonly const-qualified parameters —
name the callback via a header typedef and cast to it at the edge:
extern "<stdlib.h>";
extern "cb.h"; // typedef int (*CompareFn)(const void*, const void*);
@callerThread fnptr int32 Comparator(UnsafePtr<int32> a, UnsafePtr<int32> b); // qsort calls back on this thread
extern fn void qsort(UnsafePtr buf, usize nmemb, usize size, CompareFn compar);
...
Comparator c = cmp;
qsort(buf: a.dataPtrMut(), nmemb: 4, size: 4, compar: cast<CompareFn>(c)); // qsort WRITES: the mutable half; cast to the header's fn-ptr type
Exposing to a host — expose ✅#
extern is the host→kama direction (kama calls C); expose is the reverse — it gives a free
function a stable, host-callable entry point, decorated with KAMA_EXPORT for external linkage that survives
dead-code elimination. Its C symbol is its module path joined to its name with _: expose fn tick in
module game::sim exports game_sim_tick. C has one flat namespace and kama's modules exist to keep names
apart, so the module travels into the symbol — two modules may each expose tick, and neither collides with
the other or with a libc open/time in the host's translation unit. A loose file is in no module, so its
file name qualifies it (gameplay.kama → gameplay_update). Inside kama an exposed function is an ordinary
function of its module: it is exported, imported and called by its kama name. _ is the join rather than
kama's internal __, which C++ reserves in any identifier. @linkName("symbol") is the one way to fix a
symbol verbatim — Rust's #[export_name], the same attribute an extern fn uses to bind one (FFI — calling
C) — for a name someone else chose: a plugin API's entry point, an interrupt vector's handler, a host that
already knows the module by host_tick:
// gameplay.kama — a hot-reload module (note: no `main`)
type value World { public float32 time; }
expose unsafe fn void update(UnsafePtr<World> w, float32 dt) { /* … */ } // dlsym("gameplay_update")
expose fn int32 version() { return 3; } // dlsym("gameplay_version")
@linkName("host_tick") expose fn int32 tick() { return 41; } // dlsym("host_tick"); there is no `gameplay_tick`
(tests/expose_module_scope.d/ proves two modules' symbols through the linker, tests/linkname_expose.d/ the
@linkName; tests/support/expose_shared_check.sh both through dlsym.)
Native shared library:
kama build --shared gameplay.kama -o libgameplay.so(→.dylib/.dllper platform) builds a-fPIC -shared -fvisibility=hiddenlibrary where only theexposed symbols are visible. A hostdlopens it anddlsyms"gameplay_update"/"gameplay_version"— the reload loop (dlopen/watch/rebind overunsafe/UnsafePtr) is an ordinary library, not compiler magic. A--sharedmodule needs nomain.WASM: a normal
kama build --target wasmrun exports eachexposed function from the.wasmmodule (KAMA_EXPORT→EMSCRIPTEN_KEEPALIVE) —WebAssembly.Module.exports()listsgameplay_update, and an embedder instantiating the module calls it directly — no--shared(it is native-only; the web host re-instantiates the module). It is not reachable asModule._gameplay_updateoff the generated.js, which is a program rather than a library (tests/expose_basic.kama).The host header. A build of a program that exposes anything also writes
<project>.hbeside its output (a loose build names it for the output, less a library'slib): the prototype of every exposed function and every type they name, generated from the same tables as the definitions, so a host never hand-writes a declaration that can drift from the kama signature. The project's owncsourcesget it on their include path. It is plain C (clean C11 and C++17 under-Wall -Wextra -Werror, with anextern "C"guard) and needs no kama header. Each type is spelled for the host: a C primitive as itself andccharaschar; atype extern valueby its C name, with the header including the file thatexterns it; a kama type by the same module qualification as a symbol (geo::Vec2→geo_Vec2), opaque when it is reached through a pointer orref— its layout stays kama's to change; atype extern enumby its C name, including its header like atype extern value; atype expose enumas its integer typedef plus one constant per value (geo_Kind_Square = 4, or@linkName's name for the type); afnptras a function-pointer typedef; and a layout kama owns and promises —View/ConstView,InlineArray,Simd— from its real fields (a compiler intrinsic in no module is prefixedkama_). Two types that would share a host name are refused. The header is regenerated by every build, removed by a build that exposes nothing, and never written over a file kama did not generate (a loose build writes beside its source, whereshim.hmay be the user's own).type expose value— a layout kama owns. A host header can only name a kama value opaquely unless kama promises its layout, and the marker is that promise. The header then publishes the whole struct from the kama declaration — the pair oftype extern value, whose layout a C header states:@linkName("FrameStats") // the host's name for it; otherwise module-qualified type expose value Stats { public int32 frames; public float32 dt; public ctor of(int32 frames, float32 dt) { this.frames = frames; this.dt = dt; } } expose fn Stats frameStats() { return Stats.of(frames: 60, dt: 0.016); }typedef struct FrameStats { int32_t frames; float dt; } FrameStats; /* in the generated header */ FrameStats game_frameStats(void);The rules follow from what a published layout is. It is a
value, since a resource, view or contract owns, borrows or dispatches, which C cannot honor. It is not generic — a host needs one struct. Every field ispublic, because the header shows the whole struct and C has noprivate; and every field is something C can hold — a primitive, a pointer, afnptr, atype extern enumortype expose enum, anothertype extern valueortype expose value, or anInlineArrayof those. It is never alsoextern: one side owns a layout. It is built by its constructors and may have methods, like any value;@packed/@alignreach the header. Within kama it is emitted under kama's own C name — struct tags never reach the linker, so the host's spelling and kama's are the same layout.@linkNamefixes the host's name, and only on this form, since no other type has a name a host sees (on an enum, likewise only on atype expose enum). Fixture:tests/expose_value.d/, C building, reading and passing each shape.
Rules (checked at compile time — a clear error, never a silent no-op):
- Free functions — and one type form.
exposeis not a member modifier; on a method or field it is rejected. On a type it is spelledtype expose value(below), directly aftertype. - C-ABI-safe signature. A param or return may not be an owned-by-value type — a kama
string, a collection (DynamicArray/FixedArray/Map/Set/…), or anOwned/Shared/Weaksmart pointer — since RAII / refcount state cannot cross a raw C boundary; pass anUnsafePtr<T>or anexternstruct instead. A struct passed by value must be atype extern value— see A struct crossing by value under FFI. - No generics / no
fn ref Tplace-return (no single concrete C-ABI symbol); exported symbols are unique across the program, whether kama derived the symbol or a@linkNamefixed it — and since the_join is not injective (a_b::canda::b_cmeet), two names that produce one symbol are refused, naming both.
The published header keeps the kama spelling of every parameter name, while the implementation C
prefixes them (§ C names). A C prototype's parameter names are not part of its ABI and may differ from the
definition, and the header is documentation for the host author, compiled in the HOST's macro environment —
which kama cannot see. So a parameter named near reads as near in the header a Windows host includes,
and a clash there is the host's to resolve, exactly like a clash on an exposed field name.
expose is distinct from export (module public-surface visibility) and public/private (member
access): three boundaries, three keywords. (The full 2.0 expose — richer wasm module exports and the
scripting-host interface — is future work; the keyword is live today for the C-ABI boundary above.)
C names ✅#
kama lowers to C, and the C preprocessor is not scoped: a macro from any included header rewrites a
matching identifier anywhere it appears. One generated <name>.gen.h carries every extern header, so
importing std::fs anywhere puts the whole OS seam in front of every module's names. Measured: 4,708
macros over the shipped header set on macOS, 21,748 over one Windows TU.
So every name kama owns reaches C in a namespace no header can rewrite. The registers:
| register | holds |
|---|---|
KAMA_… |
kama's macros |
kama_… |
what the compiler owns — runtime types and their members, emitted members and temps, the prelude's own scope |
k_… |
what the user owns — fields, variant payloads and union members, parameters, locals, bindings, contract/vtable slots |
kF_… |
a file's private declarations — kF_<file>__…, or <module>__kF_<file>__… in a module |
kS_… |
a scoped package's declarations — @south/net's who is kS_south__net__who, so two scopes' net never meet |
<project>__… |
an unscoped package's declarations — geo__area, std__collections__DynamicArray…, core__println |
They are disjoint by construction, not by convention: k_ has _ at index 1 and kama_ has a, so no
user name can ever produce a compiler-owned spelling. k, an uppercase tag and _ is a prefix kama
builds (kF_, kS_), set apart by its second character in turn. A user name that already starts with k_ becomes
k_k_…, and the mangling stays reversible — strip exactly one k_. Two rules close the rest. A project's name
keeps out of k, kama, anything starting k_ or kama_, and k plus an uppercase letter alone or before
_ (packages.md), since its <project>__ prefix would open one of the other registers (a
project k's x was k__x, which a local _x also emits; a project kF_app's h would be app.kama's
private h). And a name may
start with __ but not contain one — __ is how scopes join in C, so a type a__b in geo would be
geo__a__b, module geo::a's b. A project's name, a scope and a module segment are held to the same,
since they are joined the same way. Nor may a joined name start or end with _: beside an edge _ the
join could move, so project geo_'s module x and project geo's module _x would both be geo___x, and type
T_'s method m and type T's method _m both T___m. The rule holds for every name kama joins: a project, a
scope, a module segment, a file's name (its private names are kF_<file>__<name>), and a declaration's own
name — a top-level declaration, a type's method, constructor or constant. A local, a parameter, a field and a
binding are k_<name> and joined to nothing, so __ok and a field a_ stay legal. With both rules, every run
of underscores longer than one in a C name is exactly one join, and kama demangle reads it one way. The one
place that rule meets C is the declared
surface: an extern fn whose C name has __ inside it is bound with @linkName.
The declared C surface keeps its spelling, because code on the other side depends on it: extern fn
names, type extern value fields, type expose value / expose enum fields and values, and the parameter
names in a published host header (§ Exposing to a host).
None of this reaches a human: a diagnostic, a hover, a kama query answer and an editor completion all
show the name as written.
Where one does reach you, kama demangle turns it back. Three things read names straight out of the
binary and cannot be taught otherwise — a debugger, a C compiler's own output under --keep-c, and a
crash log or objdump:
kama demangle app.kama -- kF_app__Pair_int32__make # -> Pair<int32>::make
kama demangle app.kama -- kS_south__net__who # -> @south/net::who
It is a subcommand rather than a name map written beside the build, because a map is a record and a
record can diverge from the binary being debugged — a stale one renames a frame to something plausible
and wrong, silently. It runs the front end, because the reverse of DynamicArray_int32_kama__GlobalAllocator
is DynamicArray<int32> and the argument spellings (and the dropped default) live in the resolved
program, not in the text of the name. Names are rewritten in place, so a whole line of C survives its
surroundings, and stdin is read as a batch so one process serves a whole debug session.
The reversibility rule above is what makes it possible: strip exactly one k_. Only a token no
table claimed is stripped, because a declaration's own leaf was never prefixed — a type the author
called k_Box in kk.kama is emitted kF_kk__k_Box and demangles to k_Box, while a local called
k_near is emitted k_k_near and demangles to k_near.
Values are a separate matter from names: kama demangle --lldb-init prints the command that loads the
LLDB formatters shipped beside the runtime headers, after which a string inspects as its text and an
Optional as Some(…)/None rather than as the lowering.
The residual, stated. kama compiles headers it has never seen, so a user's own extern "<vendor.h>"
could in principle define a macro spelled k_…, kF_… or kama_…. tools/check-c-names.sh holds the line for
the surface kama ships and the system headers its seam pulls in, re-measured wherever the suite runs —
because a macro surface moves with every SDK release. A vendor header that squats on kama's registers is
the one case this rule cannot close, and it is a bug to report rather than a hazard to live with.
Control flow ✅#
if/else, while, do/while, for, foreach, break, continue, return; the full operator set
(+ - * / %, bitwise, shifts, comparisons, && || !, ternary ?:), assignment ops (= += …), ++/--,
casts. Branching on an enum is done with match (see Enums & match below); arbitrary-integer branching
is done with if / else if. There is no switch statement: match on an enum, if/else if on an integer — one construct per concept (GOALS 4). A value pattern (case 1:) and a match over an integer, a string or a bool are refused, naming that rule.
&&, || and ?: evaluate an operand only when the result needs it, so n > 0 && a[0].isEmpty() never
indexes an empty a, and the arm ?: does not choose never runs — its calls, its temporaries, its drops.
break and continue act on the innermost while, do, for or foreach. A match is not a loop,
so a break in one of its arms leaves the loop around the match, and outside any loop either word is an
error:
while (true) {
match (queue.next()) {
case Some(value: job): { run(job: job); }
case None: { break; } // leaves the `while`, not just the `match`
};
}
In a parallel_for body, continue ends that element's pass. break and return are errors there: the
elements run on several workers at once, so there is no rest of the loop to skip, and the body does not run
in the enclosing function. A loop nested in the body is an ordinary loop.
Every branch and loop body must be braced. if, else, while, do, for and foreach each take a
{ … } block — never a bare statement, and never an empty ;:
if (n > 0) { return 1; } // ok — and a one-line body is fine, the rule is about the braces
if (n > 0) return 1; // ERROR: the body of `if` must be braced
if (n > 0); // ERROR: binds the branch to nothing
A bare body is where goto fail;-shaped bugs live: a later edit adds a second statement, it indents as
though it belongs to the branch, and it does not. kama has no whitespace rule to fall back on, so the brace
is the only thing that can carry that meaning — requiring it makes the bug unrepresentable. The rule is
about braces, not line breaks: if (x) { return; } on one line stays legal, because a braced body
cannot silently acquire a second statement.
The one exemption is else if. The else arm accepts a block or another if, so a chain stays flat:
fn int32 rank(int32 n) {
if (n > 100) { return 4; } else if (n > 10) { return 3; } else { return 0; }
}
That if is the branch — it cannot grow a sibling statement the way a bare body can — and requiring
else { if (…) { … } } would nest every chain for no safety gain. scope, parallel_for and
match are unaffected: they already require a block (a match arm's case P: expr; is an expression, not
a statement body).
Unreachable code is an error. A statement after one that never completes is refused, naming what leaves:
a return, break or continue; a panic; a while (true) or for (;;) with no break out of it; or an
if/else or match whose every branch does one of those. Reachability is read off the structure, never off a
folded constant, so if (true) { return 0; } followed by code stays a debug early-out, as in Java; a break from a
match arm inside an expression leaves its loop as any break does; and a value-producing match arm may end in
any of them, since it yields nothing.
while (true) { if (done()) { return give xs; } step(); }
return give xs; // ERROR: the `while (true)` above has no `break`, so nothing after it runs
Dead code is bug-shaped — Apple's goto fail (CVE-2014-1266) was a duplicated jump that left a certificate check
unreachable — and a checker that judged it would report mistakes in code that cannot run. Java and Zig refuse it
too; Go accepts it and leaves it to vet; Rust, Swift, C# and Kotlin warn.
kama has errors only, never warnings. A diagnostic refuses the program, or it is not printed. A warning is the
compiler saying it does not believe its own output: the code is wrong, and should not build, or it is fine, and
saying otherwise is noise. Treating warnings as errors does not repair that; it ties a build to one compiler's lint
set, so a newer compiler's new warning breaks code that was correct yesterday — which is why Rust discourages
#![deny(warnings)] in a library and caps the lints of dependencies, and why distributions patch -Werror out of C
packages. Every language with warnings also grows a second language to silence them (#[allow], #pragma,
@SuppressWarnings). An error in kama is a rule of the language, stated here with a fixture, so it changes only when
the language does. Advice that is not a rule — an import the package does not declare — is an editor hint from the
language server, never a line on stderr.
How far an expression may go#
kama accepts or refuses a source the same way on every machine: the answer does not depend on the host's stack, on
ulimit, or on which C compiler is installed, and kama check and kama build give the same one. Three limits say so,
each with a message that names it:
- A chain holds at most 10,000 links on its deepest path (). Each operator,
call, member access or index is a link, so
a + b + cis two andx.f().g()is four. The count passes through parentheses and arguments, so a chain inside a chain adds to it, and two chains side by side do not. - An expression nests at most 400 levels in the C it lowers to (,
). A chain past 64 links lowers flat, one C temporary or statement a link, so this limit
does not reach it and it costs linear time (), and means what the nested form means
(). The exception is a chain of calls that return a place, since a copy of a place is
not that place: about 130 of those. Each temporary sits in the function's frame, so a very long
stringchain can overflow a small stack when it runs, as a large local array can: wasm's default 256 KiB holds 6,000 terms and not 8,000. - The parser's stack holds 1,200 grammar symbols (): about 195
else ifarms, 290 nested calls or 1,180 nested blocks.
Past any of them, split the expression across statements or move the inner part into a function.
Type declarations — value / resource / view / contract / enum / intrinsic ✅#
Every type declaration is introduced by the type marker followed by a kind — parallel to fn on
every function, so declarations are greppable and self-describing:
type value Name { … }— owns nothing, copies freely (amemcpy; no hidden shared refs). Sealed (novirtual/abstract/final), no destructor. Fields default private; mark a fieldpublicper field (avaluewith all-public fields is a plain-old-data struct). Because it copies, avaluemay hold noresource— not an owning one, and not one that owns nothing but has identity (anAtomic<T>, a token), directly, in an enum payload, or through a generic instance: copying the holder would make two of it.type resource Name { … }— owns something, or has identity: move-only, RAII-dropped. Fields are private only (ownership stays encapsulated). An emptytype resource Token { }is a valid move-only identity/token. Extensible variants add a qualifier aftertype:type virtual resource,type abstract resource,type final resource.type view Name { … }— a non-owning, stack-only borrow (a slice/span; C#ref struct). It copies like a value (inline, no dtor) but owns nothing and is a second-class borrow: the escape check forbids it as a field of anything but another view, a collection element, astaticor a box — a type that holds one is a view itself (Views compose) — and allows it as a return only when it borrowsthisor aref/view parameter (the same structural rule as aref Tplace-return — no lifetime tracking). A local of the kind must root in aborrowwindow or a by-value view parameter, and its host is frozen for that window's extent, so it cannot outlive the storage it views — see Slices / spans for the window rule. A view is also never aref/outparameter: it is already a borrow. A view may not own a resource field, and its fields are private only (its rawUnsafePtr<T>must not leak). It may declare a~dtor, which closes its window rather than freeing anything — see The window — and such a view passes onlyref/const ref, the one exception to the rule two sentences up. A view's conformance is checked at theimplementssite: it may not implement a contract whosectormember constructs the implementer from parameters that carry no borrow (noUnsafePtr<T>, noref, no view) — such a constructor could only borrow one of its own locals, so no body could satisfy it. A member taking something borrowable is fine, and an instance method returning the self-type is always fine (it borrows the receiver, likeView.slice()). The check is a signature-level pre-filter for what no body could satisfy, not a replacement for the body-level escape check; it is also necessarily partial, since a marker contract declares no members at all (its factory lives in the impl) and stays caught later, at the boxing site. The flagship is the stdlibView<T>; the kind is general (type view StridedView<T>,Grid2D<T>, …). See Collections & strings forView<T>.type contract Name for <kinds> { … }— a public-only guarantee (an interface); signatures only, no bodies, no fields, no dtor. Actormay be required (a conformer has to supply that constructor), which is what lets a bound construct:T.fromStr(s: …). Types satisfy it viaimplements; it may refine another withimplementstoo (type contract Animated for value, resource implements Drawable { … }— a conformer must supply Drawable's methods as well, and dispatch throughAnimatedreaches them). Theforclause is mandatory and names which kinds may implement the contract — see below.type enum Name { … }— a plain set of variants or a tagged union. See Enums &matchbelow; it takes the sameimplementsclause as every other kind.intrinsic— the kind a built-in is (int32,float64,bool,char,string). The language declares these, so no program writestype intrinsic; the word appears only in a contract'sforlist (for value, intrinsic).
type adapter <targets> implements C { … } is not a kind: it declares no type. It writes contract C's
conformance for types declared elsewhere — a built-in, a std type, another package's type, one generic
instance, or a generic's every instance — in the module that declares C, one block for a whole set of
targets. See type adapter below.
The full model + rationale is in TYPE_MODEL.md. The kind words value / resource /
view / contract, and adapter, are contextual, not reserved — because they appear only right after
type (and intrinsic only in a contract's for list), they remain ordinary identifiers everywhere else
(int32 value = 5;). enum is the one kind word that IS a
reserved keyword, for the historical reason that it predates the type marker; that costs nothing, since
nothing else could be spelled there. The qualifiers virtual / abstract / final are reserved words;
type itself is contextual (below).
type may still NAME a binding — a field, a local, a module static, a parameter or a method — and
be read, written and passed like any other name. It is a contextual keyword: it leads a declaration
only where a declaration can begin, and is an ordinary identifier everywhere else. That puts it beside
copy/give/truncate/default/base, and beside the kind words above.
This exists for the FFI and could not be worked around there: webgpu.h calls a field type in four
structs, and an extern struct emits the literal C name, so unlike a function — whose kama binding is
simply renamed — the field is assigned BY NAME and renaming it emits the wrong C. Omitting it is not a
lesser evil: the field then holds 0, and 0 is WGPUBufferBindingType_BindingNotUsed, so an explicitly
built bind-group entry for a uniform buffer or a sampler is inert rather than under-specified.
type extern value WGPUBufferBindingLayout {
public UnsafePtr nextInChain;
public uint32 type; // the literal C name — no escape, no rename
public uint32 hasDynamicOffset;
public uint64 minBindingSize;
}
b.type = WGPUBufferBindingType_Uniform;
fn void configure(uint32 type) { … } // and it reads as an ordinary name everywhere else
configure(type: WGPUBufferBindingType_Uniform);
The contract for clause — which kinds may implement it ✅#
A type contract must declare its implementers: type contract C for <kinds> { … }. The clause takes
any combination of the five implementable kinds, comma-separated, meaning any of these:
type contract Rankable for value { … } // one kind
type contract Iterator<T> for value, view, resource { … } // a borrowing iterator is a view, a
// generating one is a value, and one
// that OWNS its source is a resource
type contract Hashable for value, resource, enum, intrinsic { … } // anything that can be a Map key
contract is not among them: a contract implementing a contract is refinement, a different axis,
already spelled by implements on the contract itself. The separator is a comma and only a comma — +
was rejected because it means conjunction in a generic bound (<K: Hashable + Equatable> = satisfy
all) and would mean disjunction here, one symbol with opposite senses.
Every kind is enforced, and each is judged as what it was declared, not as what it lowers to: a
type view codegens like a value (same layout, same copy) but implements as a view, so a contract
that does not name view rejects it. @generate-synthesized conformances go through the same gate — a
@generate(Serializable) type value needs Serializable to name value.
Widening a clause is a non-breaking change and narrowing one is not, so state the kinds a contract is
for, not merely the ones implementing it today: the clause is a design statement, and a set narrowed to
the current corpus is easily narrower than the contract's audience. Real names value alongside
intrinsic for exactly that reason — nothing but float32/float64 implements it yet, and a
user-defined soft-float value type is what it exists for.
(for both is gone. It named an arbitrary pair the moment there were more than two kinds.)
What an implements clause is checked against ✅#
An implements clause is a promise about signatures, and every part of it is verified at the
declaration — not at the call sites, and not by the C compiler:
- the member exists, and is
public(a contract is a public guarantee; a private method satisfying it would be reachable through the contract but not by name); - its return type matches the member's, including a
ref T/const ref Tplace-return — exactly, in both directions; - each parameter type matches, and so does the arity, each parameter's
ref/out, and each parameter's label — kama call sites are label-based, so the label is part of the call surface the conformance promises, not decoration; - the receiver matches: a contract member may be declared
static fn(Hasher::finishis zero-state, soH::finish(raw)monomorphizes to a direct call), andstaticmust then agree on both sides. A requiredctoris exempt — one is static by construction. A static member is called on a TYPE: it has no receiver, so a contract value cannot dispatch it and has no slot for it, andh.finish(raw: 5)through a value is refused, naming theT::finish(…)spelling; const fnon the member is honored, and so isconston a parameter. Only in one direction: an implementation may be more const than the member asks, which widens where it can be called and breaks nothing.
Matching is on the resolved type, so an alias, an import spelling, or a generic contract's
substituted parameter (Iterator<T> implemented at T = int32) compares equal — what differs is what
the two would lower to.
This applies to every kind that can conform, including an enum's conformance and a type adapter
block's, to a contract-declared operator (int32 operator+(int32 rhs) — the generic-math bound
shape), and to a conformance that dispatches only statically. A @viewable contract is the one
exception: it emits no vtable and no fat-pointer type, so its members are nominal markers rather than
slots — Iterable<T> declares fn Iterator<T> iterator() and every container correctly returns its own
concrete iterator type.
override keeps the same promise, and is checked the same way. A derived method stands in for the
base's through the base's slot, so its signature must match in every position above. It is not
covariance-aware: a derived return type would be a language feature with its own rules and its own
lowering, and accepting "any subtype" here would let a hierarchy promise what the vtable cannot keep. It has to be checked here: a
contract's vtable slot is filled with a cast, so a mismatch is invisible to the C compiler at the
declaration and surfaces — if at all — at a use site, naming mangled types the author never wrote. A
mismatched member reached through the contract does not fail, it silently does the wrong thing
(returning int64 through an int32 slot yields the low 32 bits).
type value Counter {
int32 value; // fields are private by default
public ctor make(int32 start) { this.value = start; } // named ctor (`public` to call from outside)
public fn void add(int32 n) { value = value + n; } // method (implicit self)
public fn int32 get() { return value; }
}
fn uint8 main() {
Counter c = Counter.make(start: 40); // stack value — dot-on-type construction, not `new`
c.add(n: 2); // a `value` copies on hand-off
return 0;
}
Fields, methods (take an implicit self), named constructors, field initializers (run in the ctor),
this.field, obj.method(args). Lowers to a struct + Counter__method(Counter* self, …) functions.
Members are private by default; new is reserved for the heap (Owned/Shared element construction), so
a stack value uses Counter.make(start: 40), not new Counter.make(...). A constructor may also be called
inline in a call argument — f(x: Counter.make(start: 5)) — it materializes a temporary passed by value (a
value copies, a resource moves); use a local for a ref/out parameter. On a generic type the argument
position pins the instance just as a declaration does: the parameter's type is the destination
(take(b: Box.empty()) at a Box<int32>), and with no destination the constructor's own arguments bind its
type parameters (generic(b: Box.of(v: x)), Box.of(v: 8).get()). With neither, the constructor is refused as
the mistake, the receiver of a method call included ("cannot tell which Tag to construct").
new builds an enum on the heap with the variant's own spelling — Shared<Geo> g = new Geo::Circle(r: 4);
(and new Geo::Point() for a payload-less one). A value is never converted into
a heap handle over its own type: Shared<Pt> p = Pt.make(x: 1) and Shared<Geo> g = Geo::Circle(r: 4) are
compile errors that name new, in every position a handle is bound.
(Boxing a value into a handle over a contract — Owned<Hashable> h = 20, Owned<Error> e = ParseError.of(at: 3)
— is a different operation, and legal: the value moves onto the heap, or is copied there, as any by-value hand-off
moves or copies it, and the handle drops it. It happens wherever a handle is bound — a local, an assignment, a
return, a match or ?: arm, a field initializer, an argument, a variant payload.
A ?: arm may not move a named value into the box, as no arm may move one.
A handle with its own allocator is filled with new(allocator: …) instead, since a value boxed in place comes from the
global heap.)
A type that owns a heap resource (a collection, an Owned/Shared/Weak, or another resource) is
declared type resource and is move-only:
import { std::collections::DynamicArray };
type resource Buffer {
DynamicArray<uint8> data; // owns heap → resource; fields stay private
public ctor make(int32 n) { this.data = DynamicArray.empty(); }
public fn isize size() { return this.data.length(); }
}
A value that transitively owns a resource is a compile error ("declare type resource"), and a ~dtor
is allowed only on a resource (~dtor ⟺ resource — a value owns nothing to free).
A kind that follows the type's parameters — the kind clause. A generic type that owns nothing of its own
holds whatever its argument holds, so its right kind depends on the argument: Pair<int32> should copy as its
bytes and sit inside a type value, and Pair<string> should move and drop its strings. A kind clause after
the name says so:
type resource Pair<T> value when [T: value] {
T a;
T b;
public ctor make(T a, T b) { this.a = give a; this.b = give b; }
public const fn const ref T first() { return this.a; }
}
The declaration is a resource, and it is checked as one for every instance (its fields stay private). Each
instance whose condition holds is a value instead: copied as its bytes, with no destructor and no copy
constructor, and not Copyable by declaration (that contract is a resource's; a value satisfies the bound by its
kind). So a ~Pair() and a ctor copy belong to the resource instances alone, and a conditional
implements Copyable<This>(bare: give) when [T: Copyable<T>] beside the clause serves them.
The clause narrows a type resource and nothing else; its word is value; the type is generic; and each condition
is P: value over one of its own type parameters. A value is sealed, so the clause takes no extends, virtual,
abstract or final.
A value instance must own nothing: a field the clause's parameters do not decide (a string name) is refused at the
first instance the clause makes a value. A resource instance (Pair<string>) is a resource everywhere, so a type value may not hold it.
An enum needs no clause: its kind follows its payloads by nature (Optional<int32> is a value,
Optional<string> a resource), and an InlineArray and an InlineStorage follow their element the same way. C++
makes the same property conditional (inplace_vector<T> is trivially copyable exactly when T is), as do Rust
(#[derive(Copy)] emits impl<T: Copy> Copy) and Swift (extension Box: Copyable where T: Copyable); each makes
the cheap property the conditional one over an owning base, which is the clause's shape.
value is a structural bound. Beside default, value asks of an argument what a kind clause asks: does it
own nothing? It holds for a primitive, a raw pointer, a type value, a value instance of a kind clause, an enum
whose every payload is a value, and an InlineArray/InlineStorage of values; not for a string, a heap
collection, a smart pointer, a resource, a view or a contract (both borrow). It gates a member or a
conformance (fn int32 width() when [T: value]) and bounds a type parameter (fn void f<T: value>(T x)), where
the body may then copy a T as its bytes.
A kind word names no type. value, resource, view, contract, adapter and intrinsic say what a type
is, so none of them names a type or a type parameter: type value value { … } and type contract resource for value { } built through 0.9.687, and would make [T: value] mean two things. As a field, binding or parameter
name each stays legal — Result::Ok(value: x).
RAII / destructors ✅#
A ~Type() destructor runs deterministically at scope exit, in reverse construction order, on every path
(block end, early return, break/continue). Destructible fields are destroyed in reverse declaration
order, after the destructor's own body — and a return in that body ends the body, not the destruction: the
fields, and a base's destructor after them, still run. No GC; allocation/deallocation is predictable.
Construction ✅#
A constructor is a named factory that returns a fully-initialized object, or an error. There is exactly
one kind, spelled ctor (no fn, no static — it is implicitly type-associated), and everything is one,
including deserialization and copying.
type resource Buffer {
UnsafePtr<uint8> data = null; // a field default states the empty value
int32 size;
public ctor make(int32 size) { this.size = size; } // the value under construction is `this`
public ctor withCapacity(int32 n) { return Buffer.make(size: n); } // reuse = an ordinary call
}
fn uint8 main() {
Buffer b = Buffer.make(size: 8); // dot-on-type: construction
Owned<Buffer> h = new Buffer.make(size: 8); // `new` composes — heap, an owning handle
return 0;
}
- Dot-on-type is construction, and only that.
Type.name(…)constructs;Type::staticFn()andEnum::Variant(…)keep::. So.make(greps for construction and catches nothing else. There is no namelessType(…)call form for a kama type — it silently dropped its arguments, and it is a hard error in every position (Type(…),new Type(…),try new Type(…),new(allocator: a) Type(…), and a reassignmentx = Type(…)) — atype extern valueincluded, which kept that spelling as by-name aggregate init until it gained constructors. A generic ctor puts the turbofish on the type:T::<Args>.make(…). - Nothing is constructible by default. A type with no
ctorand noof/zeroopt-in cannot be built, and the diagnostic is context-aware: it offersof/zeroonly for a transparentvalue(all fields public), never for aresource. - A constructor builds its value where it will live. A declared local (
Buffer b = Buffer.make(…)), anewblock, a field under construction (this.b = Big.make(…)), a field initializer, a returned value and amatcharm each hand the ctor their storage, so a largetype valuecosts one copy in its own place rather than two or three (tools/check-construct-frame.sh). A fallible ctor builds in theOkpayload of theResultit is handed. An argument that reads the object being built (this.c = Big.copyOf(src: this.b)) is the exception: that value is built apart, then stored. A local that receives a function's result (Big b = newBig(…)) is declared with it, so the C compiler hands the callbas its return slot, and a[v; N]field default is written into the field element by element: neither holds a second copy, in debug or release. - Reuse is a visible call. A ctor delegates by calling another (
return Buffer.make(…)). There is noinithook, no designated/final ctor, and no mandatory funnel — shared logic lives in the ctor others chain to, and it is greppable. - A self-returning
static fnis rejected as a disguised constructor; so is a class-named ctor (public Buffer(…)). Genuine static utilities returning other types (Vec3::dot→float) staystatic fn. - A contract may require a
ctor—type contract HeapOwner<T> for resource { ctor adopt(UnsafePtr<T> raw); }— and generic code bounded by it may construct through the type parameter, monomorphized to the concrete implementer. That is why there is no privilegedDefaultcontract: "default construction" is just a contract requiring a zero-arg ctor.
Complete initialization — enforced ✅#
A constructor must assign every field, checked at compile time. The returned value is complete by
delegation when the ctor's terminating move is return Other.make(…), so chaining stays clean. This is
kama's answer to "a returned object is always fully initialized" — it is proven, not conventional. The proof
is definite assignment, path by path: a field assigned in both arms of an if/else, or in every arm of a
match, is assigned after it; one assigned in only some arms is not; and a return inside a branch is judged
by what that branch assigned. A ctor whose every path returns — a trailing if/match whose arms all
return — never falls off its end, so it owes this nothing: a fallible ctor that returns only an Err or a
delegated Ok is complete as written.
Two escape hatches, both explicit and at the declaration rather than hidden in codegen:
- a field initializer —
UnsafePtr<T> data = null;,int32 len = 0;— states that field's default once, and it runs in every ctor (and for a bare local). Because it is stated once, it is read in the scope the TYPE was written in, wherever the value is made — even inside another type's body, such as std'sDynamicArray<T>, and it reads nothing of the place it runs: not a constructor's parameter, not another field, and notthis; set such a field in the constructor. @generate(zero)blesses a whole data bag's zero state (a transparent all-publicvalue).
What is exempt is not a carve-out but a guarantee the compiler supplies: a field whose type has a
default — string and the other language-owned types whose zero representation is their empty value
(see Collections — the four-ctor matrix for the one rule), and a type with a default ctor, which the
compiler calls at the fill site (element by element for an InlineArray of them). A number and a raw
pointer field are still assigned: their zero is a value, but a forgotten one is a bug, not a choice.
type resource Ring {
UnsafePtr<uint8> data = null; int32 len = 0; // stated defaults — every ctor inherits them
int32 cap; // no default -> every ctor must assign it
public ctor withCapacity(int32 cap) { this.cap = cap; }
}
The idiom for a raw handle follows: give the field's empty value a niche rather than letting zero double
as "unset". std::fs::File declares int32 fd = -1, so its destructor is if (fd >= 0) and descriptor 0
(stdin) is an ordinary ownable handle — Rust's OwnedFd.
The value under construction is this, and it needs no declaration. A constructor's whole job is to
produce the type before it returns, so the storage is implied by the function itself — and since definite
assignment already proves every field is set, a declaration would add ceremony, not proof. Declaring
uninitialized storage of the type being built inside its own ctor is therefore an error: this is the
only name it has. (An initialized local of the same type is untouched — it is a finished value like any
other.) Falling off the end returns that value, exactly as a void function need spell no return;
return give this; is the early-return form, and a bare return; is refused for naming nothing to return.
this is never assigned whole — not in a constructor and not in a method. A constructor's this
is the value being built, so building it with another constructor is return Foo.a(…);, and "delegate, then
change one field" is a private constructor that takes every field. A method borrows its receiver, so it assigns
the receiver's fields, or returns the new value for the caller to assign (l = l.next();). Replacing a receiver
whole (C++'s *this = …, Rust's *self = …) is a non-goal: an enum's receiver is a copy, and the assignment
would change nothing the caller can see.
A ref this argument on such a receiver is refused for the same reason.
A const ref this one reads it.
self is reserved inside a type body. It is the emitted C name of the receiver pointer, so a local or
parameter called self anywhere in a type — method, constructor or static fn — is a compile error
pointing at this. Outside a type body it is an ordinary identifier: a free fn or fnptr may name a
parameter self to spell an explicit receiver.
Collections — the four-ctor matrix ✅#
Every growable collection (DynamicArray, Deque, Map, Set, SlotMap, BitSet) offers empty() /
withCapacity(n) using the default GlobalAllocator, and withAllocator(a) / withCapacityAndAllocator(a, n)
for a caller-owned allocator. PriorityQueue carries capacity on its backing array; the B-tree
SortedMap/SortedSet keep their own shapes, and the always-sized FixedArray has its two builds (make(size:)
from T's default, filled(size:, value:) from a copy) each with an allocator twin. The canonical zero-arg build
is marked default, which is what makes such a field default-fillable elsewhere.
The default-allocator conveniences are gated when [A: default] — a structural bound (the argument
bound to A must itself have a default ctor; there is no nominal Default contract; value is the other
structural bound, under A kind that follows the type's parameters above). So
DynamicArray<T, BumpAllocator>.empty() does not exist: you get a clean "not available for this
instantiation" error rather than a collection with a zero allocator. Use withAllocator for a custom A.
One rule for a default. when [T: default] holds, and T.default() exists, for exactly the same
types. A language-owned type whose all-zero bytes are a real value has its default by nature: the
numbers, bool, char, UnsafePtr/UnsafeConstPtr (null), string (""), Simd/Mask (zero lanes),
and an InlineArray whose element has one (element by element). Every type a program declares — a
value, a resource and an enum — has one only by electing it: variant order must not pick a
value, and type enum Code : uint8 { Ok = 3, Bad = 200 } has no zero member at all. An enum elects the
way a value does, type enum Level { Debug, Info; public default ctor info() { return Level::Info; } }.
A never-null handle (Owned, Shared, Weak), an fnptr and a BindableFunctionPtr have none: each
one's zero is a null pointer. Until 0.9.480 the bound held for every enum and every intrinsic, so
FixedArray<Owned<Shape>>.make(size: 1) built a null handle in safe code, and T.default() still refused
int32 though the bound admitted it.
Calling the election — T.default(). The mark names which ctor is canonical; T.default() calls it
without the caller knowing the name the author chose (empty, zero, closed, …). It works on any type
that has a default by the rule above — for one that has it by nature there is no ctor, and the value is its
zero — and it is what makes the when [A: default] bound usable from kama rather than only by the
compiler's field fill:
ctor fresh() when [A: default] { this.item = A.default(); }
Electing a default stays the type's choice: a type that never marked one has no default(), and the
call site is a compile error naming that choice rather than a silently synthesized zero
(tests/default_ctor_call.kama, tests/xfail/default_ctor_missing.kama).
Derives — @generate(...) ✅#
One opt-in surface. Every name is opt-in by design; a hand-written member always wins over the synthesized body, and the nominal conformance is registered either way.
| Name | Synthesizes |
|---|---|
Serializable / Deserializable |
the reflective wire methods — see Serialization |
Formattable |
a field-dump format(ref Formatter) — Type { f: v, … } |
Equatable |
a memberwise equals(ref This); also what gives the type == / != |
Hashable |
a field-walked hash(), FNV-combined in declaration order |
Comparable |
a lexicographic compareTo(const ref This) over the fields in declaration order; also what gives the type < > <= >= and makes it a sorted-container key |
BytesViewable / Blittable |
bytes() / bytesMut(), a view of the value's own bytes — plain value only; see The bytes of a plain value |
of |
a memberwise ctor T.of(f1:, …) — bag only (a transparent value) |
zero |
a zero-init ctor T.zero() — bag only |
Equatable/Hashable/Comparable walk each field through its own equals/hash/compareTo — never a
bitwise compare, which would read padding and be wrong for any type whose equality is not its representation —
so every field must itself conform, and @skip is honored by all three (which is what keeps "equal values hash
equal" true, and compareTo answering Equal exactly when equals answers true). A float field is ordered
by its Comparable total order (NaN after every number), as it is in a SortedSet, not by IEEE <.
What may carry it. Any value, resource or enum, generic or not:
| Subject | Derives | Shape |
|---|---|---|
plain value / resource |
all eight | Type { f: v, … } |
generic Box<T> |
all eight, per instantiation | as above, for each instance |
tagged enum |
the six contract names (of/zero are a non-goal) |
per tag: Circle { r: 2 }, or the bare name for a payload-less variant |
payload-less enum |
the six contract names | the bare variant name; on the wire a plain string "Green", not an object |
generic enum (Optional<T>, Msg<T>) |
the six, per instantiation | as the tagged/payload-less rows |
A derive on a generic type is a conditional conformance: the instance carries it exactly when every
substituted field (or payload) conforms. @generate(Equatable) on Box<T> cannot hold for every T, and
the author does not write the condition because it is not a choice — it is a consequence of the fields, the
same rule Rust spells impl<T: PartialEq> and Haskell instance Eq a => Eq (Box a). An instance that
does not qualify is refused at the use site, naming the field that disqualified it.
For an enum it names the variant too — "Put's field v (Plain)" — because a sum type gives the
reader two places to look.
A conditional conformance is decided by the instance's own type arguments, never by what else the
program imports. DynamicArray<T> is Serializable exactly when T is, in a program that serializes
nothing as in one that does, and a generic function may bind its T to the contract. A container is
Deserializable only when its allocator can be default-made, because deserializing builds one from
nothing. An instance that misses a gate is refused naming the condition it failed.
A payload-less enum derives over its members: it serializes as the bare variant name, formats as it, and
tests equality and hashes as its integer — which it still is, with a derive or without: ==, cast<IntType>,
try cast, its explicit member values and match are unchanged.
A derived Comparable orders an enum by DECLARATION, then by the live variant's payload, field by field —
Rust's derived Ord, and Swift's synthesized Comparable. Declaration order and not the integer, for both
kinds of enum: a tagged enum has no integer to offer, and one rule beats one that changes when a member writes
= 200. An enum whose integers run against its declaration and that wants them as its order writes
compareTo itself (implements Comparable<This>), which is the one place that choice is spelled. Two names
for one value (B = K::A) take the first one's place, so they compare Equal as they are ==.
@generate(Comparable, Equatable)
type enum Level { Low, Mid, High }
@generate(Comparable)
type enum Shape { Dot, Circle(float64 r), Rect(int32 w, int32 h) }
fn bool demo() {
return Level::Low < Level::High // by declaration
&& Shape::Dot < Shape::Circle(r: 9.0) // the variant first...
&& Shape::Circle(r: 1.0) < Shape::Circle(r: 2.0); // ...then its payload
}
of/zero on an enum is a non-goal, not a gap: a variant is already its own memberwise constructor
(Shape::Circle(r: 2)), and zero names no variant. So is Serializable on the prelude's Optional,
whose wire form is the inline null/value case an Optional field already has.
The prelude's Optional<T> and Result<T, E> carry @generate(Equatable, Hashable, Formattable), which
is why opt == opt, an Optional Map key and ${opt} work for a payload that supports them — and,
conditionally, why they do not for one that does not.
Deliberately not in the model#
Recorded so they are not re-proposed: a nameless Type(…) call shape or a primary keyword blessing one
ctor as nameless-callable; a compiler-synthesized memberwise as the general designated ctor (of is a
bag-only convenience — a memberwise seam breaks on complex types); a separate init/onConstruction hook
(input-blind, auto-run — it does not stop logic scattering, and chaining already reaches every path); and a
mandatory "designated"/"final" ctor every path funnels through (completeness comes from definite assignment,
not from a funnel). Constructor overloading is a standing non-goal — named parameters cover it.
Immutability — const ✅#
const is runtime immutability, and it is deep: neither the binding nor anything reached through
it may be mutated. (The other two axes are orthogonal — static is runtime associated storage, comptime
is compile-time evaluation; see Compile-time constants.) It appears in exactly six positions:
| form | what it binds |
|---|---|
const T x = init; |
a local — no reassign, no write through it, no ++/-- |
const T f; in a type body |
a field — write-once, assignable only in a constructor |
const T x / const ref T x parameter |
a read-only argument; const ref is a read-only borrow |
const UnsafePtr<T> p parameter |
lowers to C const T*, for const-correct FFI — a const binding of a mutable pointer; the read-only pointer type is UnsafeConstPtr<T> (see unsafe fn) |
const fn on a method |
the method does not mutate its receiver |
comptime(int32 N) parameter list |
a comptime parameter — a compile-time value, an unrelated feature |
A free function has no receiver, so const fn does not apply to one; nor to a ctor, a destructor, or an
operator member. The qualifier follows the modifiers: public unsafe const fn parses, const public fn
does not.
const fn — a non-mutating method#
Inside a const fn the receiver is immutable, deeply. Writing this.f, writing a bare field name, writing
through this.a.b[i], ++/-- on any of those, and passing any of them to a non-const ref/out
parameter are all rejected. So is moving out of it — give leaves its source holding a moved-from
value, which is a mutation. addr(of: …) into it hands back an UnsafeConstPtr<T>, never a writable
pointer, and returning that as an UnsafePtr<T> is rejected at the return.
Symmetrically, a const receiver — a const local, a const/const ref parameter, this inside a
const fn, or a const ref T place (below) — may call only const fn methods. That gate is the point of the marker: it is what lets a
caller hold a value immutably and still use it.
type value Counter {
int32 n;
public ctor make(int32 n) { this.n = n; }
public const fn int32 value() { return this.n; } // read-only
public const fn int32 doubled() { return this.value() * 2; } // const calling const: fine
public fn void bump() { this.n = this.n + 1; } // mutating
}
const Counter c = Counter.make(n: 9);
int32 v = c.value(); // fine
c.bump(); // error: cannot call non-const method `bump` on a const receiver
const fn is ABI-neutral. It is a front-end rule only — the emitted C signature is identical either
way, so marking a method costs nothing and changes no generated code.
A const fn may not return ref T; it returns const ref T. A ref T place returned out of a const
method is a writable alias into the receiver, so c.place() = 99 would mutate a const binding with no
unsafe anywhere. What a const method may hand out is a read-only place — const ref T, C#'s
ref readonly, Rust's &T — the fourth const-rooted place beside a const local, a const ref parameter
and an element reached through an UnsafeConstPtr<T>. It reads like any place (index, field, a const fn
call, copy, addr(of:) — which yields an UnsafeConstPtr<T>) and refuses every write: assignment and
compound assignment, give out of it, passing it as a ref/out argument, and a non-const fn call on
it. The root binding may be perfectly mutable — it is the call that narrowed the place, which is what a
root-const rule alone cannot see. The same form is available on a free function and on operator[]
(below), and a const ref T result must borrow this or a ref/const ref parameter, exactly as a
ref T must.
type resource Bag {
DynamicArray<Counter> items;
public const fn const ref Counter at(isize i) { return this.items[i]; } // read-only place
public fn ref Counter atMut(isize i) { return this.items[i]; } // the writable twin
}
int32 v = b.at(i: 0).value(); // fine: reading through the place
b.atMut(i: 0).bump(); // fine: the writable twin
b.at(i: 0).bump(); // error: cannot call non-const method `bump` on a const receiver
The mirror rule: a ref T body may not return a read-only place. fn ref int32 firstMut(const ref DynamicArray<int32> d) { return d[0]; } would hand the caller a writable alias into storage it lent
read-only, and return this.at(i: 0) from a ref method would do the same through a const ref place.
Both want const ref T as their return type. Ref-constness is part of the signature in both directions: a
contract member declaring ref T may not be implemented as const ref T (every caller writing through the
slot would be handed a read-only place), nor the reverse, and an override may not change it.
The same holds for a parameter passed ref/out: a contract member declaring const ref T other may not
be implemented as ref T other (a caller holding a const place hands it through the slot, and the body
would write through it), nor the reverse, and an override may not change it either. One spelling per
member, so a reader knows where const is without opening the implementation. A by-value const T x is the
callee's own copy, invisible to every caller, and is not compared.
Unlike const fn, const ref T is not ABI-neutral: it lowers to T const* where ref T lowers to
T*, so the C compiler's own pointer-qualifier check agrees with the front end, and a body that returns
this.data[i] out of an UnsafeConstPtr<T> field type-checks in C without a cast.
The standard library's two-name split — get/getRef, iterator/iterMut, peek/peekRef — is the
convention for the WRITABLE twin (Rust's get/get_mut, not C++'s const-overloading, which would need
every accessor written twice); it is no longer the only way to reach an element through a const receiver.
Operators cannot be const fn, and need not be: a write through operator[] on a const receiver is
already rejected at the assignment, since its root is const. An operator's PLACE can be const, though —
const ref T operator[] is how a read-only view indexes.
Constness in a contract#
A contract member may be declared const fn, and an implementation must honor it — the promise is to
every caller bound by the contract, and dispatch goes through a slot, so the implementation is the only
place it can break. The same holds one level down for a const ref parameter. Only that direction is
checked: an implementation may be more const than its member asks, which merely widens where it can be
called.
This is the opposite call from unsafe, which is rejected on a contract member — and the reason is
the difference between the two markers. unsafe describes a body, which a member does not have.
const constrains what a caller may pass as receiver, so it is signature-level and belongs on the
declaration.
An override may not drop const either: the caller sees only the base declaration, so a const receiver
that is legal there has to stay legal for whatever subclass sits behind the slot.
What the standard library marks#
The query surface: length/isEmpty/capacity/count, contains/indexOf/test/isSubsetOf,
get/peek/first/last/floor/ceil, iterator (but not iterMut), all of Vec/Mat/Quat/
Duration/Instant/Fixed, and the protocols — Hashable.hash, Equatable.equals,
Comparable.compareTo, Error.message, Formattable.format, Serializable.serialize, Real's twenty-one
members. Equatable and Comparable borrow their operand const ref.
What it deliberately does not mark is as informative:
viewMut(),sliceMut(),iterMut(),dataPtrMut(),getRefMut(),peekRefMut(),derefMut(),innerRefMut()hand out a mutable window into the receiver. Const on any of them would launder exactly what the rule above closes. Each has a read-only twin that IS const —view()/slice()mint theConstView<T>,dataPtr()/cstr()hand out theUnsafeConstPtr<T>, andgetRef()/peekRef()/deref()/innerRef()/Entry.key()/value()/Output.stdout()hand out aconst ref Tplace — which is the naming convention: for a read/write pair the unmarked name is the read-only form andMutmarks the writable one (view/viewMut,iterator/iterMut,dataPtr/dataPtrMut,getRef/getRefMut,deref/derefMut). A lone place-returner with no read-only use keeps its bare name (IteratorMut.next(),Child.stdout()— everyFileop butreadAtis non-const).Map's andSlotMap'shasNext()scan forward past empty slots, so asking the question moves the cursor. They are not queries — which is whyIteratorMut.hasNextis not a const member either, even though the other implementations would satisfy it.Copyable.copykeeps a mutable borrow of its source: a retaining copy (Shared,Weak, a libraryRc) bumps a refcount reached through it.Atomic<T>marks nothing. C++ would callload()const; kama does not, because this is the sanctioned shared-mutable cell and saying otherwise would be the one place const lies.fs/net/processI/O —read,write,flush,accept,setNonBlocking— change OS state even though no kama field moves. Only the true accessors (rawFd,rawHandle,state,id,status,success) are const.
Consuming methods — give fn ✅#
A method marked give fn takes its receiver by move: the call hands the value over, as eat(b: give x) hands an
argument over, and the method may take it apart. It is how a wrapper gives back what it wraps — a buffered writer
its stream, an encoder its sink — and it is Rust's self method and Swift's consuming func (KR-261).
type resource Wrap {
Box inner; Box spare;
public ctor make(Box inner) { this.inner = give inner; this.spare = Box.make(v: 0); }
public give fn Box unwrap() { return give this.inner; } // `spare` drops here
}
Wrap w = Wrap.make(inner: Box.make(v: 7));
Box b = give w.unwrap(); // `w` is moved; using it after is an error
- The call says
give, where the move happens:give w.unwrap(). A consuming call without it is refused, naming it, and so isgivebefore a method that does not consume, or before a free function's call, whose result is fresh. A fresh receiver is already the call's own and takes no marker (Wrap.make(…).unwrap()). Usingwafter the call is the use-after-move error, and a consuming call in a loop moves its receiver on every pass unless the body gives it a new value. A void one is a statement,give w.close();, and the second call of a chain takes the first one's fresh result unmarked:(give w.intoInner()).take(). - Only what the caller owns: a local, or a by-value parameter. A field, an element, a
refparameter and aconstbinding are refused, as agiveout of them is. A contract value is a borrow and a handle's pointee is not the caller's, so neither reaches a consuming member; a generic bound does,fn W finishAll<W, E: Finishable<W>>(E e) { return give e.finish(); }. - Inside, the receiver is the method's.
give this.fmoves a field out once, and every owning field not moved drops when the method returns, on every path; one moved on some paths is the error a local's is.give thishands the whole value on, and an enum'sgive fnmaymatch (give this). Once a field has left, it is not read again andthisis not used whole: a method call on it, acopyor agive thiswould see the hole. A field may not leave inside a loop that runs again, nor from under aborrowof it. - A type with a destructor may hand itself on or let itself drop — its destructor then runs at the method's
end, once — but no field may leave it, since the destructor must find every field it drops (Rust's E0509).
Such a type leaves a stand-in instead, as a consuming
close()setsfd = -1and lets the destructor run. - Where it is declared: on a
resourceor anenum. Not on avalue, which is copied and so has nothing to consume; not on aview, which borrows; not on a type in an inheritance hierarchy, whose base methods see only the base's fields; not on astatic fn, which has no receiver.give const fnis refused, and so is a place or a view return, which would borrow the receiver the call consumes. A contract member may begive fn, and a conformance matches the marker exactly, as it matchesstatic. - The C is the borrow's: the receiver stays
T* self. The caller passes its local's address and emits no drop for it, and the callee drops what it did not move, statically: no flag, no copy of the receiver.
The declaration is always give fn and a use never is, so grep -rn 'give fn' lists the consuming methods.
Uninitialized storage — slot ✅#
A slot names the storage an out parameter is about to fill — declared externally so the reader can
see the scope the value will live in. slot means only this. A contract's requirements are its
members, never its "slots"; the one other place the word is load-bearing is the emitted vtable slot,
which is always spelled with vtable/vtbl. That is the whole of it, and it is the only kind of local a kama
program may leave without a value:
slot File f; // a HOLE: an `out` argument will fill it
openInto(path: p, dst: out f); // now it is live, and drops normally from here
Three rules, and they are what make a hole worth declaring:
- Only an
outargument fills a slot. Not an assignment (a nested one included), not a field write, not a method call, notaddr(of: x). A value that arrives one line late is an ordinary local —T x = …;says so with the value in hand, and a branch has a stronger spelling still, sincematchand the ternary are value-producing and can build aresource(Conn c = match (k) { case A: Conn.tcp(fd: 3); … };). - A slot with no
outfill anywhere is an error. A hole nothing fills is a dead declaration, not an opportunity to elide a drop. - The fill sits on the same unconditional path as the declaration — a statement of the declaring
block, or of a nested block that always runs (a plain block,
borrow,scope). Not inside anif, amatcharm or a loop the declaration is outside of. Measured relative to the declaration, so a slot declared and filled inside one branch is fine. The reason is that a conditionally-filled slot cannot be tested before use: slot validity is a compile-time fact, never a runtime check.
A slot is illegal to read until it is filled, and — the point — no destructor is emitted where it is
provably still empty. "Drop only if live" is therefore proven, not defended against at runtime. Move
state is tracked in emission order, so this is decided per exit point: a return that precedes the fill
drops nothing, while one after it drops normally. A slot filled on only some paths is dropped where the
paths join — on the others its storage is the type's field defaults, whose drop does nothing — but a slot
given away on only some paths is the ordinary "moved on some paths but not others" error, exactly as for any
local: there its storage still holds the moved bytes, and the join would free them twice (until 0.9.474 it
did, in safe code). A local with no initializer and no slot is a compile
error, and slot with an initializer is one too: each thing is said exactly one way. slot does not
run the type's default constructor; spell T x = T.empty(); if that is what you want.
Rule 3 is about the slot's own declaration, not about the callee: an out parameter is still proven
filled on every path, so the callee may fill it through an if/else, a match, or an early return —
that join analysis is where conditional filling legitimately lives.
Every read waits for the fill, whatever the slot's type. A class-typed slot's storage holds the
declaration's fill — field defaults, each field's default ctor — so that the drop at an exit the fill never
reached does nothing, and for no other reason: no read sees it. The whole value, a field at any depth, an
element, a borrow of a field (ref p.y), an interpolation hole, a const initializer, a bitcast operand and
a method call on it are each refused until the out fill has run, and so is handing it to a callee as
anything but out. A primitive slot, which has no fill at all, and an Owned/Shared one, whose zero is a
null pointer, are refused the same way. Until 0.9.479 a class slot's non-owning field read freely, and
slot Pt p; int32 y = p.y; returned the field's default — a value no constructor made.
This is not Optional<T>: a slot has no runtime tag and no drop, and it disappears entirely at
compile time. Use Optional<T> when emptiness is a value you carry, slot when it is a fact the compiler
should prove away.
Fallible construction (no exceptions) ✅#
kama has no exceptions, so a fallible constructor returns Result<T, E> (where E: Error) — an
infallible ctor returns the bare T. The fallible work lives in the ctor, and on failure it returns Err
before the object exists, so no half-constructed object can escape and match forces the caller to handle
the error. A fallible new Type.ctor(...) composes to Result<Owned<T>, E> — the box is allocated only on
Ok. An Err returned after some fields were stored drops what was stored, as the type's destructor would, so
failing late leaks nothing; so does any ctor return that hands back a value other than this. Where this is
complete — every field holds a value — it is a value like any other and is dropped through its destructor, body
and all, which is what releases what a collection keeps behind a raw pointer. Where a field is still missing, only
the stored fields are dropped: the destructor body would read the missing one's zero as a value.
type enum SizeError implements Error { TooSmall; public const fn string message() { return "size must be positive"; } }
type resource Buffer {
int32 size;
private ctor make(int32 size) { this.size = size; } // trivial, infallible
public ctor Result<Buffer, SizeError> create(int32 size) {
if (size <= 0) { return Result::Err(error: SizeError::TooSmall); } // fail before it exists
return Result::Ok(value: Buffer.make(size: size)); // delegate to the base ctor
}
~Buffer() { /* … */ }
}
fn uint8 main() {
Result<Owned<Buffer>, SizeError> b = new Buffer.create(size: 8); // fallible `new` -> Result<Owned<T>, E>
return 0;
}
A type with a meaningful inert state may instead start valid-but-inert and expose a bring_up(): Result<…> method. (This reuses named ctors + Result + Owned + RAII — no dedicated feature. See
tests/fallible_factory, tests/ctor_named_fallible, tests/dot_on_type_new_fallible.)
Inheritance & virtual dispatch ✅#
Extensible hierarchies are a resource concern (an embedded vtable breaks a value's free copy). The
extensible base opts in with a qualifier after type:
type virtual(maxDepth: 1) resource Shape { // opts in to extension, and says how deep
int32 sides;
public ctor make(int32 sides) { this.sides = sides; }
public fn int32 describe() { return this.area(); } // public surface
protected virtual fn int32 area() { return 0; } // overridable hooks are written `protected`
}
type final resource Circle extends Shape { // `type final resource` = sealed leaf
public ctor make() { this.base = Base.make(sides: 1); } // installs its base FIRST
protected override fn int32 area() { return 42; }
}
Single inheritance (extends) — one base embedded by value at offset 0 is what keeps an upcast a no-op and the depth budget meaningful; there is no multiple inheritance — base.m() for non-virtual
upcalls — subject to the same visibility rules as this., so a derived type cannot reach a private
base member by choosing the other spelling. virtual/override methods dispatch through a vtable. Inheritance is
opt-in and one-way: only a type virtual resource/type abstract resource may be extends-ed (a value,
a plain resource, and a type final resource are sealed); an overridable method is written protected
(never public/private — public polymorphism is a contract's job); type final resource/final method seal
a leaf/slot. virtual/abstract/final and protected are meaningless outside an extensible resource —
they are errors on a value, a plain resource, or a contract. See docs/KEYWORDS.md for the full kind
table.
A derived constructor installs its base ✅#
A derived type's constructor must install its base, as its first statement:
public ctor make(int32 x, int32 y)
{
this.base = Base.make(x: x); // FIRST — the base's own ctor runs
this.y = y;
}
This is not delegation. A named ctor is a factory with no self to chain into, so the base part is
built by the base's own constructor and then embedded whole — which is why the base's invariants hold
for every subclass, and why a base's field initializers reach a derived instance.
Basenames the base type, asThisnames the enclosing type, so a derived author never spells the concrete base name and renaming it cannot break subclasses. A real type literally namedBasewins; the alias is the fallback.- First, and exactly once. Until the install runs, every inherited member would read a half-built
base. Its arguments may read this constructor's parameters but not
this.<field>— the install is lowered beforethisexists. - A ctor call on the base type, not a general assignment. A base part is built by its own constructor or not at all; base fields are private, so there is nothing to adjust afterwards.
this.basemay only be assigned. To reach an inherited member the spelling isbase.<member>.- A ctor that delegates wholesale (
return Other.make(…);) owes no base — the ctor it hands off to does. - An
abstractbase's constructor may be called here and nowhere else: the value is embedded at offset 0 and the derived vtable is stamped over it before anything can dispatch.
Consequently a virtual/abstract class must declare a ctor — without one it can be neither
instantiated nor installed, so it and every type below it would be unconstructible. No generator can
stand in: @generate(zero)/of require a transparent value, and a value is sealed.
The depth budget ✅#
An extensible type states how many levels may still be added below it, and a deriving type states
at most one less — or is final, which is a budget of 0 and the only spelling for it:
type virtual(maxDepth: 2) resource Root { … }
type virtual(maxDepth: 1) resource Mid extends Root { … }
type final resource Leaf extends Mid { … }
The chain's length is therefore bounded by the root's budget by construction. The point is that the limit is met where a design opts in to extensibility, rather than arriving as a refusal on the third type — by which time the design has been built around an assumption the language was never going to honour. Inheritance is deliberately restricted here (it is a footgun more often than a tool), and a budget you must write down is how that restriction announces itself.
maxDepth: 0 is an error — extensible yet unextendable is a contradiction; write final. So is a budget
above the compiler's ceiling, KAMA_INHERIT_DEPTH (default 2: a root, a middle layer and a leaf,
which is what mainstream hierarchies use). Neither bound is clamped: a clamp would hide the very surprise
the annotation exists to prevent.
Shadowing is an error ✅#
A derived type may not redeclare a method it inherits. The only way to redefine one is override on a
protected virtual (may override) or protected abstract (must override) — the type designer decides
what is overridable, which is what protected + virtual/abstract is for.
type virtual(maxDepth: 1) resource B { public fn int32 h() { return 1; } } // no seam offered
type final resource D extends B {
public fn int32 h() { return 2; } // ✗ shadows B.h() — which body runs would depend
} // on the STATIC type of the receiver
This holds at every visibility, public included. It is a separate rule from no widening above, and they divide the work rather than overlapping: widening is about a name the base does not have, shadowing about one it does.
kama already rejects public virtual because a public override is a footgun; silent shadowing is the same
footgun with no keyword marking it at all (C# at least demands new).
Reusing a name that is private in the base stays legal, and is not shadowing: the base's member is
invisible to the derived type, so the two names are unrelated and each type sees its own. The rule asks the
same question access control does — would the derived type even see this? — so a friend grant opens no
back door either.
A derived type may not widen the public interface ✅#
The hierarchy's public surface is fixed at its root. A derived type may add fields, add
private helpers, and override the protected seams the base sanctioned (virtual = may,
abstract = must) — it may not add a public method, and it may not declare implements.
type final resource Exposer extends Base {
public ctor make() { … } // ✓ ctors are exempt
protected override fn int32 secretHook() { return 2; } // ✓ a seam the base sanctioned
public fn int32 hook() { return this.secretHook(); } // ✗ republishes a protected seam
}
type final resource Icon extends Widget implements Clickable { … } // ✗ contracts belong on the root
Substitutability is then total rather than aspirational: what a base handle can do is what any subclass can do, and no more. The rule exists for the second line above — a subclass republishing an internal seam under a new public name, handing the world a hook the base deliberately kept private.
Constructors are exempt. A derived type needs its own public ctor (RawChannel.open(…)), and
construction is not part of the substitutable surface — you build a concrete type, then hand it out as
its base.
implements is barred on a deriving type because a contract's methods are public and need not exist
on the base, so allowing it would widen the surface through a door the rule never looked at. If a
hierarchy conforms to a contract, its root declares it and every leaf inherits the conformance.
Depth — a declared budget ✅#
See The depth budget above for the rule. Widget -> Control -> Button -> … — a chain that keeps adding
middle layers — runs out of budget and is refused at the type that asks for more than its base left:
'Button' extends 'Control', which allows 1 more level(s) — so 'Button' may allow at most 0,
i.e. it must be `final`
The ceiling is KAMA_INHERIT_DEPTH in kama.cemit.h, a compile-time constant of the compiler, not a
per-project setting — it is a property of the language, not of a build. A hierarchy may ask for less than
the ceiling but never more, so a project can restrict itself further without rebuilding anything: a design
pattern that is only ever two layers says maxDepth: 1 and the compiler holds it to that.
Building kama without inheritance — KAMA_INHERITANCE=0 ✅#
make # inheritance in
make KAMA_INHERITANCE=0 # a compiler built without it
This is a build-time switch on the compiler itself, and it is not exposed to programs — there is no
flag or manifest key that turns inheritance off for a project. It exists for kama's own development, for
two reasons: to isolate what the feature costs the compiler (answerable only by building both ways and
subtracting), and to be the extraction point if inheritance is dropped — the #if KAMA_INHERITANCE
blocks are then the deletion list, already proven to compile without their contents.
Such a compiler rejects extends, a virtual/abstract class, and a virtual/override/abstract
method — all four, since a virtual class with no subclass still carries a vtable. final stays legal
(it seals a type; it does not extend one), and contracts are untouched: they are the intended way to
express polymorphism and keep their own vtables. The grammar still parses extends, so you get a real
diagnostic rather than a syntax error. tools/check-no-inheritance.sh builds the variant and exercises it.
Owning a derived through a base handle (upcast). A Shared/Owned over a derived class widens to one
over a base class (or a contract it satisfies) — the IS-A relationship, Liskov-style:
Shared<Circle> c = new Circle.make();
Shared<Shape> s = c; // upcast — retain (both handles share one Circle)
Owned<Circle> u = new Circle.make();
Owned<Shape> o = give u; // upcast — move (u consumed)
int32 a = s.describe(); // polymorphic: describe() calls the protected virtual area() -> Circle's
The widening is not a declaration's alone: a handle widens wherever one is bound — a return, an argument, an
assignment, a field, a payload — and a fresh handle (shapeOf(c: makeCircle())) widens as a named one does.
Polymorphism flows through the base's public surface, which invokes the protected virtual hooks
(Template Method) — you never call an overridable method through the handle directly. Destruction is
virtual: a virtual/abstract resource (and every owning contract handle) carries a vtable __dtor
slot, so dropping through a base/contract handle runs the most-derived destructor's full chain — the
derived's owned resources are freed, never sliced, exactly once. (An upcast only ever yields an owning
handle or a scope-local borrow; an un-owned contract value can't be stored — see Contracts.)
Contracts ✅#
A contract is a public-only guarantee — "some type satisfying this contract." It carries signatures
only: no bodies, no fields, no dtor. Besides methods it may require a ctor or a static fn, which
is how a bound gets to construct rather than only to call — ctor T fromWide(int64 v) on
std::num::FixedBacking is what lets generic fixed-point arithmetic narrow back to its backing type
(tests/contract_requires_ctor.kama).
type contract Shape for value, resource { fn int64 area(); } // a public guarantee (a "type placeholder")
type value Circle implements Shape { // a value satisfies a contract, too
int64 r;
public ctor make(int64 r) { this.r = r; }
public fn int64 area() { return r * r; } // a method satisfying Shape MUST be `public`
}
fn int64 measure(Shape sh) { return sh.area(); } // accept "any shape" — by value = zero-copy dispatch
A contract is represented as a fat pointer {obj, vtbl} (an implementation detail of type erasure — never
something you spell). Both a value and a resource may implements any number of contracts; a method that
satisfies a contract method must be declared public (the contract is public — a hidden implementer
would be reachable through the contract but not by name). The contract's own members take no visibility word:
each is public by definition, so public is redundant and private/protected could not hold. A member
fewer callers may reach is a non-goal: a call through the contract reaches the member on every implementer,
so what restricts the caller is a parameter only that caller can build — a key type whose ctor it alone is
granted (friend driver[mint]) — and, inside a hierarchy, a protected method of the extensible resource.
A contract may refine another (type contract Animated for value, resource implements Drawable { … }) for capability layering, without inheritance, and an
implementer of the refining contract implements its parents too, as Swift's protocol inheritance and Rust's
supertraits do. A Sprite implements Animated satisfies a Drawable bound and binds to a Drawable value with
no implements Drawable of its own. An Animated value, Owned<Animated> or Shared<Animated> passes where the
Drawable one is wanted: the same object, with its vtable for Drawable read out of its Animated one. A
contract value whose contract does not refine the wanted one is refused, whatever object is behind it.
Passing a contract — by value vs. ref/out (mirrors C#'s ref rule exactly):
Shape sh(by value) — "use it as a shape." A concreteCirclecoerces in (IS-A); zero-copy dispatch. This is the common path.ref Shape sh/out Shape sh— "I may reseat your handle." Requires the argument to be an actualShapevariable (its address is passed, so the reseat sticks);outadditionally requires the callee to assign it and the call site to sayout. Passing a concrete type byref/outis a compile error — bind it first (Shape s = c; measure(sh: ref s)). Mutable references are invariant: aCirclevariable isn't a slot that could hold an arbitrary shape, so it can't back aref Shape.
Borrow vs. storage — a contract value is second-class. The fat pointer borrows its object, so a bare
contract value is fine as a parameter or local (the zero-copy polymorphic view above) but cannot be
stored beyond the call that made it — a bare Shape field, return type, collection element or enum
payload is a compile error, because the borrowed object could die and leave it dangling. To keep polymorphism around,
own the object with a smart pointer over the contract (below). Ownership is always written explicitly —
never an implicit box. This is the language-wide rule "a borrow lives in a parameter or a borrow window,
never in storage; storage requires ownership" — the same reason a ref parameter can't be returned and a
returnable "reference" is always an owned smart-pointer handle.
A contract local borrows its object for as long as the local lives, so the object must stay where it is: a
local, a parameter, this, a field of one, or an element a borrow window holds still. A temporary is none of these:
Counts c = W.make(); (a constructor, a call's value, an operator's, a ?:, a copy) is refused in a declaration
and in a rebind alike, since nothing holds the value beyond the statement — bind it to a local first. A contract
PARAMETER takes one, since its borrow ends with the call. A widened scalar (Formattable f = 5) is a copy, not a
borrow. Rust and C++ would keep the temporary alive to the end of the block, a hidden owner; kama writes the owner
down. Mutating a local through
the contract is fine — the local never moves — but an element of a container that grows, or the value behind a
handle that can be reseated, is refused (Formattable fo = arr[0], Formattable fo = sh over a Shared<Box>), and
so is a static of a contract type. The value behind a handle is a contract through a contract PARAMETER,
width(f: sh), whose call holds sh still — the one way: Rust's &*sh freezes sh for the borrow's life, and
kama keeps every freeze a visible block. So does a handle over the contract itself, or over one that refines it
(draw(d: o) with Owned<Drawable> o); a ref contract parameter takes no handle, since it reseats a contract
variable and a handle is none. A
contract local may be rebound (fo = y) to any object that outlives it; one declared in a block nested inside
the local's is refused, as a view store from a nested window is (rule 4 of The window).
Owned contracts — Owned/Shared/Weak<Shape>. A smart pointer over a contract owns the concrete
object behind a fat handle {obj, vtbl} (Shared/Weak add a ctrl block). new Circle.make(...) boxes a
concrete implementer into it; p.draw() dispatches polymorphically through the vtable; dropping the handle
runs the concrete destructor through a virtual-destructor slot in the contract vtable, then frees the
object. Owned<Shape> is move-only; Shared<Shape> retains/releases (Weak<Shape>.tryUpgrade() -> Optional<Shared<Shape>>). Because the handle is an ordinary value type, it stores — as a field or a
function return:
type resource Holder { Shared<Shape> shape; public fn int64 area() { return this.shape.area(); } }
fn Owned<Shape> make(int64 s) { Owned<Shape> o = new Square.make(s: s); return give o; }
A DynamicArray<Shared<Shape>> (the engine's scene) works — polymorphic elements stored and dropped in RAII order.
type adapter — a contract for a type you don't declare ✅#
A type you declare lists its contracts in its own declaration (implements C). A type you don't declare — a
built-in, a std type, another package's type — gains one through an adapter, written in the module that
declares the contract: type adapter <targets> implements C { … }.
// the module that declares DbParam
type contract DbParam for value, resource, enum, intrinsic { const fn int32 code(); }
type adapter <User> implements DbParam { public const fn int32 code() { return this.id * 10; } } // a declared type
type adapter <int8, int16, int32> implements DbParam { public const fn int32 code() { return this; } } // built-ins
type adapter <DynamicArray<uint8>> implements DbParam { … } // one instance
type adapter <Optional> implements DbParam when [T: DbParam] { // every instance
public const fn int32 code() { return match (this) { case Some(value: v): v.code(); case None: -1; }; }
}
Targets. A target is a built-in (a number, bool, char, string), a declared type of any kind the
contract's for list admits, one instance of a generic type, or a generic type written BARE, which in a target
list means every instance (the one place a bare generic is not its all-defaulted instance). A when [T: C]
narrows that to the instances whose arguments meet it, and names the generic type's OWN parameters. It is
refused beside a concrete target, which has no parameter for it to condition. A name that is not one of the
type's parameters is refused rather than read as a fresh one, and so is a target that names no type.
The contract's for list judges each target by the target's own kind.
Where. An adapter lives in the module that declares its contract, and never over a type that module
declares: there the declaration's own implements list is the one way. So a type's conformance to a contract
is written in one of two places, the type's declaration or the contract's module. Across the modules of one
package this is how a dependency points the right way: app::db adapts app::model::User to its own
contract, and the model never imports the database layer. It also means at most one adapter for a (type,
contract) pair can exist in any program. Two packages cannot each supply one and leave an application that
owns neither unable to build, and a library that adds an adapter later can never collide with one a
consumer wrote. A program that wants a contract it doesn't own on a type it doesn't own wraps the type in
one of its own.
Members. Methods only, for exactly one contract, covering all of it. A field, a destructor or an
extends is refused: an adapter adds a contract's methods to types that already exist, never storage.
Inside the block This is the target being adapted, resolved per member of the set, and over a generic
target the type's own parameters (T) are bound per instance.
The <…> is a set because one body usually serves many targets — the prelude's per-primitive impls
collapse from 64 blocks to roughly 8 — and a <…> { … } SECTION inside the body overrides it for the
targets it names:
type contract Hashable for value, resource, enum, intrinsic { const fn uint64 hash(); }
type adapter <string> implements Hashable { // a built-in gains a contract, in pure kama
public const fn uint64 hash() {
uint64 h = 2166136261ui64; // FNV-1a
isize i = 0;
while (i < this.length()) { h = (h ^ cast<uint64>(this[i])) * 16777619ui64; i = i + 1; }
return h;
}
}
type adapter <int8, int16, int32, int64, uint8, uint16, uint32, uint64>
implements Comparable<This> { … } // ONE body for eight widths
fn uint64 hashOf<K: Hashable>(K k) { return k.hash(); } // `string` now satisfies the bound
The set form works because the bodies are genuinely identical across it — they use raw < / ==,
which stay raw C operators for all-primitive operands. It is not a substitute for per-type dispatch: a
type list cannot serve sqrt, which needs a different C function per width (sqrtf vs sqrt), and kama
has no in-body type branching by design: @compileFor is a declaration-level gate (SPEC § Conditional compilation), and a body that branched on a type would be a second, hidden one.
A built-in gets no _classes entry — every "is this a user type?" test keys on that — so its
conformance hangs on a separate registry, and a scalar target's this is the value itself: the method
takes T self by value and the call is a plain int32__hash(k). That is how Map<int32, V> /
Set<int32> get their keys. A declared target's methods hang on its own ClassInfo, and it gets the
ordinary T__as_C vtable beside its other contracts, so it binds to a contract value like any implementer.
A contract is a SCOPE. An adapter decorates a type within the scope of that contract, so a
contract-supplied method is not part of the type's own API — it is reached through the contract,
never off the bare value. Without this, an adapter giving int32 the contract Weighable would put
.weight() on every int32 in the program, including code that never heard of it.
int32 l = 3; int32 r = 7;
l.compareTo(other: r); // ERROR — `compareTo` is Comparable's, not int32's
fn Ordering cmp<T: Comparable<T>>(const ref T a, const ref T b) { return a.compareTo(other: b); }
cmp(a: l, b: r); // a BOUND — monomorphizes to a direct call, zero cost
Comparable<int32> c = l;
c.compareTo(other: r); // a CONTRACT VALUE — one indirect call
Those two are the only spellings, and both are real. The rule covers every type an adapter decorates; a
type that declares implements C in its own body is untouched — its methods are its own. String
interpolation is exempt: "${x}" is the compiler's own lowering to Formattable, not something an author
wrote.
Widening — a primitive as a contract value. A primitive or a string can be bound to a contract, as a
borrow or as an owning box, from any expression — a literal, a local, a field, a call or method result, a
cast, arithmetic — and in any position that takes a contract value:
Hashable h = 3; // a BORROW — a fat pointer over block-scoped storage. Cannot escape:
fn void f(Hashable h) { … } // the same escape check that governs every contract value applies.
Owned<Hashable> o = 42; // an OWNING box — the form that can be a field, an element, a return.
Owned<Formattable> t = give s; // a `string` box owns the string: a named one is handed off (`give`/`copy`)
The machinery is pay-for-what-you-use: the vtable and its deref thunks (a built-in's adapter method takes self
by value; a vtbl slot passes void*) are emitted only for the pairs a program actually widens. A bare named
string into an owning box is a compile error, as any owning hand-off without a marker is.
History. Before primitives had a spelling, the only way to give one a contract was implements C for T,
a block that reached into a type from anywhere in the program. M6 deleted it once primitives and enums got
spellings of their own. The adapter is its successor with a home: written only where the contract is
declared, and only for a type declared elsewhere. Its first form, type intrinsic, covered built-ins only,
and was renamed type adapter at 0.9.510.
Coherence. Two declarations of the same (contract, type) pair are a compile error: a declaration's own
implements and an adapter, two adapters, or an adapter over every instance and another over one of them.
There is no specialization, and with the home rule above no such pair can span two packages.
Static methods & operator overloading ✅#
Static methods — a static fn has no implicit self and is called at the type level with named
args:
@generate(of)
type value Vec2 {
public float64 x; public float64 y; // all-public transparent value → `@generate(of)` gives `Vec2.of(x:, y:)`
public static fn float64 dot(Vec2 left, Vec2 right) { return left.x*right.x + left.y*right.y; }
}
fn uint8 main() {
Vec2 a = Vec2.of(x: 1.0, y: 2.0); Vec2 b = Vec2.of(x: 3.0, y: 4.0);
float64 d = Vec2::dot(left: a, right: b);
return 0;
}
A static method has no vtable slot (so it can't be virtual/override/abstract) and may not touch
this or a bare field.
static marks a method, and nothing else in a type. A type holds no static storage, so static on a
field is a compile error that points at what was meant: a constant that belongs to the type is a comptime
member (public comptime int32 K = 4;, read V::K — see Compile-time constants), and state every instance
shares is a module static (below). On a ctor (called on the type already), a comptime member or comptime fn
(type-associated already) and an operator (whose arity picks its form) the word adds nothing and is refused.
It used to be dropped without a word — public static const int32 K = 4; built a per-instance field.
Module statics — static T name = const; at module scope declares a module-level mutable variable
(MCU step 1). It is firmware's home for state that outlives any one call: ISR↔main flags, peripheral
handles, ring/DMA buffers, flash tables.
type value Uart { public uint32 data; } // a peripheral's register block
static uint32 tick = 0; // deterministic const init at reset
static bool data_ready; // no initializer → zero-init
static InlineArray<uint8>#(256) rx_buf; // a zero-initialized buffer
static UnsafePtr<Uart> uart; // a peripheral handle (null until assigned)
fn void on_timer() { tick = tick + 1; } // shared with `main` in the same isolate
- Per-isolate by construction. A module
staticis not shared global state — each isolate gets its own copy (loweredstatic KAMA_ISOLATE_LOCAL T name:_Thread_localon native and on wasm — emscripten pthreads share one linear memory — and a plain zero-coststaticon a single-core--target embedded). So astaticcannot be seen by another isolate → cannot race; cross-isolate mutable sharing stays on the greppableAtomic<T>/ shared-region seam (see Concurrency). This unifies the MCU need with the threading model: the same declaration is race-free the day it runs multicore (proven ThreadSanitizer-clean). The one place the copy is a trap rather than a guarantee is a thread kama did not create — a C callback — which sees every static at its declared initialiser; that is what@foreignEntryregions check at compile time (see Foreign entry points under No-heap subset). - The MCU shape, deliberately. The type must be a value,
UnsafePtr,Simd, or anInlineArrayof values (owns nothing, needs no teardown); a destructibleresource,string, or smart pointer is rejected — and the rejection states the stance, not a queue entry: persistence is ownership. A resource that must outlive a frame lives in anOwned<T>, andrelease()hands it to a foreign API's userdata slot (see Smart pointers); astaticis not a home for one. The initializer must be a compile-time constant (a literal,sizeof, or const arithmetic, computed at compile time as acomptime's is — so one that cannot be, such as a division by zero, is an error at its line); a runtime initializer (a call /new/spawn) is rejected — omit it to zero-init. These restrictions are not stopgaps: const-init is the deterministic reset-time init a bare-metal target wants (no static-init-order fiasco, no startup hook), and value-only keeps global data off the heap. Astaticis file-private: it may not appear in anexport { … }list, and no other file — not even a sibling in the same module — can name it. Per-isolate storage is why: a second translation unit could only get its own copy, which would be a silent correctness bug rather than sharing. Publish acomptimeif the value is constant, or a function if it is not. (Privacy is the language's rule, not C's: the emitted object has external linkage, declaredexternin the shared header and defined once in its unit — the shape every function has — because a generic type's bodies are emitted in that header ahead of every unit, and a generic destructor counting into a module static in its own file is ordinary code.) Ahardwarestatic (static hardware T name) adds thevolatilequalifier for an MMIO register or single-core ISR↔loop flag —volatile Tfor a scalar,volatile T*for anUnsafePtr<T>handle.
Compile-time constants — comptime ✅#
A comptime declaration is a named compile-time constant (const-eval 6b-2). Three keywords name three
orthogonal axes: const = runtime immutability, static = runtime associated storage, comptime =
computed at compile time (and therefore also immutable and associated — those fall out). Unlike Rust's
const (which fuses immutable + compile-time), Kama keeps them separate: const may bind a runtime value
(const Box b = Box.make(...)), while comptime must fold before the program runs.
One keyword, three scopes:
comptime int32 CAP = 64; // module scope — a shared named constant
comptime int32 CAP2 = CAP + 1; // may reference an earlier comptime (folds to 65)
type value Palette {
public comptime int32 SIZE = 4; // type-associated — read `Palette::SIZE`
comptime int32 SEED = 100; // private (default for a `value`) — internal use only
}
fn void demo() {
comptime int32 N = 8; // local (function or block scope)
InlineArray<int32>#(N) a = [0; (N)]; // drives a comptime size and fill
InlineArray<int32>#(Palette::SIZE) b = [0; (Palette::SIZE)];
}
- Where it lives sets how it's reached. A module
comptimeis a module-level named constant, and it is the one module-scope declaration that can be published: name it inexport { … }and a consumer mayimportit, spell itmod::NAME, size a type with it, or take its address — the same file rung a function obeys, so an unexported one stays private to its file. (Contrast the mutablestaticabove, which is file-private and not exportable at all.) A typecomptimeis read asType::NAME, in its own type'scomptime fntoo — via::(the associated-item operator, like an enum variantResult::Okor a static factoryDeque::withAllocator);.stays reserved for constructors and instance access. A typecomptimeobeys member visibility (public/private/protected, default private for avalue) — a private one is usable only inside the type's own code, the same rule and diagnostic as a private field, and that holds in a compile-time position too: another constant's initializer or an array size may not read it from outside either. (Before0.9.444those two read the fold directly and a private constant leaked into both.) A localcomptimeis scoped to its function or block. - Initializer must fold — a literal, a
sizeof/alignof(on this build's target — see Writing a collection in kama), const arithmetic, anothercomptime, acomptime fncall, or — for anInlineArray-typed constant — an array literal of foldable elements ([1, 2, 3]or the[v; N]fill form), which bakes as astatic consttable. Acomptimewhose initializer can't fold is an error at the declaration (acomptimelocal's message points you back toconstfor a runtime-initialized immutable). - A value type. A
comptimemay hold atype valuewhose fields are numbers,bools,chars, fixed arrays or value types of those, built by one of its ctors — the compiler RUNS the ctor, and a method or operator the initializer calls, and bakes the result as astatic constobject:public comptime Perm R = Perm.fromBits(bits: 4);, thenPerm::R | Perm::W. A constant may be of its own type, a private ctor builds the type's own constants, and@generate(of)/zerobuild one too. There is no marker on the ctor: thecomptimeat the use site is the explicit part, and a body that leaves the subset (a print, an allocation, a call into C) is an error at the constant naming the member and the construct. What cannot be a compile-time value is refused there too: aresource, a field the compiler cannot hold, a fallible ctor, a ctor the reader may not call, and an initializer of another type. At run time the constant reads as a value: an operator or aconst fngets a copy, and a method that is notconst fn, arefargument or a field store is refused — it is read-only storage. - A generic value type is a value type like any other:
public comptime Metric<VirtualMachine> CPU_USAGE_AVERAGE = Metric.of(counter: 2);. A bare ctor's instance is named as at run time — by a turbofish on the type (Metric::<VirtualMachine>.of(…)), else by the type of the place it is built for: the constant itself, a local, areturn, an argument, a field, an assignment — which is the one source a phantom parameter likeEhas. Its members run with the type arguments bound, soTinBox<int32>isint32at compile time too, and an instance whose fields are not compile-time values is refused naming the field's actual type. With nothing to name the instance, the ctor is refused with the run-time sentence. - One rule at every scope. A module, a type and a local
comptimeare folded by the same interpreter, so they take the same initializers and refuse the same ones — a float, a table, acomptime fncall, a later-declared constant and a struct'ssizeoforalignofwork at all three. Until0.9.444a type's constant had only an integer fold (plus C evaluating what it could not) and a local one only "C can compute it", so one declaration meant three things by position; that ruling refused a layout at all three until kama had a model of the target to fold it from, which0.9.660added (the C compiler re-checks each answer). A plainconstlocal whose initializer happens to fold is opportunistically usable in a compile-time position too (mirroring C++constvsconstexpr:constworks when it can,comptimeguarantees it); at module and type scope there is no runtime init point, socomptimeis the only named-constant form. - Lowering — real storage, baked references. A
comptimeemits a genuinestatic const Tsymbol, so it is addressable and@section/flash-placeable (an MCU.rodatatable). But a reference from another constant's initializer (CAP2 = CAP + 1) or a comptime size is baked to a literal in the emitted C. That sidesteps C's "initializer element is not constant" rule and, more importantly, means there is no static-initialization-order dependency — Kama has no dynamic global init to order (the C++ init-order fiasco cannot occur here). Constants are order-free, like types and functions: one may read a constant declared after it, in the same file or another, whatever order the files sort in. The one reference that cannot resolve is a cycle, and it is a compile error naming the constant, not undefined behavior. AnInlineArraywhose size does not fold is refused at its declaration, naming the size.
Compile-time functions — comptime fn ✅#
A comptime fn is a function the compiler RUNS at compile time to bake its result into a static const
scalar or table — a CRC / gamma / trig lookup table computed once, sitting in .rodata/flash with zero
runtime cost (const-eval 6b-3). It extends the comptime axis to computation: comptime constants name a
compile-time value; a comptime fn produces one. A comptime function is necessarily static (it has no
runtime this to read), so the bare comptime fn form is the whole story — no extra marker.
comptime fn InlineArray<uint8>#(256) crcTable() { // top-level compile-time function
InlineArray<uint8>#(256) t = [0; 256];
for (int32 i = 0; i < 256; i = i + 1) {
uint8 c = cast<uint8>(i);
for (int32 k = 0; k < 8; k = k + 1) {
c = ((c & 1) != 0) ? cast<uint8>((c >> 1) ^ 0x8C) : cast<uint8>(c >> 1);
}
t[i] = c;
}
return t;
}
comptime InlineArray<uint8>#(256) CRC = crcTable(); // baked → static const InlineArray_uint8_256 CRC = {.v={…}};
type value Palette {
comptime fn int32 sq(int32 x) { return x * x; } // private (default) — internal helper
public comptime fn InlineArray<int32>#(8) squares() { … } // read `Palette::squares()`
}
- A baked table is a table wherever it is imported. A file that imports
CRCindexes, iterates and.view()s it exactly as its own file does, in safe code; a generated table can live in a file of its own. - Comptime-only. A
comptime fnis a compile-time symbol; it is never emitted as C. It may be called only from a comptime context — acomptimeconstant initializer, a comptime argument, or anothercomptime fn. A runtime-position call is a clean error pointing at thecomptimeconstant form. (The dual-use half is the one below: a value type's ctor, methods and operators are ordinary run-time code that ALSO runs at compile time when a compile-time expression calls them.) - Both scopes, member visibility. A top-level
comptime fnis a module-level compile-time function; a type-associated one is readType::name()and obeys member visibility — default private (scoped and access-restricted, like any member), callable from outside only when markedpublic. A private type-associated comptime fn is callable from within its own type's comptime fns. - A function like any other. A top-level
comptime fnleaves its file throughexport { … };and is reached byimport, exactly as a function is,asincluded; its body runs in the file that DECLARED it, so it may use that file's private helpers and constants wherever it is called from. An imported one is still compile-time only: a runtime call to it is rejected. It shares the one name space of functions: kama has no overloading, so a second function of its name in the module —comptimeor not — is rejected, and no binding may take its name (kama has no shadowing). A compile-time expression that calls a runtimefnis rejected, saying so. - The subset. Integer (all widths — narrow-int wrap happens on cast + typed store, so a
uint8table entry wraps at 256 exactly as the emitted C would),float32/float64,bool,char, fixedInlineArray<T>#(N), and atype valuewhose fields are those. Statements: local +constdecls,=assignment, fixed-array element writes (t[i] = …) and field writes (this.f = …),if/else,for/while/do,foreachover a fixed array,break/continue,return. Expressions: arithmetic / bitwise / comparison / logical (short-circuit) / ternary / cast, array index and field reads, and calls to other comptime fns and to a value type's ctors, methods, operators andstatic fns. - Checked like run-time code, on every path. A
comptime fnbody and every compile-time initializer answer to the rules run-time code answers to — names, types, argument labels, no implicit numeric conversion — on EVERY path: the branch no evaluation takes, and acomptime fnno constant calls, are checked like the rest, and a generic type'scomptime fnwith its member bodies. A call to anothercomptime fnthere is a call like any other. One rule at every scope, socast<uint8>(300)is refused inside acomptime fnexactly as in a run-time fn, while a value's cast wraps in both; and asizeofis ausizethere too. - Purity → reproducible builds. A comptime fn is deterministic and effect-free: no I/O, no
new/spawn, no FFI, no reads of mutablestatics, no pointers/strings, and it may call only anothercomptime fnor a value type's member whose body keeps the same rules. These are enforced structurally, on every path of thecomptime fn— a branch no evaluation takes too — so anything outside the subset is a clean diagnostic, and the same inputs always bake the same output. A value type's member stays run-time code that also runs at compile time: its body keeps the rules on the paths a compile-time call runs, and a branch only run time takes (apanicguard, say) is its own business. - Bounded. A step budget (and call-depth cap) guarantees a runaway comptime fn can't hang the compiler
(as C++ constexpr-steps / Zig
@setEvalBranchQuota); exceeding it is a clean diagnostic naming the fn.
Compile-time assertions — comptime assert ✅#
A comptime assert(cond:, msg:) states a premise the build must satisfy. It takes the same arguments
as the runtime assert —
msg: mandatory, the condition's source text auto-appended to the diagnostic — and only the comptime
marker differs, because only when it is checked differs. It emits no runtime code: an assertion that
holds costs nothing, and one that fails is a build error rather than a trap.
comptime assert(cond: sizeof(int32) * 8 == 32, msg: "int32 must be 32 bits"); // module scope
type value Fixed comptime(int32 F) {
comptime assert(cond: F > 0 && F < 32, msg: "fractional bits must fit the backing");
}
type value Vertex { public float32 x; public float32 y; public float32 z; public float32 u; public float32 v; }
comptime assert(cond: sizeof(Vertex) == 20, msg: "vertex buffer stride"); // a layout claim
fn void render() {
comptime assert(cond: sizeof(float64) == 8, msg: "float64 is 8 bytes"); // and inside a body
}
- Three scopes: module, type member, statement. A
comptime assertin a generic — type or function — is checked once per instantiation, with that instance's arguments bound, so the failure names the use site (assertion failed: F > 0 && F < 32 … [with F = 40]). That is what lets a generic reject a bad argument instead of miscompiling. A member assert has no visibility:publicon one is an error. msg:must be a plain string LITERAL — stricter than the runtimeassert, which takes any string expression. The reason is the second lowering below, whose message has to be a literal; one rule for both beats a rule that changes with the predicate.- One surface, two lowerings. Which one applies is decided by the predicate, not by the author:
- kama answers it when the predicate folds — literals,
comptimeconstants, comptime parameters,sizeof/alignof(from the target's layout model, see Writing a collection in kama), and arithmetic/comparison/logic over those. The failure is an ordinary kama diagnostic, sokama checkand the LSP report it — for the target being checked: a claim that holds on a 64-bit host and not on wasm32 is reported bykama check --target WASM. - The C compiler answers it when the predicate turns on a layout kama has no model of — a
type extern value's, which is the C header's, or any layout on an arch the model does not cover. kama emits a C11_Static_assertcarrying the message, and the target's real ABI decides; this form fails at BUILD, not atkama check, sincekama checkruns no C compiler. - Anything else — a predicate naming a runtime value, or mixing a layout fact with one — is an error.
- kama answers it when the predicate folds — literals,
- Not a
debugAssert. There is no release-stripped variant, because there is nothing to strip: a compile-time assertion never reaches the running program.
MCU codegen attributes (step 4) — @interrupt and @section(".x") are declaration attributes (the
existing @name(args) mechanism, extended from serialization to functions + statics). Each emits a C
__attribute__((...)) only on the declaration it annotates; un-annotated code is byte-identical.
@section(".isr_vector") static hardware UnsafePtr<uint32> vtor; // -> __attribute__((section(".isr_vector")))
@linkName("SysTick_Handler") @interrupt expose fn void onSysTick() { … } // -> __attribute__((interrupt, used))
@interruptbinds a function to an interrupt vector: it emits__attribute__((interrupt, used)), the ISR calling convention on Cortex-M / RISC-V / classic ARM (usedkeeps it past--gc-sections). The handler must bevoid h()(no params, no return path) and must beexposed so it has an exported symbol the vector table can reference — which the startup code usually fixes (SysTick_Handler), so an ISR normally carries@linkNametoo; without it the symbol is module-qualified like any exposed name. AVR'sISR(VECTOR)macro form (@interrupt("VECTOR")) is a later step.@section(".name")places a module static or a function in a named linker section — the ISR vector table, a flash const table, a.ramfunc, or a DMA RAM bank. The board's linker script owns the addresses.
Layout control — @align(N) / @packed. The same passthrough mechanism, applied to a TYPE rather than
a declaration: @align(N) emits __attribute__((aligned(N))) and @packed emits ((packed)) on the
struct kama generates. They are the companion to layout verification — sizeof/alignof fold and
comptime assert checks them — and verification shipped first deliberately, because asserting a layout is
what makes stating one safe.
@packed type value Reg { public uint8 ctrl; public uint32 data; } // sizeof 5, not 8 — an MMIO block
@align(16) type value Vec4 { public float32 x; public float32 y; public float32 z; public float32 w; }
comptime assert(cond: sizeof(Reg) == 5, msg: "the wire format is 5 bytes");
- The C compiler owns layout; kama predicts it and is checked. kama emits C and the C compiler lays the
struct out. kama's compile-time
sizeof/alignofcome from a model of the target that applies these attributes as GCC and clang do (@packedputs every member at alignment 1,@align(N)raises the struct's), and every baked answer is a_Static_assertin the emitted C — so the model is never a second source of truth that could silently disagree: where it would, the build fails. Nmust be a power of two, 1 to 4096. Not passthrough, because gcc and clang do not refuse a non-power-of-two — they round it up, so@align(3)would compile and quietly mean 4.- Types with a struct only —
type valueandtype resource. Anenumis refused: a payload-less one lowers to an integer and a tagged one to a tag plus a per-variant union, which an outerpackedwould not reach; an enum states its layout withtype enum E : IntTypeinstead. A declaration is refused too, so there is one way to align an object rather than two: write@align(64) type value Buf {…}and declare the static with that type. Rust makes the same call —#[repr(align(N))]is types-only. - A type may carry both, and either may sit beside
@generate(...). Fixtures:tests/layout_align_packed.kama,tests/xfail/align_not_power_of_two.kama,layout_attr_on_enum.kama,layout_attr_on_static.kama.
Operator overloading — the sanctioned exception to named-args-only (a binary operator has exactly two
operands, positional by nature). The full overloadable set is supported: arithmetic + - * / %, comparison
== != < > <= >=, bitwise & | ^ << >>, unary - ! ~, and ++/--. Arity picks the form:
| params | form | example | a op b lowers to |
|---|---|---|---|
| 0 | unary on this |
Vec2 operator-() |
Vec2__op_neg(&a) |
| 1 | binary method (this is the left operand) |
Vec2 operator+(Vec2 rhs) |
Vec2__op_add(&a, b) |
| 2 | binary free (both explicit) | Vec2 operator*(float64 s, Vec2 v) |
Vec2__op_mul(a, b) |
The free form handles the mixed-type case a method can't — a primitive on the left (s * v). Dispatch
prefers the method form on the left operand's type, else a free form on either operand's type. A binary or
unary expression whose operands are all primitives keeps the built-in C operator (zero overhead).
Type-based dispatch. A type may carry several operator* distinguished by operand type — mat * vec
and mat * mat, v * s and s * v — exactly as C++/C#/Rust allow. Resolution matches by the operand
types; a same-type / This / scalar right operand uses the bare name, a different user-type right operand its
own. Only two operators with the same symbol and operand type are a duplicate (a clean error).
Chaining & compound assignment. Operators chain freely (a + b + c, -(a + b), (a + b) * s), in any
position including a raw if/while condition: a nested rvalue is wrapped in a C99 compound-literal array so
the method form's by-pointer this is legal without a statement slot (and it re-evaluates correctly each loop
pass). Compound assignment lowers to the operator — pos += vel ≡ pos = pos + vel. An inline
constructor is a valid operand — v + Vec3.of(x: 1, y: 0, z: 0) needs no separate local. It works in an
if/while/for condition too (the condition is wrapped / uses a loop-and-a-half so the temp materializes
and re-evaluates each pass); a do/while condition is the one place it must still be bound to a local.
Comparison is a contract, not an operator. The six comparison operators are the one place where the
operator is not declared on the type: ==/!= lower to Equatable.equals, and </>/<=/>=
lower to Comparable.compareTo. Declaring operator== (or any of the other five) is a compile error
that hands back the implements form. This is what keeps a == b and a <K: Equatable> bound from ever
disagreeing — the split C# has, where operator==, Equals, IEquatable<T> and EqualityComparer<T> can
all give different answers. Rust is the same shape as kama here (a == b is PartialEq::eq).
The rule that falls out: operators a generic bound has to name are contracts; operators that are pure
concrete-type ergonomics (+, *, []) stay operator members. So Equatable is the sibling of
Comparable it always should have been, and conforming to either also buys container eligibility — a
Comparable type is a SortedMap/SortedSet key and a PriorityQueue element; add Hashable and it is
a Map/Set key.
type value Cents implements Equatable<This>, Comparable<This> {
public int32 v;
public const fn bool equals(const ref Cents other) { return this.v == other.v; } // `==` / `!=`
public const fn Ordering compareTo(const ref Cents other) { // `<` `>` `<=` `>=`
if (this.v < other.v) { return Ordering::Less; }
if (this.v > other.v) { return Ordering::Greater; }
return Ordering::Equal;
}
}
Both contracts borrow their operand (const ref This) — a comparison never consumes or copies it. != is
!equals; <=/>= are "not Greater"/"not Less", so there is nothing separate to define. Equality stays
explicit: a value that implements neither contract cannot be compared, and there is no auto-generated
structural equality — but @generate(Equatable, Hashable) will synthesize the memberwise walk on request,
and @generate(Comparable) the lexicographic order (see Derives). The true/false conversion operators are out of scope: there is no implicit truthiness — if takes a bool,
and so does every condition: while, do … while, for's, ?:'s, and the operands of !, && and ||.
A raw pointer, null and a fnptr are not conditions either.
Primitives are untouched. An all-primitive comparison keeps the built-in C operator, so float <
keeps exact IEEE semantics at zero cost and never routes through Comparable. (A float is deliberately
not Hashable — NaN and ±0.0 make it a bad key — while its Comparable impl is a total order with
NaN sorting last, which is what the sorted containers need. Same split as Rust's total_cmp vs PartialEq.)
A user type may define a place-returning index operator — public ref T operator[](usize i) —
whose body returns a place (return this.cells[i]). It lowers to T* C__op_index(C* self, size_t i),
and the caller derefs the place, so g[i] = v, g[i] += 1, m[i][j] = v, m[i].field = v, and
ref g[i] all work — the same place semantics as a built-in collection, now expressible in the
language (so a Vec/matrix can be written in kama). The place is a second-class borrow of
self: it is used transiently and cannot be stored (there is no ref-local/ref-field to hold it — GOALS 3e, no stored borrows: a place is used where it is produced),
and a const receiver makes it read-only. A type whose elements are read-only by construction — a
read-only view over an UnsafeConstPtr<T> — declares that in the operator itself: const ref T operator[](isize i) returns a read-only place (§ const fn), so cv[i].method() reaches only const fn
methods and cv[i] = v is refused whatever the receiver's own constness. Bounds safety is the operator's responsibility — a
InlineArray/collection-backed body is auto-checked; a raw UnsafePtr<T> body is unsafe. The same place-return
works for a named method — public fn ref T at(usize i) { … } — so v.at(i) = x too. It also
works on a free function and a static method — fn ref int32 at(ref Buf b, usize i) { return b.d[i]; }, called as at(b: ref b, i: 0) = 5. Because a free/static function has no this, the
returned place must borrow a ref/out parameter (the caller-held borrow that outlives the call);
a place into a local or a by-value param is rejected ("would dangle"), the same escape rule as a
method borrowing this. Generic free functions work too (monomorphized per T). A ref of a
contract is not returnable — a contract value already borrows its object, so own it
(Shared<Contract>) to hand polymorphism back.
Used in a contract, an operator becomes a bound for generic math (see below).
Generics ✅#
User-defined generics, monomorphized (one specialized copy per concrete type — elements inline, no boxing; identical layout and cost to the built-in collections).
import { std::collections::DynamicArray };
type contract Shape for value, resource { fn int64 area(); }
type value Pair<A, B> { public A a; public B b; public ctor make(A a, B b){ this.a = a; this.b = b; } }
fn T max<T: Comparable<T>>(T a, T b) { if (a > b) { return give a; } return give b; } // generic fn — args INFERRED from the call
fn uint8 main() {
Pair<int32, bool> p = Pair.make(a: 1, b: true); // generic type (args inferred from the LHS)
int32 m = max(a: 3, b: 4); // -> max<int32>, a static specialized C fn
DynamicArray<Shared<Shape>> scene = DynamicArray.empty(); // nested generics, no space (the `>>` split)
return 0;
}
Generic functions and types; multi-parameter (
Pair<A, B>), nested (Box<Pair<int32, int32>>) — nested>>needs no space.type value/resourcegenerics both work (a generic resource is move-only with a per-instance dtor). Function type args are inferred from the call — inside a generic body too, where an argument typed by the enclosingTinfers per instantiation (fn T outer<T>(T v) { return ident(x: v); }).A parameter named only in another's bound is read off that parameter's conformance. In
fn A pull<S: Source<A>, A>(ref S s)the argument bindsS, and the oneSource<…>its type implements bindsA— the conformance is declared, so nothing is guessed (Rust infers the same from a single applicable impl). One binding may open the next:<S: Source<A>, A: Boxed<B>, B>readsAoffSand thenBoffA. A type that conforms to the contract twice leaves the parameter open, and the call spells it with a turbofish.Turbofish — explicit type arguments. When inference can't determine the type args — most commonly a return-only generic whose type parameter never appears in an argument — spell them explicitly with
f::<int32>()(the::before<is unambiguous). Turbofish reaches a generic function; a generic type is still writtenBox<int32>in type position; and a generic constructor spells the type args on the type —Box::<int32>.make(...)/new Box::<int32>.make(...)(turbofish on the type, uniform).fn T zero<T>() { return cast<T>(0); } // T appears only in the return — inference can't see it fn uint8 main() { int32 x = zero::<int32>(); // turbofish supplies it int64 y = zero::<int64>(); return 0; }Contract bounds —
fn sort<T: Comparable>(…),type value Map<K: Hashable + Comparable, V>.+means AND (all listed contracts). A bound lets the body call the contract's methods on a type-param value; because it's monomorphized, those calls are static direct calls (zero cost, no vtable). Each concrete type argument is checked to satisfy its bounds, else a clean compile error. A bound on a generic contract names its arguments too, in a declared bound and awhen [...]gate alike:<P: Source<int32>>andwhen [P: Source<int32>]hold for a type implementingSource<int32>, and not for one implementing onlySource<string>; an argument may be another parameter (when [P: Source<T>]).A
whengate conditions on the declaring type's own parameters: naming one it does not have is an error, and so is awhenon a concrete type, acontractmember or atype adapterblock, where there is no parameter to condition on. Calling a member an instance's gate removed names the gate and the argument that fails it (`Wrap<Plain>` has no method `tag` — it is declared `when [P: Tag]`, and `Plain` (for `P`) does not satisfy `Tag`).A bound is what the body may call, and nothing else. Reaching a member the bounds do not declare is an error at the DECLARATION, naming the bound that is missing rather than the method that is not there:
fn T largest<T>(T a, T b) { return a.compareTo(other: b) == Ordering::Greater ? a : b; } // error: `T` has no bound providing `compareTo` — an unbounded type parameter promises nothing, // so bound it with a contract that declares `compareTo`: `<T: …>`An unbounded parameter therefore promises nothing at all — it is not "anything", it is a type with no API — and it is treated as move-only, because an instantiation may bind it to one. A parameter whose bounds are all declared
for valueis a value at every instantiation, so it is copyable (tests/xfail/generic_unbounded_call,tests/xfail/generic_wrong_bound_call), and so is one the signature puts where only a value may stand: aSimd's element, or the parameter atype valuegeneric holds (fn T unwrap<T>(Box<T> b) { return b.v; }copies). AnInlineArray's element is read as a value too when the template is checked, and an instance over an owning element is checked as instantiated (first<string>refusesreturn a[0];), since anInlineArray's kind follows its element. Copying any otherT— out of a field, out of a collection — needsT: Copyable<T>and sayscopy, since a bare hand-off of aTwhose copying is its own choice would move. Arithmetic isT: Additive<T>orT: Arithmetic<T>, comparisonT: Comparable<T>.What is checked at a generic's DECLARATION, versus at its instantiation. Every generic is analyzed as written — every rule in the language applies inside its body, with each type parameter standing for a type that promises exactly what its bounds promise. Instantiated or not: an instance cannot vouch for its template, so
fn int32 feed<T>(T w) { return w.get(); }is refused even when the program only ever passes a type that hasget. That is what makeskama checkon a library a real answer: a package's public generic cannot ship green because its own tests instantiated it with a type that happened to fit, and hand the next consumer the errors. It holds for a generic function, a generic type and a genericenumalike, and forbuild,checkand the language server identically (tests/generic_uninst_ok.kama,tests/xfail/generic_uninst_*). A mistake an instance already reported is not reported again by the walk.What waits for the instantiation is only what a concrete argument decides: whether a type argument satisfies the bounds, which
when [T: …]members exist for it, which instance afriendgrant naming one (friend Lens<int32>[c]) admits, and what a comptime parameter's VALUE decides — acomptime assertover it, a constant index against a size it sets. The as-written walk holds a placeholder for a comptime parameter, so its types resolve, and judges nothing by the value; each instance does, at the use site that chose the argument, naming it.The one thing neither can check is a constraint the language cannot spell.
cast<T>(…)inside a generic needsTto be a scalar, and no contract means "an integer primitive" — so a template that converts through its own parameter is checked at each instantiation instead. The stdlib has no such template: a value crossing a type-erased boundary is passed by ADDRESS and moved assizeof(T)bytes, which converts nothing and needs no such promise. That is the idiom to reach for;Atomic<T>is written that way throughout, and its element restriction — a lock-free machine word: an integer,bool(the done-flag), a float orUnsafePtr— is enforced by the compiler at the use site, being likewise unspellable as a bound; so is the one operation aboolcell lacks,fetchAdd/fetchSub(the shim adds raw bytes, andtrue + 1is not abool), whose flip isswaporcompareExchange. A float cell lacks the same two (no hardware has a lock-free float add); itscompareExchangecompares bits, and the accumulate idiom is acompareExchangeloop.Comptime parameters — a parameter may be a value instead of a type:
comptime(int32 N), in its own trailing list, supplied at a use site with#(4).<…>holds types;#(…)holds values. Inside the declaration it reads as an ordinary value of its type, so a length, a shift or a scale becomes a parameter rather than part of a name:type value Fixed<B: FixedBacking<B>> comptime(int32 F) { // storage AND fraction, both parameters comptime assert(cond: F > 0 && F < cast<int32>(sizeof(B)) * 8, msg: "…"); public B raw; public fn int32 toInt() { return cast<int32>(this.raw.wide() / (1i64 << F)); } // F is a value here } fn int32 shifted(int32 x) comptime(int32 S) { return x << S; } Fixed<int32>#(16) q = Fixed::<int32>#(16).one(); // Q16.16; `Fixed<int8>#(16)` is a compile error int32 y = shifted(x: 2)#(3); // the values are their own list, always last- The parameter's type is declared and the argument must be a compile-time constant — a literal or a
expression, and it needs no parenthesis of its own — a negative one is simply
f()#(-1). An argument that does not fit its declared type is an error, not a wrap. - The name is reserved for the whole declaration: a parameter, field, local,
foreachvariable ormatchbinding may not reuse it, and it cannot be assigned to. The value is resolved ahead of every runtime name, so a rebinding would be discarded rather than shadowed — the one case kama's general shadowing rules do not already cover. - Const parameters pair with
comptime assert, which is checked once per instantiation with that instance's arguments bound: a generic states its own invariant over its own parameters, and a bad instantiation is rejected at the use site, naming the arguments that broke it.sizeoffolding (above) is what lets that invariant mention a type parameter's width. - A generic
enummay not declare members at all, so a const parameter there could never be read; the kind still accepts one for parity with the other type kinds.
- The parameter's type is declared and the argument must be a compile-time constant — a literal or a
expression, and it needs no parenthesis of its own — a negative one is simply
This— the self-type. Inside a type's own body it is that type (fn This clone(),implements Comparable<This>) and needs no declaration, because nothing is erased there. A contract may not nameThisin a signature; it declares the self-type as a pinned type parameter instead:type contract Comparable<T is This> for value, resource, enum, intrinsic { const fn Ordering compareTo(const ref T other); } type value Duration implements Comparable<This> { … } // conformance: always `This` fn T maxOf<T: Comparable<T>>(T a, T b) { … } // bound: the bound's own parameter Comparable<int32> c = 3; // use as a type: a concrete nameThe reason is erasure.
Thisis a substitution, and a substitution needs something to substitute into: a generic bound monomorphizes and has that, a contract value has thrown the type away. A vtable slot must giveThisone concrete type, so it bound the contract while the function behind the slot had bound the implementing type — two bindings for one function pointer, and the cast between them was a lie the C compiler could not see. A pinned parameter is a real type argument that resolves identically on both sides, which is what letsComparable,EquatableandRealbe contract values at all.isis an identity constraint and gets its own grammar position rather than joining the:bound list, which holds contracts. At most one parameter may be pinned, it must come first, and the operand isThis—<T is Widget>(a subtype bound) is not a thing kama has: inheritance is not a bound axis — substitutability is a contract's job (SPEC § Inheritance). Chosen overSelfto pair with thethisvalue and the PascalCase-types convention.Generic math (operators as bounds) — a
contractmay declare operators, giving generic code arithmetic over any conforming type at zero cost. The prelude's areAdditive<T>,+and-, andArithmetic<T>, which refines it with*and/; a generic that adds, subtracts, multiplies or divides itsTsays so, and is checked where it is declared:// in the prelude: type contract Additive<T is This> for value, resource, intrinsic { T operator+(T rhs); T operator-(T rhs); } // type contract Arithmetic<T is This> for value, resource, intrinsic implements Additive<T> { // T operator*(T rhs); T operator/(T rhs); } fn T sum<T: Additive<T>>(T a, T b) { return a + b; } // `a + b` -> the primitive `+`, or Concrete__op_add(&a, b)Every number implements
Arithmeticwith its own operators, sosum<int32>is the bare+, overflow check and all.std::num::FixedimplementsArithmetic, andVec2/Vec3/Vec4andDuration, which add and subtract but do not multiply by their own type, implementAdditive. Any type that declares the operators andimplementsthe contract does the same (bounds are nominal). Neither unary-(an unsigned number has none) nor%(a float's is not an integer's) is in it; a contract of one's own may declare those, or+alone (tests/generic_arith).x + xon an unboundedTis refused with the bound to write.Generic contracts — a
contractmay itself be parameterized (type contract Iterator<T>), and is monomorphized per use just like a generic type (Iterator<int32>→ a specializedIterator_int32). It has full value + bound parity with a plain contract: usable as a static boundfn sum<I: Iterator<int32>>(I it)(zero-cost, direct calls) and as a dynamic fat-pointer valuefn drain(Iterator<int32> it)(vtable dispatch). A type opts in withimplements Iterator<int32>.type contract Iterator<T> for value, view, resource { fn Optional<T> next(); } type value IntRange implements Iterator<int32> { /* … fn Optional<int32> next() … */ } fn int32 sum<I: Iterator<int32>>(I it) { /* it.next() → static IntRange__next(&it) */ }(Both dispatch modes coexist by design — monomorphization can't express a heterogeneous
DynamicArray<Iterator<int32>>or open-world runtime choice; the fat pointer can. See Contracts.)Default type parameters + named type-arg override — a trailing type parameter may carry a default
= DefaultType, filled in when the use site omits it. So a library can grow parameters (a pluggable hasher, a custom allocator) without breaking existing call sites:Map<string, int32>keeps meaningMap<string, int32, DefaultHasher, GlobalAllocator>. Overriding a later default without spelling an earlier one uses Kama's named argument model applied to type args — name the arg to skip a default (Map<int32, Entity, A: ArenaAllocator>). Leading args stay positional; a positional arg may not follow a named one; the:is unambiguous at use sites (contract bounds appear only in declarations). Defaults + named overrides resolve to one canonical positional tuple before monomorphization, so the omitted, named, and fully-spelled forms all dedup to a single specialized instance.type value Wrap<T, U = int32> { public T first; public U second; /* … */ } Wrap<bool> a = Wrap.make(a: true, b: 7); // U defaults to int32 Wrap<bool, float64> c = Wrap.make(a: true, b: 3.5); // U overridden positionally Wrap<bool, U: int32> d = /* … */; // named override — same instance as `Wrap<bool>`A generic whose parameters ALL default is a complete type spelled bare —
BitSetisBitSet<GlobalAllocator>, and withtype contract Dg<A = SocketAddr>,implements Dg, aDgparameter, field or local all nameDg<SocketAddr>. A generic with a parameter that has no default names no type when spelled bare, and is refused in every position, naming what is missing. A bare bound is the one exception, and a different reading:fn f<S: Src>(S s)accepts anSimplementingSrcat any argument. Defaults are atypefeature — atype value/resource/contractor atype enum. A default fills in an argument the use site omitted, and a function's type arguments are not written at the use site at all: inference reads them off the arguments, or a turbofish spells them. So there is nothing for a default to fill in, andfn f<T = int32>()is an error naming that (tests/xfail/fn_type_param_default).Default function/constructor parameters are a deliberate non-goal (one way to do a thing) — a self-documenting named
ctor(Map.withAllocator(allocator: …)) covers that need instead.A type parameter may not shadow a visible type. A type parameter is a binder, so
fn area<Point>(…)would declare a freshPointand make the real one unreachable inside that declaration — legal in Rust and C++, and silent in both. kama rejects it and says so, because the failure otherwise surfaces as a C-compiler error about the substituted type. Visibility is the declaration's own: a type it declares, imports, aliases or gets from the prelude all count (tests/xfail/type_param_shadows_type).A name is declared once per module — kama has no overloading, so a second
fnof the same name is an error naming both declaration sites, for a plain function and a generic template alike.externis exempt on both sides: re-declaring a C entry point in each module that calls it is what anexternis for (tests/xfail/dup_fn,tests/xfail/dup_generic_fn).A member name is declared once per type — a second field or a second method of the same name is an error at the second declaration, for the same reason. A duplicate field used to reach the C compiler as
duplicate member; a duplicate method used to compile with the LAST body silently winning (tests/xfail/dup_field,tests/xfail/dup_method). Redeclaring an inherited method is the separate rule under Inheritance.Specialization is a non-goal. There is no way to give one generic function a second body for a particular concrete type argument, and there will not be — the mechanism for a per-type body is a
contract(plus atype adapterfor a type the contract's author does not declare), which is whatstd::math'sRealis. Reasoning in ROADMAP_DETAIL.md § Deferred language bits.
Access control ✅#
Encapsulation is compile-time only (the emitted C is unchanged) and stricter than C#:
- Private by default. A member with no modifier is private;
public/protected/privateset it explicitly.protected= the owner or a subclass; external code seespubliconly. - Field visibility is per field on a
value. Atype valuemarks each fieldpublic(externally accessible) or leaves it private (default, reached through accessors) — avaluewith all-public fields is a plain-old-data struct. Atype resourcekeeps all fields private (ownership stays encapsulated); atype contracthas no fields at all (an interface owns nothing; state lives in the implementing type). - Overridable methods are written
protected(public polymorphism is acontract's job); a type opts into extension as atype virtual(maxDepth: N) resource/type abstract(maxDepth: N) resourceand seals as a plainresource/type final resource.protectedandvirtual/abstract/finalare errors outside an extensibleresource. ~dtor⟺resource— a destructor is allowed only on aresource(avalueowns nothing).- A member modifier means something on its member, or it is a compile error. The modifier words are one
list shared by every member form, so each parses everywhere, and a word the member cannot use is refused
with what the word is for — never dropped.
staticmarks a method (a type holds no static storage — see Static methods);externmarks a type;virtual/abstract/override/finalmark a method of an extensibleresource, so a field, acomptimemember, an operator, an enum's method and a contract member refuse them;defaultmarks the zero-argctor;unsafemarks a body, which aconstfield, acomptimemember, acomptime fnand a contract operator lack;immutablemarks a whole type; and a destructor, which no code names, takes nothing butunsafe. Until0.9.443a word its member's arm did not read vanished: 100 of 195 modifier × member-kind cells built, most of them meaningless. - One name is one member, across inheritance too. A derived type may not reuse the name of a method it
inherits — an
overrideof aprotected virtualis the one redefinition, and astatic fnor actorunder that name is refused as well, since it would give the type two members spelled the same. friendgrants are granular and owner-declared:friend <accessor>[members];(or[...]for all privates), where the accessor is a type, a free function, or aType::method(a namedctorincluded) — greppable and explicit. Member visibility is per TYPE, by design: a non-publicctor or method is reachable only inside its type, not by a free function in the same file — a type's invariants are enforced by the type, never by file layout — and a grant is how the type names the exceptions, which is more granular than C++'sfriendsince it names members. A grant crosses generics, to the CORRESPONDING instance:friend Tree[m];inNode<K,V>letsTree<A,B>reachNode<A,B>, and a sibling instance is refused — which is whatprivatealready does between siblings, a method ofNode<int32>having no reach intoNode<int64>'s private field either, so a grant is never broader than the rule it relaxes. With only one side generic there is nothing to correspond to, and the grant holds for every instance of that side. The two argument lists must be able to correspond, so differing arity is refused where the grant is written rather than becoming a grant that never fires. A grant reaches across files and modules by qualified path for every accessor kind (friend other::mod::Holder::build[key];), with noimportneeded to name the friend; and a grant whose path names a module that is not part of this program is inert — the code it names is not being compiled, so it grants nothing — which is what lets a root module grant to siblings a given consumer never imports. So is a grant naming a declaration in a file of a present module that this build leaves out, since importing from a dependency compiles only the files the import needs: a grant to a type in a sibling file of the module still builds for a consumer that never reaches that file. A grant naming a symbol no file of a present module declares stays a compile error. A grant names members — fields, methods, namedctors andstatic fns — and a type of any kind may write one, anenumincluded; acontractmay not, since every member is already public. A generic free function accessor reaches the corresponding instance, by the same rule a generic type accessor does:friend reader[v]onBox<T>admitsreader<int32>intoBox<int32>and no sibling instance. The grant reaches the generic function's own body too, which is checked once before any instance exists, so a library's generic accessor that only its users instantiate passes the library's own check, while a generic function the type never named is refused there. Type arguments on the accessor name one instance and are honored:friend Lens<int32>[c]admits exactlyLens<int32>. An argument may be one of the owner's own type parameters —friend Tree<K,V>is the corresponding instance, the same grant the bare name makes — and the two may be mixed, which nothing else can say:friend Tree<K, int64>insideNode<K,V>reachesTree<int32,int64>fromNode<int32,float32>. They are refused on a function accessor, whose type arguments are inferred at its call, so there is no instance for a grant to name. The member list names a member the way it is declared, so it takes thecopyctor, an operator (operator+,operator[]— which grants every overload of that operator, since the operand-type suffix is not something the grant can spell), and bothcomptimemember kinds: a type-associated constant and acomptime fn.
See TYPE_MODEL.md § Access control for the full kind × visibility table.
Enums & match ✅#
A type enum declares either a plain (payload-less) set of variants or a tagged union (variants
carry payloads, and the enum may be generic):
type enum Color { Red, Green = 5, Blue } // plain: Red=0, Green=5, Blue=6
type enum Shape { Circle(float64 r), Rect(float64 w, float64 h) } // tagged union (payloads)
fn uint8 main() {
Color c = Color::Blue; // variants are scope-resolved with ::
return 0;
}
A member's value is a compile-time integer expression. It may combine integer literals, the enum's
own members, another enum's members, comptime constants and cast<…> with + - * / % << >> & | ^ ~,
and it is folded by the compiler — what reaches C is a number. A member without a value is one more than
the member before it (the first is 0), so a derived member may be followed by implicit ones, and a
member may refer to a sibling declared after it:
type enum Flag : uint8 { None = 0, Read = 1, Write = Flag::Read << 1, All = Flag::Read | Flag::Write }
comptime int32 BASE = 100;
type enum Code { Ok = BASE, Warn, Bad = Code::Ok + 50 } // 100, 101, 150
A sibling is spelled Flag::Read, as a variant is everywhere else — the bare Read C allows is an error.
The value must fit the tag — the pinned IntType's range, or int32 when unpinned — so : uint8 { A = 300 } is an error, not a silent 44.
A member defined in terms of itself is an error,
and so is an initializer that does not fold or is not an integer.
Two members may share a value (B = K::A is an alias). This is the one place enum arithmetic is written:
a value of the enum is still not a number, and Flag f = Flag::Read + 1; stays refused (see Type identity).
(tests/enum_derived_member.kama.)
An enum is a type kind like any other, so it declares its contracts inline and carries the methods that
satisfy them — the variants come first, then a ;, then ordinary members:
type enum FetchError : uint8 implements Error {
NotFound, Denied(int32 code);
public const fn string message() {
return match (this) { case NotFound: "not found"; case Denied(code: c): "denied"; };
}
}
A method is called on any value of the enum, a member written in place included (Light::On.lit()). A generic
enum's payload-less member names no instance (Slot::Empty is a Slot<what>?), so it is a receiver only once
something gives it a type, as everywhere else it is read.
The ; separating variants from members is mandatory, and it is what makes the body unambiguous: a
bare Foo variant and a Foo bar; field are indistinguishable until it appears. An enum may declare
methods with or without a contract — private unless written public, like any member, and public when
they satisfy a contract — and named ctors and friend grants, as a type does
(one of those ctors may be marked default, the enum's only way to have a default — see Collections — the
four-ctor matrix)
— and its own comptime constants and comptime fns, read E::NAME and E::name(), and comptime asserts,
which are checked like a type's (all three parsed and did nothing before 0.9.443) —
but not a field (a const one included), a destructor or an operator — its layout is its tag plus its
variant payloads, and it owns nothing beyond them; an operation on an enum is a named method.
Operators on an enum are a non-goal, declared or built in. An enum's values are named variants, so +,
| or << over them names no variant; a user operator| would reopen exactly the hole the built-in refusal
closes (Flag::Read | Flag::Write is no Flag). The two needs that look like operators each have a home
already. Order is a contract, as it is for every type: @generate(Comparable) or implements Comparable<This> gives an enum <, and == is the one operator it keeps without asking. A set of flags
is a type value holding the bits, with comptime constants for the members and its own operator| and
has(), as std::fs::Permissions is — the result of a | b is then a value of the type, which is what an
enum's could never be. C#'s [Flags] enum is the shape this rejects: there, Read | Write is an enum value
no member names, and switch cannot be exhaustive over it. Members never change
what an enum IS in C: a payload-less enum stays its integer (typedef int32_t Color), and its methods take it
by value (bool Color__isWarm(Color self)), the way a type adapter method takes a primitive; a contract
reaches them through the ordinary vtable. An enum with payloads is a tag plus a union either way.
A generic enum declares members and contracts the same way, and — like a generic value — each
instance takes them per instance: a when [...] gate on a method or on an implements entry is judged
against that instance's own arguments.
type contract Tag for value, resource, enum { fn int32 tag(); }
type enum Maybe<T> implements Tag when [T: Tag] {
Has(T v), Nope;
public const fn bool has() { return match (this) { case Has(v: v): true; case Nope: false; }; }
public fn int32 tag() when [T: Tag] { return match (this) { case Has(v: v): v.tag(); case Nope: 0; }; }
}
Maybe<Tagged> implements Tag and has tag(); Maybe<Plain> has neither, and binding it to a Tag is
refused. A missing contract method is reported once, at the template. (tests/generic_enum_members.kama,
tests/generic_enum_members_cross_module.d/.)
type enum E : IntType pins the tag to a fixed-width integer — uint8 for a wire format or a packed
MMIO field, where the default (a compiler-chosen enum width) is not something you can serialize against.
It is the only part of an enum's layout kama lets you state, and it changes the lowering: ISO C cannot set
an enum's underlying type, so a pinned enum emits typedef <IntType> E; plus an anonymous enum carrying
the constants, where an unpinned one emits a real C enum. That is a genuine difference in what the C
compiler can prove — with a plain integer tag it cannot see a switch over every variant as total — and it
is why the default: arm of a value-producing match over a pinned enum diverges (a kama_panic)
rather than falling through. Every other match emits default: break; unchanged. Fixture:
tests/enum_match_intty.kama.
An unpinned plain enum lowers to a C enum; a tagged union lowers to a tag + payload union. Enum variants are
scope-resolved with :: and constructed with named args (Shape::Rect(w: 3.0, h: 4.0)). A variant is
not a type — Rect r does not name anything, and an enum cannot nest type declarations — so Rect is a
member of Shape's scope, reached with :: like any other scope member; supplying its payload yields a
Shape. That is why construction's dot-on-type rule does not apply here: there is no type to dot.
match is the one construct for branching on an enum — payload-less enums, tagged unions, and the
Optional/Result prelude types alike — and an enum behind an Owned/Shared handle, which it matches
through the handle, borrowing the payloads in place (not give, and not a Weak, which must be upgraded first). It is value-producing (usable in statement or expression
position), enforces compile-time exhaustiveness, and accepts a _ wildcard for the catch-all case.
An enum that means to grow says so: @nonExhaustive. Outside the PACKAGE that declares it, a match on one
needs case _: even when it lists every case today — the package may add a case in a later release, and a match
with no _ would then stop compiling. Inside the package the match stays exhaustive, so the package handles each
case it adds. std's error kinds and growable options carry it — IoErrorKind, DeErrorKind, SerError,
ParseError, the DecodeErrors, Utf8Error, UuidError, TimeError, FileKind, OpenMode, Stdio,
ProcessGroup (whose cases already differ by OS), SqlValue, Interest — while closed sets (Optional,
Result, Ordering, Weekday, SeekFrom, …) stay exhaustive everywhere. Any enum may carry it, a registry
package's included. It takes no arguments, and it is an error rather than a warning: kama has none.
In a pattern, _ in place of a binding discards that payload field — as often as the pattern needs it,
case Rect(w: _, h: _) — and a discarded owning field of a consumed subject is dropped at the arm's end.
It is also the only construct that reads an enum, which is why the only way to build one from an
integer — try cast<E>(x), above — hands back an Optional<E>: a value that names no variant arrives as
a None arm of the same construct, never as a trap.
A subject that names storage is borrowed where it lives: a local, a field, an element, or a call that
returns a place (fn ref T, const fn const ref T), so its payload bindings read storage the owner keeps.
Only a fresh value, such as a constructor or a by-value call result, is held in a temporary, and the match OWNS
it, as match (give x) owns a local: nothing else does, so an arm may give an owning payload out, and the
payloads it leaves drop at the arm's end: match (resolve(host: h, port: 443)) { case Ok(value: list): addrs = give list; … } needs no local to hold the result first. A fresh subject takes no give (match (give f()) is refused, as T x = give f(); is). Through a storage-naming subject, a binding of an OWNING payload (a collection, a string, a resource, a handle)
is an alias of the subject's slot, as a foreach (ref …) binding is of an element: a change through it changes
the subject's payload, and it cannot be given away (match (give x) hands the arm the payload to own); a
place-returning call is storage-naming, so its payload stays where its owner keeps it. A
const subject's alias is const. A binding of a plain value is a copy the arm may change, as a by-value
foreach binding is — and costs no copy when nothing could tell: a payload with no destructor, out of the
match's own temporary or a local (a by-value parameter is one) that the arm never writes, in an arm that never
writes, refs or gives the binding, is read where it lies. A 16 KB case Ok(value: b): b.last() then holds one
Big in its frame, not two (tools/check-construct-frame.sh). The binding still means a copy: an arm that writes it
gets one, and so does a const ref parameter's payload, which code the arm calls could change.
Writing the subject ends its aliases. The arm may write the subject — state = State::Ready(…) is the
commonest arm there is — but the write replaces the payload an alias points into, so from that write on the
alias is gone, and a use of it is refused. A write is an assignment, a ref/out argument, or a non-const fn
on the subject or on what contains it (this.reset() writes this.state). It counts on every path it may
have taken, and inside a loop a use anywhere in the loop is refused when the loop writes the subject, because
the next pass runs it after the write. Read what you need first, or match again. Writing THROUGH the alias,
and writing a disjoint sibling field, stay legal.
A pattern NAMES the fields it binds — field: local — exactly as a call names its arguments; there is
no positional form, and kama no more exempts a one-field variant here than it exempts a one-argument call
from a label. The label is the variant's field; the identifier after it is the local it introduces, and it
may be called anything. Because the label decides, order does not: case Rect(h: y, w: x) and
case Rect(w: x, h: y) are the same pattern. Positional binding is how case Rect(height, width) compiled
clean and silently returned the wrong values — the bug class named arguments exist to remove. Three
mistakes are compile errors, each naming the fields the variant actually has: binding an unknown field
(tests/xfail/match_label_unknown.kama), binding one twice (…/match_label_duplicate.kama), and
leaving one unbound (…/match_label_missing.kama) — a pattern names every field of its variant, just as
construction supplies every one. The label is also a reference to the field, so hover, go-to-definition
and rename reach it. Pinned by tests/match_named_bindings.kama.
int32 area = match (sh) { // expression position — yields a value
case Circle(r: r): cast<int32>(r * r * 3);
case Rect(w: width, h: height): cast<int32>(width * height);
};
match (color) { // statement position — a plain enum works too
case Red: fire();
case _: hold(); // wildcard catch-all
};
An arm is either a single expression (case X: <expr>;) or a block (case X: { … }). A block
arm names the value it produces with a := <expr>; statement, which must be the block's final
statement (single-exit) — it accepts any expression, and reads as "bind this value out" (a match in a
typed position is an assignment from the outside, x = match … { … := v; }). := is distinct from
return, which leaves the enclosing function. An arm of a value-producing match must therefore either
end in := or diverge (return, a break / continue of the loop around the match, a panic, or anything
else that never completes — see Unreachable code); in particular a block arm cannot be empty,
since it would leave the match's value unset:
fn string describe(Optional<int32> reading) {
string label = match (reading) {
case Some(value: c): {
string name = "mild";
if (c < 0) { name = "freezing"; }
if (c > 30) { name = "hot"; }
:= give name; // the arm's value (must be last) — an arm is a hand-off
}
case None: "unknown";
};
return label;
}
The match subject can be a variable, a method call, a static-method call, a free-function call
(match (File.open(path: p, mode: OpenMode::Read)) { … }), or a value-producing variant constructor
(match (Optional::Some(x)) { … } — the concrete instance is inferred from the payload). Arbitrary-integer
branching (not on an enum) is done with if / else if — there is no switch.
Error model — Optional / Result ✅#
The prelude provides two tagged-union types, so error handling needs no exceptions and no null:
Optional<T>—Some(T)orNone. A value that may be absent.Result<T, E>—Ok(T)orErr(E). A value or an error.Unit— a single-variant enum (Unit::Unit), the empty value. It is the payload for a fallible operation that succeeds with nothing to return:Result<Unit, E>(the analogue of Rust'sResult<(), E>), so one error convention — alwaysResult— covers valued and void operations alike, with no placeholder payload. Example:fn Result<Unit, IoError> remove(const ref string path)instd::fs.
Both are ordinary tagged unions consumed by match, so the caller is forced to handle the empty/error
case (exhaustiveness):
fn Optional<int32> find(const ref DynamicArray<int32> xs, int32 target) { … }
int32 idx = match (find(xs: list, target: 7)) {
case Some(value: i): i;
case None: -1;
};
A yes/no question needs no match: isSome()/isNone() on an Optional, isOk()/isErr() on a Result,
each a const fn (if (cache.lookup(key: k).isNone()) { … }). They take nothing apart — the value, or a default
in its place, is still match's, the one way; there is no unwrap, unwrapOr or map.
A Result is never discarded silently. A call whose Result a statement would throw away is refused — f(); names the fix: handle it with match, or write _ = f();.
The same holds for the arm of a statement match and for a for clause, which discard their values too.
_ = expr; is the one explicit discard: it evaluates expr and drops what it produced, owning or not, at the end of the statement.
It takes what an expression produces: a named local is dropped by the end of its scope, and a { … } block ends one sooner, so _ = x; is refused.
_ is the discard in a pattern and as that target, so nothing may be named _.
An Optional stays discardable — absence is not failure — and so does any other value: a statement drops what it discards, so xs.remove(index: i); drops what it removed.
Zig refuses every ignored value, and Rust and Swift warn on an unused Result; kama has no warning channel, so the
choice was an error or nothing, and a Result nobody looks at is a failure nobody handles.
Weak<T>.tryUpgrade() returns Optional<Shared<T>>; a fallible static fn factory returns Result<T, E>
(see Fallible construction above).
A value is wrapped by naming its variant — Optional::Some(value: x), Result::Ok(value: x),
Result::Err(error: e). Nothing converts a T into an Optional<T> on its own, so handing one where the
wrapper is expected is refused at every crossing, with the variant to write:
an initializer, an assignment, an argument, a return, a field. The same holds for either arm of a Result.
A boxed error — Owned<Error> — and .as<T>(). Where a failure can come from more than one error type, the
error is boxed: reading data returns Result<T, Owned<Error>>, because the format reader fails with its own
error and a type's hand-written deserialize may fail with another. e.message() reports any of them. To branch
on one, e.as<T>() asks whether the box holds a T and answers Optional<T>: Some with a copy when it does,
None when it holds something else. It is a checked test, never a reinterpretation, and the box keeps what it
holds. T is any concrete type implementing Error — an enum, a value such as IoError, a copyable resource such as
DeError (the last two have a kind(), which is what code branches on). A type that owns something and cannot be copied is refused, since a
bitwise copy would alias the box's.
Modules ✅#
A module is a FOLDER, and a file's identity is where it sits — never anything it declares. A
project's kama.json lists its modules in a nested modules map mirroring the source tree, and a module's
name is the chain of keys read down to it, rooted at the project's name
(the Modules section above). import names a module by that full name; the
compiler resolves it through the manifest, compiles the module's files, and scopes their public symbols.
There is one keyword for depending on another module — import (it replaced using).
// geometry/kama.json
{ "name": "geometry", "version": "0.1.0", "kind": "library",
"modules": {
".": { "visibility": "public" }, // src/*.kama — module `geometry`
"graphics": { "visibility": "public" }, // src/graphics/*.kama — module `geometry::graphics`
"internals": { "visibility": ["graphics"] }
} }
// geometry/src/graphics/texture.kama — in module `geometry::graphics`, because of WHERE IT IS
export { Texture, scale }; // the public surface, at a glance — mirrors `import`
type resource Texture { ... } // declarations carry NO visibility modifier
fn int32 scale(int32 x) { ... }
type resource GpuHandle { ... } // unlisted → module-private
// main.kama
import {
geometry::graphics::Texture, // -> bare `Texture`
geometry::graphics::scale,
physics::Body as PhysBody, // `as` renames
};
fn uint8 main() {
Texture t = ...; // imported, bare
PhysBody b = ...; // renamed with `as`
int32 n = scale(x: 3);
return n;
}
There is no namespace declaration. A file used to name its own scope; the declaration and the path
could then disagree, so a file could be compiled into one scope and imported as another. Deleted
outright — namespace is not a keyword and writing one is a syntax error.
One import { … }; block per file, and every entry names a SYMBOL. a::b::X is symbol X of module
a::b, always — there is no whole-module import, so the entry is never ambiguous. as renames
(a::b::X as Y), and an entry with no scope (X) names a symbol of this file's own module, because
there the scope is the only candidate. There is no glob — unqualified-everything is not offered: explicit over implicit, and per-symbol imports are what the LSP's auto-import and closure pruning ride on.
A module path is written only in an import (KR-87) — every name a file uses is in its import list, and
every use of it is bare. geo::area() is refused in every position, naming the import that fixes it or, for
a symbol already imported, the spelling to write. Importing one symbol used to open every export of its
module to a qualified spelling, which was a qualified glob and a second way to name each thing. :: stays
for a TYPE's scope (Color::Blue, Ordering::Less), which names no module; the one other place a module
path is written is a friend grant, a module relation like an import entry rather than a use (§ Access
control). Two imports binding the same bare name is a compile error — disambiguate with as — and so is one
entry written twice. An as alias may not claim a name that already roots a project
this file can reach, which would leave the original unspellable
(tests/xfail/import_alias_shadows_project.kama).
A file's header comes first, in one order: file @compileFor(…); if the file is gated, then its one
import { … };, then its one export { … };, then its declarations. An extern "h.h"; is a declaration, so
it follows both blocks.
visibility decides what a module reaches beyond itself, in four widening forms — a list of
modules in this project, "children" (every module nested under it, at any depth), "internal" (every
module in this project) and "public" (plus dependent projects, and the only form that crosses a
project boundary). ⚠️ Nesting determines NAME, never ACCESS: a narrow parent does not confine a
public child, which follows Go rather than Rust's implicit downward grant. A module's own files always
see each other, so a list never names itself.
Visibility is per FILE. A file may name only what it DECLARES or IMPORTS — export { … }; is the
outbound half and import the inbound one, and the symmetry is the rule. A top-level type/fn leaves its
file only by being named in that file's one export block; a listed name must be a top-level declaration of
that same file, so a directory-module's files each state their own surface.
Every name in a type is held to this, at any depth — not only the name that IS the type. A type argument
(Result<Uuid, HidErr>, Optional<Result<int32, X>>), a #(K) constant, a bound and an implements
entry with its own type arguments, a module static's type, and every type a body writes — a cast<…>,
sizeof(…), an .as<…>() target, a turbofish argument, a variant expression's enum — is refused the same
way, and inside a generic body whether or not anything instantiates it. A name that is not in reach is
reported by kama, never left for the C compiler, and a name another module EXPORTS is reported with the
import that fixes it (add import { std::uuid::UuidError };); a private one says which file keeps it,
because no import can reach it. A bound or an implements entry must name a contract: a type there is
refused at the declaration, even on a generic nobody calls.
The FFI seam keeps its C spellings, and only those. An extern fn signature names types its header owns,
so a BARE name there is the header's literal C spelling and is not resolved; a module path is never a C
spelling and is refused there as everywhere. A body may name the C spellings its own file's extern fn signatures
introduced — cast<CompareFn>(c) beside extern fn void qsort(…, CompareFn compar) — and no other
unresolved name: another file's extern does not introduce it here (tests/callback_qsort.d).
A sibling in the same module is imported like anything else, and needs no path to do it, because there
is exactly one candidate: import { DynamicArray };, then the bare name at every use. A module's files
share their exported names but not a scope, so export offers a name and import accepts it — which is
what lets a reader name the source of every symbol in a file without leaving it. A name a file does not
export is the file's alone: two files of one module may each declare a private helper, a private
static n, a private type T, and neither shadows the other's — the compiler keys them by file, and only
an exported name may be declared once per module. This is the rung Go does not have
(any file of a package reaches any unexported identifier in it) and the reason Java needed sealed JARs
and then JPMS. What visibility in kama.json governs is reach beyond the module (the Modules rules above) — it says nothing about files.
An exported symbol may not name an unexported type of its own file. A project's API is derived, never
written down — the public modules of kama.json crossed with its files' export blocks — and that
enumeration is only usable if every name in it can be spelled by whoever reads it. Rust calls the family it
rules out private_interfaces. Publicly reachable positions only: a private field's type is not part of
what the export offers.
Declarations carry no visibility keyword, keeping type/fn syntax uniform, and the manifest
reads as the mirror of import. Member access (public/protected/private) is a separate axis; the
kama→host/WASM boundary (expose) is a third. The prelude floor and the built-in std::memory triad are
intrinsics — always in scope, never imported, outside the rung entirely. A file in no module — a loose
file the build was handed directly, sitting in the operand set's own root — keeps its symbols file-private,
so single-file scripts need no boilerplate and cannot be imported.
Resolution. import { a::b::c::X } names symbol X of project a's module b::c, and the answer comes from a manifest,
never from a search: the project being built, then its declared dependencies, then the stdlib bundled with
the compiler (located relative to the binary like the runtime header, so std::* resolves on any install
regardless of cwd). std, core and global are reserved roots. A module already in the compilation
satisfies an import without a disk lookup. A build with no kama.json does no searching at all — you
pass every source file, and each one's module is its directory below the operand set's deepest common
ancestor, which makes kama build a.kama b.kama a genuine subset of a project build rather than a second
dialect. Loading is transitive and deduped by path, so import cycles load once. The stdlib is optional on disk:
no import std::… means the resolver never touches it, and nothing is auto-linked — a no_std-like floor
(only kama_runtime.h is mandatory; the prelude Optional/Result/Deref/HeapOwner is baked into the
compiler).
A module import compiles only what it needs. import { a::b::X, a::b::Y } resolves to the files of that module
which declare X and Y, plus their transitive closure within it — not to every file of the module. The
closure follows references, not import edges. That was once forced: a sibling was reachable with no
import at all, so an import-edge closure would have under-computed. It no longer is — every sibling
reference now carries an import { … }; — but reference-following is kept because it is a superset of
the import graph and cannot under-compute even when the two disagree. A name a file declares itself is
satisfied there and pulls in no sibling, which is what keeps a repeated extern fn memset from tying three
files together — and is why an extern name, which keeps its literal C spelling and so is never
scope-prefixed, sits outside the file rung.
Anything the resolver does not fully understand loads the whole module, so the diagnostics are
unchanged: a bare import a::b; (nothing pins a file — and a type reached only through inference is never
spelled, so the importing file's own text cannot be used to seed one), a symbol the directory does not
declare, and a file whose declarations are
nameless but program-wide — a type adapter conformance on a primitive, or the extern seam that
spawn/parallel_for require.
Two consequences, both deliberate and both pre-1.0: a defect the compiler would report in a sibling file
nothing imports no longer fails the build, and a conformance that was arriving only because the whole directory loaded must
now be reachable. KAMA_NO_PRUNE=1 restores whole-directory loading; KAMA_PRUNE_TRACE=1 reports each
import's decision and =2 names the reference that retained each file.
Passing several files to one build still works (kama build a.kama b.kama -o app); the compiler emits a
shared header (<out>.gen.h) + one .c per unit — imports just add the resolved module files to that set.
Scope resolution uses :: (modules, qualified types, enum variants: Color::Blue); . is
instance/value access only (obj.field, obj.method()). The two are syntactically distinct, so
there's no module-vs-object precedence rule — a :: head is always a type/module, a . head always a
value. This is enforced, not merely conventional: a :: whose head is a local, a parameter or a field
is rejected with a message naming the . spelling, so field access has exactly one spelling
(tests/xfail/scope_op_on_value.kama). The one deliberate crossover is dot-on-type for constructors —
Vec2.make(...) constructs, Vec2::dot(...) calls a static fn — and the split is enforced in both
directions, so it is a real greppability guarantee rather than a convention: a static fn called with a
dot is rejected (tests/xfail/dot_on_type_not_ctor.kama) and a ctor called with :: is rejected
(tests/xfail/scope_op_on_ctor.kama), each naming the other spelling. The same holds for a read: a
variant, a field, a comptime constant or a static reached through a type with . (Color.Blue, V.N)
is rejected naming the :: spelling
(tests/xfail/enum_variant_dot.kama). And a name that resolves to nothing — a bare Nope, a Foo::Bar
with no such member, a K::ZZZ with no such variant, a type where a value is expected, and likewise a
member that does not exist (v.zz on a type with no such field, a.foo on an int32) — is kama's own
error: it is rejected by kama check, in kama's vocabulary, before any C compiler runs
(tests/xfail/unresolved_name.kama). A ctor is static (it takes no
self), so it would otherwise answer to both and grep '\.make(' would miss half the construction sites.
The rule holds through a generic type parameter too — T.deserialize(...) for T: Deserializable — and for a
ctor added to a primitive by a type adapter block. Every spelling is pinned by
tests/ctor_spelling_edges.kama.
On a generic type both forms take a turbofish, and the same .-vs-:: split applies:
Box::<int32>.make(v: 5) // ctor — dot
Box::<int32>::tag() // static — colon-colon
Here the turbofish is mandatory, unlike for a ctor: a ctor can infer its instance from its arguments,
but a static has no receiver and its parameters need not mention T, so there is nothing to infer from.
(Box<int32>::tag() cannot be the spelling — in expression position Box < int32 > is two comparisons,
which is why kama has a turbofish at all.) Pinned by tests/generic_static.kama. Relatedly, a self-returning static fn is rejected as a disguised
constructor (tests/xfail/self_returning_static_fn.kama): if it returns the enclosing type or
Result<This, E>, declare it a ctor.
main is the entry point, not a symbol. It is reached below the visibility system — every main
emits as the same C symbol, and the generated C main calls it directly — so calling main is an
error, main may not be exported, and it must be unique per PROJECT rather than per module:
two collide where a::helper and b::helper do not. Its location is unconstrained; location simply does
not scope it, which is exactly why it is not callable.
There is no global:: (retired with KR-87). It named the floor, and nothing is left for it to do: the
intrinsics are keywords (global::assert is refused, naming assert), the runtime capabilities are module
core, imported by name, and nothing may shadow a name in scope, so there is no local to reach past.
global stays a reserved project name, like std and core.
Concurrency ✅#
Kama earns data-race freedom the way it earns null-safety: by making the hazard unrepresentable
rather than checked. Where Rust proves exclusivity over shared memory (borrow checker, lifetimes,
Send/Sync, Pin, async colouring), kama removes the shared mutable state, so there is
nothing to prove — and where several isolates must change one value, a Locked<T> owns it, reachable only inside a
window that holds the lock and can neither wait nor take another. There is no async, no await, and therefore no function colouring: an ordinary
function is the only kind of function.
The model has three levels, and they all reuse the ownership rules already in this document.
Isolates — spawn ✅#
An isolate is a unit of shared-nothing execution: a real OS thread natively, a Web Worker on
wasm. It has its own stack, heap and module statics, and communicates only through channels, the
Atomic<T> seam and a Locked<T>. Isolates are meant to be coarse — roughly one per core, or a handful of
long-lived service isolates — which is what makes it honest for one to block.
There are two spawn forms, and what separates them is who owns the join.
import { std::concurrent::Isolate };
scope { spawn worker(p: give payload); } // the SCOPE owns the join — at its closing brace
Isolate h = spawn worker(p: give payload); // the HANDLE owns it; starts now, joins later
give h.join(); // explicit join, consuming `h`; `~Isolate()` also joins (RAII)
spawn and parallel_for are the language's, and they run on the isolate seam std::concurrent brings in (its
header and, natively, -lpthread), so a program that uses either imports from it — import { std::concurrent::Isolate };
when it names nothing else there.
A bare spawn must appear inside a scope { }. The
handle form need not, and that is its reason to exist: Isolate is a resource, so its join
travels with the handle, which may be moved into a field. That is how a long-lived service
isolate is written — its join is owned by an object rather than by a block, and shutdown falls out
of RAII in field order (dropping the Sender closes the channel, which ends the worker's recv
loop, which the Isolate drop then joins). Either way a forgotten join is impossible: there is no
detach-by-forgetting.
The stack ✅#
An isolate's stack is stated, and it is the main thread's. On a 64-bit native target that is 8 MiB —
what Linux and macOS give a main thread, and what kama links Windows' main thread up to from its 1 MiB
default. A 32-bit native target gives 2 MiB, since its address space would not hold many 8 MiB
reservations. Natively the size is reserved, not committed, so an isolate that never recurses deeply touches a
few pages of it. On wasm nothing is merely reserved: every stack is carved out of the module's one fixed linear
memory. So kama states 256 KiB there for the main thread and, by inheritance, every isolate (emscripten's own
default is 64 KiB). A project that sets STACK_SIZE in emSettings moves both together, so the rule holds there
as well. In a debug build a wasm frame lives in that stack, so it bounds call depth just as it does natively. Before 0.9.529 an isolate
took the OS's thread default instead: 512 KiB on macOS, 8 MiB with glibc, 128 KiB with musl. A recursion
that worked on the main thread then crashed in an isolate on two of the three.
Where the stated size is not enough, the spawn site says so — the one place that knows the depth it needs:
scope { spawn(stack: 64 * 1024 * 1024) walk(tree: ref deep); } // 64 MiB for this isolate
Isolate h = spawn(stack: 256 * 1024) worker(p: give job); // or less
parallel_for (ref Node n in nodes, workers: cpuCount(), stack: 16 * 1024 * 1024) { … }
stack: is a usize byte count, rounded up to a multiple of 64 KiB, the largest page any target uses, and
never below the platform's thread minimum (64 KiB, or 128 KiB on Linux arm64). A constant stack: 0 asks for no
stack and is a compile error. parallel_for and
parallel_spawn take it as a clause beside workers:, applying to each worker, and each clause is said at
most once.
An isolate that outgrows its stack says so and names the clause that raises it, where it was a bare segfault:
kama: isolate stack overflow (256 KiB; `spawn(stack: …)` to raise it), then the process aborts. A guard-page hit
runs on a signal stack of the thread's own on POSIX, and in a vectored exception handler with a stack guarantee on
Windows (Rust's std reports a thread's overflow the same way there); a fiber's stack is covered by the same handler.
The main thread is not: covering it would install a handler in every program, isolates or not, and its overflow is
the platform's own crash. On wasm a stack is emscripten's, and its overflow is emscripten's to report.
The bundle ✅#
An isolate entry is an ordinary top-level fn that returns void and takes exactly one
parameter — the bundle. It is void because there is
no channel to hand a result back over, and one parameter because the bundle is the boundary:
one object, one owner, one transfer. Everything an isolate receives, it receives here.
The entry may be a generic function, with its type arguments written or inferred as in a call:
spawn work::<int32>(j: give j), and spawn fiber the same.
A spawn, a parallel_for and a BindableFunctionPtr.bind may each be written in a generic's body.
The bundle has two forms, and the choice is not a style preference — they restrict on opposite axes:
| restricts | is free in | |
|---|---|---|
give — a moved resource |
the type — it must be a resource (a value, a primitive and a string are all rejected) and it must declare implements Sendable |
lifetime — the child may outlive the parent's frame, which is what the handle form needs |
ref T — a borrow |
the lifetime — only from a bare spawn inside a scope, over a place that scope outlives |
the type — any place, of any size, ref int32 included |
So a moved bundle transfers ownership (the parent provably cannot touch it afterwards), while a
borrowed one leaves the parent owning the storage — which makes ref the only way an isolate can
mutate something the parent goes on using, since join() returns void and a moved bundle never
comes back.
How state reaches an isolate — the whole surface, in four rows.
| who may touch it | lifetime | why it is sound | |
|---|---|---|---|
a moved resource |
the child, exclusively | unbounded | ownership transferred |
ref T, disjoint places |
one child per place | the scope |
no two children see the same bytes; the join orders the parent's read |
ref Atomic<T>, the same place |
every child | the scope |
atomic operations do not race |
Shared<immutable T> |
every isolate | unbounded | nothing can change |
a copy of a Locked<T> handle |
every isolate, one window at a time | unbounded | the value is reachable only inside a window that holds the lock |
Rows 2 and 3 are what a scope licenses, and they are the reason it exists: its closing brace
joins every child before any local declared in it drops, so a borrow provably cannot dangle,
the child's writes are ordered against the parent's next read with no atomic at all, and the scope
is the domain over which "no two children overlap" is decided. Rows 1 and 4 need no scope because
they are self-sufficient — one transfers exclusivity, the other removes writing from the picture.
The scope body is one more party. It runs beside its children until the closing brace, so a place a
child holds is out of its reach in the statements after the spawn: held by ref, it may not be used at all,
read or write; held by const ref, it may be read but not written. The same holds for the collection whose
elements a parallel_spawn hands its workers. A disjoint sibling field stays usable, and so does an
Atomic<T>. Before the spawn and after the scope, the place is the parent's as always.
Sendability applies to the bundle, exactly as it does to a channel element: the bundle's type must
declare implements Sendable (see Channels below for the whole rule), and a declaration over a
Shared/Weak with a mutable payload is rejected, naming the offending field, because both
isolates would release one non-atomic control block.
The same shape over a deeply-immutable payload is legal —
that is the case whose control block switches to an atomic refcount, and it is what lets many
isolates share one large read-only asset with no copy.
Channels ✅#
A Channel<T> is a typed pipe. Channel.bounded(capacity:) sets the buffer depth; capacity 0
is a rendezvous channel. sender() and receiver() hand out owned endpoints you move to whoever
needs them.
import { std::concurrent::Channel, std::concurrent::Sender, std::concurrent::Receiver, std::concurrent::Isolate };
fn void producer(Sender<int32> tx) { _ = tx.send(item: 1); }
fn uint8 main() {
Channel<int32> ch = Channel.bounded(capacity: 4);
Sender<int32> tx = ch.sender();
Receiver<int32> rx = ch.receiver();
Isolate h = spawn producer(tx: give tx); // the sender is moved into the isolate
Optional<int32> v = rx.recv(); // blocks; None once closed AND drained
return 0;
}
send(item:) returns Result<Unit, Undelivered<T>> — a channel whose receivers are all gone hands the item
back in the Err (Undelivered<T> { Item(T item) }, taken apart with match (give e) { case Item(item: x): … })
rather than dropping it on the floor, so nothing is lost, and since a discarded Result is refused, nothing is lost
silently: the sender decides what to do, or says _ = tx.send(…);. recv() returns Optional<T>: None means the channel is
closed and empty, which is why dropping the last Sender is how a producer signals end-of-stream.
A receive can decline to wait. tryRecv() takes a value only if one is buffered, and
recvTimeout(timeout:) waits at most a Duration for one; both answer Result<T, RecvError>, with RecvError { Empty, Closed } — Empty when none was there, or none arrived before the deadline, while a sender is live;
Closed is recv's None. "Not yet" and "never" are two answers because they want two responses: a connection pool shared
by worker isolates retries acquire on one and gives up on the other, which a blocking recv could not express
(an exhausted pool waited forever). The deadline runs on the monotonic clock, so moving the wall clock neither
stretches nor cuts it, and a zero timeout is a tryRecv. A lock (Mutex/Condvar) is still not in std — the
shared-nothing model makes a channel the seam, and this is that seam's missing half.
Endpoints are counted, so a channel is many-to-many. sender() and receiver() may each be
called any number of times; every call registers another endpoint, and a side closes only when its
last endpoint drops. Several Receivers over one channel is a worker pool — each item goes to
exactly one of them — and several Senders is a fan-in,
rendezvous included. The
count moves exactly where ownership does, the same way a Shared<T> control block works, so the
last holder to drop — endpoint or the Channel itself — is the one that frees the queue. There is no clone() on an endpoint and none
is needed: a Channel<T> is itself a move-only resource, so it can be moved into an isolate
whose worker then mints its own endpoint there.
On a fiber, a channel's wait parks the fiber. A recv with nothing queued, a send into a full channel — or a
rendezvous send, until its value is taken — and a recvTimeout park only the fiber that makes them, on an isolate
that runs fibers, and the core runs its others meanwhile (KR-3, docs/design/fibers.md decision 8). So two fibers on
one isolate can talk over a channel, which deadlocked the core until 0.9.751. A send from another isolate wakes a
receiver parked there, a timed receive's deadline is the scheduler's timer,
and a side's close wakes every fiber parked on the other. The same calls block the
thread on an isolate that runs no fibers, so a function is written once for both. A wait that parks is held to the
park rule: on an isolate that runs fibers, no channel wait inside a lasting access to Shared storage or inside an
@onPanic region (Smart pointers, the access model's Fibers item).
A side is also open before it is ever claimed, which is what lets that last pattern work: a
receiver may call recv() before the isolate holding the Channel has minted its Sender, and it
blocks rather than reading "no senders yet" as end-of-stream. That lasts exactly as long as the
Channel: dropping it closes every side nobody claimed, so a receiver whose sender can no longer be
minted sees None, a send nobody can receive comes back as an Undelivered error, and a channel dropped before
minting both sides frees its queue (until 0.9.477 it blocked, and leaked, forever).
Sendability is declared, and verified — the immutable model at the isolate seam. A type says
implements Sendable, and the compiler checks the claim over every field, base and variant payload; a
crossing — a spawn bundle, a Channel<T: Sendable> element — requires the declaration. Both mistakes
are errors: the lie (Bad implements Sendable over a Shared<Leaf> whose Leaf is mutable — a
non-atomic refcount two isolates would release) is refused where it is made, naming the member and the
reason; the omission (a plain Quiet { int32 n; } handed to spawn or put in a Channel) is refused
at the crossing. So the whole set of types that may leave an isolate is one grep away, and no type is
sendable by accident: a resource wrapping a thread-affine C handle whose author wrote nothing is not
Sendable, and nothing holding it may claim to be.
What may cross without a declaration is exactly what cannot carry one: a primitive and string
(declared for them in the prelude, like Hashable), a payload-less enum, a bare fnptr value, and the C
seam — UnsafePtr and an extern struct. A generic enum is not on that list: it declares, per instance,
like a generic value — the prelude's Optional<T> implements Sendable when [T: Sendable] (and Result's,
over both parameters) — so Msg<int32> with no declaration is refused as a channel element, and an
unconditional Msg<T> implements Sendable is still verified over each instance's payloads. The stdlib
writes its own rules in the same words — Sendable when [T: Sendable, A: Sendable] on every container, so
an arena-backed DynamicArray<T, BumpAllocator> cannot cross (its allocator points into the parent's
arena), and Sendable when [T: Immutable] on Shared/Weak, which is the rule "a shared handle may cross
exactly when its payload cannot change" written down rather than built in.
Two shapes are never Sendable, because their types hide what they hold. A BindableFunctionPtr bound
from a Shared<T> retains that receiver — a non-atomic refcount — and its type names only the signature.
A boxed contract Owned<C> erases the concrete type behind it, so it is Sendable only when the contract
requires it of every implementor — type contract Job for resource implements Sendable — after which
each implements Job must declare Sendable too and is verified like any other; that is what lets a job
queue of Owned<Job> cross.
A view and a bare contract value need no rule here: neither can be a field at all, so neither ever
reaches a bundle.
The stdlib's I/O vocabulary is Sendable, because a descriptor, a socket and a process handle are process-wide
rather than bound to a thread, and each owning type is move-only: File, TcpStream, TcpListener,
UnixStream, UnixStreamPair, UnixListener, UnixDatagram, UnixDatagramPair, UdpSocket, Poller, Process, Command, Output,
StringWriter, SliceReader, PeerCredentials, Descriptor, and the values SocketAddr, UnixAddr,
IpAddr, RecvFrom<A> (for a Sendable address), Event, Events, Waker, Metadata, Permissions, ExitStatus, UserId, GroupId. So a connection accepted on one isolate is
served on another, and a connection pool is shared by workers. The ReliableStream contract does not require it
of an implementor: the browser's WebSocket is a JavaScript object bound to one thread, and a MemoryStream shares its
buffers with its peer through a Shared. A channel element is
dropped exactly once — received, or buffered when the queue's last holder goes — and never as a zeroed copy: a type's
empty value is the one it states (fd = -1), not zero, and until 0.9.474 every send closed descriptor 0.
A raw UnsafePtr does cross, and that is the unsafe seam working as designed rather than a hole: the
bundle is built in an unsafe ctor and read through an unsafe fn, and joining the child — scope's
closing brace, or an explicit give h.join() — is what orders the write against the parent's read. It is the
primary isolate idiom, not an edge case.
Structured concurrency — scope ✅#
A scope { } block joins every child spawned inside it at its closing brace. It is RAII applied to
tasks: deterministic lifetimes, no orphans, and — because a child is guaranteed to be joined before
the scope exits — a child may safely borrow from the enclosing scope.
scope {
spawn writer(s: give sa); // a bare `spawn` inside a scope is a deferred-join child
spawn writer(s: give sb);
} // BARRIER: both joined here, before anything below runs
A bare isolate spawn must be a direct statement of its scope; one inside a nested block is rejected.
So a scope's isolates are exactly the spawns written in it, and can be counted by reading the
block. That matches the coarse-isolate model above: roughly one isolate per core, not one per work
item. A conditional child puts the if outside the scope,
and per-item work over a container is parallel_for. A fiber child, spawn fiber work(job: give j);, is the
one exception: it may sit anywhere in its scope's body, loops included, because the scope keeps a runtime list of
its live fibers and its closing brace waits for the list to empty (KR-3, being built: docs/design/fibers.md). That
wait is a park, so a scope with fibers may not close inside a window over Shared storage (Smart pointers, the
access model's Fibers item).
On a fiber, a join parks the fiber. A scope's close over its isolates, an Isolate's join() or drop, and a
parallel_for's barrier, made on an isolate that runs fibers, park only the joining fiber until the isolate has ended,
and the core runs its other fibers meanwhile (KR-3, docs/design/fibers.md decision 8); on an isolate that runs none
they block the thread as before. Each is then a park, held to the same rule as a fiber's own join.
What a child may borrow#
A child borrows a place — a local, or a chain of field names on one: spawn step(a: ref w.bodies)
beside spawn step(a: ref w.springs) gives two children two disjoint fields of one world.
Two children may not borrow overlapping places, and
two places overlap exactly when one is a prefix of the other, so ref w beside ref w.bodies is
refused and so is the same local twice.
An Atomic<T> is exempt — it is the sanctioned shared-mutable
cell, so several children may share one.
This needs no lifetime analysis, and that is the point. A field's storage is inside its root's
storage, so it is created and destroyed exactly with the root; bounding the root bounds the field.
The prefix test is the same placesConflict the borrow window uses, so one rule serves both.
Two shapes are refused because they are not places. An element (ref xs[0]) has a runtime index
no static rule can pin — splitting a buffer across workers is
parallel_for's job, which supplies the proof this rule cannot. A call result designates no
place at all.
A place may project through an indirection only when the handle is unique. An Owned<T> is —
nothing can copy one — so distinct roots really are distinct objects and its fields are borrowable.
A Shared/Weak is not: another handle may name the same
object, so a.bodies and b.springs could be one cell under two non-prefix names, and it is
refused. So is ref s on the handle itself, which would
borrow that object.
A ref bundle is lowered as the entry's own call would pass that argument, and every rule of a call's
ref holds. ref this borrows the object and ref o on an Owned<T> the object it holds,
for both kinds of child. The label names
the parameter, the argument is what the parameter takes,
and a read-only place cannot go into a parameter that may write it.
Data parallelism — parallel_for ✅#
parallel_for (ref T e in coll) { … } splits coll into K non-overlapping sub-Views, one per
worker isolate, runs the body over each in place, and joins them all at its own closing brace. It is
safe by disjointness — two workers never touch the same element — so it needs no lock and no
borrow checker. Workers that only READ say so — parallel_for (const ref T e in coll) — and then the operand may
be read-only (a const ref parameter): the slices are ConstViews through view(), and each binding is a
read-only place. A ref worker over a read-only operand is refused, naming that form.
import { std::collections::DynamicArray, std::concurrent::cpuCount };
fn void doubleAll(ref DynamicArray<int32> xs) {
parallel_for (ref int32 e in xs, workers: cpuCount()) { e = e * 2; } // closing brace is the barrier
}
workers: is mandatory — there is no default. kama has no optional parameters (a user fn cannot
declare one), so a built-in construct does not get one either. Write workers: cpuCount() for one slice
per core, workers: 4 for a fixed width, or any expression: workers: cpuCount() - 2 to leave headroom.
It is exactly what you asked for, capped only by length (more workers than elements would leave some
with no slice) — not silently reduced to the core count, since oversubscription is the caller's call.
A literal workers: 0 or negative is a compile error. A second clause,
stack:, sizes each worker's stack (see The stack above).
Stating it is the point: how many isolates a loop splits into used to be invisible, which is what made the
chunking below surprising. Now grep 'workers:' finds every parallelism-width decision in a codebase.
ref is mandatory: disjoint mutable access is the entire point. The input is a View<T> or any
contiguous container that exposes .viewMut() (DynamicArray, FixedArray are auto-viewed); a
non-contiguous container such as a Map has no .viewMut() and is rejected, and so is a read-only
ConstView<T> (nothing to bind ref to).
The element and every captured local reach the workers by ref, which is the borrow form of a spawn
bundle, so each faces the same sendability gate: a user element type must declare implements Sendable
(a primitive needs nothing — see Channels below for the whole rule), and parallel_spawn shares the gate.
There are usually FEWER workers than elements — workers: 8 over 30 elements is 8 isolates running 4
elements each, one after another. That is the point of the construct: the work is finite and independent,
so four elements run back to back in the same total time, and 40,000 elements need not become 40,000 OS
threads.
The one thing it asks of the body: an element may not wait on another element's progress, since only
workers: of them are ever in flight. A body that blocks until all elements have reached some point waits forever —
the count stalls at workers:, and the elements queued behind those never start. Ordinary independent work is
unaffected. When you genuinely need all N running at once — a pool whose members rendezvous — that is
parallel_spawn below, which is also why parallel_for cannot build one.
Worker pools — parallel_spawn ✅#
parallel_spawn (ref T w in coll) { … } starts one long-lived isolate per element and hands the join
to the enclosing scope { }. That second half is the whole point: the children run alongside the
statements after it.
scope {
parallel_spawn (ref Worker w in workers) { w.run(); } // K = workers.length()
{
Sender<int32> tx = ch.sender(); // …and this runs WHILE they run
int32 n = 0;
while (n < 100) { _ = tx.send(item: n); n = n + 1; }
} // sender drops: every worker wakes and finishes
} // BARRIER: all K joined here
This is what makes a pool sized to the machine writable — workers is built by an ordinary loop, so
cpuCount() elements means cpuCount() isolates. A bare spawn cannot: it must be a direct statement of
its scope, so K spawns means K typed statements. parallel_for cannot either, for a different reason —
it spawns and joins in one statement, so the producer above would never run and the program would hang.
Two differences from parallel_for, and both are load-bearing:
K is
length()exactly — never capped at the core count. A cap does not skip the extras, it runs several per isolate in turn, and a pool worker blocks until its channel closes. A capped worker would therefore never start, and it would fail silently: the running workers drain the queue, the producer closes, they exit, and only then do the rest begin and immediately see the closed end.No
workers:clause — its count IS the container's length, so a second count could only contradict it. To choose how many, size the container.The join is the
scope's brace, on every exit path, before any local drops — the same join-before-drop guarantee a barespawngets. So it needs an enclosingscopeand must be a direct statement of it.
⚠️ The coarse-isolate rule still binds, and this construct makes it easy to break. An isolate is an OS
thread (a Web Worker on wasm), so parallel_spawn over a container of work items is the anti-pattern
named above — one isolate per item — now spelled in one line. The container should hold workers: a
cpuCount()-sized pool, one per GPU device, one per shard. Oversubscription itself is fine and sometimes
right (blocked workers cost nothing but stack), which is why there is no cap; the cost that bites is stack
reservation per thread and, on wasm, Worker exhaustion. The compiler cannot tell a worker from a job, so
this is a rule you keep, not one it checks.
The four sharing seams ✅#
Cross-isolate state is confined to four greppable seams, the same way raw memory is confined to
unsafe fn:
| Seam | Meaning | Native | wasm | Bare metal |
|---|---|---|---|---|
module static |
per-isolate state — each isolate gets its own copy | _Thread_local |
_Thread_local (emscripten pthreads share one linear memory, so TLS is what makes it per-isolate) |
a plain C static, zero cost (one core = one isolate) |
hardware |
volatile MMIO and the single-core ISR↔loop flag |
volatile T* |
n/a | the register/ISR seam — not cross-isolate |
Atomic<T> |
cross-isolate mutable sharing of a scalar | _Atomic / <stdatomic.h> |
Atomics over a SharedArrayBuffer when the program creates a thread; otherwise the build is single-threaded and needs neither | atomics, if multicore |
Locked<T> |
cross-isolate mutable sharing of any Sendable value, inside a lock window |
os_unfair_lock, a futex mutex, SRWLOCK |
a pthread mutex over Atomics.wait when the program creates a thread; a flag otherwise |
a flag (one core = one isolate) |
The load-bearing rule: a module static is per-isolate by construction, so it cannot be observed
by another isolate and therefore cannot race. To share mutable state you must reach for
Atomic<T> or Locked<T>, which are visible in a grep.
That is the rule about storage. For the rule about how state reaches an isolate in the first place — the bundle, and the four ways anything crosses — see The bundle above; the two answer different halves of the same question and neither is complete alone.
Atomic<T> ✅#
import { std::concurrent::Atomic, std::concurrent::MemoryOrder };
fn uint8 main() {
Atomic<int32> counter = Atomic.make(value: 0);
counter.fetchAdd(delta: 1);
int32 now = counter.load();
return 0;
}
load / store / swap / compareExchange / fetchAdd / fetchSub, all sequentially consistent
by default. Each has an …Explicit form taking a MemoryOrder for the expert case. Atomics and Locked
(below) are the whole shared-mutable surface; general shared mutable memory stays outside the safe language.
Shared mutable state — Locked<T> ✅#
A cache several isolates write, a connection table, a metrics registry: state many isolates change, with no owner
isolate worth a channel round trip per access. std::concurrent::Locked<T> is a lock that OWNS its value, and the value
is reachable only inside a borrow window over the guard its mint returns:
fn void hit(Locked<Map<string, int64>> cache, string path) { // each isolate is handed its own copy of the handle
borrow cache.lock().value() as m { // acquires
m.put(key: give path, value: 1);
} // the window's close releases, on every path out
}
- The window's rules give Rust's
MutexGuardguarantee, with no lifetimes. The alias cannot be stored, returned or put in a collection, and the guard is a view that closes its window (The window), so nothing reaches the value once the lock is released. There is no poisoning: a panic aborts, so no window is left half-done for another. - A handle is two pointers, the cell and the value inside it,
SendableandCopyable(bare: give):copy cacheis a second handle to the same value, which is what each isolate is handed, and the last handle to go drops it. The value must beSendabletoo, as Rust'sMutex<T>: SyncneedsT: Send: a non-atomicSharedinside it would race through the window. ReadWriteLocked<T>addslockShared(), aconst fnwhose window'svalue()is aconst ref: any number of readers at once, and aconst ref ReadWriteLockedcan read but not write. Reach for it when reads are long or dominate; for a short section the readers' shared count costs more than an exclusive lock does.- The value comes back out with
give h.intoInner(), agive fn Optional<T>on both: it consumes the handle, and the one whose release is the LAST getsSome(value), any otherNone, the value living on in the handles that remain. So the workers' result leaves once they are joined (give hits.intoInner()after thescope), and when every holder calls it, exactly one gets the value, race or not: Rust'sArc::into_innerthenMutex::into_inner, which Rust added besidetry_unwrapbecause two last handles that each try, fail and drop destroy the value unseen. There is no "try, and keep the handle if not": that answer is stale the moment it is given.
Deadlock-free by construction. A lock window may reach no wait and take no other lock, so a critical section never waits on anything, and taking a lock blocks only for as long as some other section's CPU work:
No wait: the window may reach no
@parksfunction — a channel'ssendorrecv, a join (ascope's close, anIsolate's drop),sleep, the poller, a file, a socket, DNS, stdio, a child process, the print family or a log line. The walk is the one--no-heapruns, so a refusal names its chain (drain->Receiver.recv->Receiver.recvInto), and a call through a contract is judged against every implementer. Take what you need out of the window, and wait after it.No other lock: a lock window inside another is refused, since two isolates taking
athenbandbthenawould deadlock. Several locks are taken together, in oneborrow:borrow from.lock().value() as a, to.lock().value() as b { a.balance = a.balance - n; b.balance = b.balance + n; }Its locks are taken in the address order of their cells whatever order it names them in, so two
borrows namingfrom, toandto, fromcannot each hold one and wait for the other. The order is decided as the program runs: copies of a handle held by different isolates have unrelated addresses. Aborrowthat takes a lock holds only locks, so another view that closes its window (a transaction) is refused beside it; nest it outside. Two handles to one lock named in oneborrowtrap, "a lock is taken twice by oneborrow", rather than take it twice.No call it cannot see: a call through a
fnptror a bound function inside a lock window is refused, since its target could wait or lock.
What the walk cannot see, stated. It reads what kama compiles. A foreign call that blocks with no @parks wrapper
is invisible to it — it sits in unsafe, the trusted set — and so is a spin on an Atomic waiting for a thread that
needs the lock. The runtime backs the first: a lock taken while the thread holds one, which only C calling back into
kama inside a window can do, traps instead of deadlocking.
The bytes. A handle is two pointers, 16 B: the cell, and the value's place in it. The cell is a 32 B header (the
count, the kind, the value's offset, its size and alignment), the lock and the value. The lock is os_unfair_lock (4 B)
on macOS, glibc's futex-backed pthread_mutex_t on Linux (40 B on x86-64, 48 B on arm64) and SRWLOCK (8 B) on
Windows; ReadWriteLocked's is pthread_rwlock_t (200 B on macOS, 56 B on glibc, set to prefer writers, since glibc's
default starves a writer under a steady read load) or SRWLOCK. Uncontended, a window is one atomic read-modify-write
to acquire and one to release, where the channel round trip to an owner isolate it replaces is about 3 µs. On a target
with no threads — unthreaded wasm, --target embedded — the lock is a flag.
A concurrent map — ConcurrentMap<K, V> ✅#
One Locked<Map<K, V>> makes every access from every isolate take the same lock. std::concurrent::ConcurrentMap holds
N of them — shards — and a key's hash picks one, so isolates working on different keys rarely meet. Java's
ConcurrentHashMap, C#'s ConcurrentDictionary and Rust's dashmap answer the same need:
import { std::concurrent::ConcurrentMap, std::concurrent::Locked };
type value Stats implements Sendable { public int64 hits; public ctor zero() { this.hits = 0; } }
fn void hit(ref ConcurrentMap<string, int64> counts, ref ConcurrentMap<string, Locked<Stats>> stats, const ref string path) {
bool done = false;
while (!done) { // a counter: swap until it lands
int64 now = counts.getOrPut(key: path, value: 0); // inserts if absent; returns what is there
done = counts.compareAndSwap(key: path, expected: now, desired: now + 1);
}
Locked<Stats> s = stats.getOrPut(key: path, value: Locked.make(value: Stats.zero()));
borrow s.lock().value() as st { st.hits = st.hits + 1; } // its own lock, after the shard's
}
fn uint8 main() {
ConcurrentMap<string, int64> counts = ConcurrentMap.empty(); // cpuCount() × 4 shards, a power of two
ConcurrentMap<string, Locked<Stats>> stats = ConcurrentMap.empty();
hit(counts: ref counts, stats: ref stats, path: "/a");
Optional<int64> n = counts.get(key: "/a"); // a copy, taken under that shard's lock
return match (n) { case Some(value: v): v == 1 ? 0 : 1; case None: 2; };
}
- Each method takes one shard's lock for its own extent and nothing else, so the map adds no lock order of its own, and a method called inside another lock window is refused as any lock is. Nothing hands a shard out, so no caller can insert a key that hashes elsewhere.
- A value leaves only by copy.
getandgetOrPutcopy out (V: Copyable),removehands the value back, andcompareAndSwapreplaces it only while it equals what the caller saw (V: Equatable). There is nocompute: kama has no closures, and a lock window refuses afnptrcall. An entry changed in place is aLocked<V>value, copied out under the shard's lock (a handle copy) and locked on its own once that window has closed — two windows in sequence, never nested, ashitdoes above. - A handle is shared as a
Lockedhandle is:SendableandCopyable(bare: give), socopy countsis a second handle to the same shards, which is what each isolate is handed. Keys and values must beSendable. length()counts shard by shard, under each shard's lock in turn, so it is the count at some moment, as Java'ssize()is;keys()andentries()are snapshots taken the same way, each pair copied once.drain()moves every pair into oneMapthe same way, each shard empty after its turn: aputthat lands in a shard already drained stays for the next drain, so drains together take every pair exactly once. A value may own memory (ConcurrentMap<string, string>).- The shard count is
cpuCount() × 4, rounded up to a power of two (dashmap's rule) and fixed for the map's life.withShards(count:)states it;withAllocator(allocator:, shards:)gives each shard'sMapits own copy of an allocator;reserve(n:)spreads room fornentries over the shards. The hasherHand allocatorAdefault asMap's do. - The key is hashed once. The hash's top bits pick the shard, and the same hash goes to that shard's
Map, whose low bits pick the slot: the same bits twice would cluster every shard's table. TheMapmethods that take a precomputed hash are private and granted toConcurrentMapalone, since a wrong hash would lose the entry.
The bytes. A handle is an array of shard handles, 16 B each. A shard is one Locked cell: a 32 B header, the lock
and a 56 B Map — 96 B on macOS and Windows, 128 B on Linux x86-64 — and an empty Map allocates nothing, so an empty
map on a 10-core machine is 64 shards and about 7 KiB. Copying the handle costs one array allocation and an atomic
increment per shard, once per isolate it is handed to. An uncontended get hashes once, takes one lock, probes one
Map and releases. Contended, the shards are the point: eight isolates making 400,000 calls each on 1,024 string keys,
one in ten a put, take 61 ms through a ConcurrentMap and 360 ms through one Locked<Map> on a 10-core M4 (the
minimum of interleaved runs, bench/concurrent_map/run.py).
Immutable sharing — type immutable ✅#
The other way to share safely is to share something that cannot change. type immutable value T (or
type immutable resource T) marks a type deeply immutable, which the compiler verifies: every
field, base and variant payload must itself be a primitive, a string, an enum, or another deeply
immutable type. A mutable member is a compile error naming that member.
A primitive means every primitive, the platform-varying isize/usize included — they are scalars
with no interior mutability, so they are as shareable as int64. That half went unenforced and was
wrongly rejected until 0.9.144: the compiler's own list said "a primitive" while its check read an
ordered span of builtin type values that isize/usize are deliberately appended after.
It is a type qualifier and nothing smaller. immutable on a field or a method is a hard error, not
a silent no-op: the guarantee is deep and whole-type — one mutable field anywhere breaks it — so a
per-member spelling could promise nothing. const is the per-field promise (write-once, constructor
only) and const fn the per-method one.
A Shared<T> over a deeply-immutable T is Sendable — std::memory writes it as Sendable when [T: Immutable] on the type itself — so any number of isolates can hold and read
the same asset with no copy. Its control block switches to an atomic refcount only in that case, so
an ordinary single-isolate Shared pays nothing. This is distinct from a const binding, which
only promises this alias will not mutate and therefore cannot license cross-isolate sharing.
Why not green threads or async/await#
Both exist to serve "proceed until ready". Stackful green threads need a userspace stack-switching
scheduler, which on wasm means Asyncify — precisely the colouring cost being rejected. async/await
colours every function and drags in pinning. Kama takes neither into the language: isolates are
real threads, and blocking is honest when they are few; massive parallelism comes from the
never-blocking data-parallel layer. The "multiplex thousands of connections over a few threads"
ergonomic is a library concern above the language — a native scheduler can back the very same
blocking-shaped surface with fibers, with no language change and no effect on wasm.
Serialization — @-attributes + @generate ✅ (by-value + object graphs + polymorphic Shared<Contract> on every backend; see ROADMAP_DETAIL.md §4)#
Opt-in, compile-time serialization. The user-facing surface is just contracts + attributes; the wire
format is library; the object-graph algorithm is library; what the compiler contributes is reflection,
still opt-in — the per-type field walk, synthesized only for a @generate type, as a lowering to C.
Nothing is a runtime type registry: a type that did not opt in gets nothing.
Three layers.
User-facing (opt-in): the contracts
Serializable/Deserializable<T is This>, the attributes@generate(Serializable, Deserializable)(per-direction) +@field/@field(name: "wire")/@field(id: N)/@field(default)/@deprecated/@skip, and one entry pair per backend and source, e.g.serializeJsonBuffer(v:)/deserializeJsonBuffer::<T>(src:)(table below). A hand-writtenserialize/deserializewins — the derive only synthesizes for a@generatetype that supplies none (override = implement the contract yourself).Library (wire backends, swappable): the
Serializer/Deserializercontracts (writeInt32/readInt32/…,beginObject/field/variant/…) +DeError. That token vocabulary is the WHOLE protocol — an object graph is written in it too — so a type opts into serialization ONCE (@generate(Serializable, Deserializable)) and works with every backend automatically; yaml/xml/user backends are just new implementors — no compiler change. The object-graph walker is the compiler's, and it emits nothing but those same tokens: the id table is framed withbeginArray(count)and a pointer written aswriteU64, so a backend that carries values carries graphs — positional and numbered included — with no graph member on either contract and no per-backend graph code. What only the compiler can do (reflect over a node's fields, and dedup by object identity) stays with it; everything above that is the ordinary vocabulary. Four back ends ship, and the three binary ones differ by ADDRESSING, not by medium — so they live in one module,std::serialization::binary::kbin, and each entry point names its addressing (serializeKbinNamedBuffer,…NumberedBuffer,…PositionalBuffer— none is the default). Every binary form is byte-oriented: itsserialize…Bufferyields aDynamicArray<uint8>and itsdeserialize…Buffertakes bytes, not astring, since binary isn't valid UTF-8. All four stream over the sameWriter/Readersubstrate, so any of them flows to a file or socket.Entry points are
serialize…/deserialize…, and name the FORMAT and the SOURCE. The verb matches the contracts and the methods the derive emits (Serializable.serialize), and it leavesencode/decodeto mean what they mean instd::encoding::base64and::hex— a bytes-to-bytes transform, not a typed value going to a wire form. The format is in the NAME because kama has no module-qualified call — a module path is written only in animport, so it never reaches the call site and the name must carry everything.…Bufferworks on a value already in memory;…Streamworks on aReader/Writer(a file, a socket). Every back end offers all four, so the read and write sides are symmetric, and a merge pair beside them (below).back end addressing buffer stream what it is for std::serialization::text::jsonnamed serializeJsonBuffer/deserializeJsonBufferserializeJsonStream/deserializeJsonStreamtext, human-readable, UTF-8 in and out binary— KBINnamed serializeKbinNamedBuffer/deserializeKbinNamedBufferserializeKbinNamedStream/deserializeKbinNamedStreamself-describing save/load: order-independent, unknown fields skip (a strict reader refuses them) binary— KNUMnumbered serializeKbinNumberedBuffer/deserializeKbinNumberedBufferserializeKbinNumberedStream/deserializeKbinNumberedStreamschema evolution: protobuf-shaped keys, so unknown ids skip (a strict reader refuses them) and reordering survives binary— POSpositional serializeKbinPositionalBuffer/deserializeKbinPositionalBufferserializeKbinPositionalStream/deserializeKbinPositionalStreamsmallest: only values on the wire, shape agreed in advance Merge — into a value the program already holds. Each back end also offers
merge…Buffer(src:, into:)andmerge…Stream(from:, into:)(mergeJsonBuffer,mergeKbinNamedBuffer, …), for data that changes some settings on top of what the program has — a layered config file, a live reload, a remote change of one setting. A full read builds a NEW value, so every field the data leaves out takes its declared default; a merge writes only what the data carries:import { std::memory::Owned, std::serialization::text::json::mergeJsonBuffer }; @generate(Deserializable) type value Mix { @field(default) public float32 gain = 1.0; @field(default) public float32 pan = 0.0; public ctor of(float32 gain, float32 pan) { this.gain = gain; this.pan = pan; } } @generate(Deserializable) type value Settings { @field(default) public int32 voices = 8; @field(default) public Mix mix = Mix.of(gain: 0.8, pan: -0.5); } fn void reload(ref Settings s, string file) { // voices 12, gain 0.8, pan -0.5 before Result<Unit, Owned<Error>> r = mergeJsonBuffer(src: give file, into: s); // {"mix":{"gain":0.25}} } // voices 12, gain 0.25, pan -0.5 after- A field whose type is a derived record merges into what the field holds, recursively — through an
Ownedtoo. A map keyed by text merges PER KEY (Map,SortedMap, through the prelude'sMergeByKey): each key the document names merges into its entry when the entry's value merges in place (a derived record, or another such map) and replaces it otherwise, a new key is added, a key left out is kept, andnullremoves the entry — RFC 7396:{"limits":{"cpu":4,"mem":null}}setscpu, dropsmem, keeps the rest. A map written as its pair array, and every map on KBIN, is replaced whole; a map can also be merged into directly (mergeJsonBuffer(src:, into: limits)). Every other field the data names is replaced whole: a number, astring, an enum, anOptional(nullisNone), a list. A key the data leaves out is untouched, and an unknown key is skipped, as in a full read — or refused, through a strict reader. No field is required. - Atomic. If the data is wrong anywhere, the value is left as it was, and the error still names where:
{"voices":99,"mix":{"pan":"left"}}istype mismatch at `mix.pan`andvoiceskeeps what it held. Each new value is staged as it is read and written into place once the whole input has been. @generate(Deserializable)on avalueorresourcewrites it, as the membermergeFrom(r)of the prelude contractMergeDeserializable<T>, which refinesDeserializable<T>and is what the entry points bound. An enum is replaced whole, and a type with a hand-writtendeserializehas no field walk to merge; each is refused at the call, saying so. A graph is not merged into either: a node's wire id is assigned per write, so a node on the wire names nothing the program holds. KBIN's positional form carries every field, so its merge writes every field — in place, all or nothing.
A
…Streamencoder hands its sink back:Result<W, Unserialized<W>>, the sink in theOkonce the value is written and in the error beside why when it is not, so one connection carries many messages (KR-261). To write several values with one serializer, construct it, callencodeValue, andgive w.intoInner()when done. A…Streamdecoder drains its reader fully before parsing (Go's buffer-a-value model) — nothing seeks, so the streaming form is a convenience, not laziness.KBIN stores raw IEEE-754 bits (NaN/Inf round-trip) and is self-describing, so
skipValueworks and unknown fields skip cleanly. KNUM keys each value with(id << 3) | wireTypeand zigzag-varints its integers, so a small negative costs one byte; the wire type is what lets an unknown id be measured and stepped over. POS writes no names, no tags and no object framing at all — a six-fieldint32record is 24 bytes — and therefore cannot skip: a stream that does not match the type isDeErrorKind::Malformed. It carries an object graph all the same (tests/ser_pos_graph): the envelope is ordinary tokens, whose shapes it can count.The untyped value —
JsonValue. A document whose KEYS are data (glTF'sextensions, a response keyed by ids), or whose shape a program only partly knows, is read without a schema intostd::serialization::text::json::JsonValue:Null,Bool(bool value),Number(JsonNumber value),String(string value),Array(DynamicArray<JsonValue> items),Object(OrderedMap<string, JsonValue> members). It is an ordinarySerializable/Deserializabletype, so there is no second parser —deserializeJsonBuffer::<JsonValue>(src:)is the read, with the typed reader's RFC 8259 numbers, 128-level limit, errors and paths — and a typed schema may hold aJsonValuefield to keep a subtree it does not model.import { std::memory::Owned, std::serialization::text::json::deserializeJsonBuffer, std::serialization::text::json::JsonValue }; fn int64 lightCount(string gltf) { Result<JsonValue, Owned<Error>> r = deserializeJsonBuffer::<JsonValue>(src: give gltf); JsonValue doc = match (give r) { case Ok(value: v): give v; case Err(error: _): JsonValue::Null; }; if (!doc.has(key: "extensions")) { return 0i64; } return cast<int64>(doc.getRef(key: "extensions").getRef(key: "KHR_lights").getRef(key: "lights").length()); }- An object keeps document order (it is an
OrderedMap), so a document reads and writes back byte for byte; a key the document repeats keeps its first position and takes its last value, as a typed read's last wins. - A number keeps its text (
JsonNumber):18446744073709551617,1e400and1.50all survive, and the program picks the width it reads one at —asInt64(),asUint64(),asFloat64()answerNonewhen the number does not fit (a fraction is no integer,1e400is nofloat64). AJsonNumberis made from an integer, a finite float (ofFloat64refuses NaN and the infinities) or valid text (parse), so an invalid number cannot exist. - Navigating mirrors
OrderedMap:has(key:),getRef(key:)(in place; panics on a missing member or a value that is not an object),get(key:)(anOptionalcopy),at(index:)(an element in place),length(), andmatchfor the kinds themselves. - It needs a self-describing back end: JSON, or KBIN named. The read asks the reader what comes next
(
Deserializer.peekKind(), answering a preludeValueKind) and reads a number as its text (readNumberText()); the write hands the text back (Serializer.writeNumberText(text:)). KBIN carries that text under its own tag,0x18, and reads it into a typed field as JSON does. KNUM and POS cannot say what comes next — POS has no tags, KNUM's wire types do not separate an integer from abool— so a read failsTypeMismatchand a write of a number failsNotRepresentable, rather than producing a stream nothing could read. - JSON's literals are read whole:
{"a": nada}and aboolspelledtzzzareMalformed, in a typed read as in an untyped one. (Only their first byte used to be looked at.)
JSON is UTF-8 in and out.
string/charare written as raw UTF-8 bytes — JSON is a UTF-8 format (RFC 8259 §8.1), so escaping buys nothing — and only",\and the control bytes are escaped. On READ,\uXXXXis decoded to UTF-8, **including surrogate pairs**: JSON inherited UTF-16 escapes from JavaScript, so a codepoint above the BMP arrives as a😀pair, which is the ordinary shape of JSON produced elsewhere (Python'sjson.dumpsescapes *all* non-ASCII by default). The pair is combined at the wire edge and nothing above it ever sees a UTF-16 code unit — kama stays UTF-8 everywhere. An **unpaired** surrogate is malformed input and is rejected, not encoded as WTF-8. (Fixture:tests/ser_json_unicode.)- A field whose type is a derived record merges into what the field holds, recursively — through an
Compiler (opt-in reflection + the walk): the per-type field walk — the by-value
serialize/deserializefor a tree type; for a node type, that pair IS the graph walker, over internal C helpers (visit/write/read/re-wire its edges) and a pure-C id table. Zero-cost — emitted only for@generatetypes, and only the half a type needs. A node carries no extra public surface: no wrapper type, no adapter members, no runtime type registry.
Two modes, gated by whether T reaches a Shared/Weak field — a precomputed per-type flag (the
tighter sibling of the destructible transitive-ownership walk): true iff T transitively reaches a
Shared/Weak field, recursing through by-value fields, a generic instance's type arguments —
collection elements and Owned/Optional payloads alike — and enum variant payloads
(strings/scalars/enums add nothing). You get back exactly what you name:
| You name | reaches Shared/Weak |
Result |
|---|---|---|
int32 / MyEnum / MyValueType |
— / false | by value (stack) |
tree resource (User { string name }, DynamicArray<int32>, Tree { Optional<Owned<Tree>> left }) |
false | by value (stack) |
graph type (Node reaches a pointer) |
true | heap graph — written from that root, read back as Shared<Node> |
Shared<T> where T reaches nothing |
false | by value: the handle is walked through, byte-identical to a bare T |
deserializeJsonBuffer::<Shared<T>> where T reaches nothing |
false | compile error → "reaches no Shared/Weak field, so it is not a graph" |
a collection of edges (DynamicArray<Shared<Node>>), as a field or on its own |
true | heap graph; the collection itself has no identity, so it is written inline and read back by value |
a hand-written Serializable/Deserializable holding edges |
true | the same — no @generate required |
By-value (tree): a
valuetype (owns nothing) or aresourcereaching noShared/Weak(strings, collections, nested owned data — a tree, no aliasing) serializes to a bare object/array anddeserializeJsonBuffer::<T>returns it by value. AnOwned<X>field is a unique subtree, so on the wire it is exactly anX— byte-identical to a by-value field of that type; absence is spelledOptional<Owned<X>>, like every other field. The compiler recognises the handle by identity and walks through it; the triad carries noserializeof its own, because a method on a handle would shadow the pointee's through auto-deref. One consequence worth having: a field may change betweenOwned<X>and a bareXand still read data written by the other. (Fixture:tests/ser_owned_tree; KBIN:tests/ser_bin_owned_tree.)Graph (heap): anything reaching a
Shared/Weakis a graph — it can alias, cycle, or hold aWeakback-edge, none of which survive a by-value return — so it is always heap, even a single node. There is no wrapper type: what you hand a backend IS the root.serializeJsonBuffer(v: root)on the way out,deserializeJsonBuffer::<Shared<Node>>on the way in — the read gives back a handle, because a cycle cannot be returned by value. The wire is the id-table envelope{"root":1,"objects":[{"id":1,"type":"Node","value":{…}},…]}, every frame an ordinary token (the table an array, an entry a fixed{id, type, value}object with the type as avariant, a node's fields its own object), which is why the positional and numbered backends carry a graph too. A live pointer field in avaluetype is a compile error (pointers need a graph).An edge does not have to be a field, and a body does not have to be derived. A
@generatetype's edges are found by walking its fields, which is the only thing@generatebuys: anySerializablethat reaches aShared/Weaktakes part in the graph on the same terms. A collection of edges works (DynamicArray<Shared<Node>>, as a field or handed to a backend on its own) — its edges are its ELEMENTS, so they are not fields at all — and so does a hand-writtenserialize/deserializethat holds handles directly. The compiler emits such a body a second time carrying the graph, and an edge inside it interns its pointee and writes its id; reading back, ids are resolved by walking the type's fields and then iterating its elements. A container says which members the graph routes through — it is never guessed (see@serializedGraphEdgesbelow). Identity is the thing that decides therootslot: a node can be pointed at, so the root carries its id and reads back asShared<T>; a collection or a hand-written holder cannot be pointed at, so the root carries its value inline and reads back by value — there is no cycle through it to close. A hand-written type can be a graph node too, not only a holder of edges: a cycle through one round-trips, with theWeakback-edge that keeps an ownership cycle leak-free. What a pointee must have is a serde half at all — derived or hand-written; one with neither would be silently dropped from the wire, and is a compile error at the edge field.@serializedGraphEdges— a container MARKS the members the graph routes through. Serde walks what you mark, never what it guesses, which is the rule@fieldalready applies to a@generated product's fields. The attribute goes on a method, and the signature it sits on says which of two roles it plays:- an accessor — nullary, returning an
IteratorMut<T>: these are the elements carrying my edges; rewire them through here on the read pass. A KEYED container cannot answer this structurally —Map'sIterableyields KEYS while its edges live in VALUES — which is why it is marked rather than looked up by name, and marking is also what FREES the name: a container may call its accessor anything and implement as many other iterators as it likes. - a delegate — a method taking a
ref Serializer/Deserializer: the element write is handed to me. A container that writes its elements in its own body (Map,SlotMap) needs no delegate; one whose write recurses through a helper does.SortedMapis a B-tree, so its entries are spread over a tree of nodes and the element write lives inBTreeNode.
The rule that bounds both: the graph's object table travels with the serializer, and nowhere else. A method can carry it only if it is the contract
serialize/deserializeor is a marked delegate; a call threads it only when it passes the caller's own serializer (build a fresh one and you are writing a different document, which gets no table); and handing your own serializer to an unmarked, eligible method is a compile error — never a silent inline write. That last one matters because the failure it prevents is invisible at the site: every pointee below such a call is written inline and duplicated, and it surfaces far away as "unresolved reference" from the READER.- an accessor — nullary, returning an
A keyed container's edges live in its VALUES only. A key cannot be rewired in place — that would move it in the ordering or the hash — so
Map<Shared<K>, V>is a non-goal, not a "not yet". (C++ encodes the same rule independently:std::map's iterators yieldpair<const Key, T>.)
Common rules (both modes).
- Per-field marks are mandatory on a
@generated product: each field is@field,@field(name: "wire"), or@skip— an unmarked field is a compile error (no silent omission). - Field addressing — every key carries a NAME and an ID, and the backend keeps the one it is.
namedefaults to the property name andidto the field's declaration index, counting@skipped and@deprecatedfields too (indexing among only the serialized fields would renumber every later field the moment a@skipwas inserted — the exact silent wire break ids exist to prevent). An author may overrideidwith a stable, sparse protobuf-style number. The derive emits fields in ascending id, which is what makes ids and positions one thing: a named backend (JSON, KBIN) writes the name, a numbered one writes the id, a positional one writes neither and relies on the order. So a type opts in ONCE and every addressing works — there is no capability query and no genericSerializable<K>, which could not exist anyway (twofieldbodies differing only in a parameter type is overloading). Within one type a duplicate wire name is refused, and so is a duplicate id — including an explicit one colliding with another field's default.@field(id: N)needs a literal number and cannot cover several declarators at once. @deprecatedon a field: read when present, never written. Its name and id stay RESERVED — it still takes part in duplicate detection, which is the whole of that guarantee — so a stream written by an older version still decodes while new writes drop the field. It REFINES@fieldrather than replacing it, so a field marked only@deprecatedis still unmarked and combining it with@skipis refused, since@skipis absent from every backend while@deprecatedis still read. (Fixture:tests/ser_field_id.)- A field the data leaves out is a
MissingFielderror naming it unless the data may leave it out. AnOptionalabsent isNone; a@deprecatedfield and a@field(default)field absent keep their DECLARED value.@field(default)is schema evolution's ADD, the counterpart of@deprecated: a field added to a type whose data is already stored is marked so that older records still read (tests/ser_num_evolve). It needs a declared value to keep: an initializer, or a type whose default is its value. A@skipfield, never on the wire, keeps its declared value too. A key that arrives twice keeps its last value. A record that needs different handling for old data implementsDeserializableby hand. - A key no field claims is skipped — unless the READ is strict. Skipping is what lets an older reader load a
newer writer's data. A hand-written file wants the opposite, so a reader built with
strictrefuses the first such key with aDeErrorof kindUnknownFieldnaming it:unknown field `server.prot`. The read decides, not the type — one type is read leniently from a peer and strictly from a config file:import { std::memory::Owned, std::serialization::text::json::JsonDeserializer }; @generate(Deserializable) type value Limits { @field(default) public int32 voices = 8; } fn Result<Limits, Owned<Error>> load(string text) { JsonDeserializer r = JsonDeserializer.strict(src: give text); // `make` is lenient, as `deserializeJsonBuffer` is return Limits.deserialize(r: r); // {"voicez":12} -> unknown field `voicez`; `x.mergeFrom(r: r)` merges strictly }KbinNamedDeserializerandKbinNumberedDeserializerhave the samestrictctor (a numbered key is named by its id); a positional stream has no keys to be unknown. A@skipfield's key is unknown, since it is not part of the wire form; a@deprecatedfield's key and a renamed field's wire name are known; a map's keys are data, never unknown. A record nested in the one being read, an element and an enum payload are read through the same reader, so they are strict too —backups[1].hots,shape.Rect.hh. A back end written outside the stdlib implementsDeserializer.unknownField(): skip the value, or failUnknownField. - A read's error says where. A failed read returns a
DeError: itskind(), aDeErrorKind, is what a caller branches on, and itsfield()is the path from the value read to the part that failed, in the names the data uses:server.port,backups[1].port,limits.cpufor a map entry by its key (limits[1].valuein a map's pair array),Circle.radiusfor an enum payload, a renamed field by its wire name.message()says both — "missing fieldserver.port", "type mismatch atbackups[1].port" — andfield()isNonefor a failure of the value as a whole. The path is the schema's, not the wire's, so a positional backend, which carries no names, reports the path a named one does. A derived reader builds it at no cost until a read fails; a hand-written reader over a collection adds its element withDeError::inElement(error:, index:)(a field withDeError::inField(error:, name:)) and raises its own withDeError.of(kind:)orDeError.at(kind:, field:). A value of another type where the schema wants one —"port": "x"— is aTypeMismatch, input that ends early isUnexpectedEnd, and the first failure is the one reported. A failed read constructs nothing, so the type's destructor never runs for it: what the reader read in full is dropped field by field, and a collection drops the elements it holds. - Enums serialize externally-tagged:
{"tag":"V"}(no payload) /{"tag":"V","value":{fields…}}(payload); deserialize reads the tag, dispatches, constructs; an unknown tag →DeError. The variant selector has its own contract member (variant(name, index)/variant() -> FieldKey) rather than ridingwriteString: a discriminant is one ofnknown alternatives, not a string, so a positional or numbered backend spends one byte on it where a name costs the whole string. A named backend writes the name, unchanged. A payload's fields are matched by key, as a record's are — by name, by id (a payload field's id is its place, since a payload field takes no attributes), or by rank — so{"h":2,"w":3}reads intoRect(w, h)correctly; a repeated key keeps its last value, an unknown one is skipped, and a missing one is aMissingFieldnamingRect.h(anOptionalone isNone). - A map keyed by text is an object. On a back end that writes keyed maps — JSON — a
MaporSortedMapwhose keys each write onestringor one integer is written as an object keyed by that text, as every other JSON producer writes one:"limits":{"cpu":2,"mem":512},"ports":{"80":"http"}. Its keys are in order — integers numerically, strings bytewise — so aMapand aSortedMapholding the same entries write the same bytes, and insertion order changes nothing. Any other key (a struct, an enum) has no object form, so that map is an array of{"key":…,"value":…}pairs; KBIN writes every map as pairs. Which keys are text is the keys' own answer — a map cannot ask whatKis, so it asks each key what it writes, as Rust'sserde_jsondoes — so an EMPTY map, with no key to ask, is{}on JSON whatever its key type. That is the answer, not a gap: deciding by the key TYPE was weighed and declined, since it needs a branch on a type inside a generic body (the second body per type that Specialization is a non-goal rules out) or a shape descriptor onSerializable; Python'sjsonwrites{}for every empty map too. Reading takes either form for any map, so data written before the object form still loads; an integer key is parsed from its text, and one that is not an integer of that width is aTypeMismatchat that key (type mismatch at `ports.80x`).
Graph specifics. Shared/Weak fields serialize as u64 ids into the table (an absent Optional edge
is null; an expired Weak is 0), 1-based in discovery order, the root first. Shared/Weak dedup by
pointee identity; a Weak is interned only while a strong handle exists. Cycles ride Weak back-edges; a
dangling id → DeErrorKind::UnresolvedReference; a table id seen twice → DeErrorKind::DuplicateId. A polymorphic
edge — Shared/Weak<Contract> — reconstructs the concrete type from each entry's type tag and re-forms
the fat handle with that concrete's vtable; a tag naming a type that doesn't implement the contract →
DeErrorKind::TypeMismatch. A node's nested parts are walked inline: a by-value field whose type itself reaches
a Shared, or an Owned pointee that does, contributes its edges to the OWNER's entry — one table, one id
per pointee. An enum takes part on the same terms as a type: a
Shared/Weak in a variant's payload is an edge (written as an id inside the ordinary {"tag":…,"value":{…}}
frame), and a @generate enum may be a node — an edge's pointee, or the root — built on the heap with
new Geo::Circle(r: 4). Every nominal implementor of a contract used as a graph
edge, and every pointee of a concrete one, must be @generate(Serializable, Deserializable) — this is
compile-enforced: a non-@generate implementor (which would have no adapters and be silently dropped
from the wire) is a compile error at the edge field.
DeErrorKind = {Malformed, UnexpectedEnd, TypeMismatch, MissingField, UnresolvedReference, DuplicateId, UnknownField, TooDeep}, the kind a DeError carries beside its path. Every reader stops at 128 nested objects and arrays with
TooDeep: untrusted input must not decide how deep a read recurses, through a typed schema that holds itself or
the skip of an unknown field, and 128 fits the smallest stack kama runs on. A JSON number is read by RFC 8259's
grammar, and an integer field takes only an integer that fits it — 1.5, 1e3 and a value past the range are
TypeMismatch, never a truncation, a wrap or a trap.
The write is two passes (discover every pointee, so the table's length is exact before the first entry — a
positional array carries its length up front; then write), the read two passes with no rewind (allocate each
shell and stash its edge ids; then wire). The type index an index backend writes is the node type's position
in the program's closed set of node types, so adding a node type renumbers a positional/numbered graph wire —
the named backends are the schema-evolution-safe ones, as for fields before @field(id:).
The Owned/Shared/Weak triad is prelude / built-in (always in scope, no import) — RAII-over-GC is the
core model; see TYPE_MODEL.md.
import { std::serialization::text::json::serializeJsonBuffer, std::serialization::text::json::deserializeJsonBuffer, // wire backend (library)
std::memory::Shared }; // a graph root comes back as a handle
// by-value (tree): a resource reaching no Shared/Weak round-trips on the stack — an Owned child included
@generate(Serializable, Deserializable)
type resource User { @field(name: "user_name") string name; @field int32 age;
public ctor make(string name, int32 age) { this.name = give name; this.age = age; } }
// graph (heap): reaches a Shared -> what you pass IS the root; cycles rebuilt through the Weak back-edge
@generate(Serializable, Deserializable)
type resource Node { @field int32 id; @field Optional<Shared<Node>> next; @field Optional<Weak<Node>> back;
public ctor make(int32 id) { this.id = id; this.next = Optional::None; this.back = Optional::None; } }
fn uint8 main() {
Result<string, Owned<Error>> r = serializeJsonBuffer(v: User.make(name: "ada", age: 36)); // Ok: {"user_name":"ada","age":36}
string j = match (give r) { case Ok(value: x): give x; case Err(error: e): ""; };
Result<User, Owned<Error>> u = deserializeJsonBuffer::<User>(src: give j); // by value
Shared<Node> a = new Node.make(id: 1);
Result<string, Owned<Error>> w = serializeJsonBuffer(v: a); // Ok: {"root":1,"objects":[{"id":1,"type":"Node","value":{…}},…]}
string wire = match (give w) { case Ok(value: x): give x; case Err(error: e): ""; };
Result<Shared<Node>, Owned<Error>> g = deserializeJsonBuffer::<Shared<Node>>(src: give wire); // read back as a handle
// the whole graph is read before anything is usable; a dangling id is DeErrorKind::UnresolvedReference
return 0;
}
Building & debugging ✅#
kama build app.kama # this host, debug (-g, breakpoints in .kama via #line)
kama build app.kama --release # optimized, stripped, NDEBUG
kama build app.kama --target wasm # browser: .html + .js + .wasm
kama build app.kama --target EMBEDDED # bare-metal: a -ffreestanding -nostdlib object (.o)
kama build app.kama --target aarch64-linux-gnu --cc "zig cc" # cross-compile to any triple
kama build lib.kama --select OUTPUT=STATIC # a static library (libapp.a)
Toolchain setup per platform lives in targets.md.
--target takes a built-in name (HOST, MACOS, WINDOWS, LINUX, WASM, EMBEDDED), a target
your kama.json declares, or a bare <arch>-<os>-<abi> triple. Every compile and link flag follows the
selected target rather than the machine you are building on, so cross-compiling is a matter of having a
C compiler that can reach the target: zig cc does out of the box (it ships musl/mingw-w64/wasi-libc),
or declare a cc for the target in kama.json. Without one, kama transpile --target … always works —
emit the C and build it with someone else's toolchain.
A bare-metal target (any triple with os=none, of which EMBEDDED is the shortcut for this host's
arch) compiles to a -ffreestanding -nostdlib object rather than a linked executable. The synthesized entry becomes int main(void) { kama_main(); for(;;){} } — no argc/argv (there is none), and main never returns (a startup/crt0 calls
it and it spins). Fatal conditions (bounds/panic/OOM) route through an overridable weak kama_panic_handler
(default for(;;) __builtin_trap()) — provide a strong symbol to blink/reset/breakpoint. Name the board's triple directly
(--target thumbv7em-none-eabihf, with a cc that can reach it), and link the object with
your chip's startup object + linker script (memory map) as a separate step — turnkey triples, linker scripts,
and vendor HALs are a later milestone. A module static hardware UnsafePtr<T> lowers to a volatile T* MMIO register,
and module statics become plain zero-cost statics (one core = one isolate).
Debug builds are breakpoint-debuggable in an IDE (locals + call stack map back to .kama), and emit one
.c per module (faithful stepping, readable generated code). A --release build, native or wasm, instead folds
every module into one unity translation unit so the C compiler can inline across module boundaries — a
std::math operator or a collection accessor inlines into the caller's hot loop and then auto-vectorizes,
which is what lands numeric code at C parity (kama has no incremental object cache, so a build already compiles
all modules in a single invocation — the unity fold costs nothing and only unlocks inlining). The
numeric-safety checks (division by zero, TYPE_MIN / -1, a bad shift, an out-of-range float cast, a
narrowing cast, signed overflow in debug) are the compiler's own, in the emitted C — no -fsanitize flag
in either tier; signed overflow wraps (-fwrapv, passed in both tiers) in release.
One UBSan sub-check is permanently exempt: function. A kama program built under
-fsanitize=undefined should add -fno-sanitize=function, as the test suite does. This is a deliberate,
permanent exemption, not a workaround for an unfixed defect. Contract, vtable and fnptr dispatch store
every slot as Ret (*)(void* self, …) and call the concrete Ret C__m(C* self, …) through it. That
type-erased self is ABI-identical — it is how essentially all C object dispatch works, GObject and COM
included — but the function sub-check enforces exact function-pointer type identity, so it would flag
every contract call in a correct program. Every other UBSan check (integer overflow, null, bounds,
alignment, …) and all of ASan stay on. The exemption costs no real coverage: the emitter generates both
sides of a slot from one declaration, so a genuine signature mismatch fails to compile rather than
reaching a sanitizer. Making the pointer types exact would mean emitting a cast-and-call thunk per slot,
which buys nothing and adds an indirection to every dynamic call — expressly the wrong trade for the
embedded and hot-path targets.
Reserved keywords not yet implemented 🚧#
One keyword has reserved surface not yet implemented — using it is a hard error (never a silent no-op):
expose🚧 — the minimal free-function C-ABI symbol ships today; its full 2.0 surface (richer WASM module exports, the scripting-host interface) remains reserved, distinct from in-languagepublic/private(member access) andexport(the module public-surface manifest —export { … };, which ships today).
volatile is not a kama keyword: C's volatile is spelled hardware (emits C volatile for MMIO
registers and single-core ISR↔loop flags — see Module-level statics and ROADMAP_DETAIL §5). It is,
however, reserved — see below.
kama's keywords#
The complete list — 90 words, six of them contextual — and the reason it is printed here: every one that is not published is found by
walking into it. One port found three that way — base, type, slot — each costing
a build cycle to a parse error that names the token (unexpected SLOT) without saying that the word is
reserved. tools/check-keyword-list.sh holds this list identical to the lexer's table, so it cannot
drift.
abstract addr alignof as asm assert base bitcast bool borrow break case cast cchar char clong
comptime const continue copy copyElements ctor culong debugAssert default do drop else enum export expose
extends extern false file final float32 float64 fn fnptr for foreach friend give hardware if immutable
implements import in int16 int32 int64 int8 isize match new null operator out override panic
parallel_for parallel_spawn private protected public ref return scope sizeof slot spawn static
string this true truncate try type uint16 uint32 uint64 uint8 unsafe usize virtual void when while
Six of them are CONTEXTUAL — copy, give, truncate, type, slot and file may name any
binding — a local, a parameter, a field, a member, an argument label, a match label or binding, a foreach
binding, a name in an interpolation hole — and no type, free function or enum member.
Each is a keyword only at its anchor in the table below: type, slot and file where a declaration can begin,
give/copy directly before a name (the hand-off, give s), truncate before <T>( (the conversion). Anywhere
else the word is a name — give + 1, copy(x: 1) through a fnptr, truncate < n — so give(s) is a call of
something named give, and its diagnostic says so. Until 0.9.470 the
parser took copy, give and truncate only as a method's name, and none of the six as a pattern, foreach or
hole binding. Each was made contextual for the same reason: the word is one a program genuinely
wants as a name, and admitting it cost no bison conflicts at any name position. type is what lets an
FFI binding emit a C field literally called type without inventing a name
(tests/extern_field_type_keyword.d/); slot is the natural name for an index into a table
(tests/contextual_slot.kama); file leads the file gate below and is otherwise an ordinary name
(tests/contextual_file.kama) — File file = … is the spelling a user reaches for first, and measured,
the word is not an identifier anywhere in kama's own sources, so this arm exists purely for their code. The
kind words value / resource / view / contract / intrinsic, and adapter, are not keywords at all —
they lex as identifiers. fiber is a keyword only directly after spawn (spawn fiber work(…), KR-3), and a
name of any kind anywhere else — a fiber's entry included — except an isolate's entry: spawn fiber( always starts
a fiber, so spawn fiber is a grep that finds fibers and only fibers.
Which words are reserved — the greppability rule (maintainer, 2026-09-22). A word is reserved everywhere unless every KEYWORD use of it has a fixed neighbouring token that a one-line grep anchors on, so the keyword use can always be found without also finding the word used as a name. How common the word is as a name is not a reason by itself. The six contextual words pass:
| word | keyword use | grep anchor |
|---|---|---|
type |
type resource Foo { |
type + a kind word |
file |
file @compileFor(X); (line 1) |
file @ |
truncate |
truncate<int8>(x) |
truncate<T>( — <, a name, >; a binding so named reads truncate < n |
give / copy / slot |
give x, copy x, slot T x; |
the word + a name other than in — a binding so named is followed by an operator or punctuation, never a name, except in in a foreach header. For give/copy this anchor IS the lexer's rule, so the grep and the compiler cannot disagree |
By the same rule out stays reserved (no anchor: out T x and q: out quotient), and so do the call-site
intrinsics. this and the primitive type names are keywords like any other: they cannot be redeclared. The
language's type and contract names (Optional, Result, Owned, Formattable, …) are not keywords — they
lex as identifiers — but they are reserved NAMES in every scope (§ Shadowing).
C's reserved words are reserved in kama#
Every C11 and C23 keyword is a reserved word in kama and cannot be used as a name — not for a type, a
function, a field, a parameter, a local, an enum case, a variant payload, a generic parameter, a foreach
or match binding, or an exposed/extern symbol. Writing one is a lexical error that names the
spelling.
kama compiles to C, so a name that is a C keyword emits C that does not compile: int32 switch; becomes
int32_t switch;. Since every name kama owns now reaches C prefixed (§ C names), a renamed switch
would in fact be safe — k_switch collides with nothing, not even a user's own k_switch, which
becomes k_k_switch. The reservation is kept anyway, and deliberately: reserving a spelling now and
relaxing it later is source-compatible, while the reverse is not, one lexer rule covers every position
a name can appear in, and nobody needs a local called switch. It is the declared-C surface —
extern fn names, extern/expose fields and values — that still requires the reservation, because
those names are not prefixed and a C keyword there would reach the C compiler verbatim.
Twenty-one of the 59 are already kama keywords, so they were never spellable. The 38 that would otherwise lex as identifiers are:
alignas auto constexpr double float goto inline int long nullptr register restrict short signed
static_assert struct switch thread_local typedef typeof typeof_unqual union unsigned volatile
_Alignas _Alignof _Atomic _BitInt _Bool _Complex _Decimal128 _Decimal32 _Decimal64 _Generic
_Imaginary _Noreturn _Static_assert _Thread_local
Four of these are spellings a C, Go or Java reader reaches for, and their diagnostics name the replacement
rather than merely reporting that the word is reserved: int → int32/isize, double and float →
float64/float32. uint is not a C keyword and so is not reserved — it is rejected in type position
only, with the same guidance (uint32/usize). Both halves are guarded by tools/check-c-keywords.sh,
which also holds the reserved table equal to the two C standards' sets.
Known limitations (tracked → ROADMAP_DETAIL.md §1)#
Everything below hard-errors (never miscompiles) and has a clean workaround. Two kinds:
By-design rules — an rvalue can't be borrowed/reseated soundly, so these stay errors, not "unbuilt":
- An inline
new(or owned value) borrowed by aref/outor contract parameter — an inlinenewis consumed by value (the callee/caller becomes the owner). To borrow it (ref/out) or reseat a contract handle, bind it to a local first — an rvalue has no stable lvalue to write back to. (An inline stack ctor into a by-value contract param does work —f(a: Square.make(n: 3))— since the callee only borrows the caller-owned temp.) - An inline construct in a
do/whilecondition — the temp is needed at the bottom condition, whichcontinuemust reach; a portable (statement-expression-free) ISO-C lowering can't express it. Bind to a local.
Open (deferred inference) — a rare residual; bind the subject to a typed local:
- A value-producing
match/ternary as amatchSUBJECT — a bare variant constructor subject (match (Optional::Some(x)) { … }) works: the instance (Optional<T>) is inferred from the payload during the discovery pass (a function-level pre-scan supplies the param/local types) and reused at emit. A call subject works too, for every call shape and for a plain (payload-less) enum as well as a tagged union —match (classify(x: 1)),match (g.grade(score: 70)),match (Grader::always())(tests/match_call_plain_enum.kama). A variant-producing ternary as a subject works as well. The still-deferred form is a nested value-producingmatchdirectly as a subject; bind it to a typed local (Optional<int32> o = …; match (o) …).
(Target-typed inline construction works in initializers, return, operator[] place-stores,
value-producing match arms, class-typed lvalue stores, call arguments, variant payloads, and string-rvalue
indexing. Inline new heap-boxes into an owning pointer — Owned/Shared, concrete OR contract element —
in every by-value position. An owned rvalue receiver is RAII-dropped through method chains
(b.make().use()) and for .chars()/.split() over an owned rvalue.)
Reserved/runtime#
Generated C names are prefixed and module-qualified — kF_<file>__Type__member for a file-private
declaration (§ C names has the whole scheme, and kama demangle reverses it) — and the compiler's own emitted names — the members it
synthesizes (kama_vptr, kama_base, kama_tag, kama_u), its vtable slots (kama_dtor, kama_size,
kama_align, kama_type) and its temporaries (kama_ret_0, kama_strtmp0) — live in the kama_ register
(§ C names). The runtime (../include/kama_runtime.h) provides kama_string
and a kama_trace/kama_trace_get hook used by tests.
A kama name is never in danger of colliding with any of these, because a name the user owns reaches C as
k_<name> — so __-prefixed spellings and the emitter's own identifiers are not reserved against you.
A local called __ret_0 is legal kama and compiles: it emits as k___ret_0.