Goose

The Goose Standard Library — Design

This document records the original standard library design: its scope, conventions, and the builtins and language rules it required (§8). It preserves the reasoning at the time. Later changes include const types, namespaces, generic aliases, serialization, graphics, physics, user interfaces, directories and atomic replacement in os, and the base64, csv, json and regex modules; some rules and limitations below have therefore been superseded. See ../stdlib.md for the current API and ../goose_spec.md for current language rules. References of the form §N refer to that specification.


0. Summary

The original standard library comprised five Goose source files (std, math, dictionary, vec, os) with about 120 functions in total. It introduced three families of named types: dictionary<K, V> (the only facility the language has no building block for), rng (one word of PRNG state), and the vec2/3/4<T> math vectors that §6.1 already promises. Everything else is a function over the user's own arrays, structs and pools.

Everything is written in Goose except:

The Goose library code uses no heap allocator: every temporary is a local resizable or a limited array, every result is constructed in its destination (§4.3), and every higher-order function compiles to a loop (§7.6).


1. Principles

Functions over types. Goose's strength is the array family: eight kinds of array, pools, relative references, variable-mode ADTs, all named and laid out by the user. The library should operate on these user-defined containers rather than require wrappers of its own. A library function takes a slice (T[:]) or a reference to whatever array the caller has, never a library-defined wrapper. It returns either a scalar, a reference into the input, or a fresh array built at the caller's destination. New nominal types are introduced only where the language has no representation to build on (a hash table) or the spec already asks for them (vectors).

Small. The library provides one function per job, with a call that covers common uses. There are no _if/_by_key/_reverse/_indices families. A function is included only when the usual hand-written loop takes more than three lines or is easy to get wrong, as with sorting, hashing, UTF-8, float printing, and binary search. §9 lists omitted functions and the one-line loops that replace them.

Goose first, builtins by exception. A builtin is justified only when it provides an operation the language cannot express: custom typechecking (print of any type, default<T>()), a diverging call the checker must know about (abort), or a way out to the OS. Speed alone is not a reason yet, since the library's Goose code is expected to compile to the same loops a C programmer would write. Where measurement later shows that a library function would benefit from custom codegen (a word-at-a-time string hash, memchr for byte search), that function moves into the builtin table without changing its signature. The extern fn mechanism (§8.4) keeps that option open without growing the typechecker.

Make costs explicit. The library does no hidden allocation and makes no hidden copies. The conventions in §2 are chosen so that the cheap call is the natural one to write and the expensive one cannot be written by accident.

Lobster's shape, Goose's cost model. From Lobster's std.lobster, we take an API organized around functions, it blocks, find/filter/fold/map, and practical helpers such as insert_ordered. Results are built in place instead of allocating a new vector for each call. Broad builtins such as tokenize and concat_string are replaced by smaller functions that can be combined. C++ and Rust guide the list of operations to cover: sort, stable sort, binary search, heap, dedup, retain, hash map, and string search. Goose implements these without their iterator protocols, trait bounds, or wrapper types. It uses T? references, multiple returns, and return from in place of Option, Result, and exceptions.


2. Conventions

The syntax at call sites follows the passing rule of spec §4.1, whose design is recorded in §8.7: no & is written at a reference-typed destination.

2.1 How containers are passed

The function… Parameter Call sites Works on
reads, or mutates elements in place without changing the length (sort, reverse, fill, find, sum, …) xs: T[:] f(arr), f(arr[1..]), f(slice) every array kind and slices
changes the length (push_n, insert_at, remove_at, retain, heap_push, …) xs: A&, where A is a generic bound to the whole array type f(arr) every array kind that has the operations used
takes a library type (dictionary, rng) d: dictionary<K, V>& d.insert(k, v) —

Rationale, and what was ruled out:

2.2 Results

2.3 Function-value parameters

2.4 Trait-like overload sets

Goose's overloading-by-type is its trait mechanism. The library defines a few names that user code is expected to extend by adding overloads for its own types:

Set Provided for Users add Consumed by
hash(x) -> u64 every scalar type, bool, u8[:] fn hash(k: mykey) -> u64 dictionary
compare(a, b) -> i64 u8[:] fn compare(a: ver, b: ver) -> i64 sorting strings
format(out: u8[>..]&, x) every type, structurally, by the builtin (§8.1) fn format(out: u8[>..]&, p: point) print/str/format, which use a user overload for its type wherever that type occurs

