Goose

The Goose Language — Specification (v4 draft)

This working specification defines Goose's syntax, semantics, and compilation model. It provides the detail needed to implement the language, without the formal structure of an ISO specification. Appendix B lists unresolved items. A readable account of the same rules, with an example for each, is the language reference.

Priorities, in order: Speed, Safety, Expressiveness. Safety here means: no out-of-bounds access, no access to memory at a type other than the one it was written with, no dangling references into freed or retyped memory. It does not mean Rust-style aliasing control; type-safe reuse of an element (reading a valid but logically stale value) is permitted where explicitly noted.


1. Core concepts

1.1 Size classes

Every type belongs to exactly one size class:

A compound type's class is the max over its parts (fixed < variable < resizable), subject to the placement rules in §3.4.

Additionally, a type is flat if it contains no references, slices, or relative references at any depth. Only flat values may cross thread boundaries (§11.2).

1.2 Memory model

A Goose program's memory consists of:

There is no general-purpose heap and no malloc. All dynamic allocation is expressed as values living on data stacks, owned by locals (or globals). (The runtime itself may allocate internally for thread queues, §11.2.)

1.3 The stack invariant

For each data stack:

  1. Values are pushed in lifetime order and popped en masse when the owning scope exits. Lifetimes on one stack are strictly nested.
  2. At most one resizable value is live per stack, and it is always the topmost value on that stack, for its entire lifetime: the region from a live resizable's first element byte to the stack's current top belongs exclusively to its elements (its own length header lives below its elements or outside the stack entirely, Appendix C). Growth is a pointer bump; no capacity checks are emitted (address space is pre-reserved; guard pages catch pathological overflow and abort safely).
  3. Two simultaneously-live resizable values never share a stack. Locals with disjoint (sequential) lifetimes reuse stacks.
  4. A variable value may be buried under later allocations on the same stack once its construction completes. While a value is under construction on stack S, nothing else may allocate on S, nor may the resizable value it is being added to shrink; construction of nested variable parts proceeds in order as part of the same construction.
  5. Fixed-size values are placed on the native stack / in registers whenever possible; the length header of an outermost resizable local likewise lives in the owning frame, not on the data stack (Appendix C).

The compiler proves all of this statically; there is no runtime bookkeeping beyond the bump pointers themselves.


2. Lexical structure and syntax style

The syntax follows C and Rust: {} blocks, // and /* */ comments (block comments nest), semicolon-terminated statements, postfix type annotations (x: T). Identifiers [A-Za-z_][A-Za-z0-9_]*. A UTF-8 byte-order mark at the start of a source file is ignored.

Literals:

The language is expression-oriented: if, match, block and a bare { … } block are expressions; a block's value is its trailing expression. Assignment and ++/-- are statements, not expressions. A trailing construct that cannot supply a value — an if without a final else (an else if chain included) or with a branch that cannot, a match with an arm that cannot, a loop none of whose breaks carries a value (§6.4), or a block or scope ending in one of those — is a statement rather than the block's value, so a function whose result type is inferred, and a function value's body, may end in one and produce nothing.

Statement termination. Expression statements end with ;, but a statement that is a block-ended construct — if, match, block, the loops, guard … else { }, a nested fn, or a call with a trailing function block (§7.6) — needs none. Such a statement ends at its closing }: operators never continue it (foo(1) { it } followed by -x; is two statements), and using one as an operand requires parens (let v = (if c { 1 } else { 2 }) + 3;). A redundant ; after a block is harmless. Trailing commas are allowed in every comma-separated bracketed list: struct/array literals, call arguments, parameter and generic lists, field lists, match arms.

Type syntax is postfix throughout: T[k] array of T, T& reference to T, T[:] slice of T, T? optional T, T&<u8> relative reference, T&<u8 in pool> one measured from a named pool, Shape.. variable-mode ADT. One prefix, const, qualifies the first reference or slice built on the base type: const T& and const T[:] are a reference and a slice through which the pointee, or the elements, cannot be written (§9.5); const (u8[:])& parenthesizes to reach an outer one.

ns::name names a declaration of namespace ns (§11.1) wherever a declaration can be named -- a type, a call, a global, a return … from target, a pool; ::name names a global one explicitly. The :: token is distinct from ., so image::Shape.Circle is the variant of a namespaced enum.

Evaluation order is left-to-right everywhere (operands, arguments, field initializers — which named struct literals keep aligned with construction order by requiring declaration order, §4.2). Overlapping copies have memmove semantics.

An earlier by-value operand is read before a later operand executes; a reference or slice retains its address or view, not a snapshot of its contents. Indexing and slicing sample the receiver's region and length before their index or bounds run. Assignment resolves the destination first; compound assignment also reads its old value before the right-hand side. Whole-resizable assignment clears before constructing (§4.4).

A grammar sketch and precedence table are in Appendix D.


3. Types

3.1 Scalars

Integer literals are constants without a committed type: they adapt to any integer type whose range holds their value (let x: u8 = 255; is fine, = 256 is a compile error), and where nothing constrains them they are i64 (u64 for values above i64.max). A named constant — a let or const global with no written type whose initializer is an integer constant expression (let SZ = 15;, let B = SZ * 2;, let NEG = -3;) — is such a constant as well: each use of it adapts as its value written there would, at a destination (D { len: SZ } with len: u8) and as an operand alike (b + SZ with b: u8 is an 8-bit add), and it is an i64 where nothing constrains it. A global with a written type (let SZ: i64 = 15;), a local let and a var are values of their type. (An untyped float let, local or global, adapts too, §6.3.) A use adapting a named constant relies on its keeping its initializer's value, which a writable reference to it could change, so as for a compile-time size (§11.1) such a reference is an error in either order; and a division by a named constant that is zero is left to run time rather than rejected as a constant one (§6.2). All other conversions follow one rule — implicit when provably value-preserving, an explicit cast otherwise — with one exception: an integer becomes a float implicitly, rounding as float arithmetic does (§6.3).

3.2 Structs

struct X { a: i8[3], b: i32, c: i64 = 0 }

Struct and enum declarations introduce nominal types. type Name = T; declares an alias. Generic structs: struct Pair<T> { a: T, b: T }, monomorphized like functions.

A generic type's instantiations must be finite, as a function's specializations must (§7.7). Each instantiation checks its fields' types (a variant's payload included), instantiating the types they name, so fields that name the declaration again — directly or through other declarations — with a larger type argument each round would instantiate without end: struct P<T> { v: T, next: P<T[]>? } needs a P<T[]>, which needs a P<T[][]>, and so on, whether or not a program ever follows next. As for polymorphic recursion (§7.8), the compiler rejects an instantiation that would make a chain of more than 16 instantiations of one declaration, each needed by the one before, naming the first few. Fields naming the declaration with the same arguments (struct L<T> { v: T, next: L<T>? }), or with ones that stop changing, reach an instantiation already made instead, so only a type nesting one declaration more than 16 deep in its own arguments meets the bound.

3.3 The array family

All array types share element type T and differ in how their size behaves and is stored. In all of them, the representation is [metadata][element data...], inline, packed (exact layouts in Appendix C).

Type Class Stored metadata Notes
T[k] fixed none k a compile-time constant expression (literals, named constants, a global's fixed .len/.cap, arithmetic)
T[] variable length (u32 default) size chosen at construction
T[u8] etc. variable length of given unsigned int type explicit length field type: u8–u64 only
T[varint] variable varint length most compact
T[..k] fixed length only (smallest int type fitting k) capacity k is static (constant expression); in-place grow/shrink up to k
T[..] variable capacity + length (u32 default) capacity chosen at construction; in-place grow/shrink up to it
T[>..] resizable (grow-only) length element region tops a data stack; grow = bump
T[>..<] resizable (grow-shrink) length as above, may also shrink

A size or capacity k is fixed at compile time, as is the count n of a fill literal [v; n] (§4.2): a name in one is a named constant, a let or const global (§11.1), and resolves as any name does, lexically first. One may also take X.len of a global X whose length its declaration fixes, and X.cap of one whose capacity it fixes (const names = ["a", "bc"]; var counts: i64[names.len];): a written type T[k] fixes the length and T[..k] the capacity; with no written type, an array, fill or string literal initializer fixes the length, and one naming another such global what that global's declaration fixes. Where X's type fixes it too — a written one, or the T[k] an array or fill literal gives — nothing can change it, and X may be a var. Where it does not, as a string literal's const u8[:] does not (§3.7), the length is taken as a named constant's value is: X may not be a var, nor be bound to a writable reference (§11.1), as a let holding a literal's slice never is (§9.5). Either is an i64, as any .len is, which adapts as no literal does (§3.1). A local of that name — a parameter, a local variable, or one around a nested function or block —, a type parameter or a nested function hides the global there and is no constant, so a size naming it is an error, as a match pattern naming it is (§8.1); a qualified name (::K, ns::K) reaches the global past it. So is, in a function's or a block's parameter and result types, the name of one of its parameters or type parameters, and in a generic struct's or enum's field types the name of one of its type parameters: a type parameter stands for a type, and there are no size parameters (fn f<N>(a: i64[N]) is an error). A type alias's sizes name what they name where the alias is declared.

Indexing is bounds-checked — against k for fixed arrays (checks statically elided where provable), against the current length for all others.

A stored length must hold the actual element count without truncation. For example, T[u8] can hold at most 255 elements. A construction exceeding its length field's range must be rejected when known statically or abort when discovered at runtime, including when adapting another array, a call result or a string. It must never produce an image whose metadata and element region disagree.

Element restrictions:

Growth operations (push, append, …) exist only on resizable arrays and on limited arrays ([..k], [..]) up to capacity (exceeding capacity aborts). Shrink operations (pop, pop_n, resize downward, clear) exist on limited arrays anywhere, on [>..<] wherever no live variable refers into it (§5.2), and on a grow-only [>..] exactly where the compiler can see that no reference or slice into it is live (§5.1); they abort when they would shrink below empty (pop on an empty array, pop_n of more elements than the array holds, resize to a negative length).

Built-in members: .len (always, returns i64), .cap (limited arrays), .push(v) (returns a reference to the new element on resizable and limited arrays — the idiomatic way to link up just-built data), .append(src) (src an array/slice of the element type, whose elements are copied: the references they hold must outlive the array, §9.2, and none may be self-relative, §3.9; an array literal there is built instead as a run of such elements, §4.2), .pop(), .pop_n(n) (drops the last n elements at once and returns nothing; a negative n aborts), .resize(n, v) (grow with fill value v, copied into every slot added, or shrink), .resize(n) (shrink only), .clear() per the rules above, and .index_of(r) -> i64 (fixed, limited and resizable arrays of fixed-size elements): the index of the element r refers to, (addr − base) / elemsize. r must be rooted at the array exactly (§9.2), so the division is exact and the result is in range without a runtime check. A root names a variable, not a part of it, so where the array is a field or an element of what its root holds, the reference counts as rooted at the array only if that storage holds the element type nowhere else: not in another field or element, nor inside an element. A parameter's storage is judged at each call (§10.2). A reference rooted elsewhere is a compile error. Growth always supplies element values — no operation can expose uninitialized slots (§5.3). Per UFCS these are ordinary functions: a.push(v) is push(a, v).

3.4 Placement rules (what may contain what)

At most one resizable per struct (the tail). (Future extension: two resizables with memmove-on-insert semantics, opt-in.)

No type contains itself by value, directly or through what it contains -- fields, payloads in either ADT mode, variant types, array elements: it would be part of itself. It may refer to itself, since a reference or a slice is fixed-size whatever it points at: an ADT's payload may link to its own ADT or one of its variants, and a type behind a reference may contain the type holding the reference (enum List { Nil, Cons { v: i64, next: List? } }, or struct A { b: B? } with struct B { a: A }).

3.5 Algebraic data types

enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
    Point,
}

The only dynamic-polymorphism mechanism in the language (no inheritance, no traits, no vtables). Representation: integer tag (smallest storage type that fits the variant count, default u8) followed by the payload.

Every ADT type can be used in two modes, chosen at the use site by the containing declaration:

Allowing either variant replacement or interior references, but not both, preserves the soundness of existential types (Grossman, "Existential Types for Imperative Languages").

Payload-less ADTs are ordinary C-like enums and are fixed, 1 byte by default. Variant types (Shape.Circle) are themselves nominal struct-like types (§8.2).

3.6 varint fields

varint is LEB128: little-endian base-128, 7 payload bits per byte, high bit = continuation; 1–10 bytes; full 64-bit range. Stored encodings are canonical: the shortest encoding of the unsigned or zigzag-transformed value. A verified byte image must preserve that invariant (§12). LEB was chosen because this type is optimized for values that are usually very small but have occasional outliers, where it outperformed other formats (measurements summarized in Appendix B, Resolved, item 1).

There is one varint type, with two encodings by position — this is user-visible whenever Goose data is serialized directly:

Because the two encodings differ, varint values are never copied byte-wise between contexts: any varint-to-varint construction (e.g. a struct varint field initialized from arr.len) goes through i64 — decode, re-encode.

Restrictions: a varint field/element is written only at construction of its containing value; changing it means reconstructing the container. Reads decode to i64; construction stores accept any integer type except u64 (a varint holds exactly the i64 value range). A struct containing varint fields is variable-class. References to varint fields are always read-only (§3.8). Such a field is still storage of type varint, which a varint& destination binds without &, as a reference destination binds any lvalue of its pointee type (§4.1); an i64& does not bind it, though it reads as an i64. That i64 is no storage, so a format overload taking an i64 by reference is given it in a read-only temporary (§3.7).

3.7 Strings

There is no built-in string type. A "string" is any array-family type with element u8: u8[] (flat string with a fixed length), u8[varint] (compact), u8[>..] (string builder), etc. String literals are static constant u8 data, implicitly copyable into any of these representations and usable directly as slices, of type const u8[:] (§9.5): read-only through the type, so a literal is stored only in a slot declared const. No encoding is enforced; UTF-8 is a library-level convention.

Text. Every scalar, bool, and u8 array or slice has a text form: integers in decimal, floats in the shortest form that reads back to the same value of their own type (so an f32 0.1 prints as 0.1, not as the digits of the f64 it widens to), with .0 after a whole number so it still reads as a float (1.0, -0.0, 2147483600.0; 1e+20 keeps its exponent form), infinities as inf and -inf and every NaN as nan; true/false, and a u8 array's bytes as they are. Three builtins produce it, each taking any number of arguments and inserting nothing between them: print(a, b, …) writes the forms to standard output followed by a newline, as one write, so lines printed by different threads (§11) never interleave; format(out, a, b, …) appends them to out, any growable u8 array; str(a, b, …) builds a fresh u8[>..] of them, constructed at its destination like any resizable result (§7.3), so words.push(str("item", i)) writes straight into the element. A character literal is an integer (§2), so str('x') is "120"; a byte is appended with push.

Every other value type renders structurally, so print(v) shows any value: an array or slice of other elements as [1, 2, 3], a struct or ADT variant as its positional literal with the type name (vec3<f32> { 1.0, 2.0, 3.0 }, Circle { 1.5 }, a payload-less variant as Shape.Dot), a reference as its pointee and a null optional as null; a u8 array nested inside an aggregate is quoted and escaped. References are followed as deep as they go, into values of the very type holding them too, so a value whose references lead back into it never finishes rendering: its thread runs out of native stack, a safe abort (§9.3). A user overload fn format(out: u8[>..]&, v: T) (taking T by value or by reference) renders a T through itself instead, wherever a T occurs in an argument, so a type can choose its own text once for all three builtins. Taking it by reference, the overload is given the storage the T lies in, as a reference parameter is (§4.1): the variable, field or element holding it, or what a reference argument names, as writable as that is (§9.5); a T that is no storage lies in a temporary, read-only: a call's, an operator's or a cast's result, a literal, a .len or .cap, or the value of an if, match or block, a copy of what the branch taken gives even where that names a variable (§4.1). The overload is looked up in the namespace T is declared in, then globally (§11.1): rendering follows the type, not the namespace of the code printing it. A type that reaches itself through references renders structurally only where no overload renders a part of it, at any depth: otherwise it needs an overload of its own, which may render its parts through theirs. The three builtins evaluate and render their arguments in order, each just before its text, and call the overload in the middle of rendering the argument holding the T: neither the variables the arguments after that one name nor the rest of that argument may refer into what the overload shrinks (§5.1). An array's elements, and a variable-mode ADT's payload (§3.5), are rendered where they lie, by the count and the variant read as their rendering begins: an overload called for one of their parts may not shrink the storage they lie in, which frees the elements, or, as a whole assignment does (§4.4), rebuilds every variable-size part of the value holding them, a reference to a whole resizable value being no exception. A slice's elements are rendered from the view their rendering began with, and a value behind a reference from where the reference pointed then: an overload that re-points the slice or rebinds the reference meanwhile does not change the rest (§2). A fixed-mode ADT's payload is rendered from a copy taken once its tag is read, since an overload may overwrite the ADT with another variant and nothing may refer into its payload (§3.5): the rest of the payload is still the variant the rendering began with, and an overload taking a part of it by reference is given the copy's, read-only. The payload renders as its variant's literal even where there is an overload for the variant type, which renders values of that type.

