Vow Grammar Reference¶
Complete grammar for the Vow programming language. Vow source files use the .vow extension.
Line comments. // starts a line comment extending to end of line. Comments are stripped during lexing and never enter the token stream. Block comments (/* */) are not supported. Machine-relevant intent belongs in contracts; comments are for non-semantic rationale.
Module Declaration¶
Every file begins with a module declaration:
<Name> is a PascalCase identifier. There is no semicolon.
Use Declarations¶
Import other modules with dot-separated paths:
This resolves relative to the main source file. The module loader first uses
<rootdir>/foo/bar.vow.d when that declaration stub exists, and otherwise
falls back to <rootdir>/foo/bar.vow. If the stub's declarations carry a
vow block, the stub cannot be relied on: a bodyless declaration has no
implementation for the verifier to check a call site against, so a contract
there would otherwise be silently dropped from verification. In that case the
loader loads the sibling <rootdir>/foo/bar.vow source instead, where the
usual intra-module requires-as-assert/Caller-blame mechanism applies
unchanged. A stub whose declarations carry no vow block is unaffected and
is still preferred over source. A stub shipped with no sibling .vow source
at all (e.g. a library distributing only its interface) is also unaffected —
a call through it remains non-modelable in the verifier (Skipped, never
falsely Verified).
Const Declarations¶
Named constants with compile-time values:
const MAX_SIZE: i64 = 1024;
const NEG_ONE: i64 = -1;
const DEBUG: bool = true;
const SLOTS: u64 = 8;
const MAX_BYTE: u8 = 255;
Supported value forms: integer literals, boolean literals, negated integer literals. Constants are inlined at every use site (zero runtime cost). The type must be any of the 10 integer types (i8, i16, i32, i64, i128, u8, u16, u32, u64, u128) or bool. Integer constants are subject to the same compile-time range check as integer literals: a value outside the declared type's range, including a negative value for an unsigned type, is LiteralOutOfRange. A constant keeps its declared type at every use (it does not context-coerce like an unsuffixed literal), so a u64 constant indexes a Vec directly and an i64 constant does not. Constants are referenced by name in expressions like any other identifier.
Functions¶
Pure Function¶
Function with Effects¶
Effects appear in brackets after the return type: [io], [read, write], [io, panic].
Declaration-Only Function¶
A semicolon in place of the body declares only the function signature. The
type checker registers the signature, but body checking and IR lowering skip
the declaration. vow decl emits these declarations in .vow.d stubs.
Function with Vow Block¶
fn divide(x: i64, y: i64) -> i64 vow {
requires: y != 0
requires: x >= -9223372036854775807 || y != -1
} {
x / y
}
The vow block sits between the signature and the body. Clauses:
- requires: <expr> — precondition (blame: Caller)
- ensures: <expr> — postcondition (blame: Callee); use result for the return value
- invariant: <expr> — loop invariant (blame: Callee)
Multiple clauses are separated by commas; the comma may be omitted. A semicolon between clauses is a parse error:
fn clamp(x: i64, lo: i64, hi: i64) -> i64 vow {
requires: lo <= hi,
ensures: result >= lo,
ensures: result <= hi
} {
if x < lo { lo } else { if x > hi { hi } else { x } }
}
Where Clauses (Refinement Types on Parameters)¶
fn safe_sub(a: i64 where a >= 0, b: i64 where b >= 0) -> i64 vow {
requires: a >= b,
ensures: result >= 0
} {
a - b
}
where constraints on parameters become additional requires in verification (and Caller-blame runtime checks under --mode debug). A where clause is checked exactly like a requires clause, in a scope holding only its own parameter plus module constants and functions:
- It can only reference its own parameter. A sibling parameter or
resultis an undefined name (TypeMismatch, "undefined variable"), with a hint pointing atrequires/ensuresfor conditions that span several parameters or the return value. Any other undefined name is the same error with the usual "did you mean" hint. This is a type error in both compilers; it never reaches IR lowering. - It must evaluate to
bool(ContractTypeMismatch, hint "parameterwhereclauses must evaluate tobool"). - It must be pure: no call to an effectful function and no heap write through an argument (
EffectViolation, see "Contract Purity"). - It cannot contain a tuple expression (
UnsupportedFeature, "tuple expressions are not supported in contract predicates"). - Integer literals are range-checked against the compared type, and the unsigned-comparison rules apply as in any other expression (
LiteralOutOfRange,TautologicalComparison).
On a declaration-only function (fn f(x: i64 where x > 0) -> i64;) and on the parameters of an extern function the clause is checked by the same rules but has no body to enforce it in: state the foreign function's real preconditions in its vow contract.
Public Functions¶
Types¶
Primitive Types¶
| Type | Description |
|---|---|
i8 |
8-bit signed integer |
i16 |
16-bit signed integer |
i32 |
32-bit signed integer |
i64 |
64-bit signed integer |
i128 |
128-bit signed integer (verifier may time out; see below) |
u8 |
8-bit unsigned integer |
u16 |
16-bit unsigned integer |
u32 |
32-bit unsigned integer |
u64 |
64-bit unsigned integer |
u128 |
128-bit unsigned integer (verifier may time out; see below) |
f32 |
32-bit float (IEEE-754; the native backend models + - * / and comparisons, see cli.md, and skips %; the ESBMC backend has limited support) |
f64 |
64-bit float (IEEE-754; the native backend models + - * / and comparisons, see cli.md, and skips %; the ESBMC backend has limited support) |
bool |
Boolean |
() |
Unit type; its only value is also written () (not allowed as a parameter type) |
! |
Never type (diverges) |
Vow targets 64-bit only and has no isize/usize. Excluding pointer-width
types preserves binary fixed-point reproducibility across compilation hosts;
see ADR 0001. The signedness of a
length is independent of this determinism rationale, so
ADR 0003 makes lengths fixed-width u64:
.len() on Vec, String, HashMap, and BTreeMap returns u64. A Vec
index expression has exactly the type u64 (see Indexing), so
v[i] with i: u64 needs no cast, and v[i] with i: i64 is a
TypeMismatch. String offsets (byte_at, substr, substring,
matches_literal_at) are u64 too, and push_byte takes a u8; see
String offsets.
128-bit implementation status: i128/u128 are executable end to end:
full-range literals, arithmetic, casts, narrowing, and parse_i128/parse_u128
compile natively. A 128-bit value may be an Option, Result, or enum
payload, a struct field, or a Vec element (see the layout below). The ESBMC backend does not model 128-bit aggregates and constants, so
it reports Skipped rather than an unsound result; the native backend
(--backend native) models them exactly. Never weaken
contracts to fit the verifier.
Aggregate layout: every struct field and enum payload occupies one 8-byte
slot regardless of declared type (narrow ints are padded). An i128/u128
struct field or enum payload occupies two consecutive 8-byte slots, low limb
first (ADR 0001 decision 9), so
every field or payload after it moves up by one slot and the allocation grows
with it. A struct has no tag, so its first field is slot 0; an enum's tag takes
slot 0 and payloads start at slot 1. A Vec<i128>/Vec<u128> element is 16
bytes (two slots, low limb first); len and cap still count elements. There
is no packing or natural-alignment layout today; FFI structs that need a
specific C layout must shim through Vec<u8> or extern wrappers.
Built-in Parameterized Types¶
| Type | Description |
|---|---|
Vec<T> |
Growable array. T must be non-linear (see Linear Structs) |
Option<T> |
Optional value (Some/None) |
Result<T, E> |
Success or error |
String |
UTF-8 string (backed by Vec |
HashMap<K, V> |
Key-value map (open-addressing hash table; expected O(1) amortised insert/get/contains_key/remove). K must be an integer type of at most 64 bits or bool; V may be any non-linear type except i128/u128/f32/f64 |
BTreeMap<K, V> |
Sorted key-value map (binary search; ascending iteration). K must be i64; V may be any non-linear type except i128/u128/f32/f64 |
Slice Types¶
The syntax [T] is not a type in Vow. It parses, but the type checker rejects it
wherever a type is written (parameter, return, field, enum payload, let
annotation, cast target, alias or constant) with UnsupportedFeature ("slice types
([T]) are not supported in Vow"), once per bracket pair. No expression creates,
indexes, iterates or measures a slice, so no value of that type could exist. Use
Vec<T> to hold a sequence of values. See
Slice types for the diagnostic.
User-Defined Types¶
Structs and enums (see below). A struct, enum or type alias may not be named after
a type the language already binds: a primitive type (i64, bool, String, ...)
or one of Vec, Option, Result, HashMap, BTreeMap. The declaration is
rejected with UnsupportedFeature ("Vec is a builtin type name and cannot be
declared as a user type"), because the resolver binds those names
before any user type and the user type would otherwise alias the builtin in some
positions and shadow it in others. See
Reserved type names for the diagnostic.
Literals¶
Integer Literals¶
Unsuffixed integer literals default to i64 in expression position, and
context-coerce to any of the 10 integer types when the
surrounding context fixes one — let bindings, function arguments, struct
fields, and the typed operand of an arithmetic, bitwise, or comparison
operator. The same coercion applies to constant expressions composed entirely
of unsuffixed integer literals (e.g. 1 + 2, 1 << 3, -5).
Out-of-range literals in a typed context are a compile-time error:
let x: u8 = 300; // error: LiteralOutOfRange — 300 does not fit in u8
let y: i8 = 200; // error: LiteralOutOfRange — i8 range is -128..=127
Suffixed integer literals force the type at the literal:
Suffixed forms are supported for all 10 integer widths. A suffixed literal is
sugar for the cast NNN as T (so 42u8 and 42 as u8 are the same AST and
print as 42 as u8). The suffix fixes the literal's type: it is never
coerced to the surrounding context, so let x: i64 = 5u8; is a TypeMismatch.
The value is range-checked against the suffix type, so 256u8 is a
LiteralOutOfRange error. A negated literal is checked as a negative value
(-128i8 is valid), and negating an unsigned literal is a TypeMismatch
(-1u32 is an error). Suffixes inside match patterns are not part of this rule.
There are no usize/isize suffixes, because there are no such types. A literal
carrying one is an InvalidIntSuffix error at lex time rather than a silently
dropped suffix:
Integer tokens store their decimal digits as an unsigned magnitude. A leading
- is a separate unary-negation expression and is not part of the literal.
For i128 and u128, compiler IR and serialized modules carry that value as
two unsigned 64-bit limbs (lo, hi), least-significant limb first. This wire
layout is shared by the Rust bootstrap and self-hosted compilers.
Float Literals¶
Out-of-range literals are a compile-time error, same as the integer case: a
literal whose magnitude exceeds f64::MAX (~1.7976931348623157e308) parses
to infinity and is rejected as InvalidCharacter at lex time, rather than
silently becoming a float value that has no valid C double-literal
representation:
Boolean Literals¶
String Literals¶
Supported escape sequences: \n, \t, \r, \\, \", \0.
A literal that reaches end of file without a closing quote, or a trailing \
with nothing to escape, is an UnterminatedString error. A byte that starts no
token is an InvalidCharacter error. Neither is skipped silently.
String literals have type String and are backed by a read-only static
descriptor. Passing or returning a literal does not allocate. To obtain a
mutable, arena-owned copy, use String::from("...").
Operators¶
Wrapping Arithmetic (default)¶
| Operator | Meaning |
|---|---|
+ |
Add (wrapping) |
- |
Sub (wrapping) |
* |
Mul (wrapping) |
/ |
Div (wrapping) |
% |
Rem (wrapping) |
Wrapping operators silently wrap on overflow. For unsigned operands, including
u8, division and remainder use unsigned semantics.
Division and remainder are the exception when no wrapped result exists. A zero
divisor aborts with ArithmeticOverflow for /, %, /!, and %!; signed
MIN / -1 likewise aborts for both / and /!. These rules apply at every
integer width. Signed MIN % -1 is representable as 0 and does not abort.
For f32 and f64, the unchecked +, -, *, and / operators lower to
native floating-point arithmetic. Unchecked % is accepted by the frontend
and lowers to a floating-point remainder opcode, but native backends do not yet
implement that opcode; a build fails closed with CodegenUnsupported. The
checked operators (+!, -!, *!, /!, %!) are rejected on f32/f64
operands at type-check time with UnsupportedFeature: "checked" means "abort
instead of wrapping on integer overflow," and IEEE-754 float arithmetic has no
wrapping semantics to check against, so there is no principled meaning to give
a checked float operator. Use the unchecked operators for float arithmetic.
Checked Arithmetic¶
| Operator | Meaning |
|---|---|
+! |
Add (checked) |
-! |
Sub (checked) |
*! |
Mul (checked) |
/! |
Div (checked) |
%! |
Rem (checked) |
Checked operators abort with ArithmeticOverflow on overflow. Operands must be
integer types; f32/f64 operands are rejected at type-check time (see
above).
In verification. The abort is modelled, not ignored: a checked operator is a
strictly different proof obligation from its wrapping sibling. An execution that
overflows aborts and therefore never returns, so it cannot witness a violated
ensures or invariant — which is why replacing + with +! can turn a
counterexample into a proof. Whether such an aborting execution is reachable is
reported separately, as an
ArithOverflowReachable warning, so a proof
never hides a program that can die at the operator. Checked operators do not require the [panic] effect (see Effect Types). Widths i8/u8 through
i64/u64 are modelled; 128-bit checked arithmetic is reported Skipped
(fail-closed) rather than modelled as wrapping. See
verifier-discipline.md.
128-bit arithmetic. All operators work on i128/u128 operands. /, %,
/!, %!, and *! have no native lowering at 128-bit width, so the compiler
routes them through runtime helpers; this is invisible in the language, and
their trap behaviour matches the narrower widths exactly. Division or
remainder by zero aborts at every width, as does signed / and /! on
MIN / -1, whose quotient is not representable. MIN % -1 is 0 and does
not abort.
128-bit values carry both limbs through locals, parameters, returns,
temporaries, struct fields, Vec elements, and the payloads of enum,
Option, and Result values (stored in two consecutive slots, see the
aggregate layout above). The width comes from the checker, so it holds wherever
the value came from: built in the same function, received as a parameter,
handed back by a call, or read out of a Vec element or a struct field; through
a match arm, ?, or .unwrap(); at every payload position; and at every
Vec index read or write, push, and for loop. pin_to_root and
Vec::from_raw_parts_copy copy one 8-byte slot per element, so they reject
Vec<i128>/Vec<u128> with a TypeMismatch; build such a Vec with push.
Verification is a separate matter. 128-bit literals (in a body or in
requires/ensures/invariant) are verifiable, and are modelled exactly under
the bit-vector encoding (both backends), i128::MIN, i128::MAX and u128::MAX included; under
ESBMC a proof obtained by the --encoding ir timeout fallback is weaker (it is
reported as plain proven). Under the default ESBMC backend every 128-bit aggregate access
is Skipped rather than modelled: a contracted function that reads or writes a
128-bit aggregate field or enum payload is reported Skipped, with FieldGet at
128-bit width or FieldSet at 128-bit width, rather than being modelled through
the verifier's 8-byte heap slot. --backend native models a 128-bit struct
field or enum payload exactly, as one 128-bit term (it occupies two slots, and a
narrow field declared after it lives in the shifted slot). A function that
reads, writes, or pushes a Vec<i128> or Vec<u128> element is Skipped
under ESBMC (an unsupported Call extern opcode); --backend native models the
element of a parameter, a local Vec or one held in a struct field exactly as one
128-bit value (a write through a struct field is unsupported-opcode). A call to parse_i128 or
parse_u128 is Skipped under ESBMC, whose 64-bit Option model cannot represent the
result (the native verifier gives the payload its 128 bits and models the call
as an opaque total function of the string). Contracts over 128-bit parameters
alone do verify.
Runtime violation values are not one of those gaps: a scalar i128/u128
binding captured by a vow block reports its full value in the runtime
VowViolation values map. See errors.md for how
that value is encoded and what a consumer must do to read it exactly.
Saturating Arithmetic¶
Saturating arithmetic uses named compiler intrinsics rather than a third
operator family. The u8 intrinsics are:
| Function | Signature | Behavior |
|---|---|---|
add_sat_u8 |
fn(a: u8, b: u8) -> u8 |
clamps sums above 255 to 255 |
sub_sat_u8 |
fn(a: u8, b: u8) -> u8 |
clamps differences below 0 to 0 |
mul_sat_u8 |
fn(a: u8, b: u8) -> u8 |
clamps products above 255 to 255 |
These functions are pure and have direct verifier semantics; they do not lower to wrapping arithmetic.
Comparison Operators¶
| Operator | Meaning |
|---|---|
== |
Equal |
!= |
Not equal |
< |
Less than |
<= |
Less than or equal |
> |
Greater than |
>= |
Greater than or equal |
For f32 and f64 operands, the six operators are IEEE-754 comparisons.
Any comparison with a NaN operand is false, except !=, which is true. Both
operands must have the same type.
Bitwise Operators¶
| Operator | Meaning |
|---|---|
& |
Bitwise AND |
\| |
Bitwise OR |
^ |
Bitwise XOR |
<< |
Left shift |
>> |
Right shift |
Bitwise & | ^ require integer operands of the same type and work on all 10
integer widths. >> is arithmetic (sign-extending) for signed types
(i8..i128) and logical (zero-extending) for unsigned types
(u8..u128).
Shift count type. The right operand of << and >> is u32 for every
left-operand width (i8..i128, u8..u128): let x: i64 = ...; let s: u32 = 3;
x << s is well-typed and has type i64. Unsuffixed integer literals on the
right side context-coerce to u32: given let x: u8 = ..., x << 3 is
well-typed (3 coerces to u32). The left operand keeps its own integer type;
the shift result has the left operand's type. Any other count type is a
TypeMismatch. For 64- and 128-bit left operands only, a count of the same
type as the left operand (i64 << i64, u64 >> u64, i128 << i128,
u128 >> u128) is also accepted, because existing 64-bit bit-manipulation code
carries its count in the value's own type; narrow left operands accept u32
only.
An unsuffixed-literal left operand has no type of its own: with a non-literal
count it takes the count's type, and the pair must satisfy the rule above. So
1 << n is well-typed (and has type u32, i64, u64, ...) for a count n
of type u32, i64, u64, i128 or u128, and a TypeMismatch for any
other count type (i8, u8, i16, u16, i32, f64, ...). Write 1u64 << n
to fix the shifted type explicitly. The literal is not range-checked against
the count's type.
Shift count range. A const-expression shift count that is negative or
>= bit-width(LHS) is a compile-time error (ShiftCountOutOfRange), at every
width: (x: u8) << 8 and (x: i64) << 64 do not compile. Dynamic shift counts
(x << n where n is not a const expression) get a check on the operation
that the verifier proves: the count must satisfy 0 <= count < width(LHS) at the point
of the shift, so a negative signed count is rejected as well. At runtime only
8-bit shifts trap (i8/u8 with count >= 8 aborts with ArithmeticOverflow);
wider shifts mask the count to the operand width, like the hardware shift.
Unsuffixed literal coercion still applies for &, |, ^ operands: with
let x: u64 = ..., 3 & x and x | 0xff type-check because the literal
side coerces to u64. Use a suffix to force a different type explicitly.
Logical Operators¶
| Operator | Meaning |
|---|---|
&& |
Logical AND (short-circuit) |
\|\| |
Logical OR (short-circuit) |
! |
Logical NOT |
&& and || use short-circuit evaluation: for a && b, b is only evaluated if a is true; for a || b, b is only evaluated if a is false.
Operator Precedence¶
From loosest to tightest, Vow follows the usual C/Rust precedence for logical and bitwise operators:
||, &&, comparisons (== != < <= > >=), |, ^, &, << >>, + -, * / %
Unary - and ! bind tighter than every binary operator and tighter than
as Type, matching Rust: -x as u64 is (-x) as u64; write -(x as i64) to
negate a cast result. The other postfix forms (.field, .method(),
[index], (args), ?) bind tighter than unary, so -v.len() is
-(v.len()). as Type binds tighter than every binary operator, so
a.len() as i64 + 1 is (a.len() as i64) + 1.
& is only the infix bitwise AND operator (lhs & rhs). There is no prefix
&expr: Vow has no borrow expressions, so &x, &mut x, &&x (and x & &y)
are UnsupportedFeature errors at the & (or &&) token, identically in both compilers (see
errors.md). Pass the value itself. The type
syntax &T is still accepted in signatures and annotations, but no expression
creates a value of that type: a &T parameter can only be passed on from
another &T parameter, so a program has no way to introduce one. Do not
declare reference-typed parameters.
Unary Operators¶
| Operator | Meaning |
|---|---|
- |
Negation (not allowed on unsigned types); on f32/f64 an IEEE sign flip, so -0.0 is negative zero |
! |
Logical NOT |
? |
Unwrap (propagate error), postfix |
Block-like Expressions and Parentheses¶
if, match, while, for, loop, and a { ... } block are block-like.
An unparenthesised block-like expression ends the expression it starts: no
postfix operator (., [, (, ?, as) and no binary operator may follow it
directly, so if c { 1 } else { 2 } as u64 and if c { 1 } else { 2 } + 1 are
parse errors. As the right operand of a binary operator or the operand of a
unary operator it is fine (3 * if c { 1 } else { 2 }), but it still ends the
whole expression, so 3 * if c { 1 } else { 2 } as u64 is a parse error too. A parenthesised
expression is a primary expression whatever it contains, so every operator may
follow it:
let a: u64 = (if c { 1 } else { 2 }) as u64;
let b: i64 = (if c { 1 } else { 2 }) + 1;
let n: u64 = (if c { v } else { w }).len();
Parentheses are not an AST node: the canonical printer re-inserts them exactly
where a block-like expression, a binary or unary expression, an assignment, or
break/return is the left operand of a binary operator or the receiver of a
postfix operator, so parse -> print -> parse is idempotent.
An expression statement ends with ;. Only two forms may omit it: the last
expression of a block (its value) and an unparenthesised block-like expression
(if c { f(); } g();). Any other statement without ; is a parse error
(UnexpectedToken) at the next token, in both compilers, and parsing stops
there. A let statement's trailing ; is optional.
An assignment's type as an expression is always (), independent of its
right-hand side's type — so a block, if-branch, or match-arm whose value is
a bare assignment (no ;) is itself ()-typed, not the assignment's RHS type.
A scalar type name after as (i8 through u128, f32, f64, bool) never
takes generic arguments, so a following < is a comparison or shift:
x as u64 < y and x as u64 << 1 mean (x as u64) < y and (x as u64) << 1.
Type Cast¶
as is widening-only across integer types. Any narrower integer can be
cast to any wider integer; signed sources sign-extend, unsigned sources
zero-extend:
let a: i32 = -1;
let b: i64 = a as i64; // sign-extend: -1_i64
let c: u8 = 200;
let d: u64 = c as u64; // zero-extend: 200_u64
let e: u32 = 1;
let f: i64 = e as i64; // unsigned-to-signed widening, value preserved
as between signed and unsigned of the same width is also allowed
(machine-level bit reinterpretation): i64 as u64, u64 as i64, i32 as u32,
etc.
Narrowing via as is a compile-time error (NarrowingCastNotAllowed):
To narrow, use a named intrinsic that makes the intent explicit. For every
narrowing pair (src, tgt) the compiler exposes three free functions:
| Intrinsic | Behavior on out-of-range input |
|---|---|
<src>_to_<tgt>_try(x) -> Option<tgt> |
returns Option::None |
<src>_to_<tgt>_wrap(x) -> tgt |
truncates (low bits, two's-complement) |
<src>_to_<tgt>_sat(x) -> tgt |
clamps to the target type's range |
Example:
let big: i64 = 300;
match i64_to_u8_try(big) {
Option::Some(b) => use_byte(b),
Option::None => fallback(),
}
These intrinsics are emitted by the compiler so the verifier sees their semantics directly in its model (the C model under ESBMC, exact bit-vector terms under the native verifier).
For the u8 target, the available narrowing source types are i16, i32,
i64, i128, u16, u32, u64, and u128. Each source provides all three
forms, for example u16_to_u8_try, u16_to_u8_wrap, and u16_to_u8_sat.
For the i32 target, the available narrowing source types are i64, u32,
u64, i128, and u128, each providing all three forms:
i64_to_i32_try/_wrap/_sat, u32_to_i32_try/_wrap/_sat,
u64_to_i32_try/_wrap/_sat, i128_to_i32_try/_wrap/_sat, and
u128_to_i32_try/_wrap/_sat.
The remaining executable sub-64-bit targets expose these complete families:
| Target | Narrowing source types |
|---|---|
i8 |
i16, u16, i32, u32, i64, u64, i128, u128 |
i16 |
i32, u32, i64, u64, i128, u128 |
u16 |
i32, u32, i64, u64, i128, u128 |
u32 |
i64, u64, i128, u128 |
Every listed source/target pair provides _try, _wrap, and _sat. Same-width
signedness changes use as, which reinterprets the bits and is not narrowing;
i128/u128 additionally have the explicit same-width intrinsics described
below.
i128 and u128 appear in these tables only as sources: they are the widest
integer types, so no other width narrows into them. The one conversion between
them is the same-width sign change, which has i128_to_u128_wrap/_sat and
u128_to_i128_wrap/_sat (no _try, because 128-bit enum payloads are not
supported yet). There is no i128/u128 to i64/u64 narrowing family; use
int128_to_string/uint128_to_string to observe a full 128-bit value.
No implicit conversions: i64 + u64 and u8 + i32 are type errors. The
operands must already have the same type. The compiler does not coerce
across integer types at operator sites — only literals coerce, per the
Integer Literals rules.
Let Bindings¶
Immutable¶
Bindings are immutable by default. Reassigning a binding that was not declared
mut is a compile error (ImmutableAssignment). mut is required only for
whole-binding reassignment x = e; field writes (s.f = e) and index writes
(v[i] = e) are permitted through any binding and do not require the base to be
mut.
Mutable¶
A let mut binding that is never reassigned is a compile error (UnusedMut) —
drop the mut. Because only whole-binding reassignment counts as a use of mut,
a binding mutated solely via s.f = e, v[i] = e, or a method call should be
declared let, not let mut.
Pattern Destructuring¶
let (a, b): (i64, i64) = (1, 2);
let (a, (b, c)) = (1, (2, 3));
let (_, b) = (side_effecting_call(), 2);
let accepts identifier, wildcard (_), and tuple patterns, recursively for
nested tuples. There is no runtime tuple value: a tuple let pattern is a
compile-time desugaring into one independent let per leaf, so the
initializer must be a syntactic tuple literal of exactly the same arity at
every nesting level (let (a, b) = f();, where f returns a tuple, is
rejected — tuples are not yet first-class values). A wildcard leaf still
evaluates and lowers its corresponding initializer element for its effects;
it just binds no name. A tuple let pattern cannot bind a linear-typed
element. All other pattern kinds (literals, enum variants, struct patterns,
or-patterns) are refutable and are rejected in let position, since a let
pattern must always match. Rejected shapes produce error[UnsupportedPattern]
(see errors.md).
Control Flow¶
If / Else¶
if/else is an expression — both branches must have the same type. The condition must have type bool (or never). There is no else if keyword; nest if inside else:
While Loop¶
While Loop with Invariant¶
let mut i: u64 = 0u64;
while i < n vow {
invariant: i <= n,
invariant: v.len() == i
} {
v.push(i);
i = i + 1;
}
State the bound the loop actually maintains. invariant: i >= 0 looks like a
lower bound but is always true once i is unsigned, and the type checker
rejects it as TautologicalComparison. .len() is u64, so a length clause
compares directly against a u64 counter; a counter of any other type needs
an explicit as cast.
Descending Loops¶
A descending index loop guards on > 0 and decrements as the first statement
of the body:
let n: u64 = v.len();
let mut i: u64 = n;
let mut acc: u64 = 0u64;
while i > 0 vow { invariant: i <= n } {
i = i - 1;
acc = acc + v[i];
}
i starts at n, not at n - 1, and the decrement brings it into range
before the first use. The guard is therefore also the bounds check: every
v[i] in the body runs with i < n.
One constraint on the shape above is easy to miss: the body must stay pure.
A print_* call inside a contracted function gives that function an effect,
and the verifier model is restricted to pure functions, so the contract is
reported as VerificationSkipped rather than checked.
This idiom does not by itself make a length-bounded loop provable. A loop
without an invariant that runs longer than the verifier's unwind bound
(ESBMC's --max-k-step, or the native backend's internal bound of 64) returns
unknown unless the native backend closes it by induction (docs/spec/cli.md →
"Native backend", "Loops"), for this form and the signed while i >= 0 form
alike. What the idiom buys is a
writable invariant rather than a stronger proof: i <= n is expressible on
an unsigned index, whereas the signed form's companion clause i >= -1 is not
— a negative literal does not fit u64 at all.
Never while i >= 0¶
let mut i: u64 = v.len() - 1; // wraps on an empty collection
while i >= 0 { ... } // never exits; rejected by the checker
Both lines are broken independently. i >= 0 is universally true on an
unsigned type, so the loop has no exit; the type checker rejects the
comparison outright as TautologicalComparison, in a loop condition just as
in a contract clause. The initializer is the half no diagnostic catches: on an
empty collection v.len() - 1 wraps to 18446744073709551615 and the
first index read runs far out of bounds.
- inside the guard, -! outside it¶
Plain wrapping - is the correct decrement in the canonical form. The guard
i > 0 has already discharged the underflow obligation, so no check is
needed, and plain - is exactly what the verifier's bit-vector encoding models — a
wrong guard therefore stays observable through invariant: i <= n. Reserve
-! for decrements whose non-negativity the guard does not establish.
Note one asymmetry while reading that advice. -! traps at runtime under
--mode debug, reporting {"error":"ArithmeticOverflow"}, and the verifier
models that abort: an aborting execution never returns, so it cannot witness a
violated ensures, and a reachable abort adds an ArithOverflowReachable
warning instead of failing the build. n -! 1 and n - 1 are therefore
different obligations. Do not reach for -! expecting it to turn an underflow
into a failed proof.
Why there is no reverse-range syntax¶
Vow has no numeric range form, and adding one is rejected rather than pending:
loopis not an escape hatch.break valueis restricted toloop, and an unboundedloopis verifiable only through itsinvariantandbreakstructure (the native backend modelsloop; ESBMC does not).- A reverse for-each would cover very little. Most reverse scans need the
index, not the element — they read a parallel array at
i, or write a second collection at the samei. - A range desugar would have to thread through index-type decisions that are hardcoded in both lowerers, where a miss is silent rather than loud: a signed comparison applied to unsigned operands raises no type error.
- A genuine range type would add a new type-system axis, which the language design rule rejects outright.
For-Each Loop¶
Iterates over each element of a Vec<T>. The loop variable x is bound to each element in turn. Desugars to a while loop with index arithmetic — zero verification overhead.
For-Each Loop with Invariant¶
Loop (Infinite)¶
loop creates an infinite loop. The expression returns the type of the break value:
let idx: i64 = loop {
if data[i] == target {
break i as i64;
}
i = i + 1;
if i >= n { break -1; }
};
Under the ESBMC backend an unbounded loop cannot be verified; the native backend models loop with break and continue. Use while with invariants for loops that verify under both.
Break¶
break exits the innermost loop. Inside loop, break value sets the loop's result:
break; // exit while or loop (loop returns Unit)
break value; // exit loop with a value (only inside loop, not while)
Continue¶
continue skips the remaining statements in the current loop iteration and jumps back to the loop header:
Inside while and loop, continue emits back-edge values for any mutated variables. Inside for, it also advances the loop index.
Return¶
Struct Definitions¶
Linear Structs¶
Linear struct values carry a linear obligation. The obligation must either be consumed before the value's owning region closes or transferred to the caller by returning the value.
Owned enum wrappers inherit that obligation transitively. A user enum,
Option<T>, or Result<T, E> is linear when one of its owned payload paths is
linear; matching such a value consumes the wrapper exactly once and transfers
the obligation to the selected bound payload. A reference type (&T) is never a
linear owner. Collection types do not acquire linear ownership from
their element type, and they cannot hold linear values: a Vec<T> element, a
HashMap<K, V> value, or a BTreeMap<K, V> value that is or transitively
contains a linear owner (a linear struct, or an Option, Result, or user
enum wrapping one) is rejected where the collection type is written. The
containers copy and shift entries bitwise, so storing a linear value would
duplicate its obligation or let it escape the checker. Vec and HashMap use
UnsupportedFeature; BTreeMap uses BTreeMapValueMustBeNonLinear. A nested
collection (Vec<Vec<Token>>) is reported once, at the innermost collection that
holds the linear value, and a type alias is reported once, at its definition. A
Vec element test is about ownership: Vec<&Token> borrows and is accepted,
while a tuple that holds a linear owner is rejected.
A linear value that is no longer needed is discharged with the intrinsic
drop(value) (see Linear Intrinsics). Passing it to a
function that consumes it, returning it, or matching it are the other ways to
satisfy the obligation; there is no implicit end-of-scope discharge, so a live
obligation at scope exit is RegionLinear.
Assigning a new value to a mut linear local whose previous value was already
consumed starts a fresh obligation that must itself be consumed; overwriting a
value that is still live discards it and is reported as RegionLinear.
An unbound _ match catchall cannot discard a still-reachable linear payload:
every variant that owns a linear payload must first have an explicit arm that
binds and consumes or transfers that payload.
Struct fields cannot own linear values, even when the containing struct is
linear, because field access does not provide move-out semantics. Allowing an
owned field would let repeated reads transfer the same obligation more than
once. Reference-typed (&T) and collection fields do not become linear owners
under this rule.
Struct Literals¶
Struct literal names must be PascalCase:
A struct literal must initialise every declared field. An omitted field is a
TypeMismatch (struct `Point` literal is missing field `y`, one per missing
field in declaration order, at the literal's span), never zero-initialised.
An identifier that does not start with an upper-case letter is never a struct
literal: in while c { } and if c { } the { opens the body.
In the head of if, while, for … in and match, a Name { or
Path::Variant { is never a struct literal or brace-form enum constructor; the
{ always opens the body. This is what lets if DEBUG { } and
if c == Color::Red { } parse. To use a struct literal there, parenthesise it
or bind it to a let first:
Delimiters re-enable literals inside a head: (…), call and method-call
arguments, […], blocks, match arms and vow { … } blocks. The canonical
printer parenthesises a head that would otherwise expose a struct literal, so
parse → print → parse stays idempotent.
Field Access¶
Field Assignment¶
Passing Semantics¶
Structs are heap-allocated. A struct value is a pointer to a heap region, so passing a struct to a function passes the pointer — the function operates on the same heap data, not a copy. Field assignments inside the called function are visible to the caller:
fn shift_right(p: Point, dx: i64) {
p.x = p.x + dx;
}
fn main() -> i32 [io] {
let p: Point = Point { x: 0, y: 0 };
shift_right(p, 5);
print_i64(p.x); // 5 — mutation visible to caller
0
}
This enables in-place mutation patterns (e.g., make/unmake in search trees) without cloning. The same aliasing semantics apply when structs are stored in containers — see Indexing. To avoid aliasing, construct a fresh struct literal with the desired field values.
Note: For linear owner types (a linear struct or an owned enum wrapper that
contains one), passing the value to a function consumes it; the caller cannot
access it afterward. Returning a linear value transfers the obligation to the
caller, so this is the normal way to hand an updated linear value back out of a
function.
Enum Definitions¶
Variant kinds: unit (Empty), tuple (Circle(i64)), struct (Named { x: i64 }).
Enum Construction¶
let s: Shape = Shape::Circle(5);
let none: Option<i64> = Option::None;
let some: Option<i64> = Option::Some(42);
Built-in Enums¶
Option<T> has variants Some(T) and None.
Result<T, E> has variants Ok(T) and Err(E).
For scalar payloads supported by native aggregate codegen, match and
.unwrap() extract the declared type, including f32 and f64; ?
currently propagates Option payloads only. The ESBMC backend's C model moves f32
and f64 enum payloads and struct fields through its integer slots by
IEEE-754 bit pattern (an f64 as its raw 64 bits, an f32 as its 32 bits
zero-extended), as native codegen does, so contracts over float payloads
verify under the default bit-vector encoding; --encoding ir does not
model the reinterpretation and can report a spurious counterexample.
Float equality follows IEEE-754: a NaN payload does not equal itself.
Pattern Matching¶
Match is an expression. The scrutinee must have an enum type, including an
applied built-in enum such as Option<T> or Result<T, E>. All arms must
return the same type. Patterns must be exhaustive.
Pattern Kinds¶
| Implemented pattern | Example |
|---|---|
| Wildcard | _ |
| Immutable identifier binding | value |
| Qualified enum variant (unit) | Option::None |
| Qualified enum variant (tuple payload) | Option::Some(value) |
Tuple-variant patterns must provide exactly one payload binding for every
declared payload, and each binding may be only _ or an immutable identifier.
The qualified enum name must match the scrutinee's enum; a variant from another
enum neither binds payloads nor counts toward exhaustiveness.
Nested payload destructuring is not implemented. A catchall _ or immutable
identifier arm must be the final arm because it matches every enum value.
For an enum that can own linear payloads, _ is allowed only after explicit
arms have handled every variant with a linear payload; otherwise the catchall
would silently discard an outstanding linear obligation.
Mutable identifier, literal (integer, boolean, or string), tuple, struct,
enum-struct, or-pattern, unqualified enum-variant, and nested payload patterns
are not implemented. Parsed unsupported forms produce
error[UnsupportedPattern]; forms that the parser cannot represent produce
error[UnexpectedToken]. Both are compile-time failures and no executable is
produced.
Method Calls¶
Collection constructors take their type from an annotation. Vec::new(), HashMap::new(), and BTreeMap::new() carry no element, key, or value type of their own; it comes from the binding's annotation (let m: HashMap<i64, i64> = HashMap::new();). Calling a method on a collection whose type was never written (let m = HashMap::new(); m.insert(1, 2);) is a TypeMismatch in both compilers (cannot infer the collection type of the receiver of ...), one error per call, so a map can never silently default its key and value to i64 and bypass the key and value type checks.
Vec Methods¶
| Method | Signature |
|---|---|
Vec::new() |
() -> Vec<T> |
Vec::from_raw_parts_copy(ptr, len) |
(i64, u64) -> Vec<T> for one-slot scalar T (not i128/u128) |
.push(val) |
(T) -> () |
.pop() |
() -> () |
.len() |
() -> u64 |
.clear() |
() -> () — frees buffer, resets to empty |
.truncate(n) |
(u64) -> () — shrinks to n elements, frees excess memory |
v[i] |
Index read, i: u64 — copies slot value; aliases heap types (panics if out of bounds) |
v[i] = val |
Index write, i: u64 — copies value into slot |
Vec indices and .truncate(n) take exactly u64; an unsuffixed integer literal coerces, and any other integer type needs an explicit as u64. See Indexing.
Vec has no .get(i) method: element access is v[i] only, and v.get(i) is an UnknownMethod error in both compilers.
String Methods¶
| Method | Signature |
|---|---|
String::from(s) |
(String) -> String — mutable copy |
String::new() |
() -> String |
String::from_raw_parts_copy(ptr, len) |
(i64, u64) -> String |
.len() |
() -> u64 |
.byte_at(i) |
(u64) -> i64 — the byte at offset i in 0..=255, or -1 when i >= len() |
.push_byte(b) |
(u8) -> () — appends one byte |
.push_str(s) |
(String) -> () |
.clear() |
() -> () — frees buffer, resets to empty |
.contains(s) |
(String) -> bool |
.eq(s) |
(String) -> bool |
.substring(start, end) |
(u64, u64) -> String — bytes [start, end), both bounds clamped to len() |
.parse_i64() |
() -> Option<i64> |
.parse_u64() |
() -> Option<u64> |
String offsets are exactly u64 and push_byte's argument is exactly u8; see String offsets.
push_byte's argument is a byte value, not an index. It is typed u8, so the byte range 0..=255 is a fact of the type: an unsuffixed literal outside that range is a LiteralOutOfRange error, and any other integer type (i64, u64, ...) is a TypeMismatch. A wider value is narrowed explicitly with a narrowing intrinsic (i64_to_u8_wrap, u64_to_u8_wrap, i64_to_u8_sat, i64_to_u8_try, ...) — as u8 from a wider type is NarrowingCastNotAllowed. There is no silent truncation: s.push_byte(300) is rejected, and the explicit s.push_byte(i64_to_u8_wrap(300)) appends 44. byte_at returns an i64 in -1..=255, where -1 means the index is past the end: copy a whole range with substring or push_str, and when transforming byte by byte make i < s.len() a precondition, because wrapping the -1 sentinel would append 0xFF.
HashMap Methods¶
| Method | Signature |
|---|---|
HashMap::new() |
() -> HashMap<K, V> |
.insert(k, v) |
(K, V) -> () |
.get(k) |
(K) -> Option<V> |
.contains_key(k) |
(K) -> bool |
.remove(k) |
(K) -> () |
.len() |
() -> u64 |
Key and value types. The runtime stores each key and each value in one 64-bit slot and compares keys by value. A HashMap key must therefore be i8, i16, i32, i64, u8, u16, u32, u64, or bool; every other key type is an UnsupportedFeature error in both compilers. String, Vec, struct, enum, Option, and tuple keys are heap-backed handles that would compare by pointer, so a lookup with an equal-but-distinct String would silently miss (and a mutable String mutated after insertion would corrupt the map). i128/u128 keys would be truncated, and f32/f64 have no total equality. Hash or intern such keys to a u64 at the call site (hash_str / hash_u64, see Hashing) and keep a side table for the originals. A HashMap or BTreeMap value of type i128, u128, f32, or f64 is likewise an UnsupportedFeature error: map values occupy a single 64-bit integer slot, so a 128-bit value would lose its high word and a float has no slot encoding. A HashMap value that is or transitively contains a linear struct is an UnsupportedFeature error for the same reason BTreeMap rejects it (BTreeMapValueMustBeNonLinear): the map copies values bitwise and get would hand out a second copy of the linear obligation. Narrow integer values (i8 … u32) are stored widened and read back at their declared width. The check applies wherever the map type is written (annotations, parameters, returns, fields, aliases, constants), including nested inside Vec, Option, tuples, and other maps. A 128-bit integer nested inside an aggregate value (Option<u128>, a struct field) is not a map restriction: aggregates hold 128-bit fields in two slots, so only a bare 128-bit map value is rejected.
Set idiom. The unit type () is a valid map value, so HashMap<K, ()> and BTreeMap<K, ()> are sets: s.insert(k, ()); adds a member, s.contains_key(k) (s.contains(k) for BTreeMap) tests membership, s.remove(k) deletes it, and s.get(k) returns Option<()>. The value () has type () (it checks against a () annotation or return type), and the runtime stores it in the usual 64-bit slot as 0. A function cannot take a () parameter (UnsupportedFeature: the argument carries no information and has no ABI slot), so pass the set itself or a key instead.
Representation and cost. A HashMap is an open-addressing hash table with linear probing, at most 3/4 full, that doubles when it fills; removal leaves no tombstones, so lookup cost does not degrade after churn. insert, get, contains_key and remove take expected O(1) time (amortised for insert, which occasionally rehashes) and len is O(1). The hash is a fixed, seedless mixer, so a program's behaviour and its compiled binary are reproducible; the cost is that keys chosen to collide on purpose can lengthen probe chains, which is acceptable for compiler-style workloads but is not a defence against hash flooding. A HashMap has no iteration API and its slot order is unspecified; use BTreeMap when order matters.
HashMap::get returns Option<V>, exactly like BTreeMap::get: a missing key is None, never a default value, so let a: i64 = m.get(k); is a TypeMismatch in both compilers. Handle both cases with match (or ?), or call .unwrap() to assert the key is present: it aborts with UnwrapOnNone on a missing key and requires the [panic] effect. A contract can state a binding as result.get(k).unwrap() == v; guard it with an earlier result.contains_key(k) clause (as in the examples), because the verifier reports a missing key there as a failed unwrap() on None, which carries no contract blame.
BTreeMap Methods¶
Keys must be i64 (K violations raise BTreeMapKeyTypeMustBeI64). Values may be any
non-linear type other than i128/u128/f32/f64 (rejected with UnsupportedFeature, because a
value occupies a single 64-bit integer slot) — integers, bool, structs, Vec<T>, Option<T>, or nested combinations.
A V that is or transitively contains a linear struct is rejected with
BTreeMapValueMustBeNonLinear, because the runtime/verifier shift values bitwise and
would silently duplicate a linear obligation.
Storage is two parallel sorted arrays (binary-search lookup, sorted-insert writes).
Iteration order is ascending by key and is deterministic across runs and compilers —
prefer BTreeMap over HashMap for any map whose iteration affects compiler output.
| Method | Signature |
|---|---|
BTreeMap::new() |
() -> BTreeMap<K, V> |
.insert(k, v) |
(K, V) -> Option<V> (returns the previous value bound to k, if any) |
.get(k) |
(K) -> Option<V> (returns the value bound to k, or None) |
.contains(k) |
(K) -> bool |
.len() |
() -> u64 |
Option Methods¶
| Method | Signature |
|---|---|
.unwrap() |
() -> T (takes no arguments; aborts with UnwrapOnNone on None; requires [panic] effect) |
.unwrap() is also available on Result<T, E>, where it returns the Ok payload
and aborts with the same UnwrapOnNone diagnostic on Err. The abort is emitted in
every build mode — .unwrap() is a partial operation, not a vow check.
The ? operator on Option<T> or Result<T, E> propagates None/Err to the caller (the calling function must return Option or Result).
Indexing¶
v[i] = new_val evaluates v, then i, then new_val, left to right, in both compilers. This
order is observable whenever any of the three sub-expressions has a side effect — not only a
declared [io]/[read]/[write]/[panic]/[unsafe] effect, but also a local-variable mutation
that a sibling sub-expression later reads, such as v[i] = { i = i + 1; 7 }: i is read as its
pre-mutation value because i is evaluated before new_val.
The index expression of a Vec read or write must have exactly the type u64. An unsuffixed integer literal coerces to u64 (v[0] needs no suffix); a literal that does not fit, such as v[-1] or v[18446744073709551616], is a LiteralOutOfRange error. Any other integer type (i8 … i128, u8 … u32, u128) and any non-integer index is a TypeMismatch error, in both compilers; widen or convert explicitly with as (v[i as u64]). The same rule applies to the index-shaped Vec method argument of Vec::truncate, which takes exactly u64.
The Vec runtime helpers take a pointer-width unsigned index, so a u64 index is never reinterpreted as negative: an index at or beyond v.len() is out of bounds, including values above i64::MAX.
Indexing v[i] is defined only for Vec<T>. A HashMap, BTreeMap, String, Option, or any other type has no index operator: m[k] is a TypeMismatch ("index operation on non-indexable type") in both compilers, for reads and for assignments. Read a map entry with m.get(k), which returns an Option<V> so a missing key is never a default value or a runtime trap, and write one with insert. Read a String byte with byte_at.
Lengths are u64 (see the Vec method table), so an index derived from one needs no conversion:
let n: u64 = v.len();
let mut i: u64 = 0;
while i < n vow { invariant: i <= n } {
let x: i64 = v[i];
i = i + 1;
}
Arithmetic and comparison do not mix signedness: i64 + u64 and u64 < i64 are TypeMismatch. Convert at the binding with as; same-width as casts between i64 and u64 are legal. A signed value that is already known to be a valid position is converted at the index site (v[k as u64]); a negative k becomes a huge u64 and fails the bounds check.
String offsets¶
String offsets are u64, exactly like Vec indices: a position or length into a String is never negative, and String::len() is already u64. Every offset or length argument has exactly the type u64:
s.byte_at(i)—is.substring(start, end)—startandendstring_substr(s, start, len)—startandlenstring_matches_literal_at(s, pos, literal)—pos
An unsuffixed literal coerces to u64 and is range-checked against it (LiteralOutOfRange when it does not fit); any other integer type, including i64, is a TypeMismatch. Convert at the binding with as u64 (same-width casts between i64 and u64 are legal); a negative signed value cast to u64 becomes a huge offset, which is out of range rather than silently clamped to the start. A 128-bit offset is a TypeMismatch, not a codegen question.
Because an offset cannot be negative, the runtime has no negative-offset behaviour. Offsets past the end of the string behave as follows, and this is the whole specification:
byte_atreturns-1wheni >= len(). The-1is the out-of-range sentinel of the-1..=255result; it is not a reachable byte value.substring(start, end)clampsstarttolen()and thenendinto[start, len()], so a reversed or oversized range yields the empty string or the tail, never a panic.string_substr(s, start, len)clampsstarttolen()andlento the bytes remaining afterstart.string_matches_literal_atreturns0whenposplus the literal's byte length exceedslen()(including when that sum overflowsu64).
The verifier is stricter than the runtime for byte_at: an index that is not provably < len() fails verification as index out of bounds, because reaching the -1 sentinel is almost always an agent bug. substring, string_substr and string_matches_literal_at are modelled with exactly the clamping above on unsigned values. A length contract on the result, such as ensures: result.len() <= s.len(), proves.
byte_at returns a byte value in -1..=255, not a position, so it stays i64. push_byte takes a byte value, not an offset, and is u8 (see the String method table).
Indexing uses copy semantics: v[i] copies the 8-byte slot value and v[i] = val copies a value into the slot. The base container is not consumed.
For primitive types (i64, bool), this is a genuine value copy — the result is independent of the container. For heap types (Vec<T>, String, structs, enums), the 8-byte slot holds a pointer, so indexing copies the pointer, creating an alias. Both the container slot and the local variable point to the same heap data:
let buckets: Vec<Vec<i64>> = Vec::new();
buckets.push(Vec::new());
let b: Vec<i64> = buckets[0]; // b aliases buckets[0]
b.push(42); // visible through buckets[0]
This aliasing is the intended behavior for arena and hash-table patterns where bucket contents are read and mutated repeatedly through index access.
Extern Blocks¶
Declare external C functions (a vow contract block is required):
extern "C" {
vow {
requires: fd >= 0
ensures: result >= 0
}
fn write_thing(fd: i32, ptr: i64, len: i64) -> i64 [io];
}
Omitting the vow block produces a MissingContract error (see errors.md).
Type Aliases¶
Effect System¶
Effects are explicit. Every function declares which side effects it may perform. Pure functions (no effects) need no annotation.
Effect Types¶
| Effect | Meaning |
|---|---|
io |
Output and process/system interaction (print, process, time, mkdir, remove, rename) |
read |
Reads of external state (file system, environment, arguments, stdin) |
write |
File system writes (fs_write) |
panic |
May panic (unwrap, etc.) |
unsafe |
Unsafe operations (FFI, raw memory) |
Each effect is independent — io is not a superset of read or write. A function that prints and also reads a file declares both: [io, read].
.unwrap() requires [panic]. Out-of-bounds indexing, the checked operators (+! -! *! /! %!) and the /, % zero-divisor traps do not: their aborts are verification obligations of pure functions, and declaring an effect would remove the function from the verifier model.
Propagation¶
A function must declare every effect that any function it calls may produce:
If caller omitted [io], the type checker would emit EffectViolation.
Contract Purity¶
Contract expressions (requires, ensures, invariant) must be pure — they cannot call effectful functions.
They also cannot write through any argument, even when the write happens inside a helper that declares no effect. Declared effects (read, write, io, panic, unsafe) cover filesystem/stdio/panic/FFI only — a plain struct-field assignment, a Vec/map element write, or a mutating builtin method call (push, insert, clear, …) through a parameter is invisible to that check, since Vow passes structs, Vec, String, and maps by pointer. For example:
fn mark(p: Point) -> bool {
p.x = 1;
true
}
fn make_point(x: i64, y: i64) -> Point
vow {
ensures: mark(result)
}
{
Point { x: x, y: y }
}
mark declares no effect, so the declared-effect check alone would accept ensures: mark(result). But mark writes result.x as a side effect of being evaluated for the check itself — a write that is not part of make_point's own body and that a caller relying on result.x == 1 would never see if contract evaluation were ever skipped. This is rejected with EffectViolation, the same diagnostic the declared-effect check uses, blamed on the callee. The check is transitive: a helper that only writes through another helper it calls is rejected too, and the search for a reachable write looks through — not into — control flow (if/match/loops inside a clause are still searched for writes, not forbidden outright). Builtin read-only methods (len, get, contains, contains_key, eq, byte_at, substring, parse_i64, parse_u64, unwrap) are unaffected; an unresolvable callee is rejected (fails closed) rather than silently assumed pure.
Builtin Function Signatures¶
FFI Wrapper Intrinsics¶
| Function | Signature | Effects |
|---|---|---|
pin_to_root |
fn(value: String) -> String and fn<T>(value: Vec<T>) -> Vec<T> for one-slot scalar T |
[] |
pin_to_root is a compiler intrinsic, not a user-defined generic. Each call site is monomorphised from the argument type. It always deep-copies the supported heap value into root storage; it does not inspect descriptor tags and does not claim idempotency. The current supported forms are String and Vec<T> where T is a one-slot scalar type (i8..i64, u8..u64, f32, f64, bool; not i128/u128, whose elements take two slots). Pointer-containing payloads, user structs, enums, and maps require hand-written deep-copy wrappers at the FFI boundary.
Linear Intrinsics¶
| Function | Signature | Effects |
|---|---|---|
drop |
fn(value: L) -> () for a linear owner L |
[] |
drop is a compiler intrinsic, not a user-defined generic. L must be a linear owner: a linear struct, or an owned enum wrapper (Option, Result, or a user enum) that contains one. Any other argument type, or an argument count other than one, is a TypeMismatch. drop consumes the value exactly once (a second use is LinearTypeViolation) and discharges its obligation. It has no runtime effect beyond that: it runs no destructor, frees nothing, and lowers to no instruction other than the consume marker the type and region passes already track. It is verifier-neutral: the consume marker is a no-op in the C model, so a function that drops a linear value is verified exactly as if the call were absent. A user-defined function named drop takes precedence over the intrinsic.
String::from_raw_parts_copy(ptr: i64, len: u64) copies len bytes from a raw C pointer into a fresh String. Vec::from_raw_parts_copy(ptr: i64, len: u64) copies len one-slot scalars into a fresh Vec<T> (T is not i128/u128). The pointer is i64 and the length is u64, so a signed length must be converted explicitly (n as u64); the code generator converts pointer and length values to the platform pointer-sized ABI type at the FFI boundary. Both helpers have a FreshInCaller return summary.
For pointer-containing C payloads, a wrapper must be written per type: call the extern, recursively copy every Vow-owned heap subobject into the target region, free every C-owned pointer according to the extern's ownership contract, then return the Vow-placed value. A bytewise copy of a pointer-containing payload is unsound because it preserves stale pointers into C-owned storage.
Print / IO¶
| Function | Signature | Effects |
|---|---|---|
print_str |
fn(s: String) -> () |
[io] |
print_i64 |
fn(v: i64) -> () |
[io] |
print_u64 |
fn(v: u64) -> () |
[io] |
eprintln_str |
fn(s: String) -> () |
[io] |
try_eprintln_str |
fn(s: String) -> i64 |
[io] |
try_eprintln_str writes s and a newline to stderr and reports the outcome instead of ignoring it: 0 when written, 1 when stderr is a closed pipe (BrokenPipe), 2 for any other write failure. A closed stderr descriptor (EBADF) counts as 0, matching Rust's standard library. SIGPIPE is blocked for the duration of the call, so a closed pipe returns 1 instead of terminating the process. eprintln_str ignores write failures entirely.
Debug¶
| Function | Signature | Effects |
|---|---|---|
debug_str |
fn(s: String) -> () |
[] |
debug_i64 |
fn(v: i64) -> () |
[] |
debug_u64 |
fn(v: u64) -> () |
[] |
Debug print semantics: Debug prints are effect-free and callable from pure functions. In debug and sanitize modes (--mode debug, --mode sanitize), they write to stderr. In release and profile modes, the debug call itself is not emitted — no function call occurs. However, argument expressions are still evaluated (a direct literal such as "label" is static, while String::from("label") still allocates a mutable copy). They are also no-ops during verification. Use them to trace values inside pure kernel code without restructuring the effect hierarchy.
Filesystem¶
| Function | Signature | Effects |
|---|---|---|
fs_read |
fn(path: String) -> String |
[read] |
fs_open |
fn(path: String) -> i64 |
[read] |
fs_read_line |
fn(handle: i64) -> String |
[read] |
fs_status |
fn(handle: i64) -> i64 |
[read] |
fs_read_status |
fn() -> i64 |
[read] |
getenv |
fn(name: String) -> String |
[read] |
path_lookup |
fn(name: String) -> String |
[read] |
mktemp_dir |
fn(prefix: String) -> String |
[io] |
fs_close |
fn(handle: i64) -> i64 |
[read] |
fs_write |
fn(path: String, data: String) -> i64 |
[write] |
fs_exists |
fn(path: String) -> i64 |
[read] |
fs_mkdir |
fn(path: String) -> i64 |
[io] |
fs_listdir |
fn(path: String) -> Vec<String> |
[read] |
fs_remove |
fn(path: String) -> i64 |
[io] |
fs_remove_dir |
fn(path: String) -> i64 |
[io] |
fs_remove_dir_all |
fn(path: String) -> i64 |
[io] |
fs_is_dir |
fn(path: String) -> i64 |
[read] |
fs_is_symlink |
fn(path: String) -> i64 |
[read] |
fs_rename |
fn(old: String, new: String) -> i64 |
[io] |
String Operations¶
| Function | Signature | Effects |
|---|---|---|
string_substr |
fn(s: String, start: u64, len: u64) -> String |
[] |
string_split |
fn(s: String, delim: String) -> Vec<String> |
[] |
string_starts_with |
fn(s: String, prefix: String) -> i64 |
[] |
string_ends_with |
fn(s: String, suffix: String) -> i64 |
[] |
string_matches_literal_at |
fn(s: String, pos: u64, literal: String literal) -> i64 |
[] |
string_trim |
fn(s: String) -> String |
[] |
string_to_upper |
fn(s: String) -> String |
[] |
string_to_lower |
fn(s: String) -> String |
[] |
string_replace |
fn(s: String, from: String, to: String) -> String |
[] |
string_join |
fn(parts: Vec<String>, sep: String) -> String |
[] |
Conversion¶
Formatting uses four baselines; widen via as for narrower types. The
128-bit formatters exist because most 128-bit values do not fit i64/u64, so
they cannot be formatted by narrowing first:
| Function | Signature | Effects |
|---|---|---|
int_to_string |
fn(v: i64) -> String |
[] |
uint_to_string |
fn(v: u64) -> String |
[] |
i64_to_string |
fn(v: i64) -> String (alias of int_to_string) |
[] |
int128_to_string |
fn(v: i128) -> String |
[] |
uint128_to_string |
fn(v: u128) -> String |
[] |
The 128-bit formatters render the full decimal value (i128::MIN is 40
characters, u128::MAX is 39). The verifier has no model of their result, so a
function that calls one is reported Skipped rather than proven.
Parsing exposes a try-form for every integer width but u64:
| Function | Signature | Effects |
|---|---|---|
parse_i8 |
fn(s: String) -> Option<i8> |
[] |
parse_i16 |
fn(s: String) -> Option<i16> |
[] |
parse_i32 |
fn(s: String) -> Option<i32> |
[] |
parse_i64 |
fn(s: String) -> Option<i64> |
[] |
parse_i128 |
fn(s: String) -> Option<i128> |
[] |
parse_u8 |
fn(s: String) -> Option<u8> |
[] |
parse_u16 |
fn(s: String) -> Option<u16> |
[] |
parse_u32 |
fn(s: String) -> Option<u32> |
[] |
parse_u128 |
fn(s: String) -> Option<u128> |
[] |
parse_i64 is also available as the String method .parse_i64(). There is
no free parse_u64; parse a u64 with the method .parse_u64().
Each parse_X returns Option::None for malformed input, empty strings, or
values outside the target type's range. Parsing never substitutes a numeric
sentinel for failure; callers that need a fallback must choose it explicitly
when handling Option::None.
In particular, parse_i8, parse_i16, parse_u8, parse_u16, parse_i32,
parse_u32, parse_i128, and parse_u128 enforce their exact signed or
unsigned fixed-width ranges.
Every parse_X shares one set of lexical rules, identical at every width:
surrounding Unicode whitespace is trimmed first; a leading + and leading zeros
are accepted ("+7" and "0007" parse as 7); "-0" is Some(0) for a signed
type and None for an unsigned one; and "+", "-", the empty string, and any
interior non-digit ("1 2") are None.
Float bit patterns. The compiler's own float literals go through two
helpers that carry an f64 as its IEEE-754 bit pattern in a u64:
| Function | Signature | Effects |
|---|---|---|
parse_f64_bits |
fn(s: String) -> u64 |
[] |
format_f64_bits |
fn(bits: u64) -> String |
[] |
parse_f64_bits trims surrounding whitespace and returns the bits of the
parsed f64, or the bits of 0.0 (0) for text that is not a float; it never
fails. format_f64_bits renders the f64 with those bits in the shortest
decimal form that reads back as the same value. The verifier has no model of
either (their other side is a String): a function that calls one is reported
Skipped.
Narrowing intrinsics (per Type Cast): for every narrowing
pair the compiler emits <src>_to_<tgt>_try, <src>_to_<tgt>_wrap, and
<src>_to_<tgt>_sat free functions with the semantics described in that
section.
Collections¶
| Function | Signature | Effects |
|---|---|---|
vec_sort |
fn(v: Vec<i64>) -> Vec<i64> |
[] |
Time¶
| Function | Signature | Effects |
|---|---|---|
time_unix |
fn() -> i64 |
[io] |
time_unix_ms |
fn() -> i64 |
[io] |
time_micros |
fn() -> i64 |
[io] |
time_unix() and time_unix_ms() return wall-clock seconds and milliseconds since the Unix epoch (0 if the clock is before it). time_micros() is a monotonic clock in microseconds counted from its first call in the process (the first call returns about 0); use it to measure elapsed time, never as a timestamp.
System¶
| Function | Signature | Effects |
|---|---|---|
num_cpus |
fn() -> i64 |
[io] |
proc_sample |
fn() -> String |
[io] |
gzip_write_file |
fn(path: String, data: String) -> i64 |
[io] |
memory_root_arena_bytes |
fn() -> u64 |
[io] |
memory_peak_bytes |
fn() -> u64 |
[io] |
memory_alloc_count_since_start |
fn() -> u64 |
[io] |
num_cpus() returns the number of available logical CPUs (from std::thread::available_parallelism), or 1 if the query fails. Used to size worker pools (e.g. the default --verify-jobs value).
proc_sample() samples the calling process and its child processes and returns one group|rss_kb|cpu_pct line per group, separated by newlines (all integers); it returns the empty string where the sample cannot be read (non-Linux hosts). gzip_write_file(path, data) writes data gzip-compressed to path and returns 0 on success or -1 when either argument is not a valid path/string or the file cannot be written. Both exist for the --perfetto tracer.
memory_root_arena_bytes() returns the current bytes retained by root-region
arena chunks. It is a gauge, not a monotone counter: adding a root chunk raises
it, while reclaiming an abandoned single-resident oversized root chunk during
backing growth lowers it. memory_peak_bytes() returns the peak live bytes
retained by all open arena chunks since process start.
memory_alloc_count_since_start() returns the number of successful Vow arena
allocation requests since process start. Peak bytes and allocation count are
monotone non-decreasing and saturate at u64::MAX rather than wrapping. These
queries do not allocate; they are effectful because they observe runtime process
state.
Encoding¶
| Function | Signature | Effects |
|---|---|---|
hex_encode |
fn(data: Vec<u8>) -> String |
[] |
hex_decode |
fn(s: String) -> Vec<u8> |
[] |
Hashing¶
| Function | Signature | Effects |
|---|---|---|
hash_u64 |
fn(x: u64) -> u64 |
[] |
hash_str |
fn(s: String) -> u64 |
[] |
Both are pure and deterministic across runs, platforms and compilers. hash_u64 is the SplitMix64 output function applied to state x (z = x + 0x9E3779B97F4A7C15; z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9; z = (z ^ (z >> 27)) * 0x94D049BB133111EB; z ^ (z >> 31), all arithmetic wrapping), so hash_u64(0) == 0xE220A8397B1DCDAF; it is a bijection on u64, which is also the function HashMap uses to place its keys. hash_str is 64-bit FNV-1a over the string's bytes (offset basis 0xCBF29CE484222325, prime 0x100000001B3), so hash_str("") == 0xCBF29CE484222325 and hash_str("a") == 0xAF63DC4C8601EC8C. Combine hashes in Vow, e.g. hash_u64(a ^ hash_u64(b)).
A hash is not an identity: distinct strings can collide. To key a HashMap by String, use hash_str(s) as the u64 key and keep a side table (for example a Vec<String> indexed by interned id) that confirms the original string on every hit, resolving collisions explicitly. The native verifier (--backend native) encodes hash_u64 exactly, as the closed 64-bit term above, so a property of hash_u64(x) is proven or refuted for the runtime's value. The ESBMC backend, and hash_str under both backends (a fold over every byte of a string of unbounded length, with no closed form), have no model of the result: a function that calls one is reported as skipped rather than proven.
Input¶
| Function | Signature | Effects |
|---|---|---|
args |
fn() -> Vec<String> |
[read] |
stdin_read |
fn() -> String |
[read] |
stdin_read_line |
fn() -> String |
[read] |
stdin_ready |
fn() -> bool |
[read] |
Process Management¶
| Function | Signature | Effects |
|---|---|---|
process_exit |
fn(code: i64) -> ! |
[io] |
process_run |
fn(cmd: String, args: Vec<String>) -> i64 |
[io] |
process_get_stdout |
fn() -> String |
[io] |
process_get_stderr |
fn() -> String |
[io] |
process_start |
fn(cmd: String, args: Vec<String>) -> i64 |
[io] |
process_start_capped |
fn(cmd: String, args: Vec<String>, mem_kb: i64) -> i64 |
[io] |
process_wait |
fn(pid: i64) -> i64 |
[io] |
process_wait_timeout |
fn(pid: i64, timeout_ms: i64) -> i64 |
[io] |
process_poll_wait |
fn(pid: i64, timeout_ms: i64) -> i64 |
[io] |
process_kill |
fn(pid: i64) -> i64 |
[io] |
process_stdout_for |
fn(pid: i64) -> String |
[io] |
process_stderr_for |
fn(pid: i64) -> String |
[io] |
process_start_piped |
fn(cmd: String, args: Vec<String>) -> i64 |
[io] |
process_write_stdin |
fn(pid: i64, data: String) -> i64 |
[io] |
process_close_stdin |
fn(pid: i64) -> i64 |
[io] |
process_read_line |
fn(pid: i64, timeout_ms: i64) -> String |
[io] |
process_read_status |
fn(pid: i64) -> i64 |
[io] |
args semantics: args() returns all process arguments including the program name at index 0 (matching C argv and Rust std::env::args() conventions). For ./my_program foo bar, args() returns ["./my_program", "foo", "bar"]. Use args[1] onward for user-supplied arguments. The Vec is empty only if the OS provides no arguments (unusual). Returns an empty String element if an argument is empty (""). Non-UTF-8 arguments are included as-is (byte content preserved).
fs_read semantics: fs_read(path) opens the file at path, reads its entire contents, and returns a String. Returns "" (empty String) on any error (file not found, permission denied, I/O error, non-UTF-8 path). Does not block on regular files. An empty result is ambiguous on its own, because an empty file also reads as ""; call fs_read_status() immediately after fs_read to tell the two apart.
fs_read_status semantics: fs_read_status() reports the outcome of the calling thread's most recent fs_read: 0 ok (including an empty file), 1 not found, 2 permission denied, 3 any other I/O error (a directory, a read error), 4 invalid path (null, non-UTF-8, or containing a NUL byte), and -1 if the thread has not called fs_read yet. The status is thread-local, so concurrent threads do not race; read it on the same thread, immediately after the fs_read it describes, because the next fs_read overwrites it. Other fs_* builtins do not touch it.
getenv semantics: getenv(name) returns the value of environment variable name, or "" when the variable is unset, its name is empty or contains = or a NUL byte, or its name or value is not valid UTF-8. An unset variable and a variable set to the empty string are not distinguished.
path_lookup semantics: path_lookup(name) resolves an executable the way command -v does, minus shell builtins and aliases. A name containing / is tested directly and returned unchanged when it is a regular file with an execute bit; otherwise each entry of $PATH is searched in order (an empty entry means the current directory) and the first <entry>/<name> that is an executable regular file is returned. Returns "" when nothing matches, name is empty, or $PATH is unset and name contains no /.
mktemp_dir semantics: mktemp_dir(prefix) atomically creates a new directory <tmp>/<prefix>.XXXXXXXXXX with mode 0700 and returns its path, where <tmp> is $TMPDIR (trailing / stripped) or /tmp when $TMPDIR is unset or empty. Returns "" when prefix is empty or contains / or a NUL byte, or when creation fails. The caller owns the directory and must remove it (fs_remove its files, then fs_remove_dir; or fs_remove_dir_all).
Streaming file input: fs_open(path) opens a file for incremental reading and returns a positive handle, or -1 on path/open error. fs_read_line(handle) reads one line from the current cursor and returns it as a String, including the trailing newline when present. It returns "" at EOF, for an invalid handle, or after a read error. A blank line is returned as "\n", so newline-delimited callers can distinguish a real blank line from EOF by content. After fs_read_line(handle) returns "", call fs_status(handle) to distinguish EOF from error: 0 means the handle is open with no EOF/error state, 1 means EOF, and -1 means invalid handle or read error. fs_status(handle) reports the result of the most recent fs_read_line(handle) call on that open handle; read it immediately after a "" return because later reads may update it. fs_close(handle) releases the handle and returns 0 on success or -1 for an invalid/already-closed handle. Long-running programs must close handles they no longer need. All streaming handle operations use the [read] effect, including fs_close, because closing a read handle releases read-stream state and does not mutate filesystem contents. The current runtime stores streaming handles in one process-global table, and fs_read_line holds that table lock while it reads the next line. This keeps the API simple for single-stream file processing, but it is not intended for latency-sensitive concurrent reads from multiple slow handles.
fs_remove_dir vs fs_remove_dir_all: fs_remove_dir(path) is rmdir: it removes path only when it is an empty directory and fails (non-zero) when the directory is non-empty, is not a directory, or does not exist, so it can never delete files by accident. fs_remove_dir_all(path) recursively removes path and everything beneath it and is irreversible; it fails (non-zero) when path does not exist, and a symlink at the top level is unlinked rather than followed. Prefer fs_remove_dir unless a whole tree must go.
Filesystem return values: fs_write, fs_mkdir, fs_remove, fs_remove_dir, fs_remove_dir_all, and fs_rename return i64: 0 on success, non-zero on failure. fs_open, fs_status, and fs_close use the streaming status codes above. fs_exists, fs_is_dir, and fs_is_symlink are predicates: they return 1 for true, 0 for false. Errors (null pointer, invalid UTF-8) also return 0, so callers cannot distinguish "false" from "error". fs_is_symlink uses lstat-equivalent semantics: a symlink reports 1 even when its target is a regular file or directory.
string_starts_with / string_ends_with / string_matches_literal_at return values: Return i64: 1 if true, 0 if false.
string_matches_literal_at literal operand: The third argument must be written as a string literal at the call site. The compiler lowers that literal to static bytes plus an explicit byte length, so no temporary String allocation is created and embedded NUL bytes are preserved. Passing a variable or computed String as the third argument is a type-check error (StaticLiteralRequired). Use string_starts_with, string_ends_with, or String methods when the needle must be dynamic.
process_run vs process_start: process_run(cmd, args) runs a subprocess synchronously and returns its exit code. After it returns, process_get_stdout() and process_get_stderr() retrieve the captured output of the most recent process_run call. process_start(cmd, args) launches a subprocess asynchronously and returns a process ID. Use process_wait(pid) to wait for completion and get the exit code, and process_stdout_for(pid) / process_stderr_for(pid) to retrieve output.
process_wait_timeout: process_wait_timeout(pid, timeout_ms) polls a process started with process_start until it exits or the timeout (in milliseconds) elapses. Returns the exit code on completion, -1 on error, or -2 on timeout. After a timeout, the process is still running; use process_kill(pid) to terminate it.
process_start_capped: process_start_capped(cmd, args, mem_kb) starts a child like process_start (same ID, same process_wait*, process_poll_wait, process_stdout_for and process_kill behaviour) with two differences. The child leads its own process group, and process_kill and a process_wait_timeout expiry SIGKILL the whole group, so grandchildren die with it; after a normal exit the group is swept the same way. On Linux the child also runs with an address-space limit of mem_kb KiB (mem_kb <= 0 means no limit; other platforms ignore it). A child killed by a signal reports -1000 - signal instead of -1, so a signal death can be told from a spawn error. Returns -1 if the command cannot be started.
process_poll_wait: process_poll_wait(pid, timeout_ms) waits up to timeout_ms milliseconds for a process started with process_start to exit, without killing it on timeout. Returns the exit code on completion, -1 on an unknown process ID, or a large negative sentinel if the process is still running after the timeout (left alive, not killed). Unlike process_wait_timeout, callers can re-poll and impose their own watchdog deadline instead of the process being killed automatically.
Interactive child processes: process_start_piped(cmd, args) starts a child like process_start but with stdin, stdout and stderr all piped, and returns a process ID (-1 if the command cannot be started). The ID works with process_wait, process_wait_timeout, process_poll_wait, process_kill, process_stdout_for and process_stderr_for. process_write_stdin(pid, data) writes all of data to the child's stdin and flushes it; it returns 0, or -1 for an unknown or non-piped ID, a closed stdin, or a child that stopped reading (a broken pipe never terminates the program). process_close_stdin(pid) closes the child's stdin so it sees end-of-file; it returns 0 (also when already closed) or -1 for an unknown or non-piped ID. process_read_line(pid, timeout_ms) returns the child's next stdout line including its trailing \n (a final line without a newline is returned as-is), or "" when no line is available. timeout_ms bounds the whole call: a negative value blocks until a line or end-of-file, 0 only polls. Bytes read before a timeout are kept for the next call. process_read_status(pid) reports the outcome of the last process_read_line on that ID: 0 a line was read, 1 end-of-file (stdout closed and fully consumed), 2 timed out with the child still running, -1 unknown or non-piped ID. process_wait closes the child's stdin first so a child waiting for end-of-file can finish; process_wait_timeout and process_poll_wait leave stdin open, so call process_close_stdin first for a child that only exits at end-of-file. Once process_wait, process_wait_timeout or process_poll_wait has finished the child, or process_kill has run, the ID is no longer piped: process_read_line returns "" with status -1 and process_write_stdin and process_close_stdin return -1. Whatever stdout the program never read is then available only as a whole through process_stdout_for(pid) (process_kill discards it), so read the child's last lines before polling it to completion. While the program only calls process_read_line, a child that writes faster than the program reads blocks on a full pipe instead of growing the program's memory, so a program that writes a large payload to the child's stdin without reading its output can deadlock against a chatty child exactly as with a raw pipe. process_wait, process_wait_timeout and process_poll_wait instead collect all remaining stdout in memory, and stderr is always collected in full until the ID is waited on or killed. Every piped ID must eventually be passed to process_wait or process_kill.
process_kill: process_kill(pid) sends a kill signal to a running process and waits for it to exit. Returns 0 on success, -1 on error. No-op (returns 0) if the process has already completed.
stdin_read vs stdin_read_line: stdin_read() reads the entire stdin stream into a single String (unbounded memory). stdin_read_line() reads one line at a time, including the trailing newline. Returns "" (empty string) at EOF. The returned String is runtime scratch storage valid until the next stdin_read_line() call. Process each line before reading the next one for bounded memory; use pin_to_root(line) before the next read when a line must be stored, returned, passed to a function that may store it, mutated, or otherwise retained. The direct scratch line is read-only. The scratch buffer keeps the largest line capacity seen so far, so memory is bounded by maximum line length rather than total input, but one very large line can retain that capacity for the process lifetime.
let lines: Vec<String> = Vec::new();
let mut line: String = stdin_read_line();
while str_len(line) > 0 {
// Without pin_to_root, lines.push(line) would store the scratch alias, not a copy.
lines.push(pin_to_root(line));
line = stdin_read_line();
}
let mut line: String = stdin_read_line();
while str_len(line) > 0 {
// process line (has trailing \n)
line = stdin_read_line();
}
stdin_ready: stdin_ready() returns true if stdin_read_line() would return immediately without blocking, false otherwise. Uses a non-blocking poll with zero timeout. Use this in computation loops that must remain responsive to external input:
while !stdin_ready() && depth < max_depth {
// continue searching
depth = depth + 1;
}
if stdin_ready() {
let cmd: String = stdin_read_line();
// handle command
}
Canonical Form¶
The canonical printer normalizes source: parse → print → parse is idempotent. Effects are sorted alphabetically, indentation uses 4 spaces, trailing expressions omit semicolons.