Two resolution facts (§7.1) shape how these are written:

2.5 UFCS

x.f(args) is f(x, args) (§7.1). By the passing rule (§8.7), x.f() binds x by reference exactly when f's first parameter is a reference type, for the same reason that a free call f(x) does. So d.insert(k, v), q.heap_push(v) and r.rand_int(6) all bind their receivers as their signatures specify. An earlier idea, a per-function marker for by-reference receivers, was withdrawn for two reasons. It would have taught callers to drop & everywhere, which brings back the accidental copy as soon as someone forgets to declare the marker. And it made the call form depend on knowing how each function was declared.

A reference-returning call is not an assignable location (get_or_insert(d, k, 0) += 1 is rejected); binding it needs the annotated form let c: i64& = d.get_or_insert(k, 0); (§3.8). The dictionary API sidesteps this with the block-taking update (§5.2).

2.6 Naming

2.7 Strings

There is no string type (§3.7); the library's string functions are functions on u8 arrays with these roles:

Role Type Notes
input u8[:] literals, any u8 array and any slice coerce
output being built u8[>..]& the "builder"; push/append/format are the primitives
owned, stored u8[], u8[varint], u8[..k] fields and elements; a u8[>..] result can be stored in them

All functions are byte-oriented and ASCII by default (to_lower, trim); each_utf8/push_utf8 are the only encoding-aware functions. Comparison is == (structural, §4.5) and compare. A character literal is an integer constant (§2): format(out, 'x') appends 120, and a byte is appended with out.push('x') or format(out, "x").


3. Module map

File Imports Contents Leans on (§8)
stdlib/std.goose — scalars, bits, hashing, rng; array HOFs, searching, sorting, heaps, resizing; strings, formatting control, parsing, UTF-8 default<T>() for sum; abort
stdlib/math.goose — libm as extern fn, PI/TAU, radians/degrees, is_nan/is_inf extern fn
stdlib/dictionary.goose std dictionary<K, V> and its functions default<T>()
stdlib/vec.goose std, math vec2/3/4<T>, float*, double*, int*, vector functions —
stdlib/os.goose std files, stdio, arguments, environment, time, sleep, entropy extern fn, runtime C in src/runtime/runtime_os.h

import std; must be written explicitly; nothing is imported automatically. The compiler resolves import std; by looking in three places, in order. First it looks in the importing file's package root (spec §11.1: the main file's directory for the program's own files, the library's directory for a library module). Next it looks in --stdlib <dir> or GOOSE_STDLIB, if given. Finally it walks up from its own executable's directory, looking for stdlib/std.goose (three levels). This way build/Debug/goose.exe, build/goose, bin/goose.exe and an installed <prefix>/bin/goose beside <prefix>/stdlib all find it, without requiring a single installation path. Unused library functions cost only parse time: functions are typechecked in call-graph order from main, so an unreferenced generic is never instantiated.


4. std

Signatures are written in documentation form. Where a function's return type is the element type of an A& parameter, the source omits the annotation (it is inferred from the body, §7.1) and the documentation writes -> T with "T = the element type". xs: T[:] accepts every sliceable array (§2.1).

4.1 Scalars and bits

fn min<T>(a: T, b: T) -> T
fn max<T>(a: T, b: T) -> T
fn clamp<T>(x: T, lo: T, hi: T) -> T
fn abs<T>(x: T) -> T            // integers; plus abs(f32) and abs(f64) overloads
fn rem<T>(a: T, b: T) -> T         // integers; pairs with `/`
fn div_euclid<T>(a: T, b: T) -> T  // integers; pairs with `%`
fn sign<T>(x: T) -> T           // -1, 0, 1 at x's type; f32/f64 overloads
fn lerp(a: f64, b: f64, t: f64) -> f64      // and an f32 overload
fn next_pow2(x: i64) -> i64     // smallest power of two >= max(x, 1)
fn popcount(x: u64) -> i64
fn clz(x: u64) -> i64           // 64 for x == 0
fn ctz(x: u64) -> i64           // 64 for x == 0
fn rotl<N>(x: u32, n: N) -> u32 // and u64, and rotr; n modulo the width
fn byte_swap(x: u32) -> u32     // and u16, u64
fn is_pow2<T>(x: T) -> bool
fn ilog2<T>(x: T) -> i64        // -1 for x <= 0
fn isqrt<T>(x: T) -> T          // aborts for x < 0
fn gcd<T>(a: T, b: T) -> T      // and lcm; assert the result fits
fn swap<T>(a: T&, b: T&)