3.8 References

T& is a reference to a T: one machine address, no pointer arithmetic, no casts, never dangling.

References are transparent (like C++ references): an expression that denotes a reference behaves as its pointee in every value context — reads, arithmetic, comparisons (r1 == r2 compares the pointees), passing to a by-value parameter, print(r) — all operate on the target. There is no dereference operator; the load is implicit (copy(r) names a copy of the pointee where §4.1 asks for one).

Optionals. References are non-nullable by default. T? is an optional T: represented as a nullable reference to T (null = address 0, no space cost), with all reference semantics and restrictions (roots, lifetimes, writability). This composes with any type — pass i64? for an optional integer. Applied to a type that is already a reference, ? simply makes that reference nullable (T&? ≡ T?), and so does it to a type argument that is one: T? of Node& is Node?, of Node&<u32 in pool> the optional relative reference. T& of a reference type argument is that reference itself, as & of a location holding one gives the stored one (loaded, if relative). The literal null is the empty value of any optional type; an optional struct field with no declared default defaults to null. Optionals are not transparent: access requires narrowing via if/while/guard/assert(r)/== null/!= null tests. Flow typing: inside the guarded region the optional is a T& in every respect — transparent, writable through, bound by .= (if n { let m .= n; … }) — and a while condition narrows its body the same way even when the body rebinds the variable, since the test runs again before each iteration (while n { …; n .= n.next; }). Elsewhere narrowing is killed by rebinding the variable. o .= &x / o .= null rebind an optional; a rebind to a plain reference narrows it, a rebind to anything possibly null un-narrows it.

What references may point to. Anything except the interior of a fixed-mode ADT payload (§3.5). A reference into a grow-shrink array [>..<] lives in a variable only and must no longer be live when the array next shrinks (§5.2).

References into a grow-only resizable [>..] remain valid for as long as they can be named: grown memory never moves, and the array shrinks only where the compiler can see that no such reference or slice is live (§5.1). References into limited arrays [..k]/[..] are also allowed and stay type-valid across pop/push reuse (§5.3).

3.9 Relative references

A relative reference is a storage form of reference stored as a narrow offset instead of an address. Spelled T&<u8>, T&<u16>, T&<u32>, T&<u64>, T&<varint> — widths are spelled unsigned (or varint), like array length field types. It comes in two forms, differing only in what the offset is measured from: self-relative, the default, measured from the offset field itself; and pool-relative, written T&<u32 in pool>, measured from a named pool's base.

Both forms have the optional spelling T&<u8>?, which reserves offset 0 for null. A pool-relative offset is biased by one. A self-relative field can share its address with an enclosing value, so a non-null store must not encode as the optional's null: such a store aborts, while a statically known collision may be rejected during compilation. A non-optional self-relative reference has no null sentinel: offset 0 denotes its field address, as in self in a value's first field. Null is therefore representable in any relative location whatever the location's root: a value known to be null stores into an optional relative slot without a root check of its own. Known to be null are the literal; a variable bound to nothing but null so far (§9.5), a parameter given null, and a call whose every return gives null, none of which has a root; and an optional reference whose only root is static data where static data holds nothing but null: a writable one (§9.5), or one whose pointee type no literal supplies, since a read-only u8 reference may point into a string literal. As one alternative of a merged value (§9.2) a null names no array: if c { null } else { &pool[i] } is a link into pool. So a sentinel-ended chain does not force plain links on the whole structure.

Self-relative. Constrained to point within the same enclosing array/pool as the location storing it.

Pool-relative (in pool). pool names a global var (or reusable var) of a grow-only resizable type ([>..]) whose storage can hold a T by value — an element, or a by-value field of one, transitively (the candidate notion of §9.5). Anything else is a compile error, which for a local or parameter pool points at the self-relative form: the pool is named where the field is declared, and a local's name means nothing there.

Values. A relative reference is storage — a field, an element, or a variable or parameter whose declaration writes the type (or an alias of it) — and never a value: loading one yields the plain reference, and that is what anything else receives. A function's result may not be declared relative (declare the plain T& or T?, which a relative slot receiving it encodes), while a result a type argument makes relative, the -> T of fn get<T>(a: T[>..]&, i: i64) over an array of links, is the plain reference, as indexing gives it. So is a variable or parameter a type argument makes relative: let t: T and v: T over an array of links hold the plain reference, which is how generic code moving elements through a T local (std's swap_at, §3.8) moves links as it moves plain references. Each store back into an element encodes the reference again, so a self-relative one must still point within that element's root and an in pool one into its pool. A field or element of type T (T[4], struct Box<T> { x: T }) is a link. default<T>() of an optional relative reference is likewise the plain null, at any width.

self. A non-optional relative reference has no null, so a value whose links point back at itself — the sentinel of a circular list, the first node of a pool — could not be constructed at all: there is nothing yet for it to point at. self, written as the entire initializer of such a field in a struct or variant literal, denotes the value that literal is constructing: Node { key: -1, prev: self, next: self }. It is legal only there, and only when the field's type is a non-optional relative reference whose pointee is the type of the value the literal constructs (for a variant literal in either enum mode, §3.5, that is the enum, tag included); self in a nested literal names the literal it is written in, never an enclosing one. That type must not be resizable: an offset alone cannot reach a resizable value's header, which is why the same-root rule keeps every other relative reference away from one. In a self-relative field the stored offset is minus the field's own byte offset within the value, so it is the one relative reference whose meaning does not depend on where the value lives. In an in pool field it is the value's own offset in the pool, which only a literal being built inside pool has — a push or append, an alloc_index/alloc_ref, or an element store into it; anywhere else it is a compile error. This allows a structure to use non-optional links throughout. A sentinel eliminates the need for null links, and loading a non-optional relative reference requires only an addition, without a null test.

Which form. Self-relative for position-independent blobs — a compact tree that is saved, mapped or moved whole (single-byte links to nearby nodes, see A.2) — and for structures living in a local or parameter pool, which has no name to write. in pool for relink-heavy structures in a global pool, where a store is a subtraction from a base already in a register rather than from the field's own address; for links into that pool from other arrays, which self-relative cannot express at all; and wherever the offset is wanted as an index, since &pool[i] encodes as i * sizeof(T) + 1 and index_of (§3.3) reads it back.

3.10 Slices

T[:] = reference + element count, referring to a contiguous run of Ts inside some root. Slices are the universal "process a range" parameter type, unifying all array representations. Like references, slices carry a root and participate fully in the lifetime system (§9), which prevents dangling slices. They are the intended read path; mutation idiomatically goes through references — but writes through a slice are legal when its provenance is writable (§9.5).

3.11 Index and bound types

An index type uN in a is an unsigned integer that is below a.len wherever it can be observed. A bound type uN upto a is one that is at most a.len: the end of a range. Indexing a with an index of a needs no check, and an index stored in data keeps that guarantee wherever it travels. This is how an index loaded out of one array (a graph's edge targets, a mesh's index buffer) is checked once, where it enters the program, rather than wherever it is used (docs/design/index_types.md).

struct Node { lo: u32 upto edges, hi: u32 upto edges }
var nodes: Node[>..] = [];
type NodeId = u32 in nodes;
var edges: NodeId[>..] = [];

4. Values, copying, and assignment

4.1 Everything is a value; passing by size class

Goose has value semantics: a variable, field or element owns its value inline, and a destination (an assignment, an argument, a return, a push, a field initializer) receives a value of its own. How a value gets there depends on its size class (§1.1):

So f(xs) hands a u8[][>..] variable to fn f(xs: u8[][:]), fn f(xs: u8[][>..]&) and fn f(xs) alike by reference, out.push(w) needs out.push(copy(w)) when w names storage, and x.f(a) (§7.1) reads as f(x, a) whatever x's size class. &x is still how a reference to a fixed value is spelled at an untyped destination (let r = &n;, or equivalently let r .= n;, §3.8; f(&n) into an untyped parameter); wherever the destination's own type is a reference it is redundant, and a redundant & is a warning.

Where an if, match, block, loop or bare { } has no destination type — an un-annotated let/var, a result whose type is inferred (§7.1), an untyped parameter, an operand, or the value a for, [..] or builtin works on — its value is a copy of what the branch taken gives, unless its branches join as a slice (§6.4). A reference to a fixed-size value, &x included, is copied as its pointee there, as it is wherever a reference meets a value (§3.8): let r = if c { &a } else { &b }; makes r a copy of a or b, so the &s are redundant and warn. A branch naming non-fixed storage, or a reference to it, is an error there, as at a value-typed destination: let x = if c { copy(a) } else { copy(b) }; spells the copy. At a reference-typed destination — an annotated let/var, a reference parameter — each branch binds by reference instead, whatever its size class: let r: i64& = if c { a } else { b }; binds a or b itself, f(if c { a } else { b }) hands a or b itself to fn f(xs: i64[>..]&) as g(if c { i } else { j }) hands the i64 i or j to fn g(x: i64&), and fn h(p: u8[:]&) given if c { s } else { t } re-points the slice variable the branch taken names. A function value's call is a call, not such a construct: its value is a temporary even where its body's value names a variable (§9.2).

There is no ownership transfer beyond the return move, no destructors, no Drop, no reference counting. Deallocation is exclusively scope exit resetting stack pointers. (A move operation for resizable arrays — assign + leave source empty — is anticipated but not in v1.)

Copying a variable or resizable value costs O(size). The explicit copy marks that cost in the source.

Shadowing: an inner scope may re-declare a name (a distinct variable).

4.2 Construction contexts

Variable and resizable values come into existence only at these points, each of which provides fresh storage in a statically known place:

Construction writes metadata first, then elements or fields in order, as required by invariant §1.3(4). Construction of a limited array writes only its metadata and any provided elements; the remaining capacity is reserved but uninitialized — this is safe because no read path to uninitialized slots exists (§5.3), and cheap because the runtime commits skipped address ranges explicitly (Appendix C.4).

While a value is built in place in an array — an element pushed into or allocated in it that is variable-size or holds relative references, a call's array result appended to it, an appended array literal of such elements, or the new contents of a whole assignment (§4.4) — nothing may grow that array, neither the expression being built nor a function it calls (§1.3(4)). The compiler rejects a growth it cannot show to be of a different array. A whole assignment builds its new contents over the old ones, so its right-hand side may not use the array at all (§4.4).

Literal forms usable in any construction context:

4.3 The in-place construction guarantee

A constructed nonfixed value is built directly in its receiving storage, including through calls and the selected branch of an if/match/case-dispatch. Passing through such an expression is not a reason to materialize a separate result and then copy it into place.

The same guarantee applies to fresh fixed-size aggregates containing relative references when the receiver takes their representation. Their result channels name the receiving storage, including through multiple and nonlocal returns; correctness does not depend on inlining or C compiler optimization. Other fixed-size results may travel as ordinary C values.

The packed byte layout may require relocation within the receiving storage: a length prefix whose width becomes known after constructing the elements can grow or shrink, and a value-form result delivered as an element run can lose its header and unused capacity. Moving the element bytes for these layout changes is permitted. The final representation, reference targets and evaluation order must remain the same; an initializer must not be re-executed to discover its size.

A limited-capacity receiver must establish that the complete live result fits before writing outside its available slots. When the result's length is not available before evaluating it, temporary construction for this capacity check is permitted. This exception concerns a bounded receiver; it does not permit staging an ordinary fresh result for a resizable one.

Copying an already-constructed value (an explicit copy, a by-value argument from fixed storage, or the repeated value of a fill) is an ordinary copy. The guarantee concerns fresh construction; §7.3 extends it to returned locals and specifies the copies associated with different returned locals and exits that abandon a partial destination.

4.4 Assignability

An lvalue may be assigned (=) after construction iff its size cannot change or it can absorb the change (for reference-typed lvalues these rules govern the pointee write that = performs; the reference value itself is updated only by .=, §3.8):

The right-hand side of a whole assignment of a resizable runs once the old contents are gone, while the new ones are built in their place, so it may not use the array: not name it (its length included), nor a reference to it or to the value holding it, and neither may a function it calls, through a global or captured variable that function names. x = [x[1], x[0]], x = f(x) and g = rebuilt() for a rebuilt that reads g are errors. As with growth (§4.2), so is a use the compiler cannot show to be of a different array: of a reference parameter a caller may bind to the global being assigned, say, or through a call into a recursive cycle still being checked, which counts as using every global and every variable in scope. Where the assignment names the array as a field (t.chars = …), the right-hand side may still name the holding value's other fields that cannot lead back to it: flat ones (§1.1), and ones whose references, however many it takes, are to storage of no type that can be the assigned location, a resizable on the way from it to the array, or hold the value it is a field of. t.chars = render(t.font) is fine, and so is s.path = find(s.start) through s: Walk& for struct Walk { start: Cell&, path: Pos[>..] }, as is a reference of such a type bound to a variable first; a field last: Walk& or other: Pos[>..]& may refer to s or to s.path, and naming it is an error. An assignment through a reference field (d.r = … for r: u8[>..<]&) writes the array the field refers to, which may lie anywhere an array of its type can, the holding value's other fields included: there a field that is, or holds by value, an array of that type may be the one assigned (d.r .= d.own), and so may what another field refers to, so naming either is an error, but for a local whose record shows where the reference field may point. A slice or reference into the old elements is the shrink rule's to judge (§5.1): the right-hand side runs after the shrink the assignment starts with, so one it uses is live there. New contents computed from the old are built in a variable of their own and assigned as copy() of it, the one copy spelled out where it is paid (§4.3).

let forbids assigning that name or field as a whole, and nothing more: the contents of a let array or struct are as writable as their type says (let xs = [1, 2, 3]; xs[0] = 9; is fine), and so is what a reference to it reaches. Contents are made read-only by the type, const T (§9.5), and const x = e; declares a let of type const T: neither reassigned nor written into. A var of type const T is the converse: reassigned (=, compound assignment, ++), never written into. Definite assignment is enforced: no reads of uninitialized locals; every declaration either has an initializer or is provably assigned on all paths before use. A let declared without an initializer gets its value once: assigning it, or binding it with .= (§3.8), is an error wherever some path may have given it one already -- in a branch that joins there, an earlier iteration of a loop it is declared outside of, or a function called on the way, the assigned value's own calls included (§7.5). A recursive cycle (§7.8) cannot assign such a let declared outside it, which all its activations share. Fixed arrays require full initialization (every slot is indexable); the [v; n] fill literal makes large ones cheap.

4.5 Equality and comparison

==/!= are structural and require both operands to have the same type: scalars and bools by value; structs and fixed arrays memberwise (pad and ADT padding bytes excluded — semantic comparison is per-member; memcmp is a valid optimization only for gap-free layouts without floats, since a float compares by IEEE value wherever it sits: -0.0 == 0.0, and NaN is unequal to itself); array-family values and slices by length then elements (sequential walk for variable elements) — two operands of different array kinds compare too, as slices, whenever their slices would have one type (§3.10): a u8[>..] field against a string literal, a u8[..32] against a u8[:], with no [..] needed; ADTs by tag then payload.

