This is the target contract for owned Vec[T] and a string builder. A scoped
Vec(T) subset now supports default initialization (including locals in the
portable VM), checked indexing, fallible VecPush, VecClear, VecFree,
VecSwap, VecPop and VecGet results carried as Option(T) records, a
Vec(u8) string builder through BuilderAppend and BuilderFinish, an
explicit VecClone(dest, src) with a recoverable failure result, and
VecSlice(values, low, high) borrowed views, in C99,
Go, and the portable VM. A vector can live in a local, global, record
field, or fixed-array element reached from a record. Direct vector bindings,
whole aggregates containing vectors, vector-bearing record-literal fields, and
plain record-member paths rooted at a local aggregate have move checks on
declaration, assignment, argument passing, and return. Go can report capacity overflow but
its runtime may terminate on physical allocation failure; this is not yet the
full recoverable failure contract below. VecPop and VecGet require the
Option record template from std/option.zi to be visible in the using module.
Current safety limits: C99, Go, and the portable VM drop direct owned
Vec locals and parameters at block close, return, break, and continue. They
also recursively drop vectors reachable through record fields and fixed arrays.
A direct move clears its source before the next drop; an aggregate move clears
every reachable vector field in native targets, while the portable VM clones at
the destination boundary and relies on the checker to make the source
unreachable. Moving a plain record-member path clears only that field in native
targets and invalidates only the corresponding VM storage slot. An aggregate
move source must be a whole local binding or fresh call result; a member move
must be rooted at a local aggregate. A record literal can initialize a
vector-bearing field from a local vector or fresh call result; using that
source again is rejected. Indexed and pointer-backed member paths, and paths
rooted at a global, remain whole-aggregate/global move errors. A direct or
record-held TextView rejects mutation of its local backing place until the
view leaves scope, including views returned by checked calls and views nested
in their result records. The check is field-sensitive: sibling record fields
remain writable, while the borrowed field, its array storage, and whole
aggregate replacement are rejected. A view-bearing global conservatively keeps
its global backing aliases for the rest of the program: assignment from local
or parameter-backed storage is rejected, and mutation of a tracked backing
global is rejected in any function. A view reached through a local pointer created
with Jai *value protects its pointee binding and pointer aliases while live.
Local copies of pointer parameters keep the parameter's backing identity.
TextView through an opaque local pointer is rejected when its backing lifetime
cannot be proved. Separate pointer or slice parameters with the same type are
treated as possible aliases, with sibling record fields still disjoint.
Checked procedure bodies summarize parameter and global writes, including
transitive calls; a live view rejects a call that writes its backing storage.
String argument temporaries stay live through later argument evaluation and
the call, and unknown callbacks conservatively write reference arguments.
The checks use lexical scopes; close a view's scope before reusing its buffer.
Differently typed pointer aliases and raw native FFI still need explicit
memory contracts. A raw foreign declaration has no checked read/write summary;
the caller remains responsible for its pointer arguments. The current origin
summary tracks every global in the program. The
rest of this page describes the intended contract, not a guarantee that every
case is enforced today.
A fresh Vec call result used as a whole expression statement is dropped
immediately. A result used for member or index access must first be bound to a
local so its lifetime is explicit; an ordinary call can consume the fresh
result directly.
BuilderFinish consumes the builder: the returned string keeps the builder's
bytes without copying them on native C when nonempty. That target
still retains nonempty finished bytes for process lifetime; it now releases
reserved storage when the result is empty. Go copies into its immutable string
representation. The VM copies into an owned snapshot, preserves its owner
through aliases and ranges, and collects unused snapshots at safe statement
and call boundaries. Appending an empty string always succeeds
without allocating.
Text in std/vec is owned text: a record whose one field is
bytes: Vec(u8), so it moves and drops like any record that owns a Vec,
in a local, a record field or a Vec(Text) element. TextCopy(value) copies
a string into one. TextView(text) borrows a Text place in place, and a
Text place passed, returned, assigned or declared where a string is
expected lends the same view. The view follows the lifetime rules above: it
cannot outlive its Text, and the Text cannot be mutated or moved while the
view is live. TextView of a fresh call result is rejected; bind the Text
first. tests/owned_text.sh checks C with an allocation counter and under
AddressSanitizer, Go and portable bundles from source and saved IR.
A Text call result, and BuilderFinish where a Text is expected, is
kept by an owner that takes it: a declared, assigned or returned Text, a
Text parameter, element or field, or a discarded statement. The checker
rewrites a Text call result anywhere else into vec.TextDetach(result),
which keeps the string semantics described above: the bytes stay allocated
for the rest of the program. A detached result that is only read within its
statement, by a comparison, .count or a string parameter whose callee's
checked body only reads it, instead moves into a scoped Text local declared
before the statement, which lends its view and frees it when the block
closes; BuilderFinish in such a position becomes TextFromBuilder the same
way, and so does a string local such as line := Render(...) initialized
from one when every use after its declaration inside its block only reads
it. A read may pass through an ifx arm, a local, a procedure that returns
its argument where that result is only read, or a record literal field
handed to a callee whose record parameter only reads that field, followed
transitively. Results computed after another call in the same expression,
after a global the producing call may write, in a while condition or
for step, or in an ifx arm giving a string stay detached so nothing runs
earlier than written; literals, -1 and globals it leaves alone may come
first. An else if becomes an else block holding a plain if, and the
second operand of && or || or a scalar ifx arm runs as statements
that leave its value in a local, so results read there are scoped to their
own blocks (tests/text_condition_arms.sh). tests/text_scoped_arguments.sh shows no allocations left in 1000 rounds of such reads and keeps stored, pushed,
returned, aliased, deferred and global arguments and locals alive.
tests/owned_text_results.sh counts 1000 allocations kept in 1000 rounds of
three Text results used as strings, from the one read after a call, and
none kept as Text. The standard producers, such as TextReplace,
Float64Repr, JsonWrite and JoinPath, return Text the same way;
tests/std_text_results.sh measures 1000 allocations kept in 1000 rounds of
four producers used as strings, and none kept as Text.
Generic
Option and Result are currently ordinary records with explicit status
fields, applied as Name :: Option(T) or Name :: Result(T, E). They copy
their fields. Fixed arrays and borrowed slices remain value and view types,
respectively.
Values and ownership
Scalars, immutable strings, and aggregates whose members are copyable retain
value-copy semantics. An owned value, including Vec[T], should move on assignment,
argument passing, and return. A move source must be a local binding, a plain
record-member path rooted at a local aggregate, or a fresh call result. The
member can be a Vec or a record holding Vecs, as in last := popped.value. The
checker rejects moving indexed or pointer-backed member storage and moving a
global, because their aliases cannot yet be represented precisely enough. The
checker rejects use after move and assignment over a direct or aggregate-held
owned vector; moves inside an if whose every arm returns do not reach the
join. The target rule is that every path drops each owned local exactly once,
with a moved source zeroed so a later drop is harmless. All current backends
implement this for direct Vec bindings, plain record-member paths, and
recursively owned vectors in records and fixed arrays.
A view declared as []T from
VecSlice(values, low, high) borrows its source binding until the view's
scope closes: moving or mutating the source, including VecPush, VecPop,
VecClear, VecFree, VecSwap, and the string builder operations, is
rejected while the view is live; VecGet and VecClone sources stay
readable. The source can also be a Vec field reached from a local record or
pointer, as in VecSlice(h.items, 0, n), h.items[1:], or the view a
for h.items or for it.parts loop takes. That borrow is field-sensitive:
elements stay writable, and so do sibling fields and their Vecs, while
resizing or freeing the viewed Vec, moving or assigning it or a record that
holds it, or calling a procedure whose summary does either, is rejected.
The source can be a global Vec, or a Vec field of a global record, under the
same rules: while the view is live, growing, freeing or replacing that Vec is
rejected here and in any procedure whose summary does so. Because a later
push anywhere may move a global's elements, such a view also lives no longer
than its block: returning it or storing it in a global or an outer binding
is rejected. VecClone(dest, src) requires a fresh or moved-from destination
and returns false on allocation failure without changing the source.
Native output and the portable VM agree on the observable value of
every moved binding: the source is unreachable after the move.
Recoverable errors
Result(T, E) stores is_ok, value, and error; a caller checks is_ok
and handles the error explicitly. Option(T) stores has_value and value.
Neither record has a hidden active case, and the language does not provide
postfix error propagation. An owned error type will need defined move and drop
behavior across all backends before it can be used in a portable Result.
Growable storage
Vec[T] owns its allocation and reports length and capacity separately.
Growth checks size overflow and returns an allocation error without changing
the vector. VecPush consumes its input on either outcome; VecPop removes
the last element and returns Option(T), leaving the vector unchanged when it
is empty. Indexing has the same checked bounds behavior as fixed arrays, and
VecGet returns Option(T) instead of trapping on an out-of-range index. A
string builder owns UTF-8 bytes in a Vec[u8]; BuilderAppend can fail, and
BuilderFinish consumes the builder without copying its bytes. Immutable
string values remain unchanged.
The parser, checker, checked .zir, C/Go emitters, portable verifier, VM,
and host boundary implement the direct-vector operations above, with the
ownership and automatic-drop limitations called out at the start of this page.