rem and div_euclid supply the missing partners of truncating / and Euclidean % without changing either operator. They compute at the operands' integer type, using the truncating quotient and its remainder, whose product and subtraction fit whenever the quotient does. Forming a - a % b instead can underflow. rem handles divisor -1 before division, since its zero result fits even when the quotient does not. The test for a negative value with all bits set accepts unsigned types too and requires integer operands. div_euclid uses / for its initial quotient, preserving its overflow checks.

abs and sign have explicit float overloads. popcount/clz/ctz use the runtime's extern intrinsics (§8.4).

Rotations and byte swaps are runtime functions too, so that speed does not rest on the C compiler recognizing a shift idiom: they use MSVC's intrinsics and GCC's and clang's byte-swap builtins, and the rotate idiom GCC and clang both turn into rol/ror. The count is generic so that a u32 count, as hashes keep them, picks the u32 overload exactly; an i64 one would make every non-i64 count a conversion that ties the two overloads; it is masked to the width before it goes to the runtime. The other bit helpers are Goose. ilog2 follows the -1-for-none convention of positions rather than aborting. gcd and lcm reduce their operands to u64 magnitudes, so MIN needs no case of its own, and assert that the result fits back into T: a wrapped divisor or multiple is meaningless, so unlike * they check for overflow at unsigned types too.

4.2 Hashing

fn hash(x: i8) -> u64        // … one overload per integer type, i8 … u64
fn hash(x: bool) -> u64
fn hash(x: f32) -> u64
fn hash(x: f64) -> u64
fn hash(s: u8[:]) -> u64     // 8 bytes per multiply; any u8 array coerces
fn hash_combine(seed: u64, h: u64) -> u64   // for composite keys

The integer hashes are a multiply-xorshift mix (Fibonacci hashing), so the low bits are well distributed and dictionary can mask rather than divide. The byte hash reads 8 bytes at a time, with the length as the seed. Each word but the last gets a multiply and a rotate. The last word is the final 8 bytes (or, for inputs shorter than 8 bytes, the bytes gathered into one word), and it gets two multiply-xorshift rounds, so that every input bit reaches the low 32 bits that dictionary masks and keeps as slot tags. Otherwise, keys that differ only in their last characters (item_17, item_18) would share their low bits.

Each word's round has to wait for the previous word's multiply. So for keys longer than 24 bytes, the words before the last 24 bytes or fewer are split over two lanes (the even and the odd words of each 16 bytes), and a processor can overlap the two lanes' rounds. The second lane is rotated and folded into the first before the remaining words. This two-lane form is a separate function (hash_lanes), called from an early return, which the optimizer keeps out of line. That keeps the code for short keys small enough for the C compiler to inline into a dictionary's lookups.

A user key type defines fn hash(k: key) -> u64 { hash_combine(hash(k.a), hash(k.b)) }. Fixed-capacity inline strings (u8[..16]) hash through the slice overload, which is what makes them usable as dictionary keys.

4.3 Random numbers

struct rng { s: u64 }                    // splitmix64 state; the seed is the state
fn rand_u64(r: rng&) -> u64
fn rand_int(r: rng&, n: i64) -> i64      // uniform in [0, n)
fn rand_flt(r: rng&) -> f64              // uniform in [0, 1)
fn shuffle<T>(xs: T[:], r: rng&)         // Fisher-Yates

Create a generator with var r = rng { 12345 };, then call r.rand_int(6). splitmix64 has one word of state, uses only 64-bit arithmetic, and passes the usual test batteries; the benchmarks keep their own xorshift. To seed from the OS, use os's random_seed().

4.4 Arrays: reading and searching