References and slices follow a top-level rule: as the direct operands of == they have value-like semantics — a reference compares its pointee (transparency, §3.8), a slice compares length then elements. As members of a compared composite they compare by identity (the reference address, the slice's address+length): recursing through them would turn == into an unbounded pointer traversal. Optionals compare as nullable references (o == null is the null test); .==/.!= compare two references by address rather than by pointee (§3.8). Ordering < <= > >= exists on the numeric types only, with operands unified per §6.1.


5. The resizable/shrink rules

5.1 Grow-only [>..]

"Grow-only" names the guarantee, not the operation set: the array never shrinks while a reference or slice into it can be live. Memory below the top never moves and is never reused while the owner lives, so every interior reference and slice stays valid for as long as it can be named, and a bound on the length holds across every push.

Use grow-only arrays for arenas, pools, string builders, tree storage, and scratch buffers that are refilled or popped between phases.

When a grow-only array may shrink. The receiver is the array's variable, a reference variable or parameter bound to the whole array, a global, or the tail field of a struct reached through any of those (s.items.pop() for s: Stack&), which shrinks the storage the struct lies in as a reference to the field would — not a temporary, and not a reusable pool (§5.4: its freelist keeps every slot live). The shrink may be anywhere in an expression — an operand, an argument, a condition, the value of a block, if, match or loop — so print(a.pop()) and if !load(s) { … } are fine. At the shrink, nothing live may refer into the array. That rules out what its statement evaluated before the shrink and uses after it (§2): an earlier argument or operand that is, or holds, a reference or slice, an earlier field or element of a literal being built, the array an indexing or slicing reads, the destination of an assignment. So use(a[0], a.pop()), a[a.pop()], S { r: a[0], n: a.pop() } and a[0] = a.pop() are errors, while a[0] + a.pop() reads its left operand first and is not. Nor may a value be under construction in the array's storage (§1.3): an element push builds in place (§4.3), a literal run append builds, or a call's result appended. And it rules out any reference or slice variable rooted at the array (a var reference the same-depth rebinding rule (§9.2) could retarget into it counts, as does one not bound yet further down a loop body), and any value that holds a reference into it — a struct with a reference field, an array of slices, a let copy of an element of such, and the struct whose tail the array is, through its other fields. Values hold references only where a store put them, and every store the checker has seen is on record (§9.2), so this half of the test is exact to the store: the error names the holder and the line where a reference into the array was stored into it. A value whose type cannot hold a reference to anything the array's elements contain by value, however many references it leads through, is never a holder, nor is one linked by relative references alone (a node pool): those point within their own root. A reference to a whole array or table is no reference into its elements, which a shrink leaves it pointing at: struct Walk { start: Cell&, text: u8[>..]&, path: Pos[>..] } holds nothing a shrink of path frees, and w.path.clear() through w: Walk& may be followed by any use of w. A view of the elements taken through such a reference is stored, if at all, where the holder's type has room for one. The same holds after a whole assignment: s.r = [1, 2] for r: u8[>..]& may be followed by any use of s. (The right-hand side of a whole assignment is another matter: it runs while the array is rebuilt, and may not reach the array at all, §4.4; a variable it takes a reference out of counts as holding what that reference leads to, but one whose flat fields alone it reads does not.) A reference to a slice variable or to a holder, the storage whose tail shrinks among them, counts as what it refers to: while the reference is live, so is whatever that slice or holder may refer into.

A whole assignment of a value that holds no references — refs = [], s.refs = [], a struct literal with none in it — empties the part of the holder it writes: what was stored there before is gone. The part is a variable's own storage or the pointee of a let reference or of a reference parameter, which no rebinding moves, and the fields named after it. A holder each of whose slots that can hold a view into the array lies, on every path to the shrink, in a part so emptied — the whole of it, or its fields one by one — holds only what was stored into its storage since; but no longer once a store into that storage the record cannot show is made, one moving the holder's own references within it, or once a call that may write into the part runs, a callee's stores never being on record in their order: one handed a reference that may point at the holder's storage, with room for the part or lying in it, or anything whose references may lead to storage of the part's type, whatever it points at; a nested function; one handed a block or any other function value; and a block's own call. A call handed a struct of the function's own built from the holder's reference fields, whose references lead only to what those do, may not. So for i in 3 { refs = []; pool = []; refs.push(pool.push(i)); } passes, the store at the end of one iteration being gone by the shrink at the start of the next, while emptying refs on some paths only, after a store that reaches the shrink, or only in a copy of it, does not; and so does s.refs = []; s.pool = []; s.refs.push(s.pool.push(i)); through s: S&, struct S { pool: i64[>..]&, refs: (i64&)[>..] }, the path to the pool being no reference into it, and a view taken through it stored, if at all, in refs.

Nor may the shrink free what a for loop around it walks (§6.5): each reference or slice on the path to its sequence, which the loop loads again on every iteration, and the sequence it iterates in place — a slice, an element, a field — unless that is a resizable array, whose length the loop reads again.

A variable is live at the shrink when it can be read again afterwards: it is named later in the shrink's statement — in an operand evaluated after it, or, where the shrink is in the condition of an if or guard, the scrutinee of a match or the sequence of a for, in the parts that one leads to — later in its own block or in an enclosing block it was declared in, or anywhere in a loop that contains the shrink and that the variable was declared outside of, since the next iteration runs the rest of the body again. But where a statement of the shrink's own block after the shrink's statement is a return or a break, and neither the shrink's statement nor one between them has a continue that could lead back into a loop, nothing that exit leaves runs again from the shrink: the rest of that block, and for a return the rest of the function's body, for a break the rest of the loop or block it ends, that loop's next iteration included. So a decoder may result.clear(); return result; inside its loop while a dictionary of views into result that the loop fills is still in scope; the exit's own statement is read after the shrink like any other. A function value's body runs in the middle of a statement of the function calling it (§7.6), so that function's variables count as well, and that function may call it again: a variable declared outside the body counts wherever the body names it, as in a loop. Only code that can name the variable counts — its own function's, and that of the nested functions and function values written in it — so the same name in another function is another variable. "Named" is syntactic — any mention, a call of a nested function that mentions it included — so the test never depends on what the optimizer proved. A reference whose last use is before the shrink is dead, and its block need not end: let w = line[..5]; print(w); line.clear(); is fine, and a scratch buffer refilled per iteration, or a stack popped between phases, hands out slices of itself freely — "reusable scratch" and "structure I can point into" are the same type. The operations themselves are the grow-shrink ones: a stack-top move and a length store. Assigning the array whole (a = …), or a value holding it, replaces its elements and is a shrink under the same rule, wherever its location could be the receiver of a clear: for s: Stack&, s.items = [], s.items = load(f) and s = fresh() are shrinks of the storage s names, as s.items.clear() is. The right-hand side runs after the shrink, so a variable that side names is used after it. print, str and format evaluate and render their arguments in order (§3.7), so a variable a later argument names is used after a shrink an earlier one makes, by a call in it or through a format overload rendering it.

Through a reference or of a global, a shrink cannot see the callers' variables, so it is checked at every level: each function specialization records which globals, and which parameters' pointees, it shrinks — directly or through its own callees — and what its calls stored into those pointees, and a call is checked as if it performed those shrinks and stores in the caller, each grow-only shrink where it stands in the body: against the stores the body made before it (in a loop, any the body makes), not those it makes after, and with the parts of reference parameters' pointees the body had emptied before it on every path empty in the caller's holders too, where the argument names such a part — the caller's variable, a field path in it, or the pointee of its own let reference or reference parameter, which its own callers judge the same way — of storage no nested function or function value names. A function doing s.refs = []; s.pool = []; s.refs.push(s.pool.push(i)); may be called again and again on the same holder, in a loop or through a function taking the holder by reference. The pointees of a parameter that holds references by value (a struct with a reference field, an array of slices) are what those references point to: a shrink or a store through one of them is of the caller's storage, not of the callee's copy of the holder. The other globals are live wherever a global receiver shrinks, and what is stored into them may come from functions checked after the shrink, so they are judged once the whole program has been checked: by then every store into a global, and every binding of a global reference or slice, is on record, wherever in the program it is made, and the shrink is an error where another global may hold a reference into the array — the error names that global and the line of the store. A call into a recursive cycle still being checked counts as shrinking every array a function of the cycle textually shrinks. A user format overload (§3.7) is a call at the print, str or format that renders a value through it, with the format call's receiver as the builder where the text lands in it. The rest of the argument holding that value is rendered around the call, so it is still in use there — where the argument lies, the elements of the arrays and slices it renders, what its references lead to — unless the overload takes the whole argument, the value itself or a reference to it.

A reference whose root is inexact (§9.2) — a value chosen between arrays, if c { a } else { b }, or one read back out of a container — may point at any array of its type that the root bounds, and a shrink through it counts as a shrink of each of them: those in variables at the root's scope or outside it, and the caller's arrays a parameter's references lead to, for which the callers check it. The same holds where a call shrinks what an inexactly rooted argument points at. Read out of the storage behind a reference parameter — s.text = … for s: S& — the reference may point at anything the callers or the function stored there: the function judges its own variables against every array of the type outliving the call, and its callers judge the shrink as one of an array that storage leads to, each by its argument. A struct of the caller's leads to the arrays its record says it refers to, of the type shrunk, or holding or leading to one: s.text = copy(t) in a callee frees no global u8[>..] the caller views, where the caller's struct refers to a local text and a node pool. One built from the caller's own struct parameter's reference fields leads where those do, which the caller's callers judge in turn.

Nor can a shrink inside a function tell what its parameters point into: a parameter's pointee is whatever each call passes, which may be a global, a variable of an enclosing function, or what another parameter points into — an inexactly rooted argument may be any array its root bounds. Where a reference, slice or holder parameter, or a variable bound from one, is still used after a shrink of a global, of such a variable or of another parameter's pointee — or a view of a global or of such a variable is still used after a shrink of a parameter's pointee — the specialization records the pair, and each call judges it by the arguments it passes: a call that may make the two one array is an error, as a variable of the caller still used would be, and a call passing its own parameters on records the pair for its own callers. A call into a recursive cycle still being checked is judged again against the pairs the cycle records once the whole cycle is.

5.2 Grow-shrink [>..<]

5.3 Limited arrays [..k] / [..]

Grow and shrink freely within fixed capacity; not stack-top-bound (their capacity is reserved at construction), so they can live anywhere fixed/ variable values live ([..k] is fixed-class, [..] variable-class). A T[..] constructs either from element contents (capacity = initial length) or with the [..cap] literal (capacity cap, length 0).

Interior references and slices are allowed, including surviving pops: because capacity and element type are fixed, every slot within capacity, once first written, remains a valid value of the element type forever — pop-then-push reuses memory at the same type. Never-written slots are unreachable: indexing checks len, references/slices can only be created into [0, len), and every growth operation supplies values (§3.3). A stale reference reads a valid, possibly-different value ("type-safe reuse", §9.4), never corrupt memory. Indexing through the array checks against current length; access through retained references does not (they were validly created).

5.4 reusable arrays (safe allocation escape hatch)

A grow-only resizable local of fixed-size elements may be declared reusable (the keyword prefixes the declaration: reusable var pool: Slot[>..] = [];, also valid on globals). The compiler pairs it with a hidden freelist (itself a small resizable of indices, on its own stack, counted in N):

Semantics, not just implementation: all elements remain valid at all times. A reference to a freed-then-reused slot reads a different (same-typed) value — type-safe reuse, never memory corruption. This is the language's answer to tree-mutation workloads that would otherwise need an allocator.

Slice pools. Declared reusable[] (reusable[] var pool: T[>..] = [];, also valid on globals), the array hands out runs of elements instead. Its hidden freelist holds (index, count) spans sorted by index, and a freed run merges with the spans it touches:

Every element an allocation or a growth adds is a default value (§4.2), so those two need an element type that has one. The length is evaluated once, and a negative one, or one no data stack could hold, aborts before the pool changes. The slice handed to free_slice or realloc_slice must be one of the pool's runs: one the pool handed out, a re-slice of it, or a slice of the pool itself. Where the checker sees that, because the slice is rooted at the pool exactly (§9.2), nothing is checked when the call runs. Where the pool is a global or a variable of the calling function, or a pool reference that can only be one of several such (Pool references below), a slice rooted exactly at a global or a variable of the calling function that the pool cannot be is a compile error. Any other slice, such as one read out of storage where runs of other arrays could be kept as well, is checked when the call runs: unless it is empty, its elements must lie inside the pool's length starting on an element boundary, or the call aborts before the pool changes. An empty slice frees nothing and grows like a new run, wherever it points. A grown slice may be copied, which a value holding self-relative references cannot survive (§3.9), so realloc_slice is a compile error for such an element type; in pool references copy fine, and the other two operations never move an element. The single-slot operations do not exist on a slice pool, nor these on a reusable one; push and append add at the end of either without consulting the freelist.

The guarantee is the same: a slice still naming freed elements reads whatever their next owner wrote, or the default values an allocation put there. Freeing a run twice is a logic error of the same kind — the freelist can then hand the same elements out twice, but never anything outside the array.

Pool references. A T[>..]& variable or parameter bound to a pool is a pool reference: the pool's operations work through it as on the pool, since it carries the pool's freelist beside the address. So is one bound to a value that chooses among pools of one kind, or pool references to them — an if, a match or a block giving one wherever it gives a value — which carries the freelist of the pool it chose; a choice between the two kinds of pool, or between a pool and another array, binds a plain reference. A variable is one from its first binding on, and .= rebinds it only to a pool of the same kind, or to a pool reference to one; pointing it at any other array needs a new variable. A variable first bound to anything else stays a plain reference, which .= may point at a pool, as at any array, but which offers none of the pool's operations. Only a plain reference variable or parameter is a pool reference: an optional one, a field, an element and a function's result are not, even where they point at a pool.


6. Expressions and arithmetic

6.1 Operators

C/Rust set: + - * / % (% is Euclidean on integers, §6.2; on floats it is C fmod), comparisons, ! && || (short-circuit, bool only), bitwise ~ & | ^ << >> (on integers), assignment statements =, += etc. (the compound form of every binary operator, shifts included) on assignable lvalues, .= (reference rebinding, §3.8), reference identity .==/.!= (§3.8), and ++/-- as statements on integer lvalues (no expression form). Precedence is Rust's (Appendix D): the bitwise operators bind tighter than comparisons, so x & mask != 0 is (x & mask) != 0, and shifts bind tighter than &. Range expressions a..b appear only in slicing brackets, for headers, and match arms.

Operand unification. A binary numeric operator's operands must reach one common type, which is also the result type, found as follows: equal types stand; a constant adapts to the other operand's type (compile error if its value does not fit); otherwise, if exactly one operand implicitly widens into the other's type (§6.3), the wider type wins; and an integer operand meeting a float one converts to the float's type, so i32 * f32 is an f32 multiply and i64 + f64 an f64 add. A float that takes its type from float literals — the literal itself, or n * 0.5 built from literals and integers — adapts to the other operand's float type as a literal does (x * (n * 0.5) with x: f32 computes in 32 bits throughout), and is an f64 where neither operand gives it a type. Anything else — same-width signed/unsigned, u64 with anything signed — is a compile error asking for a cast. Nothing here invents a type absent from the expression: u8 + i64 is an i64 add, but u32 + i32 does not become 64-bit arithmetic implicitly. Exceptions: shifts take the left operand's type as the result (the count is any integer type, masked per §6.2), and ==/!=/orderings unify the same way but produce bool. So a constant shifted by a count that is no constant keeps the constant's freedom: 1 << k is no constant, but takes the integer type its destination or other operand has as a constant does, where the constant fits it (let m: u64 = 1 << k;, m | (1 << k), a u8 field), and is an i64 where nothing gives it one. So does an integer operation over such shifts and constants ((1 << k) - 1, ~(1 << k)), computed at that type throughout; a typed operand, unary - or a cast gives it a type of its own. So does ~c of a constant c >= 0, whose value depends on the width it is computed at: it adapts where c fits, so let a: u8 = ~4; is 251 and flags & ~F_ROAD clears a flag in a u8, while ~4 is -5 at a signed type or where nothing gives it one; -~c is the constant c + 1, and a named constant ~c (§3.1) adapts as ~c does. Unary - requires a signed (or float) operand; ~ any integer, keeping its type.

Comparisons and u64. A comparison produces bool, so it does not need a common numeric result type. However, comparing signed and unsigned operands must preserve their values. u64 is the only unsigned type with no signed supertype (u8–u32 widen into i64, §6.3). A comparison between u64 and a signed type is therefore allowed when the signed operand is known non-negative: it converts to u64 without changing value, and the comparison is a single unsigned one — no wider than either operand, and never a hidden branch.

"Known non-negative" is deliberately a syntactic property, not an inferred one: a non-negative integer literal, a .len or .cap (non-negative by §10.4), or a let bound to one of those. A let is never reassigned, but a writable reference to it can still change its value (§4.4), so a comparison may not rely on a let (or on one it was bound to) that is also bound to a writable reference anywhere: by &, .=, or a reference parameter, field or binding that is not const, in a nested function too. Either order is an error. Whether a comparison compiles thus depends only on what is written, never on how much the optimizer managed to prove. Other cases require an explicit cast because conversion may change the value. A cast does not, however, preserve every comparison: x as! i64 on a u64 above i64.max silently compares as negative, and the checked x as i64 aborts in debug on a value that was perfectly legitimate to compare. When the rule permits it, use the direct comparison to preserve the values without conversion overhead.

Elementwise math: the arithmetic operators apply memberwise to any two values of the same struct/fixed-array type whose scalar leaves are all integers or all floats, none of an index or bound type (§3.11); the result has that same type. The compound forms += -= *= /= %= apply to these same types, resolving the destination once and reading its old value before the right-hand side runs (§2). Unary - negates such a value memberwise where no leaf is an unsigned integer (-v, with the same result type; a float member negates exactly, so 0.0 becomes -0.0, and an integer one overflows as a scalar - does). This covers vector math without an operator-overloading feature. The standard library supplies float3 and friends; named vector ops (dot, cross, normalize, …) are ordinary stdlib overloads per math type, not language builtins.

* and / also accept one scalar operand, in either order, when every numeric leaf of the struct/fixed array has exactly the same type. The scalar converts implicitly to that leaf type (§6.3): float3 * 2.0, 2 * float3, and float3 / f32 produce a float3; 2.0 / float3 divides the scalar by each component. The aggregate's type never widens: an f64 variable scaling a float3 needs as f32, and an integer scalar must fit or widen into the integer leaves. Nested structs and fixed arrays work the same way; aggregates with mixed leaf widths or kinds, or no numeric leaves, do not support scalar operands. +, -, and % still require two matching aggregates. The compound forms aggregate *= scalar and aggregate /= scalar follow the same rule. Both operands are evaluated once, left to right, before any result member is written, including when the scalar is a member of the destination.

6.2 Integer semantics

6.3 Conversions

One principle: within a kind, a conversion the machine can prove value-preserving is implicit; anything that could lose a bit or flip a sign takes a cast. Between the kinds, an integer becomes a float implicitly, and a float becomes an integer only through a cast: dropping the fraction is the real loss of information, while an integer too large for the float's mantissa rounds as every float result does.

Redundant casts. An as or as! that changes nothing is a warning: one whose operand has its type already, or converts to it implicitly where the cast stands, so that deleting the cast leaves every type, operation, value and call of the program as it is. n as i32 of an i32 n warns, as do 17 as u32 passed to a u32 parameter, b as i64 of a u8 b stored in an i64, and w as f32 in (w as f32) * 0.5 passed to an f32 parameter, where w * 0.5 is an f32 multiply too. A cast that decides anything stays silent: a type nothing else gives (let k = 5 as u32;, print(b as u8)), an operation's width or signedness ((a as i64) * b with i32 operands), a float's rounding ((n as f32) * 0.1 stored in an f64), a call's overload or a generic's instantiation. Of two casts that cannot both go, as in (a as f32) * (b as f32) of two integers, the right one warns, and of a cast directly inside another where both could, the outer one. A generic function's body, a function value's, and what is declared in either are not judged: another instantiation may need the cast. The warning comes once per cast as written, where every check of it agrees.

6.4 Control expressions

if c { e } else { e }        // expression; both arms required when used as value
while c { s }
for v in e { s }             // see §6.5
loop { s }                   // infinite; exit via break
block { s }                  // early-out construct: break E exits with value E
{ s }                        // a plain scope with a value; not a break target
guard c else { s } …         // if c { … } else { s }: … is the rest of the block
guard c; …                   // if c { … }
match e { ... }              // §8
return e? (from f)?          // §7.3, §7.9
break e?                     // exits innermost loop/block, optionally with value
continue

block { } exists to promote early-out style anywhere, not just at function top level. break binds to the innermost loop/while/for/block; labels are not in v1. For definite assignment (§4.4) and narrowing (§3.8), what holds after one of these is what holds at every way out of it: each break, and the end of a block's body, a while's condition found false or a for's last iteration; a loop has only its breaks. All break E of one construct must agree on E's type: a later break's value constructs into the type an earlier one gave the construct.

A construct's branches — its arms, its tail and its breaks — join into one type. A literal branch adapts to the others' type, as at any typed destination (§3.1): an integer constant to an integer type it fits (if c { x } else { 0 } has x's type, a u8 for an x: u8), and a float literal, or a float of literals and integers (§6.3), to an f32 (if c { x } else { 0.62 } is an f32 for an x: f32). An integer branch beside a float one converts to the float's type (§6.3): if c { n } else { 0.5 } is an f64 for an integer n. A [] branch adapts to no other branch: it takes an element type only from a destination (§4.2). A construct whose branches are all literals is itself literal-like, wherever it meets a type, as at a typed destination: integer constants adapt to any integer type every one of them fits (g(if c { 1 } else { 2 }) passes an i32 to fn g(x: i32), and (if c { 200 } else { 100 }) + b is a u8 add for a b: u8), and are an i64 where nothing gives them a type; float literals, and floats of literals and the integers among them, adapt to either float width (k(if c { 0.5 } else { 0.25 }) passes an f32 to fn k(x: f32)), and are an f64 where nothing gives them a type; branches that are all [] take the array type they meet (h(if c { [] } else { [] }) passes an i64[] to fn h(x: i64[])), and are an error where nothing gives them one. A break with such a number settles no type for the later ones: loop { if c { break 1; } break 2.5; } is an f64. A bare { … } in expression position — a match arm of several statements, say — is only a scope: break inside it still leaves the enclosing loop.

Arrays and slices of one element type join as a slice. Where one branch's value is a slice — a string literal, say — and another's is an array of the same element type, or where the construct's destination type is such a slice (an annotated variable, a declared result, a parameter declared as a slice), an array a branch gives is a whole-array slice of that array itself (§3.10) rather than a copy of it (§4.1): let name = if item.count == 0 { "none" } else { item.name }; makes name a const u8[:], which views the u8[..48] item.name where that branch is taken. The slice is read-only where any branch is: a const slice (a string literal among them), or an array a view of which would be (§9.5). Like any slice it must not outlive what it views (§9.2): an array declared in the branch, or a temporary made there — a call's array result, an array literal — is an error. Arrays of two different types join so only where a slice joins them as well, or the destination is one; otherwise they do not agree. A break agrees with the first: after a slice, a break's array is a slice of itself, while an array first is what a later slice constructs, where it can.

guard guards the rest of the block it is in, whatever kind of block that is — a function's body, a loop's, a branch, a block, a plain scope, a function value's body (§7.6): { s1; guard c; s2 } is { s1; if c { s2 } }, and { s1; guard c else { s3 } s2 } is { s1; if c { s2 } else { s3 } }. The code after a guard runs only where c holds, and knows it — including flow-narrowing of T? (guard r else { return; } leaves r: T&). A guard jumps nowhere: guard c; skips the rest of its block, which directly in a function's body returns from it, in a loop's body goes on to the next iteration, and in a function value's body ends that call of the value, not the function running it. Leaving more than the block is the else block's job: guard c else { break; } leaves the loop, guard c else { return; } the function. An else block that does not leave runs instead of the rest of the block, which then ends. Where the rest of the block ends in a value, the if is the block's value (block { guard ok else { break 0; } n * 2 }); a guard is a statement of its block, never an operand. What is declared after a guard is declared in the if's block, so a nested function declared before the guard cannot call one declared after it (§7.5).

6.5 for

Built-in iteration only (no iterator protocol):

All array-family types and slices are iterable. Custom access patterns are provided by HOFs taking static function values (§7.6), which compile to plain loops.

Range/count bounds are evaluated once, before the first iteration; an empty or reversed range performs no iterations. continue advances to the next index or sequential element just as reaching the end of the body does.

A loop iterates its sequence in place, not a copy of it. Traversal of a resizable array tests the current length before each iteration, so permitted growth can add elements to the traversal and a shrink ends it early; its elements never move. A slice keeps its own length: growing its source does not extend that view. A slice, and anything else the loop walks in place — a fixed or limited array, an element or a field of a larger value — is live for the whole body, as a slice variable would be: a shrink of a resizable array it lies in or views is an error, however the body reaches it (a call, a function value; §5.1, §5.2). An index on the path to the sequence is evaluated once, but the references and slices on it (refs[i].items) are loaded again on every iteration, so the arrays they lie in may not shrink either. Iterating a copy (let row = a[i]; for x in row over fixed-size rows), or a reference read out first (let r .= refs[i]; for x in r), leaves the array free.

Specialization-time expansion. const for specializes a fragment once per entry of a compiler-known sequence. Its v1 sources are fields<T> with two bindings (field name string and field accessor), variants<E> with two bindings (variant name string and variant), types<T1, T2, ...> with one type binding, and [c1, c2, ...] with one constant binding. fields, variants and types are contextual identifiers in this header, not keywords or ordinary function calls. Bindings use required angle brackets:

fn visit_fields<T, F>(value: T&) {
    const for <N, A> in fields<T> { F(N, &value.A); }
}
fn transform_fields<T, F>(value: T&) -> T {
    T { const for <N, A> in fields<T> { A: F(N, value.A), } }
}
fn each_case<T, F>(value: T) {
    match value { const for <N, V> in variants<T> { V c => F(N, c), } }
}

fields<T> visits direct, non-padding fields of a concrete struct or variant type (a variant's payload) in declaration order. Its accessor identifies the field of that particular struct instantiation; another struct with the same field name does not match; a variant's accessor reaches a value of that variant type only, not the ADT before it is matched. It can appear in a member access or initializer label. variants<E> visits every variant of a concrete ADT in declaration order, with or without payload; its variant binding is written where a variant's name is: a type, a constructor, a match pattern with or without a binder, and, for a variant without payload only, a value. It is not stored, passed or turned into a number, and its name string is no persistence identifier. Bindings follow lexical scope and shadowing, including in nested functions. Types and constants may be heterogeneous; all three sources may be empty. Constants are scalar or string literals, named global constants, and the integer constant expressions supported for array sizes, including enclosing expansion bindings. There is no arbitrary compile-time execution.

A statement fragment is a separate scope for each entry. A constructor fragment contributes comma-separated initializer entries, flattened before ordinary default, completeness, ordering and destination checks. A match fragment contributes arms, flattened before the checks of §8.1: every variant once, _ last, and the reference-binder and lifetime rules for each generated arm. A generated arm may bind a variant without payload, to an empty value of its variant type. An expansion produces no runtime iterator or reflection metadata. Its body contains ordinary runtime code, with the usual reference, lifetime and construction rules. Returns retain their lexical targets; a break or continue cannot cross an expansion boundary. The full v1 rules and implementation limit are in the const for design.


7. Functions

7.1 Declarations and generics

fn name(a: i64, var b: f64, xs: i32[:]) -> i64 { ... }
fn generic<T>(a: T, b: T) -> T { ... }
fn also_generic(a, b) { ... }        // untyped params are generic
fn scaled(a: i32, b: i32 = 0, c: f32 = 1.0) -> f32 { ... }   // scaled(2) is scaled(2, 0, 1.0)

Declarations at top level are order-independent (whole-program compile); only global initializers have ordered semantics (§11.1).

7.2 Parameter passing

Uniform with = (§4.1): the parameter's type is the destination, and the argument connects to it by size class. A fixed-size argument is copied into a by-value parameter and bound by a reference-typed one (fn inc(n: i64&) takes inc(n); inc(&n) says the same and warns). A non-fixed lvalue argument binds by reference to a reference, slice or untyped parameter and is an error at a by-value one — f(copy(xs)) spells the copy, f(make()) constructs the result in the parameter's slot (§4.3). Array→slice at call sites (§3.10) is the one shape change, cheap and copy-free by definition. A by-value non-fixed parameter reserves the statically assigned stack region for the value at the call site, constructs it there, and the callee owns it like any local. A parameter the call leaves to its default (§7.1) takes it the same way, as the argument written in its place: xs: T[>..] = [] builds an empty array in the slot, and r: i64& = counter binds the global.

7.3 Return values and result placement

Normal completion. The checker infers whether each function specialization can resume its immediate caller. A body whose paths all terminate, loop without an exit, or return ... from an outer function does not resume that caller; this propagates through ordinary calls and static function values. A variant-dispatched call has this property only when every case does. A return targeting the function itself, including one taken by a nested callee, resumes its caller normally.

A function declared to return values needs no result on a path ending in such a call. For example, fn fail() { abort("failed"); } permits fn value() -> i64 { fail(); }. This is a control-flow fact, not a value type: fail() still produces no value to pass as an i64 argument or to forward in an explicit return fail();.

After a proven exit, the remaining statements and trailing expression in that block are neither typechecked nor emitted. They still must parse. This does not make checking depend on constant folding: both branches of if false { ... } else { ... } are checked. Unknown recursive calls are assumed to return, so inference may miss a recursive function that never returns; it does not skip a continuation merely because the recursive callee has not yet been checked.

Fixed-size returns use registers/native stack or a caller-owned data-stack slot for large values (C.3). A variable or resizable return value is constructed directly in its destination (§4.3); the callee is compiled knowing the destination stack (statically or as a hidden argument), writes element data there, and returns the value's metadata (lengths) outside the element run. Where a result is first built with its packed value header, the header is removed in place under §4.3's relocation exception (Appendix C.3):

Named results (guaranteed NRVO). When every return of a nonfixed result returns the same local variable (and those returns are its last uses), that local is allocated at the return destination from its declaration — return x then costs exactly the same as returning the constructing expression directly. The same holds where the other returns hand back values whose construction does not read or write that local, such as return [] or return str("none") beside return x: such a value is an exit's value under Exits below, built behind the local and moved down over it. When different locals are returned on different paths, only one can live at the destination and the others are copied on return; so is a local another return's value may read or write. The compiler warns where a return keeps a returned local from its destination that way. This is in addition to §4.3's layout relocation.

Exits. A return or break taken while its destination already holds part of a value -- inside an element of a literal being built there, say, or, for a return from (§7.9), below a named result built in the destination the target handed down -- abandons that part. Its own value, constructed behind it, is moved down over it: a copy only such an exit costs.

Destination requests. The construct-here information handed to a callee (or any constructing expression) comes in one of two forms, chosen by the receiver: one value of type R (receivers that store a value: let, params, =, push of one element — R's nested metadata, if any, is part of the constructed bytes), or a run of elements of type T (receivers that splice: append) — the callee emits raw elements and returns the count. The request kind is part of the specialization signature (§10.2); the same function may be compiled in both forms for different callers. A callee that merely constructs its result passes the destination through. A callee that must first operate on its result as a whole array may make one copy into the requested element run, just before returning. Where its packed value can occupy the receiving storage, removing the header is instead the layout relocation of §4.3 and may be performed by the callee or receiver. A local resizable can use metadata outside the element region (Appendix C.2), with its elements already in place.

Consequence: returning a built-up value and out-parameter style are the same cost, and building a variable element "inside" a container is idiomatically a function call in argument/push position.

7.4 Growing through a reference

Passing &v where v is resizable lets the callee push/append: per call site the compiler specializes on the identity of the stack v tops (§10.2), or passes it as a hidden argument / fat reference (Appendix E) when one body serves multiple stacks. No surface syntax distinguishes these.

An in-scope "current pool" mechanism (allocation without naming the array, compile-time bound to whichever suitable array is in scope) is deferred (TODO).

7.5 Nested functions and free variables

Functions may be declared inside functions. A nested function may read and write the enclosing function's locals ("free variables"), subject to those variables' normal rules (writability §9.5, roots §9.2, shrink rules §5). For definite assignment (§4.4), a call assigns a free variable only where every way out of the body does: each return, those of a function value's body and a callee's return … from among them (§7.6, §7.9), and the end; where any of them may have assigned it, a let declared without an initializer takes no value after the call. A function value's body (§7.6) encloses what is written in it the same way: a function declared there, or a function value written there, may also name the value's parameters and the body's locals.

Names in a nested function resolve where it is declared, whatever surrounds a call of it: a free variable is the one in scope at the declaration, not one of the same name that a scope around the call declares, or that is declared further on, and none that one of the function's own type parameters hides (§11.1). The functions it calls are those in scope there and every function declared in the blocks around its declaration, before or after it, so nested functions may call each other in either order, as a recursive descent parser's do; calling one of them before the enclosing code has reached its declaration is an error. That code names a nested function from its declaration to the end of its scope. It may pass the name directly as a static function argument (§7.6).

Nested functions of one name declared in one block overload as top-level functions do (§7.1), so case functions (§8.2) may be nested and use free variables. A name reaches the set of the innermost block declaring it: for the enclosing code the members declared so far, for a nested function's body all of them, before or after it. A later one with the parameter types of an earlier one shadows it instead, as a later local of a name does: the enclosing code calls the latest declared so far, a nested function's body the latest declared at or before it, else the first after it. A set in an inner block hides an outer block's of the name entirely, and a top-level one, as a namespace's set hides the global one (§11.1).

Implementation model: free variables become hidden reference parameters of the nested function. A nested function's value never outlives the function declaring it (function values do not escape, §7.6), and a call where a variable it names is out of scope is rejected, so every call can pass them. As by its parameters, a nested function is specialized by how a call finds its free variables (§10.2): where each points, what it holds and whether it is assigned, and for a let whether it may be. So is a function calling it, and one calling a function value that names such a variable. A call sharing a specialization with an earlier one leaves them as that one's check did: rebound, assigned, holding what the body stored. When a nested function is passed as a static function value and inlined into its HOF — the expected, common case — the hidden parameters disappear entirely; un-inlined builds (debug) keep them as real arguments.

7.6 Static function values

fn foo<F: fn(i64)>(a: i64) { F(a); }
foo(1) { print(it) }                  // trailing-block sugar, implicit `it`
foo(1) { x => print(x) }              // named block params
fn h(v: i64) { print(v) }  foo(1, h)  // or pass a named function

Function values are compile-time entities passed as generic parameters, not runtime data. The : fn(...) bound is optional documentation: a bare <F> works identically — the call F(a) typechecks per instantiation like everything else (passing a non-function just produces the error at that call, reported with the instantiation chain). F is the function value wherever it is in scope, hiding a variable around a nested function's declaration and a global or function of its name (§11.1), and is called as F(a): a member call a.F() names it too, and is an error. A block's value is checked without the destination type its call's value meets, so it cannot be a [] (nor a construct whose branches all are one), which would have no element type (§4.2).

Its receiving storage is supplied independently of that type inference: relative links in a fresh result are checked against where it will be constructed, including when the block is called from another function.

A fresh block result follows the same construction rules as a named function's result, including literals containing self-relative references. Selecting a fresh result through if, match, loop, or block preserves that property. A tail that instead names existing storage (or a reference to it) is still a copy and must obey §3.9's relative-reference restrictions.

A function argument must be a function name (including a bound generic function parameter) or a block literal. A name with several overloads passes them all, and each call of the value resolves among them as a call by that name would: each(xs, sort) with fn each<F>(xs: i64[:]) { F(xs); } calls std's one-argument sort<T>, and one value may reach different overloads at different calls. Runtime expressions producing a function value, such as a call, if, match, block, or loop, are rejected. Evaluate runtime work in ordinary statements before the call; use locals when its ordering with other arguments matters. A named function keeps its parameter defaults (§7.1) as a value: F(x) may leave out what it declares defaults for. A block's parameters have none. A function value passed as an argument may follow fewer arguments than the callee has parameters where the rest have defaults: each_step(5, show) for fn each_step<F>(n: i64, step: i64 = 1).

Function values capture enclosing locals per §7.5, and cannot escape: storing them, returning them, or putting them in data is a compile error. Every call is direct and inlinable; HOFs compile to hardcoded loops (Lobster-style guarantee). There are no closures-as-objects and no runtime function pointers in v1.

A plain return inside a function value returns from the lexically enclosing named function, not from the HOF that calls the value — so a HOF-based iterator has the same structuring power as for. This is implemented with the return from mechanism (§7.9), the HOF's frames unwinding transparently.

7.7 Generics and monomorphization

Monomorphic specialization, C++/Lobster-style: type parameters are unconstrained; the body is re-typechecked per unique combination of (type arguments, reference roots (§9.2), writability provenances (§9.5), static function values, destination stacks). Errors are reported at the offending instantiation with the compile-time call chain — the whole-program, call-graph-order compiler can always show which call path produced the failing instantiation. Every instantiation the program reaches is checked, so there must be finitely many: a recursive call may not keep making new ones (§7.8), nor may a generic type's fields (§3.2).

Type variables are never checked abstractly. A generic body is checked only at an instantiation where every type is concrete — including the result of every call it makes on a function-value parameter, since that value's body is checked inline against the concrete argument types at that point (§7.6). So a HOF never needs to state what its function value returns: map's result element type is the type of F(x) in that instantiation, even when the block is { generic(it) } and that type depends on the instantiation.

Call-site type arguments. Type arguments are inferred from the argument types whenever they appear in the parameter list: fn foo<T>(x: T) can be called as foo(1) without an explicit type argument. The typechecker must support inference for both <T>-style and untyped (implicitly generic) parameters. Where several arguments mention one type variable, the typed ones bind it and a literal then adapts (max(n, 0) with n: u32 is the u32 instantiation), so an i64 literal never fixes the type by coming first, nor does an array literal of constants (§4.2); among literals, a float one binds before an integer one, which converts to it (§6.3): max(1, 0.5) is the f64 instantiation. A float of literals and integers (n * 0.5) binds as a float literal does. A [] or null binds nothing: it takes the type the other arguments, literals included, give its parameter, wherever it stands among them (both([], xs) for fn both<T>(x: T[], y: T[]) is the instantiation both(xs, []) is). An explicit list f<i64>(x) is allowed; it is needed when no argument mentions the parameter (e.g. qget<i64>(), or zero<f64>() for fn zero<T>() -> T) or only a [] or null does (g<i64>([]) for fn g<T>(xs: T[])); it binds the leading type parameters in order, and the rest are inferred. Syntactically, f< commits to a type argument list only when the <…> is immediately followed by (, by { for a struct literal (Pair<i64> { … }), or by .ident { for a variant literal (Opt<i64>.Some { … }); otherwise < is the comparison operator.

Floating computation context. After ordinary argument inference and overload resolution, an f32 destination can refine a call's inferred f64 type parameter to f32. This applies when the declaration returns that bare type parameter (-> T) and every parameter mentioning it is a written, bare value parameter (x: T). Its arguments must be adaptable float literals or expressions (§6.3), adaptable integer constants, or directly nested calls satisfying this same rule. Matching those arguments as f32 must select the same overload with all other bindings unchanged; ambiguity or a different selected overload prevents the refinement. Normal resolution must succeed first: result context never rescues a failed or ambiguous call.

With integer width and height, the destination below therefore chooses f32 for both calls and their argument arithmetic:

let scale: f32 = max(0.5, min(width / 960.0, height / 600.0));

An explicit type argument for T, an already typed numeric argument, a reference or container parameter mentioning T, or a default supplying such a parameter commits its type. The rule does not infer a type parameter from the result alone, change an integer computation into floating arithmetic, or extend through an intervening operator or stored call result. Overload selection still sees the call's ordinary inferred type. Without an f32 destination, calls continue to default to f64; an inferred binding such as let saved = min(width / 960.0, height / 600.0); stores an f64 result.

This is not a general change to mixed-argument unification. With var count: i32 = 1;, let value: f32 = min(max(0.25, 0.5), count); still errs: count is a typed integer argument, so result context does not refine that call. Reversing the arguments does not make it valid. Write min<f32>(max<f32>(0.25, 0.5), count) to choose floating arithmetic explicitly for that mixture. In contrast, count * 0.5 is an adaptable floating expression and can participate in the contextual rule above.

An explicit inner min<f32>(...) also chooses f32. Alternatively, max(0.5, min(width / 960.0, height / 600.0)) as f32 computes in f64 and then rounds the result once: a cast does not pass its target type into its operand. These are different choices: min<f32>(n * 0.1, 1.0) with integer n = 9 computes 0.90000004, while min(n * 0.1, 1.0) as f32 computes 0.9. A typed intermediate is also valid, but is not needed just to choose a generic call's type.

Literal arguments. Where a literal is the only thing binding a type variable — the parameter is untyped, or its type is a bare T no typed argument mentions — the variable takes the literal's default type (§3.1), and inside the specialization the parameter is a literal parameter: a constant of unknown value that adapts wherever the bare parameter meets a type, as the literal would have, so push_n(flags, 1, n) with fn push_n<A, T>(xs: A&, v: T, n: i64) pushes a u8 into a u8[>..], and fill(small, -7, 2) an i16 into an i16[..4], with no suffix or cast at the call. The value is not part of the specialization: every call with a literal shares one body, checked once with the value unknown, and the body records each type the parameter adapted to (in itself, in a callee it passed the parameter on to as a literal, and in a block it handed it to). Each call's literal is then checked against every recorded type — push_n(flags, 300, n) is an error naming the push that takes a u8 — once the whole program is checked and the records are complete. What the body cannot do with an unknown value it cannot do with a literal parameter: [0; n] needs a constant, and v + 1 is an ordinary i64 expression, exactly as let y = v; commits y to i64, so a recursive call on v + 1 passes a typed value and terminates. var parameters and extern functions take literals as ordinary values.

Passing arrays by reference is the generic way to write mutating range algorithms (each array-family type instantiates its own copy); slices are the uniform non-mutating way.

7.8 Recursion

Non-recursive call graphs are the default and require nothing. Recursion is opt-in and annotated: the entry function of every recursive cycle is marked recursive fn (the keyword alone: roots and destinations follow from the entry call as for any specialization, §7.7), all functions in the cycle need fully explicit signatures (no inference across the cycle back-edge; a result inferred as a reference, §7.1, is an error in any of them, since the back edges need its roots before its returns are checked), and — the key restriction — no function in the cycle may call into the cycle while one of its nonfixed locals is in scope. A call into the cycle is any call that can lead back to the caller: the back edge, and every other call reaching a member of the cycle. A nonfixed local — a variable- or resizable-class let or var, or a match arm's by-value copy of such a payload — holds a data stack (§10.3) from its declaration, whose initializer builds it in place (§4.3), to the end of its block, so it counts at a call in its own initializer too; were one in scope at such a call, every activation would hold a stack of its own. So a scratch buffer declared in a block or a loop body that ends before the recursive call costs the recursion nothing, its stack free again before any deeper activation starts; one declared before a loop whose body recurses is still in scope at that call, and is an error there, reported with the local. A by-value nonfixed parameter is in scope for the whole body, so no function in a cycle can have one: it takes a reference or a slice instead. A function value's body is part of the function it is written in, so the locals it declares count where it calls into the cycle, as do those of the function calling it; a nested function in the cycle is a member like any other, while the locals of an enclosing function outside the cycle are owned outside it (below). Growable data that must outlive a recursive call is owned outside the cycle and passed in (references, slices, reusable pools, or one struct of references to several tables, A.6). The compiler checks every call into a cycle in call-graph order; recursion depth then only consumes native call stack.

Temporaries. The same holds of the unnamed nonfixed values of the statement making the call: at a call into the cycle, no nonfixed value of the enclosing statement may be live, since each lives on a data stack of the activation (§10.3) — a temporary until its statement ends (§9.2), a value under construction until it is complete — and would otherwise cost a stack per activation as a local in scope does. That forbids: (a) a nonfixed temporary as an argument of the call — a call's result, a copy, a str, a literal of nonfixed class, or the copy a by-value nonfixed parameter takes of its argument (§4.1) — which the callee's view keeps alive across the call; (b) the call nested inside a nonfixed value under construction: an element or field initializer of an array or struct literal of nonfixed class, or an argument of print or str, whose text is being rendered (format is no such value: its text goes into the storage it is given); (c) a nonfixed temporary evaluated earlier in the same statement, whether or not something has consumed it since — a temporary lives to the end of its statement, as does one built by the head of a construct the call is inside (an if condition, a match scrutinee, a while condition, a for sequence), which runs in the scope around the construct; (d) the call's own nonfixed result anywhere but as the whole value of a return of the same result type — through the branches of an if or match and a block's tail, or by return … from — where it is built in the caller's destination, or pushed or appended in place into resizable storage owned outside the cycle, where it is built at that storage's top (which §1.3(4) then rejects wherever a function of the cycle grows that storage, as every one appending into it does); anywhere else — an argument, an operand, a dropped statement value, an element of a literal, a value assigned (which §4.4 rejects as well) — it needs a destination temporary taken before the call and held across it. The compiler reports each case with the call and the value. What to do instead: evaluate the value in a statement of its own before the call (let width = str(n).len;, the binding being fixed-size), pass scratch in from outside the cycle (the mark / resize(mark) pattern below), or take an output buffer by reference and build into it in place instead of returning a growable value. Fixed-size values and temporaries are never affected: a slice of a fixed literal, a struct of fixed fields, a large fixed value (§10.3) all cost no data stack at the call. So a program's stack count is a compile-time constant, with the number of worker threads (§11.2) the only dynamic factor.

Scratch passed down this way can be viewed across the recursive calls. An activation that takes let mark = scratch.len of a grow-shrink scratch array, pushes onto it, views its own part and ends with scratch.resize(mark) leaves the array as long as it found it, so its calls back into the cycle are balanced (§5.2) and the view stays in use across them:

recursive fn walk(scratch: i64[>..<]&, depth: i64) -> i64 {
    let mark = scratch.len;
    scratch.push(depth);
    let mine = scratch[mark..];
    var total = 0;
    if depth < 3 { total = walk(scratch, depth + 1); }
    total += mine[0];
    scratch.resize(mark);
    return total;
}

Polymorphic recursion. A recursive call may instantiate its callee with other type arguments than those of the call it sits in (§7.7): flip<A, B> calling flip<B, A> is back at flip<A, B> one round later, and that call is the cycle's back edge. The instantiations a recursion reaches must be finite, though. Every branch of every specialization is checked, whatever values reach it at run time, so a call whose types grow with each round — g(a, n - 1) with let a: T[1] = [x] inside g<T>, or grow<T[2]>(n - 1) inside grow<T> — would instantiate without end, even where n bounds the recursion at run time. The compiler rejects such a call once it would put more than 16 specializations of one function in progress on one compile-time call path, the way C++ bounds template instantiation depth, and reports it with the chain of instantiations that led there. A finite recursion has one specialization in progress per instantiation on its cycle, or per level of a nested type it descends through, so only an unusually deep one meets the bound.

Cycle store rule. Within a recursive cycle, distinct activations of the same local are statically indistinguishable, so the §9.2 depth check is not sufficient there. Therefore: a reference whose root is a local of a cycle function may be passed down as an argument, but may not be stored into any location, nor returned. A function is in the cycle for its whole body, not from its call into the cycle on: the stores it makes before that call are inside the cycle too. Rebinding one of the activation's own reference variables (cur .= cur.next) is not such a store: the variable dies with the activation, and what it is bound to came from one that outlives it. References rooted outside the cycle are unrestricted — in particular a pool handed to the cycle by reference (a parameter whose pointee is resizable-class, which no cycle function can hand on to the cycle, holding none across a call into it), so a recursive builder can push into a caller's local pool and link what it pushed. A local of an enclosing function outside the cycle — a free variable (§7.5) of a nested recursive fn — outlives every activation the same way, so a parser keeps its pool, its key table and its input as locals of a non-recursive parse, and the nested recursive functions store, link and return references rooted at them freely. Because the cycle's functions are checked against the entry call's roots, every recursive call must pass such a pool by the same reference the entry call did: swapping two pools, or passing a different one, at a back edge is a compile error.

A reference or slice parameter the cycle passes on is rooted outside it the same way, whatever its pointee's size. Where the call that first reached the function rooted the parameter's argument exactly, outside every recursive cycle — a local of a function that is neither recursive nor in a cycle, or a global — and every call back into the cycle passes the parameter on — itself, a subslice or field of it, or a parameter of the calling activation passed on in the same way — the parameter points there in every activation, and references rooted at it or read through it may be stored inside the cycle, into storage that is the same in every activation: a local, a pool, or what another such parameter points at or leads to, not what a parameter the cycle passes something else does. So a recursive walk over a context of borrowed tables (A.6) stores the views it reads through the context into the tables it reaches through it. A parameter whose argument is, on every call, a reference or slice read through one such parameter, or through a pool, points outside the cycle in every activation as well, though not at the same storage, so what is rooted at it may be stored the same way while it is no storage to store into: a builder hands the node read through the context to the function that links it under a new one (rest(c, c.nodes.push(…))). Nothing makes a recursive call pass such a parameter on, so it is restricted only once a store relies on it: a later call into the cycle that passes it anything else is a compile error reported at the store, and one before any store makes the store the error. (Further refinements are future work, TODO.)

Cycle return roots. A back edge reaches a function whose own returns may not have been checked yet, so the roots of its result cannot come from them at once. Instead a cycle is checked in rounds: in the first, a back edge's result points nowhere yet, and nothing checks or stores it, nor does the back edge shrink, grow, store or rebind anything; each later round gives every back edge what the round before recorded of its callee — the union of the roots its returns give, one root for each way a return can go, mapped through the back edge's own arguments (a back edge reuses the body whatever it passes, so it maps a parameter's root through the arguments it gives the parameters the entry call gave that root — it may give two of them two arrays where the entry call gave one — and merges the lot as a call merges its callee's returns, §9.2), read-only where any return is — and the round after the last that changed anything is the one whose errors stand. That includes a variable outside the cycle, which is one variable at every level of a nested function's recursion (§7.5): a round that rebinds it, or stores into it, has read it for the next level as it was before. A cycle function may therefore return the result of a back-edge call, and a parenthesised subexpression in a recursive-descent parser needs no wrapper node (bench/goose/calc_noparen.goose); one that returns either of two pools it is handed can store the result where both outlive the store, and one that returns a view of a grow-shrink array on any path has a result no store may keep (§5.2). A result that holds references — a struct with slice fields, say — is checked the same way, by the roots of what it holds (§9.2), so a recursive builder may return views into an arena that outlives the cycle, built from its children's, with the recursive call ahead of the base case's return or after it:

struct Fragment { text: const u8[:] }
var arena: u8[>..] = [];

recursive fn nest(n: i64) -> Fragment {
    if n == 0 { arena.append("x"); return Fragment { text: arena[..] }; }
    let child = nest(n - 1);
    return Fragment { text: child.text };
}

7.9 return … from (long-distance return)

return E from f     // f = a named function on every compile-time call path
                    //     from f to this statement

f is resolved where the returning function is written -- a nested function in scope, else the name's overload set by the rules of §11.1 (from image::parse is explicit) -- and the target is the innermost enclosing call of a function in that set. An unrelated function that merely shares the leaf name never catches the return.

Returns E as the result of the innermost active call of f, unwinding all frames in between. It is the language's opt-in, lightweight "exception" mechanism (e.g. a deep parser/loader error doing return err from load_level), and the mechanism behind function-value return (§7.6).

Semantics and implementation:

Error handling has no required error-value convention: an application may use a bool, enum, string, i64, or another type. By convention, the error is the last of multiple return values. Use ordinary multiple returns for local propagation and return from to return across several calls.


7.10 extern fn

extern fn sqrt(x: f64) -> f64;
extern "sqrtf" fn sqrt(x: f32) -> f32;
extern "gs_os_read_file" fn read_file(path: u8[:], out: u8[>..]&) -> bool;
extern fn gl_vertex3(v: float3&);

A declaration without a body binds a Goose signature to a C function: the optional string is the C symbol (default: the Goose name). It is top-level only, fully typed, not generic, and joins overload resolution like any function. A call compiles to a direct C call with no calling-convention extras, and a prototype is emitted from the declaration unless the runtime declares the symbol (the os library's gs_os_* functions, declared in src/runtime/runtime_ext.h and defined in src/runtime/runtime_os.h). User C reaches the program through --include <header>, emitted after the generated type declarations so a header can implement shims against the generated typedefs. The types in every extern fn signature are emitted even when the function is unused, including the complete types behind reference and slice parameters. An unused extern does not require a C implementation to link. Every name that comes from the program appears in the C with a _g suffix -- a struct Stats with a field lo is Stats_g with lo_g -- which is what keeps the generated file clear of the C keywords and of whatever the platform's headers declare, without depending on a list of names to avoid. A namespaced declaration ns::x (§11.1) is ns_x_g followed by the namespace's length -- image::Pixel is image_Pixel_g5 -- so a::b_c and a_b::c stay apart and no namespaced name coincides with a global one. An extern fn's own symbol is the exception: that is the C name the declaration gives, unchanged.

An extern fn's trailing parameters may have defaults (§7.1), which each call passes: the C function gets every argument.

What crosses (layouts per C.2): the integer and float scalars and bool; any flat fixed-size value (including structs, fixed and static-capacity limited arrays, and fixed-mode ADTs), by value as its packed C type; T& to such a T, as a pointer; T[:] of such a T, as the slice struct ({ data, len }); and u8[>..]&, as the resizable's header plus its stack (gs_rref), which C appends to through gs_bld_append(ref, p, n); C compiled on its own, a package's, finds both in goose.h (docs/packages.md). Returns are one scalar or flat fixed-size value by value, or nothing; references, slices and builders are parameter-only forms. Everything else — varints, references inside structs, variable and resizable values by value, slices of variable elements, optionals — is rejected at the declaration. A parameter of reference or slice type is what it says (§9.5): a read-only argument needs it declared const.

C must preserve the types and lifetimes of borrowed storage, must not retain a borrowed argument beyond the call, and may only append to a builder. The compiler assumes an extern may write through its arguments but does not mutate unrelated Goose globals; C code is responsible for honoring those obligations.

7.11 Exporting Goose functions to C

export fn hello_world() -> const u8[:] {
    return "Hello, world!"
}

export "c_sum" fn sum(a: i32, b: i32) -> i32 { a + b }

An export fn defines a normal Goose function and emits a C-callable wrapper. The optional string names its C symbol; otherwise the function's leaf name is used. Exported functions must be top-level, non-generic, have explicit parameter types, and have at most one explicitly typed return value. Default arguments, return ... from, reusable-pool parameters, and string-builder parameters are not supported at the C boundary. Parameters otherwise use the same fixed-size C shapes as §7.10.

Scalars and flat fixed-size returns are returned by value. A read-only const u8[:] return is exposed as const uint8_t * and an additional int64_t *out_len parameter; the returned bytes are not NUL-terminated, and their storage remains borrowed according to the Goose function's lifetime. Other references, slices, and variable-size returns are rejected.

Generated C retains its normal main unless the program is compiled with --header out.h, which builds it for a C host: the generated C has no main and a source without a Goose fn main() is accepted. The header declares goose_init(argc, argv), which a C host must call before any exported function. It initializes the runtime and the globals and then runs the Goose fn main(), if there is one, so main is where a library keeps its own start-up code; exported functions may be called from it. A second call does nothing. Initialization is idempotent on the initializing thread; exported calls and mutable Goose globals are not thread-safe and must remain on that thread. --header requires generated C output and at least one export, and writes declarations and the required packed C type definitions for the exported API.

An exported C symbol is emitted as written, as an extern one is, and must be a C identifier that is not a C keyword and that the program does not otherwise reserve (main, goose_init, or a name beginning with gs_).

The wrapper calls the normal generated Goose specialization, not its hidden internal C signature. This keeps Goose's stack argument and other compiler implementation details out of the function's public C signature.

7.12 simd fn: versions per instruction set

simd fn encode(out: u8[>..]&, src: u8[:]) { ... }

simd asks the implementation to build the function once for the baseline instruction set of its target and once more for each wider one it supports (AVX2 and AVX-512 on x86-64), and to run, every time the function is called, the widest version the CPU running the program has. It is meant for a function holding a hot loop the C compiler can vectorize, which the baseline's instruction set can only do poorly or not at all: byte transforms, codecs, kernels over arrays.

simd has no meaning of its own: every version computes exactly what the baseline does, so no program's output, aborts or exit status depends on which one runs, or on whether there are versions at all. Integers already compute at their exact widths (§6.2), checks abort where they would, and float operations round as the baseline's do, one rounding per operation: no version fuses a multiply and an add into one rounding where the baseline would not. An implementation may ignore simd entirely, as the C backend does under compilers other than clang on x86-64 and under the JIT.

Versions are per function: everything the function contains is in each, including the callees inlined into it. The function itself stays out of line, since a caller it was inlined into would run the baseline's code, and each call to it pays for one test of the CPU's level, so it suits a function that runs a loop rather than one called from inside one. The cost is code size: the function's body once per version.

simd comes after export and before recursive; it applies to functions with bodies, nested ones and generic ones included (each specialization has its versions), and extern simd fn is an error.

7.13 inline fn: inlined whatever its size

inline fn project(cam: Camera, p: double3) -> f64, f64, bool { ... }

inline asks for the function to be inlined at every call, however big it is and however many calls there are. Without it, the implementation decides from the function's size and its number of calls, and a function called from a hot loop can stay a call because it is called from several places.

inline has no meaning of its own: a program computes the same with or without it. It is a request the implementation may not be able to grant at every call. Where Goose cannot inline a function itself (one returning several values, §7.3), the C backend asks the C compiler to inline it instead. Where inlining is off (-O0), every call stays a call.

inline is a keyword only directly before fn, recursive or thread_fn, so it stays usable as a name. It is an error together with extern (no body to copy), export (its C wrapper calls it), simd (a caller would run the baseline only), recursive and thread_fn.

7.14 checkfree fn: no bounds check left

checkfree fn bfs(dist: i32[>..]&, q: NodeId[>..<]&, src: NodeId) -> i64 { ... }

checkfree asks the compiler to reject the program if a bounds check is left anywhere in the function's text after bounds-check elimination (§10.5): in each specialization of it, each simd version and each copy inlined into a caller. It changes nothing about what the program computes or how it is compiled. A check belongs to the text it is written in, so a caller's argument expression is the caller's, and a nested function or block written inside a checkfree function is checked as part of it.

8. ADTs in use

8.1 match

match shape {
    Circle c => c.r * c.r * 3.14159,   // payload by value (a copy)
    Rect r   => r.w * r.h,
    Point    => 0.0,
}
match sexp {
    Sym &s   => use(&s.name),          // payload by reference (&-binder)
    List &l  => walk(l.kids),
}
match n { 0 => "zero", 1..10 => "small", _ => "big" }
match t { GROUND => "ground", world::GATE => "gate", LO..HI => "mid", _ => "other" }
match d { 0, 6 => "weekend", 1..6 => "weekday", _ => "no day" }
match dir { North, South => "vertical", East, West => "horizontal" }

8.2 Case functions (match as an overload set)

The cases of a match may instead be written as separate functions, each taking one variant type in the same parameter position:

fn area(c: Shape.Circle) -> f64 { c.r * c.r * 3.14159 }
fn area(r: Shape.Rect) -> f64   { r.w * r.h }
fn area(p: Shape.Point) -> f64  { 0.0 }

let a = area(s);     // s: Shape — dispatches on the tag, like a match

This is the language's "virtual function" idiom, without vtables or inheritance, and it keeps the closed-world exhaustiveness guarantee.


9. References, lifetimes, and safety

9.1 The one rule

A reference/slice must not outlive the variable that owns its target, and must never observe its target at a wrong type. Everything below is the static enforcement of this; there are no lifetime annotations anywhere in the surface language, and no aliasing/exclusivity restrictions at all (aliased references are fine — without shared-memory concurrency, aliasing alone cannot break type safety given the layout rules of §3/§5). This outlives-rule is the language's entire "borrow checker".

9.2 Roots and the depth check

Every reference/slice value has one or more static roots: each a local or global variable, or a temporary (below), that bounds the scope its target lives in; a value that may point at several places has one root per place (merged values, below), and every rule asks each of them. A root is exact when that variable's own storage contains the target, and inexact when it only bounds the target's lifetime — the owner is then that variable or one further out. Every root a &lvalue creates is exact; the reads out of containers of §9.5 are where inexact ones come from. Compilation in call-graph order with per-instantiation specialization means roots are always statically known — parameters' roots come from each call site, and functions are specialized per distinct root (Rust-lifetime precision via monomorphization, with zero syntax).

The rules below are the scope rules, and hold of exact and inexact roots alike. Only rules that need the target's identity rather than its lifetime consult exactness — storing a reference into a relative-reference location (§3.9), converting one to an index (index_of, §3.3), which array a shrink through a reference frees (§5.1), where a store through a reference lands (the store rule below), and the compiler's proof that two references name different arrays — and each of those takes its conservative answer without it. A relative reference that names a pool (§3.9) is where an exact root also comes from: a load out of one is rooted at that pool, exactly, whatever container it was read out of.

Rules (scopes ordered by nesting; globals are the outermost scope, §11.1):

Violations are compile errors. There is no escape hatch in v1.

9.3 What the runtime still checks

Aborts (message + exit; not catchable). In a contained worker (§11.2), each but the last two ends only that worker:

exit(code) ends the program normally with the given process exit code. Both abort and exit never return, which the checker knows: code after them is unreachable, so a branch ending in one gives no value (§6.4).

9.4 Type-safe reuse and stale references

With reusable arrays and limited arrays, a stale reference can read a different value of the correct type (type-safe reuse). This can cause a logic error, but not memory corruption, type confusion, or out-of-bounds access. Slot reuse allows these errors while avoiding a general-purpose allocator.

External bytes must be validated before they become a Goose value. from_bytes (§12) checks an image's framing, tags, lengths, and self-relative links. Each link must point to an element start in the same image. Invalid input returns false and an empty array, so it cannot introduce an unchecked reference. This verifies safety, not data integrity: edited bytes may still describe a valid structure. Detecting changes to the original data requires a separate check, such as a checksum.

9.5 Writability

Everything is writable unless its type says otherwise. Any type may be qualified const (§2): a const i64[3] or const Point is a value whose contents cannot be written into (its elements, its fields, and whatever a reference to it reaches), and const T& / const T[:] are a reference and a slice through which the pointee, or the elements, cannot be written: no assignment, compound assignment, ++, or growing/shrinking operation through it compiles. Assigning a const value as a whole is the binding's business (let, §4.4), and a copy of one is a fresh, writable value: a cursor var s: const u8[:] = text; advances with s = s[1..] and never writes a byte of text. const is shallow: a reference or slice read out of a field of a const Box& is as writable as the field's own type says, and so is a path through it (b.r.n = 7, b.s[0] = 7). The exception is a self-relative reference (§3.9), which points within the value or array holding it, and so is no more writable than the path that reaches it. Every value of reference or slice type is either read-only or writable, and where that comes from is inferred, per instantiation, exactly like roots, with no annotation needed:

Read-back roots. A container names a scope, not the storage its contents point into, so the root of a reference or slice of pointee type T read out of a container C is re-derived. A candidate is a variable whose own storage can hold a T by value — an array of T in any array kind, a struct or ADT payload with a T field, a T itself, and so on through by-value nesting; a variable that merely holds references to T is not one, and a reference or slice field ends the search. Static data is a candidate for the element types a literal can supply; for a writable reference or slice only when nothing else is, since a writable slot is never given a literal (above) and so holds static data only as a null or an empty slice. Out of a field, an element or a global, a variable holding a grow-shrink array whose elements can hold a T is no candidate either: a reference rooted at it is never stored (§5.2), whatever else of it could hold the T. A reference to the whole array, or to what holds it, is stored like any other: its pointee is no element, and the variable stays its candidate. Then, by where C's own root lies:

  1. A global. Only globals outlive globals (§11.1), so the owner is a global candidate whatever local scope is open. One candidate: that variable, exact. Otherwise the global scope, inexact.
  2. A local of the function being checked, at any block depth, by-value parameters included. Everything stored into C was reachable from this frame and had to outlive C, so the owner is a local declared at or outside C's block, a reference parameter's pointee, a global, static data, or storage of the caller's that a parameter leads to through references: the ones a by-value parameter holds, which its argument filled, and the ones in what a reference parameter points at. This function cannot enumerate that storage, and it all outlives the parameter's root — for a by-value parameter the root of what its argument held (§9.2's generic roots of reference fields) — so that root is its candidate. Every candidate is a root of the value: a local or a global exactly, such a parameter's root as a bound on the storage behind it. The value names one array only when there is exactly one candidate in all, and it is not such a parameter's root. The candidates are the variables in scope at the read: an array that comes into scope after it cannot be the owner and is no root of it. Where C is a local holding values rather than a reference or slice, everything stored into it since it was made is on record -- through a reference to it, by a function it was passed to, or later in a loop around the read -- and where each of those was rooted at one variable exactly, at static data, or at the storage a parameter's argument holds, the value's roots are theirs instead, but for a variable whose storage cannot hold the value's pointee type, which owns none of what was stored there of that type: an i64& read out of a holder that also views a u8 buffer is not rooted at the buffer. A word of words(buf) for a local buf points into buf, exactly, whatever else in scope could hold a u8, and a by-value parameter holds what its argument did, so a slice of g.edges for a g: Graph parameter is bounded by that argument's root, not by the callee's other parameters, which can hold its element type but none of the caller's views. A copy of a field or an element of such a local -- a let of one, a for or match binder's copy, a popped element, a value loaded whole through a reference to it -- holds no more than was stored into the local, which is then on record for the copy too: out of let hs = [H { r: a }], hs[0].r points into a, exactly. A copy out of any other container has only its container's scope on record, and takes the candidates. A local of a function that a nested function's or a function value's body is written in is read here as one of the body's own, its stores on record alike: the body is checked for the state each call finds it in (§7.5, §7.6). And a for loop reads its binder so in each of its checks, so that what the loop body stores into the holder it walks reaches the elements later iterations read.
  3. A reference parameter's pointee, or itself inexact. The owner may be caller storage this function cannot enumerate: the root is C's, inexact. A non-recursive function can retain the caller's more precise record when its argument names a local holder exactly, all the holder's recorded contents point into one root exactly, and only parameters can reach that holder during the call: neither the function nor a function value it runs can name it directly. Reads then have that contents root, including any roots stored through possibly aliased parameters while the body runs. A nested function or callback's stores keep the distinction that the resulting references came from that holder's contents; call sites judge them by the actual stores into its storage, including aliased stores. Stores feed back around loops, and calls are specialized for the contents they receive; a call after a rebind is checked for the new contents. The same rule applies when a parameter forwards such a holder to another function. It does not separate fields whose contents have different roots: those retain the conservative bound. For example, a Cache& naming a local Cache { bytes: buffer } keeps buffer as the root of cache.bytes, so an unrelated reusable byte pool cannot be that buffer. Shrinking it still checks every live reference and stored view into buffer, including those in the caller.
  4. A temporary (§9.2). Everything in it came from the literal's initializers, from the call whose result it is, or from the value it is a copy of, so the root is theirs: the innermost root among the initializers, the one the result's contents are rooted at, or the one the copied value's are, exact when that is one variable exactly.

The globals a candidate may be are all of the program's. A function that a global initializer calls is compiled before the globals declared after that initializer, but the compilation serves the calls made once they exist too, so in it the global scope stands for those as well, inexact.

A global var of reference or slice type is a slot any function may bind (§9.2), and the compilation of a function using it serves every call, those after a function compiled later has bound it included. So a use of one in a function's body is a read out of a global (case 1): each global whose storage can hold its pointee, exact where there is one, but never a grow-shrink array's elements, where no binding of a global points (§5.2). One naming a pool (§3.9) points into that pool, and a global let keeps its binding. A global initializer runs once, when the bindings made so far are all there are, and a use there has their roots.

An optional variable bound only to null so far (let none: Node? = null;, or var best: Node? = null; before anything binds it) holds null. Where every binding that can come before a use of it has been checked, the use is a null, which has no root (§3.9). For a local that is wherever it is used: in the body of the function declaring it, a loop's later bindings included (§9.2), and in a nested function's or a function value's body, which is checked for how a call finds the variable (§7.5), so that a call after a binding has a check of its own, and whose own bindings reach a loop or a recursion around the call as the declaring body's do. For a global it is in the global initializers, and anywhere for a global let, which only its initializer binds. A global var used in a function's body is read as above.

A relative reference T&<w> read out of C points within C's own root array by construction (§3.9), so it takes C's root and C's exactness whichever case applies. One that names a pool needs no candidates at all: it points into that pool, so it is rooted there and exact, and this is how a container of links stays usable where a container of plain references would be ambiguous.

A diagnostic that turns on an inexact read-back names the container it came out of and the candidates it could not choose between ("n was read out of slots and may point into pool or spare", "g is a global var, which any function may bind, and may point into pool or spare"), or, where the candidates are the caller's to know, the parameter whose pointee bounds it.

Writability checks depend on the callee's operations. A function that mutates its slice argument can compile for a writable argument and fail for a read-only one. The compiler reports the full compile-time call chain, including the source of the read-only data. const is required on slots that must accept read-only references or slices; parameters and results may also use it to state that they are always read-only.


10. Compilation model

10.1 Whole program, call-graph order

Goose is a closed-world, whole-program compiler. Functions are typechecked and specialized in call-graph order (callers before callees), so argument types, reference roots, writability, destination stacks, and static function values are always concrete. Recursion is the annotated exception (§7.8).

10.2 Monomorphization

Each function is compiled per unique (argument types, reference roots, writability provenances, destination/target stacks and request kinds, static fn values), a nested function's free variables counting as arguments (§7.5). Roots and stacks are compile-time constants inside each specialization — pushes through references compile to direct bumps of a known global — except where the compiler chooses a hidden stack argument to share one body across contexts (§4.3, §7.4).

10.3 Stack assignment (deterministic recipe)

Traverse specializations DFS from main (and global initializers). Each specialization carries a compile-time stack environment: the watermark of stack indices in use on entry, and a map from each of its nonfixed locals / by-value params / in-flight returns to an index. Assign each new simultaneously-live resizable (and each data-stack-resident variable value, which shares by nesting per §1.3(4)) the lowest index not in use at that point; indices free again when the owning scope exits (sequential reuse), so a function called after a local's block has ended may use that local's index again (which is what lets a recursive cycle's functions own scratch, §7.8). A pending nonfixed return's destination stack is part of the in-use set from the call site until the value is received — which is also what makes return from safe (§7.9). N = the maximum index + 1 reached anywhere. Threads run the same algorithm per thread program (§11.2).

Implementations may replace per-context specialization with hidden runtime stack arguments where profitable; semantics are identical.

Fixed locals normally go to the native stack / registers. The C backend places fixed locals and temporaries larger than 4096 bytes on data stacks, using the same scope watermarks and hidden stack indices as nonfixed values. This threshold is a backend policy, not a language limit or a change to a type's size class, packed layout, value semantics or reference lifetime. It prevents large arrays and records, including temporaries introduced by inlining, from exhausting a platform's much smaller native call stack. Inside a function of a recursive cycle (§7.8) such values stay on the native stack instead: a data stack held across a call into the cycle would cost a stack per activation, which §7.8 rules out for every value, while the recursion's depth is bounded by the native stack already, and a fixed value belongs with fixed values. A large fixed parameter copy, local or temporary of a cycle function is therefore a native value, which deep recursion over large values avoids by taking them by reference; rejecting such parameters and locals in cycle functions instead would have made the backend's threshold a language rule. Variable-class locals may be placed on the native stack (alloca) instead of a data stack when the compiler chooses; the current backend places them on data stacks. Resizable frame objects (C.2) still keep their fixed prefix and tail header in the native frame.

10.4 Runtime environment

At startup, reserve N address regions (target: multiple GB each; commit-on- touch via guard pages — prototyped at github.com/aardappel/stackalloc), plus guard gaps between regions so runaway growth aborts cleanly. An implementation budgets the address space it spends on regions over every thread program at once (32 TB by default here), which is what bounds hardware_threads() (§11.2); a region the platform refuses may be reserved smaller, which nothing the implementation elides depends on. Platforms without address-space reservation (wasm today) fall back to index-based references + bounds-checked growth, with reduced performance.

The 48-bit size limit. A data stack reserves at most 2^48 bytes. Consequently, in every conforming implementation:

This limit allows up to 256 TB in one value while leaving headroom for arithmetic the compiler can reason about. The guard region after each reservation already turns an attempt to exceed it into a safe abort (§9.3), so no growth operation needs a separate check for this limit. A fixed array's size is a constant checked at compile time. Implementations may impose a smaller limit (wasm32 is inherently capped at 2^32); they may not raise it, so a program's meaning never depends on the target having more.

Making the limit part of the specification provides three guarantees:

Compilation target: C/C++ first, LLVM later. Representation and calling convention: Appendix C.

10.5 Bounds-check elimination

The index and slice checks of §9.3 are the one abort the compiler routinely proves unnecessary, so the analysis that removes them is part of the compilation model rather than an implementation detail. It runs over each specialization after optimization, and it is required only to be sound: every check it removes is one that could not have aborted, and what it fails to prove stays in the program. Nothing about a program's meaning depends on how much it proves, except whether a checkfree function compiles (§7.14), for which the specification fixes what every implementation must prove.

What the language gives it, beyond ordinary flow facts (loop headers, conditions and their negations, assert, match arms):

Known gaps are TODO 0f. Whole-program compilation lets the analysis reach across calls: each specialization sees concrete argument roots, a caller's facts reach an inlined callee body directly, and a call that is not inlined carries them too. Every call site records what it proves about the arguments it passes — constant bounds on an array's length, and how the passed lengths and integers relate — and a specialization enters with the meet of those facts once all of its sites have been analyzed (callers are analyzed first; a site inside a recursive cycle leaves its callee with nothing). A thread's entry point and an export fn, which C may call with any arguments, enter with nothing. In the other direction a callee is summarized rather than assumed hostile: the storage it may resize or overwrite, through its reference parameters, in globals, or in captured outer locals, is computed as a fixpoint over the call graph, and a call kills only what those effects can name. A kernel fn blur(src: u8[>..]&, dst: u8[>..]&) that only reads and writes elements thus sees src.len == W * W when every caller established it, and leaves the caller's own facts about src intact.


11. Program structure

11.1 Modules and globals

11.2 Concurrency

No shared mutable memory, ever. The model is a statically typed cousin of the Linda tuple-space / coordination style.

11.3 Dynamic stacks (future)

A resizable-of-resizables local, each element owning its own dynamically created stack, accessed via fat references. Noted for the future; not in v1. (The thread mechanism of §11.2 already introduces the dynamic-stack-block machinery this needs.)


12. Builtins, the standard library, and what v1 leaves out

The language's own functions:

Builtin Meaning
print(a, b, …) the arguments' text and a newline to standard output (§3.7)
str(a, b, …) -> u8[>..] the arguments' text as a fresh string, built at its destination (§3.7)
format(out, a, b, …) the arguments' text appended to out, any growable u8 array (§3.7)
assert(c) aborts unless c, a bool or an optional, which it narrows (§9.3, §3.8)
abort(msg: u8[:]), exit(code: i64) end the program; both diverge (§9.3)
copy(x) -> T a fresh copy of the stored value x names (§4.1)
default<T>() -> T the value a fixed-size T has before anything is written to it (§4.2)
hardware_threads() -> i64, thread_spawn(worker, args…) -> i64, thread_spawn_contained(worker, args…) -> i64, thread_wait(id) -> bool, thread_wait(id, msg) -> bool workers (§11.2)
qput(v) -> bool, qget<T>() -> T, qget<T>(ms) -> T, bool, qpoll<T>() -> T, bool the typed queues (§11.2)
qlimit<T>(n) a queue's limit of messages (§11.2)
to_bytes(a) -> u8[>..], to_bytes(a, out) an array's image — a varint byte count then its element region — fresh, or appended to a growable u8 array (§3.9, design/serialization.md)
bytes_of(a) -> u8[:] the element region alone, as a view: no copy, and never writable
from_bytes<T[>..]>(b: u8[:]) -> T[>..], bool a verified array from an untrusted image; empty and false if it is not one. Also builds T[>..<] and the T[] family
embed_shader(path) or embed_shader(stage, source, …) -> const u8[:] compile-time GLSL compilation to a static shader blob; graphics extension, detailed in the implementation notes, section 3.14
embed_file(path) -> const u8[:] the bytes of a file, read when the program is compiled, as a view of static data; path is a string literal (or a let/const global holding one), relative to the file with the call; each distinct file is read once

And the array members, ordinary functions of their receiver per UFCS (a.push(v) is push(a, v)):

Member On Meaning
.len -> i64 every array kind and slices the element count (§3.3)
.cap -> i64 limited arrays the capacity (§3.3)
.push(v) -> T& limited, grow-only, grow-shrink one element, constructed in place (§3.3)
.append(src) limited, grow-only, grow-shrink an array or slice of elements (§3.3)
.pop() -> T, .pop_n(n), .resize(n, v?), .clear() limited, grow-shrink; grow-only where §5.1 allows a shrink shrinking, and growing with a fill value (§3.3, §5)
.index_of(r) -> i64 fixed, limited, grow-only, grow-shrink the index of the element r refers to (§3.3)
.alloc_index(v) -> i64, .alloc_ref(v) -> T&, .free(i) reusable pools slot reuse (§5.4)
.alloc_slice(n) -> T[:], .realloc_slice(s, n) -> T[:], .free_slice(s) reusable[] pools slice reuse (§5.4)

For serialization, the byte-count prefix must account for exactly the remaining input. Validation establishes complete elements, representable lengths, in-range tags, booleans encoded as 0 or 1, canonical varints, and valid self-relative targets before exposing a value. A link must name an outer element or the correctly tagged payload of one; interior-field targets are currently unsupported. Failure returns an empty array and false, not a partially verified value. Success owns a copy of the payload. The implementation notes, section 6.9, give the byte contract and current resource limits; section 11 identifies violations still to fix.

Everything else is the standard library: Goose source under stdlib/, reached by import (§11.1) and documented in stdlib.md (the design it came from is design/stdlib_design.md). The math types of §6.1 are its vec module; the C behind math and os enters through extern fn (§7.10).

Deliberately out of scope for v1: error-value conventions (§7.9); move operations for resizables; multiple resizables per struct; two-way growth arrays; inline compaction / copying GC for pools; mixed-type pools; alignment annotations and explicit vector types (simd fn, §7.12, only widens the instruction set a function is built for); dynamic stacks; labeled break; namespace privacy, re-exports and nesting (§11.1).


Appendix A. Worked examples (informative)

A.1 A list of strings, flat

fn read_words(text: u8[:]) -> u8[][>..] {     // grow-only array of variable strings
    var words: u8[][>..] = [];
    var i = 0;
    while i < text.len {
        let start = i;
        while i < text.len && text[i] != ' ' { i++; }
        words.push(text[start..i]);            // copies the slice into place, flat
        while i < text.len && text[i] == ' ' { i++; }
    }
    return words;                              // NRVO: built on the caller's stack
}

One contiguous block: len | (len | bytes)*. Iteration is sequential; no allocations happened anywhere.

A.2 A compact tree with relative references

enum Sexp {
    Sym  { name: u8[varint] },
    List { kids: (Sexp..&<varint>)[varint] },  // tiny self-relative links
}
var pool: Sexp..[>..] = [];                    // grow-only pool of variable ADTs

Children are built first (pushed earlier into pool), parents store tiny backward offsets: (a (b c d)) ≈ 21 bytes total, serializable by memcpy.

A.3 A growable hash table (open addressing), in-model

struct Map { slots: Slot[>..] }           // Slot fixed-size; grow-only

Growth: slots.append(…) doubles in place (a bump, no move), then rehashes in place by cycle-walking; or rebuild into a fresh local and copy back via whole-resizable assignment (§4.4). Deletions use tombstones or the reusable mechanism. No allocator, no reference invalidation during lookup.

A.4 Case functions as virtuals

enum Node { Num { v: f64 }, Add { l: Node..&<u16>, r: Node..&<u16> } }
recursive fn eval(n: Node.Num&) -> f64 { n.v }
recursive fn eval(n: Node.Add&) -> f64 { eval(n.l) + eval(n.r) }

A.5 Long-distance errors

fn parse_expr(l: Lexer&) -> Expr.. {
    guard l.tokens_remain() else { return Expr.Nothing {}, "eof" from parse; }
    ...
}
// All deep failures land here; the error is the last return value (§7.9).
fn parse(src: u8[:]) -> Expr.., u8[] { ...; return parse_expr(&l), ""; }

A.6 A context of borrowed tables

struct Ctx {                          // a slice and three references: fixed-size
    source: const u8[:],
    nodes: Node[>..]&,
    text: u8[>..]&,
    scratch: i64[>..<]&,              // the recursion's working stack
}
recursive fn walk(c: Ctx&, n: i64, depth: i64) -> i64 {
    let mark = c.scratch.len;
    c.scratch.push(depth);            // above its callers' entries
    ...
    c.scratch.resize(mark);
    return total;
}
fn compile(source: const u8[:]) -> i64 {
    var nodes: Node[>..] = [];        // one local per table
    var text: u8[>..] = [];
    var scratch: i64[>..<] = [];
    ...
    var c = Ctx { source: source, nodes: nodes, text: text, scratch: scratch };
    return walk(c, 0, 1);
}

A struct owns at most one resizable (§3.4), and a function in a recursive cycle none that stays in scope across a call back into the cycle (§7.8), so a program whose state is several growable tables owns each as a local of a driver function and passes the rest of the program one struct of references to them. The tables are rooted outside the cycle, which grows and shrinks them through c like any pool handed to it (§7.4, §7.8), and since every call back into the cycle passes c on, the views read through it — a node's name copied into text, say — may be stored into the other tables inside the cycle too. They last for one call of the driver: each compilation starts from fresh tables, and no state is global.


Appendix B. TODO / open items

Collected from the design discussion; each needs future resolution work. The newest, highest-priority items first; items since resolved are kept at the end, each with where its resolution lives.

0a. Mixed-signedness ergonomics in practice — §6.1's unification deliberately rejects u32 + i32, and u64 against anything signed outside the comparison rule now in §6.1. Watch whether real code (hash kernels, size math against .len's i64) still accumulates casts. Note this is a u64-only problem: u8/u16/u32 all widen implicitly into i64 (§6.3), so the same code on a u32 hash composes already. What remains is the analysis half rather than the language half: the bounds-check pass (§10.5) follows a u64 value's range through %, & and casts, but drops it the moment the value is bound to a u64 local, because in general a u64 need not fit i64. A let has one value, so it could carry its initializer's range the way §6.1's non-negativity already does — the narrow, checkable version of "track that this u64 fits i64". Until then, the open-addressed map in bench/goose/words.goose keeps the four checks around slots[idx]. 0f. Bounds-check analysis, known gaps (§10.5) — a loop's exit condition is a disjunction (!(i < n && p)), which a difference-constraint domain cannot represent, so post-loop bounds rest on the inferred invariants instead; LLVM meets the same wall and works around it by duplicating blocks so the conditions never merge, which is available here too. A value loaded from an array has no known range, so indirect indexing (dist[out[k]], bench/goose/graph_csr.goose) keeps its check unless what is stored has an index type (§3.11). Release-mode wrapping (§6.2) is deliberate and stays, so the analysis proves absence of wrap explicitly where it needs to. 0c. Pointee writes through optionals — a narrowed optional writes through fine, but there is no way to write through an optional without narrowing; and rebinding to a plain reference first (let r: T& = o;) is the only escape hatch. Possibly fine; revisit with usage. 0d. Lifetime precision, remaining cases — two checker conservatisms still exceed the spec: long-distance returns (§7.9) written in a named function only carry references to globals/static data (precise rule, which a function value's return follows: rooted at or above the target's frame), and the recursive-cycle store rule (§7.8) admits only globals, free variables, pool parameters and parameters every call back into the cycle passes on as roots of stored references — a reference to a caller's fixed-size local that such a call replaces is still pass-down-only inside a cycle, and so is a merged value that may be one rooted at a parameter the cycle passes on. (One-root-per-reference-variable is a language rule, §9.2; a function's result is rooted as a branch's value is, at the innermost of its returns' roots. Writability follows const types; storage no longer removes read-only restrictions, §9.5.) 2. Error propagation sugar — return from is the mechanism; revisit whether a convention/sugar layer (a try-alike) is wanted once idioms emerge. 3. Move operation for resizables — assign-and-leave-source-empty, as the one sanctioned "move". 5. Cycle store rule refinement (§7.8) — the pass-down-only rule is conservative. Parameters the cycle passes on as it was given are exempt; one a recursive call gives other storage stays pass-down-only even where every activation's store would outlive what it is given, and so does one whose first argument is a local of a recursive function outside the cycle. Explore per-activation reasoning. 6. "Current pool" implicit destinations — allocation without naming the array, bound at compile time to the in-scope array of the right type. 7. Mixed-type pools & pool GC — internal vs external references, inline compaction, copying; interacts with 6. 8. Multiple resizables per struct (memmove-on-insert, opt-in) and two-way growth arrays. 9. Concurrency surface finalization — queue fairness/capacity, select, worker-local init, flat-type relaxation for self-contained relative-reference values (send whole trees through queues). 10. SIMD/alignment — measure whether packed layouts cost real SIMD performance; consider opt-in aligned types if so. Related: narrow-lane elementwise ops (§6.1) should vectorize now that arithmetic runs at the element width; verify with the particle/sum benchmarks. simd fn (§7.12) gives a function versions for the wider instruction sets; what remains open is alignment, and whether a function should be able to say which versions it wants (an AVX-512 version is not faster for every loop). 13. Labels for break — if early-out patterns demand them. 15. Wasm fallback — index-based reference representation details (docs/design/wasm.md compares the options). 16. Relative-reference region tracking — copies of values containing self-relative references are currently rejected outright (§3.9; the in pool form already copies); track the region an offset ranges over so whole-region copies (and serialization moves) can be proven safe.

Resolved

0e. Per-array index types — DONE, see §3.11 and §7.14 (docs/design/index_types.md): u32 in a is an index validated once against a grow-only global a, which then never shrinks, so indexing a with it, and another array once their lengths are related, needs no check; checkfree fn makes a check left in a function an error. 0b. Reference address identity — DONE, see §3.8: r1 .== r2 / r1 .!= r2 compare references by address; == stays the pointee comparison.

0h. Passing by size class — DONE, see §4.1 (and §7.2, §3.8, §6.5): fixed values connect to destinations by value; non-fixed lvalues bind by reference at reference-typed, untyped and un-annotated destinations and are an error at value-typed ones, which take an rvalue or an explicit copy(x); a redundant & is a warning. 0i. Resizable tails get their own frame header — DONE, see C.2: a resizable-tailed struct with an all-fixed prefix is a frame object, so &s.tail is an ordinary reference; variable-size prefixes and resizable-class ADTs keep the bytes-on-stack shape. 0j. Slices of grow-shrink arrays — DONE, see §5.2 (superseding TODO 4): a slice or reference into a [>..<] is created like any other and held by variables only; a shrink is an error while one is live, naming it and where it was bound; specializations record what they shrink so that calls are checked the same way. 0g. Recursive results' roots at back edges — DONE (§7.8, cycle return roots): a cycle is checked in rounds, so a back edge's result carries the merge of the roots the cycle's returns give, mapped through that back edge's own arguments, and points nowhere yet in the first round, whose checks the next round repeats with it. A result holding references is checked by the roots of what it holds, the same way. What remains: a back edge that passes storage of its own for a parameter the entry call gave static data gets a holder result that may only be passed down.

  1. varint format benchmark — DONE: ULEB128 adopted (§3.6). Break-even vs the best branchless format sits at ~70–75% single-byte values (a cliff, not a slope); above it ULEB wins ~3x, below it loses up to ~3x. Revisit only if a per-field format choice is ever wanted for unpredictable-length data.
  2. [>..<] interior-reference relaxation — DONE, see §5.2: the liveness test of §5.1, plus per-specialization shrink summaries for the shrinks it cannot see directly (through a reference, or of a global).
  3. FFI — DONE, see §§7.10–7.11: extern fn binds a Goose signature to a C function, and export fn exposes a Goose function through a C-callable wrapper. The flat fixed-size types by value, references and slices to them, and u8[>..]& builders cross where supported; other shapes are rejected at the declaration.
  4. Open syntax details — DONE: both trailing-block parameter forms exist (the implicit it and x =>, §7.6), and recursive fn is the keyword alone on a cycle's entry function (§7.8).
  5. Stdlib math types — DONE, see stdlib.md (vec): vec2/3/4<T> with float3 and friends as aliases; the named ops are overloads per size, and elementwise arithmetic is the language's (§6.1).
  6. Serialization of relative-reference structures — DONE, see §12 and docs/design/serialization.md: to_bytes(a) copies an array's element region into a fresh u8[>..], from_bytes<T[>..]>(bytes) verifies an image and returns the array plus a bool. The verifier is generated per element type next to the size and equality walkers, and a rejected image yields an empty array, never an unproven reference (§9.4). An image is little-endian by definition and carries a varint byte count in front, so a reader of a stream can size its read; bytes_of(a) is the payload as a view, for a save that copies nothing. What v1 does not admit: references into a field of an element, from_bytes into a fixed or limited array, and a big-endian host (which aborts rather than writing bytes only it can read).

Appendix C. Representation and calling convention (normative for the C backend)

C.1 Data stacks

Each data stack S has a control block { top: byte* } — a global for the main program; threads use an array of control blocks reached from a hidden per-thread base pointer (§11.2). Allocation is p = S.top; S.top += size. Scope exit restores the watermark recorded at scope entry. No other runtime state exists.

C.2 Value layouts

Resizable values. A resizable's element region always tops its data stack (§1.3(2)). A resizable array is represented as a header in the owning frame — { base: byte*, len: int64 } — plus the element region on the data stack. &v yields the header's address (a fat reference is that pointer plus the stack identity); with the stack identity known statically (or carried as a fat reference / hidden argument), push compiles to *(T*)S.top = e; S.top += size; h.len++.

A resizable-tailed struct whose other fields are fixed-size (and hold no relative reference) is a frame object: a C struct of those fields followed by the tail's own header (or, for a tail that is itself such a struct, its frame object), held in the owning frame exactly as a fixed value would be, with only the innermost tail's elements on the data stack. &s is the object's address plus the stack identity, &s.tail the tail header's, so a tail can be referenced, sliced and grown through a reference like any standalone resizable; s.f through either is a plain member access. Copying, passing by value and returning such a value move the frame object where an array moves its header (C.3) and the elements where an array moves its elements. Nesting composes: a struct whose tail is a frame object embeds it.

The two shapes that are not frame objects keep the bytes-on-stack form behind a base pointer: a struct with a variable-size prefix (a u8[] field before the tail) and a resizable-class ADT, whose payload shape is per variant. Their header's base is the value's start on the data stack and len the tail's count; a reference to such a nested tail is a compile error — reference the owning variable. A tail of such a shape whose type is a frame object takes the same form: its fixed fields are bytes of the value, where a bare pad inserts nothing (§3.2), and its own tail's count is that header's len, so its tail cannot be referenced either.

C.3 Calling convention

C.4 Guard pages and commit order

Construction rules (§4.2) write stacks front-to-back. When an allocation skips ahead without writing (a large limited-array capacity), the runtime commits the skipped range explicitly (VirtualAlloc/mprotect) instead of relying on touch order — Windows-style guard-page auto-commit covers the ordinary sequential-write case; Linux uses reserve + explicit commit. Fresh pages arrive zeroed from the OS; reused stack memory does not — harmless, since uninitialized slots are unreachable (§5.3). A final unmapped gap after each region turns runaway growth into a safe abort.


Appendix D. Grammar sketch (informative)

program     := namespace? topdecl*             // imports may precede namespace
namespace   := "namespace" ident ";"
topdecl     := import | struct | enum | typealias | fndecl | globaldecl
import      := "import" "."? ident ("." ident)* ";" | "import" ident "from" strlit ";"
declname    := ident | ident "::" ident | "::" ident   // the namespace, else the file's
qname       := ident | ident "::" ident | "::" ident   // a declaration reference
struct      := "struct" declname generics? "{" fieldlist "}"
enum        := "enum" declname generics? "{" variant ("," variant)* ","? "}"
variant     := ident ( "{" fieldlist "}" )?
fieldlist   := field ("," field)* ","?
field       := ("let" | "const")? ident ":" type ("=" expr)? | "pad" intlit?
typealias   := "type" declname "=" type ";"
generics    := "<" ident (":" type)? ("," ident (":" type)?)* ">"

fndecl      := "checkfree"? ("export" strlit? | "extern" strlit?)? "checkfree"? "simd"?
               "inline"? "recursive"? ("fn" | "thread_fn")
               declname generics? "(" params? ")" ("->" rettypes)? (blockexpr | ";")
                                                 // checkfree at most once (§7.14)
                                                 // nested: ident only
params      := param ("," param)* ","?
param       := "var"? ident (":" type ("=" expr)?)?
                                                 // untyped => generic; defaults
                                                 // trailing, not in a block (§7.1)
rettypes    := type ("," type)*                  // no parens in declarations
globaldecl  := ("reusable" ("[" "]")?)? ("let" | "var" | "const") declname (":" type)? ("=" expr)? ";"
                                                 // no initializer: var with a type only

type        := "const"? (prim | qname tyargs? | "(" type ")") postfix*
             | "const"? indextype                // no postfix (§3.11)
                                                 // const: the first & or [:] (§9.5)
indextype   := ("u8"|"u16"|"u32"|"u64") ("in" | "upto") qname
                                                 // parenthesized as a for binder's type
prim        := "bool"|"varint"|"i8"|…|"u64"|"f32"|"f64"
             | "fn" ( "(" types? ")" ("->" (type | "(" types ")"))? )?
tyargs      := "<" type ("," type)* ","? ">"
uint        := "u8"|"u16"|"u32"|"u64"|"varint"
postfix     := "[" expr "]"                      // fixed array (const expr)
             | "[" uint? "]"                     // variable array
             | "[" ".." expr? "]"                // limited (const expr)
             | "[" ">" ".." "<"? "]"             // resizable
             | "[" ":" "]"                       // slice
             | "&" ("<" uint ("in" qname)? ">")?  // reference / relative
             | "?"                               // optional (any type)
             | ".."                              // variable-mode ADT
             | "." ident                         // variant type

stmt        := decl | assign | incdec | guardstmt | exprstmt
decl        := ("reusable" ("[" "]")?)? ("let" | "var" | "const") identlist (":" type)?
               (("=" | ".=") exprlist)? ";"     // .= binds by reference (§3.8)
assign      := lvalue assignop expr ";"          // = .= += -= *= /= %= &= |= ^= <<= >>=
incdec      := lvalue ("++" | "--") ";"
exprstmt    := expr ";"
guardstmt   := "guard" expr ("else" blockexpr | ";")  // guards the rest of its block (§6.4)

expr        := control | binary
control     := ifexpr | matchexpr | blockexpr | loops | jumps
ifexpr      := "if" expr blockexpr ("else" (ifexpr | blockexpr))?
loops       := ("while" expr | "for" forbind "in" iter | "loop") blockexpr
forbind     := "&"? ident (":" type)? ("," ident (":" type)?)?
iter        := expr | expr ".." expr
jumps       := "return" exprlist? ("from" qname)? | "break" expr? | "continue"
matchexpr   := "match" expr "{" arm ("," arm)* ","? "}"
arm         := pattern "=>" expr
pattern     := ident "&"? ident | item ("," item)* | "_"   // a variant and its binder, ...
item        := bound (".." bound)?
bound       := "-"? (intlit | qname)             // qname: a variant or a constant (§8.1)
blockexpr   := "{" stmt* expr? "}"

binary      := (precedence climbing; tightest → loosest)
   postfixe := primary ( "(" args ")" trailingblock?
             | "[" sliceargs "]" | "." ident | "as" "!"? type )*
   unary    := ("-" | "!" | "~" | "&") unary
   levels   := * / %  →  + -  →  << >>  →  &  →  ^  →  |
             →  < <= > >=  →  == != .== .!=  →  &&  →  ||
primary     := literal | qname | "null" | "self" | "(" expr ")"
             | arraylit | structlit | genericcall
genericcall := qname tyargs "(" args ")" trailingblock?
trailingblock := "{" (params "=>")? stmt* expr? "}"
sliceargs   := expr | bound? ".." bound?         // bound := "^"? expr
args        := (expr ("," expr)* ","?)?
arraylit    := "[" (exprlist ","? | expr ";" expr)? "]"
structlit   := (qname tyargs? | varianttype) "{" (fieldinits | fieldinits? "..")? "}"
fieldinits  := fieldinit ("," fieldinit)* ","?   // before "..": the "," required
fieldinit   := (ident ":")? expr

Known parse notes: structlit vs trailingblock vs blockexpr ambiguity is resolved as in Rust (no struct literals or trailing blocks directly in if/while/for/match/guard scrutinee position); a statement that is a block-ended construct needs no ; and ends at its } (§2); f< commits to a type argument list only when followed by (, {, or .ident { (§7.7), otherwise < is comparison; T&<u8> tokenizes as & < in type context only; a..b ranges exist only in for headers, slice brackets, and match arms; multiple return types in a function type (not a declaration header) require parens around the list; T&? parses to the same type as T?, and ? on an already-optional type is an error; from is not reserved — return E from f is recognized by the identifier that follows it, so from remains usable as a name.


Appendix E. Implementation notes (informative)

What the current compiler does where the text above leaves it a choice. The compiler's own description, pass by pass and analysis by analysis, is implementation.md.