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).
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 |
| 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 int32 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. 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 an ordinary "no such method".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 int32 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.
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 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, needs Unicode tables, and stays a package concern — see ROADMAP_DETAIL.md §2. - search —
find(substring:)returnsOptional<usize>(the first byte offset,Nonewhen absent — null-safe, no-1sentinel);contains(substring:),startsWith(prefix:),endsWith(suffix:)returnbool;isEmpty(). - 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: …; };
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, intrinsic { const fn void format(ref Formatter f); }
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. "${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 int32 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 does not. It is named after the contract (Formattable), notdisplay/debug— Kama has one to-string contract, no Display/Debug split; a${x:?}-routed structuralDebugderive stays a possible additive future.Tagged strings ✅ — an identifier placed immediately before a string (
html"…",sql"…",stripIndent"…"; no space) makes it tagged. The compiler splits the string into its trusted literal parts and its rendered holes (each hole run throughFormattable) and hands them to a functionfn R name(ref Template t), which decides how they combine — so a tag is just a function you can define:fn string html(ref Template t) { /* literals verbatim, holes HTML-escaped — XSS-safe */ } string page = html"<b>${user}</b>"; // ${user} escaped, <b> kept raw SqlQuery q = sql"… WHERE id = ${id}"; // holes become `?` params, out-of-band (injection-safe) string s = stripIndent"…"; // the literal template is dedented; hole values verbatimBecause parts and holes stay separate, a tag treats literals (trusted) and holes (values) differently:
htmlescapes holes but not literals,sqlnever splices a hole into the query text (values go to a params array),stripIndentdedents only the template.Template(a prelude type) exposespartCount()/holeCount()/part(at:)/hole(at:);stripIndent,html,sql(+SqlQuery) live instd::fmt. Format specifiers compose inside a tag (sql"…${amt:.2}"). An unknown tag (no matchingfnin scope) is a compile error. (Type-preserved params — each hole keeping its static type into the params list rather than a renderedstring— is a compatible future extension; see ROADMAP_DETAIL.md §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 int32 main() {
FixedArray<int32> a = FixedArray.make(size: 4); // fixed buffer, zero-initialized
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
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;
}
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.
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 int32 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 h.mint() as v { … } ✅#
A view borrows storage it does not own, so kama answers how long is this view valid? with lexical
scope — not a lifetime annotation, and not a programmer's promise. A borrow block is that extent,
and it is purely lexical: one C block plus one initializer per binding, no runtime cost.
borrow d.view() as v { … } // `Viewable<ConstView<T>>` grants `view()`; `ViewableMut<View<T>>` grants `viewMut()`
borrow m.values() as vals { … } // `ValuesIterable<I>` grants `values()` — `Map` has no `view()`
borrow buf.span() as s { … } // a user contract's own grant
Three rules, and together they are what lets the paragraph above say "cannot dangle":
- The host is a MINT CALL, and the window is over the call's receiver. The mint is any nullary
member of a
@viewablecontract the receiver's type implements — 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. - The host place is FROZEN for the extent of the block — no assignment, no non-
constref/outargument, nogiveout of it, no non-const fnreceiver overlapping it, and the alias itself may not be reseated. 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 an element write (a[0] = 7cannot realloc, and aView<T>is a mutate-through window in the first place). - A view LOCAL's root must already be lifetime-bounded — a
borrowalias, a by-value view parameter, a derive off either, or a free function's result when every view handed to it was bounded. 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.
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. 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.
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).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/collection/enum, and may be returned only when it borrowsthisor aref/view parameter (soarr.slice(...)on aref/thisreceiver is fine; a view over a local is rejected). 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.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, and writingpublicon it is a compile error rather than a silent downgrade (the same rule aviewfield already obeys). 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.@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, a collection element or anenumpayload — 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.
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 intrinsic 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, 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 intrinsic 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 int32 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. 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.
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 ({"slots":[1,2,1],"values":[10,30]})
— 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. len and the free list are derived on read and never go on the wire.
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 emits an ascending pair array). 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.
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>, 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:) 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 two-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 void deallocate(UnsafePtr pointer, usize bytes, usize align);
}
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/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 int32 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. It does not promise that the
object never names malloc, since an unreached stdlib body may. 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_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, and a contract member or virtual method is dispatched through a slot. Marking the
contract member @noheap is what lets the proof cross it — every implementation is then checked
against that promise, exactly as const fn already works on a contract
(tests/noheap_contract.kama). A virtual call the compiler devirtualizes
(a final class, a final fn, or a method nobody overrides) is a direct call and needs no annotation.
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, and which one that is is a runtime
fact, so the drop is rejected in a no-heap region like any other slot call, even when the handle's own storage is an arena.
Two things prove it, and both are declarations rather than inference:
@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.
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 or releases goes through one funnel, the runtime's kama_alloc/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 void deallocate(UnsafePtr pointer, usize bytes, usize align) { … }
}
GlobalHeap has the same two 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 two 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.- 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 int32 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.
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 int32 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 int32 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) |
A marker on a fresh rvalue is an error. Move tracking is compile-time: reading a moved value, moving out of a field/element, moving inside a loop a value declared outside it, and a conditional move that is still live at scope exit are all rejected — there is no runtime drop flag.
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 or type in scope — declared in the module or imported,
and with nothing implicit in scope (KR-87) that needs no exception. 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
};
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; 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.
Functions ✅#
fn int32 add(int32 a, int32 b) { return a + b; }
fn int32 main() { return add(b: 20, a: 10); } // named args; reordered to declared order
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 int32 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. That
temp dies at the end of the statement, 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.
"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 int32 main() { return 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.
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. 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.
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 intrinsic 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).
Two limits worth knowing. A nested generic call cannot infer — log(x: exp(x: 1.0)) fails because
the inner call's return type is the very T being resolved; bind it to a local (kama's usual "bind it to
a local" rule). And 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.
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.
sync::{Mutex, RwLock, Once} has no counterpart here and that is a stance, not a gap — the
shared-nothing isolate model means Atomic<T> is the one shared-mutable seam (see Concurrency).
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 intrinsic 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. Floats go through strtod behind kama_fmt.h, whose checked entry point
reports ERANGE as OutOfRange rather than folding it to an infinity.
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, and full Unicode character properties belong in a package (see the Unicode
stance 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.
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). - 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 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 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: sizeof does not
fold, bitcast is refused (no width until the target is known), and there is no wire format.
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: they refuse sizeof folding at compile time and refuse
bitcast (an equal-width reinterpret needs a width), while Formattable's widening cast<int64> needs none.
What decides is whether the operation needs to know the width.
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`, which an enum may implement
(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 int32 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<int32>(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 — or Append) plus free readFile/writeFile/readText/writeText/stat/readDir/remove,
createDir/createDirAll/removeDir/removeDirAll/rename/exists, and a Metadata of size,
isDir, modified (a std::time::Timestamp, one-second resolution) and readOnly (the recorded
permission bit, not an access check); std::net gives RAII TcpListener/TcpStream (blocking TCP) and
UdpSocket. All fallible calls return Result<…, IoError>, consumed by match.
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.
⚠️ 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(InvalidInput), 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().
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.
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 per-stream Stdio { Inherit | Piped | Null } — spawns an owned, move-only type resource Process via start(), or captures via the one-shot run() -> Output { status, stdout, stderr } (which
drains stdout+stderr concurrently through std::net::Poller, so a child that fills both pipes can't
deadlock the parent). Process gives wait() (blocking reap → ExitStatus { code, signal, success() }),
tryWait() -> Optional<ExitStatus> (non-blocking), kill/terminate/signal, 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. 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 seam (poll on POSIX, a reader thread per pipe on
Windows) so both platforms take the same code path. wasm has no process model.
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). 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 int32 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;
}
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 int32 main() { return 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. 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 int32 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
DeError::Malformed.
sleep(d: Duration) blocks the calling thread for at least d: 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.
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 int32 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 }; — two
submodules, sha1 and sha256, each exporting a streaming hasher (make(), update(ConstView<uint8>),
finish()), a one-shot function of the same name as the module, and DIGESTBYTES/BLOCKBYTES. The
digest is an InlineArray<uint8>#(N) — 20 or 32 bytes on the stack, no allocation anywhere, so both are
present in a --no-heap build.
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. TLS, ciphers, key exchange and signatures are a
package (@kama/sodium), because they are libsodium's job and carry the constant-time burden. 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) and the RFC 6455 example. HMAC and SHA-512 are the
recorded next cut.
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 int32 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 asDeError::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 int32 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 int32 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 int32 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/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.
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 (until 0.9.232 the local form was refused, the field form never was). What stays
resolves is a drop: 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 int32 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.
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 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 the first external project 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: src/app.kama 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 — so the same program gets the same verdict on a Mac and on a Windows box. Building still follows the host and its toolchain; only the cover is host-independent. 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. 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.sizeoffolds to a compile-time constant for a fixed-width scalar —int8…int64,uint8…uint64,char,float32,float64, including through a bound type parameter (sizeof(T) * 8inside a generic) — so it can drive acomptimeinitializer or a comptime argument. It does not fold for anything whose size the target decides rather than the language:usize/isize(Csize_t— 4 bytes on wasm32/thumbv6m, 8 on x86_64),bool,string, and user aggregates, whose layout belongs to the C compiler.alignofnever folds — alignment is an ABI choice, not a language guarantee (1 on AVR;_Alignof(double)is 4 on i386). Both keep working everywhere a runtime value works. The premises the fold rests on (CHAR_BIT == 8, andfloat/doubleat 4/8) are_Static_asserted inkama_runtime.h, so the C compiler verifies them for the real target on every build. What kama will not fold,comptime assertlets you assert anyway — it hands an aggregate/alignofpredicate to the C compiler as a_Static_assert.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 where a conversion can start, sostring'struncate(maxBytes:)— and any method of that name — still reads as a member.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 (f.kama:12)).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. (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.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. A place is second-class (used in-place, never stored). 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). - A borrowing iterator holds an
UnsafePtrcursor (its ownunsafeinternals); theforeachsurface is safe.
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.)
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 int32 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 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 int32 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. 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.
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 escape hatch for the blind seam. 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 int32 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 int32 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. Three 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, and file-private declarations (k_F<file>__…) |
<project>__… |
a module'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. 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 and anything starting k_ or kama_ (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). And a name may
start with __ but not contain one — __ is how scopes join in C, so a local Fmain__helper spelled
the file-private helper's C name (k_Fmain__helper). 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 -- k_Fapp__Pair_int32__make # -> Pair<int32>::make
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 k_Fkk__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_… 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).
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).
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, a collection element, or anenumpayload, 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 declare a~dtoror own a resource field, and its fields are private only (its rawUnsafePtr<T>must not leak). 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.type intrinsic <targets> implements C { … }— the kind a primitive is. It declares nothing new; it decorates existing built-in types with a contract's methods, one block for a whole set of widths. Seetype intrinsicbelow.
The full model + rationale is in TYPE_MODEL.md. The kind words value / resource /
view / contract / intrinsic are contextual, not reserved — because they appear only right after type, 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; 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 intrinsic
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 int32 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()).
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 — is a different operation, and legal.)
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).
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. 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 int32 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. - 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.
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); @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: an intrinsic collection, whose
zero representation is its valid empty value, and a type with a default ctor, which the compiler
calls at the fill site. (A generic field could not spell the latter anyway — there is no expression for
"the default A".)
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.
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 and the always-sized FixedArray keep their own shapes. 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). 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.
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 elected one, value or resource, 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 |
of |
a memberwise ctor T.of(f1:, …) — bag only (a transparent value) |
zero |
a zero-init ctor T.zero() — bag only |
Equatable/Hashable walk each field through its own equals/hash — 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 both (which is what keeps "equal values hash equal" true).
What may carry it. Any value, resource or enum, generic or not:
| Subject | Derives | Shape |
|---|---|---|
plain value / resource |
all seven | Type { f: v, … } |
generic Box<T> |
all seven, per instantiation | as above, for each instance |
tagged enum |
the five 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 five contract names | the bare variant name; on the wire a plain string "Green", not an object |
generic enum (Optional<T>, Msg<T>) |
the five, 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 payload-less enum derives over its members: it serializes as the bare variant name, formats as it, and
compares 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.
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 is 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.
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, 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. Not inside an
if, 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 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.
Two consequences worth stating plainly. A class-typed slot is valid-but-empty from the declaration on,
so reading a non-owning field of one or handing it to a callee is fine; an Owned/Shared slot is not
— its zero value is a null pointer, so reading through it is rejected, as is reading a primitive slot,
which has no field-default fill behind it.
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.
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 int32 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
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). A contract may refine another (type contract Animated for value, resource implements Drawable { … }) for capability layering, without inheritance.
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, or collection element
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 "borrow is parameter-only; storage requires
ownership" — the same reason a ref parameter can't be returned and a returnable "reference" is always an
owned smart-pointer handle.
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 intrinsic — a primitive declares its conformances ✅#
A primitive is a type kind like any other, and it declares conformance the same way: type intrinsic <targets> implements C { … }. The <…> is a set, because one body usually serves many widths — the
prelude's per-primitive impls collapse from 64 blocks to roughly 8. Inside the block This is the target
being decorated, resolved per member of the set.
type contract Hashable for value, resource, enum, intrinsic { const fn uint64 hash(); }
type intrinsic <string> implements Hashable { // a primitive 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 intrinsic <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 primitive gets no _classes entry — every "is this a user type?" test keys on that — so the
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 contract is a SCOPE. A conformance decorates a primitive within the scope of that contract, so a
contract-supplied method is not part of the primitive's own API — it is reached through the contract,
never off the bare value. Without this, any package declaring type intrinsic <int32> implements 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 impl block 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 (an intrinsic's 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.
Why a kind rather than a mechanism. Before this, a primitive had no kama spelling at all, so the only
way to give it a contract was implements C for T — a retroactive block reaching into a type from
outside. Giving primitives (and enums) a spelling removed that mechanism's whole job rather than fencing
it, and the block itself is now gone from the language. See The contract model for the full argument.
Coherence. Two declarations of the same (contract, type) pair are a compile error, whichever kind
declares them — a class's or enum's own implements list, or a type intrinsic block. When the two
claims come from different packages the message names both — kama's whole-program view makes the
conflict directly visible, so no orphan rule is needed to forbid legal-but-unusual cases in order to
prevent one the compiler can simply see.
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 int32 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.
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,InlineArrayorSimd(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); 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— 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. A localcomptimeis scoped to its function or block. - Initializer must fold — a literal,
sizeofof a fixed-width scalar (notalignof, and notsizeofof ausize/aggregate — 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 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()`
}
- 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. (This is a strict subset of a future dual-use /constexpr-style relaxation, so it can widen later without breaking anything.) - 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. - 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, and fixedInlineArray<T>#(N). Statements: local +constdecls,=assignment, fixed-array element writes (t[i] = …),if/else,for/while/do,foreachover a fixed array,return. Expressions: arithmetic / bitwise / comparison / logical (short-circuit) / ternary / cast, array index reads, and calls to other comptime fns. - 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 fn. These are enforced structurally — anything outside the subset is a clean "unsupported in comptime fn" diagnostic — so the same inputs always bake the same output. - 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");
}
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,sizeofof a fixed-width scalar, and arithmetic/comparison/logic over those. The failure is an ordinary kama diagnostic, sokama checkand the LSP report it. - The C compiler answers it when the predicate turns on a layout fact kama deliberately does not
model — an aggregate's
sizeof, anyalignof,sizeof(usize). kama emits a C11_Static_assertcarrying the message, and the target's real ABI decides. This needs no layout model in kama, which is exactly whysizeoffolds only for fixed-width scalars. - 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,
- ⚠️ The caveat, and it is the price of the split: the layout form fails at BUILD, not at
kama check. The C compiler is what rejects it, andkama checkruns no C compiler — so the LSP cannot show it. A scalar predicate has no such gap. Prefer the scalar form when a claim can be stated either way. - 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 hands the rest to the C compiler — 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");
- kama does not own layout, and deliberately does not start. It emits C and the C compiler lays the
struct out; a layout model in kama would be a second source of truth that could disagree with the real
one per target. So these state a constraint and
sizeof/alignof/comptime assertverify what the toolchain actually did. 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/static (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
(see Derives). The true/false conversion operators are out of scope: there is no implicit truthiness — if takes a bool.
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) { return a > b ? a : b; } // generic fn — args INFERRED from the call
fn int32 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); }).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 int32 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 intrinsicblock, 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).What is checked at a generic's DECLARATION, versus at its instantiation. A generic nobody instantiates is still fully analyzed — every rule in the language applies inside its body, with each type parameter standing for a type that promises exactly what its bounds promise. That is what makes
kama checkon a library a real answer: a package's public generic, which its own tests may never instantiate, cannot ship green and hand its consumers 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_*).What waits for the instantiation is only what a concrete type argument decides: whether that argument satisfies the bounds, which
when [T: …]members exist for it, and acomptime assertover a const parameter — each reported 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, 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:type contract Arithmetic<T is This> for value { T operator+(T rhs); } fn T sum<T: Arithmetic<T>>(T a, T b) { return a + b; } // `a + b` -> static Concrete__op_add(&a, b)The concrete type declares
implements Arithmetic<This>(bounds are nominal), anda + bin the monomorphized body lowers to a direct call — no vtable, no boxing.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>`Defaults are a
typefeature — 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(plustype intrinsicfor a primitive), 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).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. A grant naming an unknown symbol inside a module that IS in the program 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. 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 int32 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 IoError : uint8 implements Error {
NotFound, Denied(int32 code);
public const fn string message() {
return match (this) { case NotFound: "not found"; case Denied(code: c): "denied"; };
}
}
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
— but not a field or a destructor — its layout is its tag plus its
variant payloads, and it owns nothing beyond them. 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 intrinsic 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.
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 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 / break / continue); 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"; }
:= name; // the arm's value (must be last)
}
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;
};
Weak<T>.tryUpgrade() returns Optional<Shared<T>>; a fallible static fn factory returns Result<T, E>
(see Fallible construction above).
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 int32 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. 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).
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 intrinsic 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 intrinsic 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. 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 and the
Atomic<T> seam. 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
h.join(); // explicit join; `~Isolate()` also joins (RAII)
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 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 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 |
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.
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 int32 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 SendResult<T> { Sent, Undelivered(T item) } — a channel whose receivers are
all gone hands the item back rather than dropping it on the floor, so nothing is silently lost
and the sender decides what to do. 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.
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 endpoint to drop 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.
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.
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.
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 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 spawn must be a direct statement of its scope; one inside a nested block is rejected.
So a scope's children 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.
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.
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.
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.
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 three sharing seams ✅#
Cross-isolate state is confined to three 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> |
the only cross-isolate mutable sharing | _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 |
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>, which is 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 int32 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 are the
whole shared-mutable surface; general shared mutable memory stays outside the safe language.
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)/@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.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 binary— KNUMnumbered serializeKbinNumberedBuffer/deserializeKbinNumberedBufferserializeKbinNumberedStream/deserializeKbinNumberedStreamschema evolution: protobuf-shaped keys, so unknown ids skip and reordering survives binary— POSpositional serializeKbinPositionalBuffer/deserializeKbinPositionalBufferserializeKbinPositionalStream/deserializeKbinPositionalStreamsmallest: only values on the wire, shape agreed in advance A
…Streamencoder CONSUMES its sink (kama refuses a move out of a field, so it cannot hand it back); to keep writing to the same sink, construct the serializer yourself and callencodeValueon it. 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 isDeError::Malformed. It carries an object graph all the same (tests/ser_pos_graph): the envelope is ordinary tokens, whose shapes it can count.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.)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.)- 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. Map<K,V>serializes as an array of{"key":…,"value":…}pairs (a generic key can't be a JSON object key).
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 → DeError::UnresolvedReference; a table id seen twice → DeError::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 →
DeError::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.
DeError = {Malformed, UnexpectedEnd, TypeMismatch, MissingField, UnresolvedReference, DuplicateId}.
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 int32 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 DeError::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 native build 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 — 89 words, six of them contextual — and the reason it is printed here: every one that is not published is found by
walking into it. The first external project 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 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 field, a local, a parameter, an argument label, a member) and lead a declaration only where a
declaration can begin. 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 are not
keywords at all — they lex as identifiers.
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< (a method is .truncate() |
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 |
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 — k_F<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.