fn each_rev<T, F>(xs: T[:])                    // F(x), last to first
fn each_chunk<T, F>(xs: T[:], n: i64)          // F(chunk: T[:], i); the last chunk may be short
fn find<T, F>(xs: T[:]) -> T?                  // first x with F(x), as a reference; null if none
fn find_index<T, F>(xs: T[:]) -> i64           // …or its index, -1 if none
fn position<T>(xs: T[:], v: T) -> i64          // first i with xs[i] == v, -1 if none
fn contains<T>(xs: T[:], v: T) -> bool
fn any<T, F>(xs: T[:]) -> bool
fn all<T, F>(xs: T[:]) -> bool
fn count<T, F>(xs: T[:]) -> i64                // number of x with F(x)
fn min<T>(xs: T[:]) -> T                       // asserts xs.len > 0; F(a, b) forms too
fn max<T>(xs: T[:]) -> T
fn min_by<T, F>(xs: T[:]) -> i64               // index of the smallest F(x); -1 if empty
fn max_by<T, F>(xs: T[:]) -> i64
fn last<T>(xs: T[:]) -> T&                     // asserts xs.len > 0
fn lower_bound<T, F>(xs: T[:]) -> i64          // first i with !F(xs[i]); F must be true on a prefix
fn binary_search<T>(xs: T[:], v: T) -> i64, bool   // sorted by <; position (or insertion point), found
fn find<T>(xs: T[:], sub: T[:]) -> i64         // first occurrence of a subsequence, -1 if none
fn rfind<T>(xs: T[:], sub: T[:]) -> i64
fn starts_with<T>(xs: T[:], p: T[:]) -> bool
fn ends_with<T>(xs: T[:], p: T[:]) -> bool

each_chunk exists because row slices are the documented way to get bounds checks out of 2-D kernels (bench/goose/blur_rows.goose); for y in h { each_chunk(img, w) … } is that idiom with the arithmetic done once. find/rfind/starts_with/ends_with are generic over the element type, so they also serve as the string functions (find(line, "://")). They coexist with the predicate find because a function-value argument and a slice argument never match the same overload.

4.5 Arrays: transforming

fn map<T, F>(xs: T[:]) -> U[>..]                 // U = F's result type (§8.5)
fn filter<T, F>(xs: T[:]) -> T[>..]
fn fold<T, A, F>(xs: T[:], acc: A) -> A          // acc = F(acc, x) for each x
fn sum<T>(xs: T[:]) -> T                         // fold from default<T>()
fn concat<T>(a: T[:], b: T[:]) -> T[>..]

map's body is var out = []; for x in xs { out.push(F(x)); } return out;, which uses the empty-array declaration of §8.5. sum needs default<T>(); fold(xs, 0.0) { a, x => a + x } is a form that does not need it.

4.6 Arrays: in place

fn fill<T>(xs: T[:], v: T)
fn copy_into<T>(dst: T[:], src: T[:])            // asserts equal lengths; memmove semantics
fn reverse<T>(xs: T[:])
fn sort<T>(xs: T[:])                             // by <
fn sort<T, F>(xs: T[:])                          // by F(a, b); unstable, in place, no allocation
fn stable_sort<T>(xs: T[:])
fn stable_sort<T, F>(xs: T[:])                   // natural merge sort; one temporary of xs.len elements
fn to_lower(s: u8[:])                            // ASCII, in place
fn to_upper(s: u8[:])

sort is a pattern-defeating quicksort modelled on pdqsort and Rust's ipnsort. A first pass finds where the run at the start of the input ends; if that run is the whole input, the sort is done. Otherwise each round takes a median pivot (the median of three elements, or, from 128 elements on, the median of three medians of three). It partitions with a branchless Lomuto loop (every element is swapped, and the boundary advances by the comparison's outcome). It then pushes the larger side onto an i64[..144] stack of (lo, hi, budget) ranges and continues with the smaller side. So the sort never recurses, allocates nothing, and holds at most log₂(2⁴⁸) ranges.

Everything to the left of a range sorts before or equal to everything in it. So when the pivot does not sort after the element just before the range, one pass moves the elements equal to the pivot to the front and drops them from the range, as pdqsort does. As a result, many equal keys take only linear passes.

Each range carries a budget of 2·log₂(n) rounds. When the budget runs out, heapsort finishes the range, which bounds the worst case at O(n log n). test/stdlib/stdlib_sort.goose checks this by using McIlroy's adversary to build an input that targets the pivot choice. Ranges of ten elements or fewer are insertion sorted: at those sizes, the branchy insertion sort measured faster than further partitioning.

One input shape is slower than it was with the Hoare partition that sort used before: input that is in order except for a few misplaced elements. The branchless partition swaps every element, which rotates each side of a sorted range by one. So nothing below the first run check sees any order to exploit, and such input takes about two thirds of the time of random input. The old quicksort's well-predicted scans took half the new time on it (200k elements: 1.6 ms against 0.8 ms; random input 2.5 ms against 8.3 ms).

stable_sort is a natural merge sort. One pass splits the input into runs that are in order: it reverses strictly descending runs and extends runs shorter than 16 by insertion sort. If the input is a single run, the sort ends there. The ends of the runs are stored in an i64[>..]. Each pass then merges neighboring pairs of runs between xs and a T[>..] temporary on a fresh data stack (a stack index, not an allocation). The passes alternate direction, so that no pass has to copy back.

A merge of two runs that are already in order just copies them. Otherwise each merge step either branches on the comparison or uses conditional moves to select the element and the side. A merge branches when the previous merge switched between its runs on fewer than one step in four. This is because a branch on random input mispredicts about every other step, while on structured input a predicted branch lets the comparisons overlap. That matters most for a comparator that loads what it compares (BWT's rank pairs: 3.7x between the two forms on its unsorted rounds, against 1.5x the other way on random integers).

Both sorts leave a permutation of the input, whatever the comparator returns. Every index stays in range however the comparisons come out, so a comparator that is not a strict weak ordering only results in an unspecified order.

4.7 Arrays: changing the length

fn push_n<A, T>(xs: A&, v: T, n: i64)            // n pushes; grow-only arrays' answer to resize
fn insert_at<A, T>(xs: A&, i: i64, v: T)         // shifts [i..) up; needs push
fn remove_at<A>(xs: A&, i: i64) -> T             // shifts down; needs pop; returns the element
fn swap_remove<A>(xs: A&, i: i64) -> T           // O(1), reorders
fn retain<A, F>(xs: A&)                          // keeps x with F(x), stable; needs resize
fn dedup<A>(xs: A&)                              // drops adjacent duplicates (sort first for all)
fn heap_push<A, T>(xs: A&, v: T)                 // min-heap by <; F(a, b) forms of all three exist
fn heap_pop<A>(xs: A&) -> T
fn make_heap<A>(xs: A&)                          // O(n) heapify

Each instantiates only for array kinds that have the operations it uses (a grow-only array pops through the reference wherever nothing in the caller points into it, spec §5.1). A [>..<] is the expected heap and queue container; a limited [..k] inside a struct works too.

4.8 Strings

fn format_int(out: u8[>..]&, v: i64, base: i64, width: i64, pad: u8)   // base 2..36, right-aligned
fn format_uint(out: u8[>..]&, v: u64, base: i64, width: i64, pad: u8)
fn format_flt(out: u8[>..]&, v: f64, decimals: i64)   // fixed decimals
fn parse_int(s: u8[:]) -> i64, bool              // optional sign, base 10, whole string
fn parse_int(s: u8[:], base: i64) -> i64, bool
fn parse_flt(s: u8[:]) -> f64, bool              // sign, fraction, exponent; correctly rounded
fn compare(a: u8[:], b: u8[:]) -> i64            // bytewise lexicographic: -1, 0, 1
fn trim(s: u8[:]) -> u8[:]                       // ASCII whitespace; a sub-slice, no copy
fn trim_start(s: u8[:]) -> u8[:]
fn trim_end(s: u8[:]) -> u8[:]
fn split(s: u8[:], sep: u8) -> u8[:][>..]        // slices into s; empty parts kept
fn split(s: u8[:], sep: u8[:]) -> u8[:][>..]     // a separator of several bytes
fn each_split<F>(s: u8[:], sep: u8)              // F(part: u8[:]); no array built
fn each_split<F>(s: u8[:], sep: u8[:])
fn words(s: u8[:]) -> u8[:][>..]                 // the runs between whitespace; none empty
fn each_word<F>(s: u8[:])                        // F(word: u8[:])
fn join<T>(out: u8[>..]&, parts: T[:], sep: u8[:])   // T any u8 array/slice type
fn format_replaced(out: u8[>..]&, s: u8[:], old: u8[:], with: u8[:])   // `from` is a keyword
fn each_utf8<F>(s: u8[:])                        // F(cp: i64); malformed bytes yield 0xFFFD
fn push_utf8(out: u8[>..]&, cp: i64)             // the UTF-8 bytes of a code point
fn ascii::is_digit(c: u8) -> bool                // ASCII only, as <ctype.h> in the C locale
fn ascii::is_alpha(c: u8) -> bool
fn ascii::is_alnum(c: u8) -> bool
fn ascii::hex_value(c: u8) -> i64                // 0-15, -1 if not a hex digit

Plain rendering of scalars and strings is the builtin format (§8.1); the functions here add formatting control and parsing. split, which returns slices into its input (no copies, with elements rooted at the caller's text), is the Goose replacement for Lobster's tokenize; the each_split form does not build an array at all. words is the whitespace tokenizer a command line needs, and split at ' ' is not: words drops the empty parts that repeated spaces and tabs would leave.


5. dictionary

5.1 Representation

struct dictionary_slot<K, V> { key: K, val: V, tag: u32 }
struct dictionary<K, V> { count: i64 = 0, slots: dictionary_slot<K, V>[>..<] = [] }

5.2 API

fn get<K, V>(d: dictionary<K, V>&, key: K) -> V?              // reference to the value, or null
fn contains<K, V>(d: dictionary<K, V>&, key: K) -> bool
fn insert<K, V>(d: dictionary<K, V>&, key: K, val: V) -> bool  // overwrites; true if the key was new
fn get_or_insert<K, V>(d: dictionary<K, V>&, key: K, v0: V) -> V&
fn update<K, V, F>(d: dictionary<K, V>&, key: K, v0: V)        // F(val&): inserts v0 if absent, then calls F
fn remove<K, V>(d: dictionary<K, V>&, key: K) -> bool
fn clear<K, V>(d: dictionary<K, V>&)
fn reserve<K, V>(d: dictionary<K, V>&, n: i64)                 // capacity for n entries without rehash
fn each<K, V, F>(d: dictionary<K, V>&)                         // F(key, val&); order unspecified

Word counting, the canonical use (prototyped end to end):

var counts = dictionary<u8[:], i32> {};
each_split(text, ' ') { counts.update(it, 0) { it += 1; } };
counts.each() { w, n => if n > 100 { print(w, " ", n); } };

update exists because the natural form, get_or_insert(d, k, 0) += 1, is not an assignable location, and the annotated binding needs the value type written out (§2.5). A block receives the reference without either problem.

A reference returned by get or get_or_insert, or an each binder, may not be in scope at the next insert, update, remove or clear. The slot array is grow-shrink, so the checker reports this (spec §5.2) rather than letting a rehash or a backward shift leave the reference pointing at a different, well-typed slot. Bind such references in their own block, or use update.

5.3 Sets and other idioms


6. vec

struct vec2<T> { x: T, y: T }
struct vec3<T> { x: T, y: T, z: T }
struct vec4<T> { x: T, y: T, z: T, w: T }
type float2 = vec2<f32>;   type float3 = vec3<f32>;   type float4 = vec4<f32>;
type double2 = vec2<f64>;  type double3 = vec3<f64>;  type double4 = vec4<f64>;
type int2 = vec2<i32>;     type int3 = vec3<i32>;     type int4 = vec4<i32>;

fn dot<T>(a: vec2<T>, b: vec2<T>) -> T            // and vec3, vec4
fn cross<T>(a: vec3<T>, b: vec3<T>) -> vec3<T>
fn length_sq<T>(v: vec3<T>) -> T                  // all three sizes
fn length(v: float3) -> f32                       // f32 and f64 instances, all sizes
fn normalize(v: float3) -> float3
fn distance(a: float3, b: float3) -> f32
fn lerp<T>(a: vec3<T>, b: vec3<T>, t: T) -> vec3<T>
fn xy<T>(v: vec3<T>) -> vec2<T>                   // also xy(vec4), xyz(vec4)
let float3_0 = float3 { 0.0, 0.0, 0.0 };          // _0 and _1 for all nine aliases
let float3_1 = float3 { 1.0, 1.0, 1.0 };

Elementwise + - * / are provided by the language (§6.1). The prototype confirmed that a type float3 = vec3<f32> alias constructs positionally, adds memberwise and instantiates generic dot for f32 and i64. The int* aliases are i32 because vectors are data (§3.1) and an i32 component widens into an i64 index implicitly. Elementwise min/max/abs are deliberately absent: a min<T>(a: vec3<T>, b: vec3<T>) overload is ambiguous with the scalar min<T>(a: T, b: T) (both are at the generic tier), and min(a.x, b.x) per component is what a user writes anyway. The lengthening constructors (vec3(v, z)) were dropped: vec3 { v.x, v.y, z } is as short and avoids a function named like a type.


7. math and os (extern-backed)

math is libm, declared once per function and float width:

extern fn sqrt(x: f64) -> f64;        extern fn sqrt(x: f32) -> f32 = "sqrtf";
// sin cos tan asin acos atan atan2 exp log log2 log10 pow floor ceil round trunc, likewise
let PI = 3.141592653589793;  let TAU = 6.283185307179586;
fn radians(deg: f64) -> f64
fn degrees(rad: f64) -> f64
fn is_nan(x: f64) -> bool             // x != x
fn is_inf(x: f64) -> bool

floor/ceil/round/trunc return floats, as in C; as i64 converts. Float % is already fmod (§6.1).

os wraps a limited set of C functions in src/runtime/runtime_os.h, enough to write tools and benchmarks:

fn read_file(path: u8[:], out: u8[>..]&) -> bool     // appends the whole file
fn write_file(path: u8[:], data: u8[:]) -> bool
fn append_file(path: u8[:], data: u8[:]) -> bool
fn file_exists(path: u8[:]) -> bool
fn delete_file(path: u8[:]) -> bool
fn read_line(out: u8[>..]&) -> bool                  // stdin; strips the newline; false at EOF
fn read_stdin(out: u8[>..]&)                         // everything until EOF
fn write_stdout(s: u8[:])
fn write_stderr(s: u8[:])
fn flush_stdout()
fn args() -> u8[:][>..]                              // slices of a global buffer, over extern arg_count()/arg(i, out)
fn env(name: u8[:], out: u8[>..]&) -> bool
fn time() -> f64                                     // seconds since the epoch
fn clock() -> f64                                    // monotonic, high resolution
fn time_ns() -> i64      fn clock_ns() -> i64
fn sleep(seconds: f64)   fn sleep_ms(ms: i64)
fn random_seed() -> u64                              // OS entropy, for rng

Directory listing, subprocesses, sockets and date formatting are not in v1. exit(code) is a builtin rather than an extern because the checker must know it diverges (§8.3).


8. Builtins and language rules the library needed

All of these are now in the spec. They are ordered by how much of the library depends on them.

8.1 The print / str / format family

Three builtins share one rendering engine. Each takes any number of arguments of any type and renders them in sequence with no separator:

print(a, b, …)                 // stdout, newline at the end
str(a, b, …) -> u8[>..]        // a fresh string, constructed at the destination
format(out, a, b, …)           // append to any growable u8 array; out.format(a, b, …) via UFCS

str covers the need to quickly concatenate a few things into a new string, which Goose does in place and should encourage: words.push(str( "item", i)) writes directly into the element slot. Each type is rendered as follows:

Code generation specializes rendering for each type, using a traversal like the one for equality. This follows Lobster's approach to printing values for debugging (spec §3.7).

8.2 default<T>()

This is the value a fixed-size T has before anything is written to it, with declared defaults honoured: numbers 0, false, empty arrays (length 0, capacity as declared), null optionals, an empty slice, variant 0 with its own default payload, and structs field by field. A field with a declared default (§3.2) gets that default, and every other field gets its type's default. So a struct that declares count: i64 = 1 as an invariant keeps it when the library fills a slot with default. sum needs it (an accumulator seed for generic T), and so does dictionary (fill values for fresh slots); it is also useful anywhere resize(n, v) needs a neutral v. It is a builtin because a generic function has no way to write a literal of an unknown type (§4.1), and because only the compiler knows the field defaults.

8.3 abort(msg: u8[:]) and exit(code: i64)

Both are diverging calls: code after them is unreachable, so a branch that ends in one gives no value (§6.4). abort prints goose runtime error: <msg> and exits nonzero, like the checked aborts of §9.3; exit flushes and exits with the code. The library uses abort for contract violations that are not plain asserts (an unknown base in format_int or format_uint).

8.4 extern fn (spec §7.10)

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

8.5 map and the empty-array declaration (spec §4.2)

No change to generics is needed: a generic body is checked only at an instantiation where every type is concrete, including what F(x) returns (spec §7.7). What map does need is its result local, var out = [];, whose [] has no element type yet. An empty-array local without an annotation gets a pending array type, which the first push/append/assignment of a concrete element completes. Using the local in a way that needs the element type before then (out[0]) is an error, while out.len is fine. A body that never completes the type is an error at the end of the body. Codegen sees only completed types. So map is

fn map<T, F>(xs: T[:]) { var out = []; for x in xs { out.push(F(x)); } return out; }

with the return type inferred as usual (§7.1), and the same rule makes var names = []; idiomatic user code.

8.6 Where import std; looks (spec §11.1)

This consists of the search path of §3, a --stdlib flag and a GOOSE_STDLIB variable. The compiler finds the source tree's stdlib/ from its own location, so the test runner needs no flag.

8.7 Checker and language rules

Passing by size class (spec §4.1). This rule replaced "explicit & on both ends". It depends on the size class of the value being connected to a destination, where a destination is a call argument, a let/var initializer, an assignment, a field initializer, a push, a for binding, a match binder or a queue put:

The rule applies to individual constructs as follows:

For the library, this means no binder parameters and no & in any example; UFCS works for every mutator; generic HOFs could even be untyped; and the library's own copies become visible where they are intended (stable_sort's temporary is a construction).

Pros, beyond the UFCS fix that motivated the rule:

Cons, and what limits them:

Slices of [>..<] (spec §5.2). Creating a slice or reference into a [>..<] is legal. A shrinking operation on that array (pop, resize, clear, whole assignment) while a variable in scope may refer into it is a compile error at the shrink. The error names the variable and where it was bound, so the programmer can fix either end: end the slice's block before the shrink, or move the shrink. Scope stands in for liveness. This is the same test that a grow-only local's shrink already used (§5.1), and it needs no last-use analysis. Inside a loop it is exact, since a binding made earlier in the body is in scope at the shrink. Such a slice may be passed down and returned, but not stored into a container (a field, an element, a global); that restriction is what keeps every holder a variable the scan can see. Some shrinks cannot be seen by a scope scan: those through a reference parameter, and those of a global. These are summarized per function specialization (which globals, which parameters' pointees, transitively through callees), and a call is checked against the summary in the same way, so the error for a callee's shrink is reported at the call. The memory argument still holds: a live [>..<] owns its stack region exclusively (§1.3(2)), so the rule catches logic errors, not memory errors. The same pass treats the whole assignment of a grow-only array as the shrink that it is (§5.1).

References to resizable tails (spec C.2). Before the frame object existed, &bag.items was rejected. A resizable-tailed struct was a single frame header { base, len }, whose base is the start of the struct on the data stack (static prefix fields, then tail elements) and whose len is the tail's count. A T[>..]& is a pointer to a header whose base must be the element region, and there was no such header for the tail alone. An earlier option, offsetting the struct's header, fixes this at the cost of addressing every struct relative to its tail. The review's instinct, to give the tail its own independent header, leads to this layout: a resizable-tailed struct is represented as a frame object that holds its fixed prefix fields exactly as a fixed local would, followed by the tail's own gs_rhdr. Only the tail's elements are stored on the data stack. With this layout:

Copying such a value copies the frame object and the elements (the RzShape prefix walk of the bytes-on-stack shape becomes a struct copy plus EmitCopyElems). By-value parameters and returns pass the frame object where an array passes its header (C.3 passes "the metadata appropriate to the type"). Nesting works the same way: a struct whose tail is itself a resizable-tailed struct nests frame objects. The two awkward cases keep the bytes-on-stack shape, behind a base pointer in the frame object: a variable-size prefix (a u8[] field before the tail, which RzShape already refuses to copy), and a resizable-class ADT, whose payload shape differs per variant. Spec C.2 changes from "outermost values have a frame header" to "a resizable-class value's fixed part lives in the owning frame, and every resizable tail has its own header there". In the compiler, StructInst::frameobj marks the shape, and codegen's IsFrameObj routes locals, parameters, returns, globals, copies, literals and references through the frame object. The checker's "nested resizable" error remains only for the variable-size-prefix and ADT shapes.

Untyped parameters bind exact types (spec §3.8). An untyped parameter binds a reference argument as a reference, exactly as an explicit <T> parameter does, so f(&gs) into fn f(xs) mutates gs.

return null is root-neutral (spec §9.2). fn find<T, F>(xs: T[:]) -> T? written as for &x in xs { if F(x) { return x; } } return null; typechecks: a null return names no root, so it adds nothing to the roots the other returns give.

Generic type aliases (type set<K> = dictionary<K, bool>;) do not parse. Non-generic aliases of generic instances (type float3 = vec3<f32>;) work and are all vec needs, so this is a convenience only.

Constants in thread programs. let globals of flat fixed type are static data (§11.1); a thread_fn should be allowed to read them (PI, float3_0), since they are not mutable shared memory. This is still open: the checker bans every global from a thread program.

Identity of references (spec TODO 0b) is not needed by the library. lru.goose relinks its entries with index_of (spec §3.3), the subtraction the checker validates through roots.


9. Deliberately not included

These operations can be expressed using existing functions and short loops: